From 7aae52d18dbcde028ae671f74d9b18019dcd9d15 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 23 Dec 2025 14:41:36 -0500 Subject: [PATCH 1/2] Bump pydocket to 0.16.2 for task cancellation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a test that verifies task cancellation actually interrupts running coroutines (they receive CancelledError) rather than just marking the task as cancelled in Redis while the coroutine continues to completion. This requires pydocket >= 0.16.2 which added best-effort cancellation via Redis pub/sub signaling to workers. Closes #2679 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 2 +- tests/server/tasks/test_task_methods.py | 43 +++++++++++++++++++++++++ uv.lock | 10 +++--- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5d3e1cda4..b480dc7d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "mcp>=1.24.0", "openapi-pydantic>=0.5.1", "platformdirs>=4.0.0", - "pydocket>=0.16.0", + "pydocket>=0.16.2", "rich>=13.9.4", "cyclopts>=4.0.0", "authlib>=1.6.5", diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py index ba8b6e64d..ee971b2e7 100644 --- a/tests/server/tasks/test_task_methods.py +++ b/tests/server/tasks/test_task_methods.py @@ -172,3 +172,46 @@ async def test_task_cancellation_workflow(endpoint_server): # Task should be in cancelled state status = await task.status() assert status.status == "cancelled" + + +async def test_task_cancellation_interrupts_running_coroutine(endpoint_server): + """Task cancellation actually interrupts the running coroutine. + + This verifies that when a task is cancelled, the underlying asyncio + coroutine receives CancelledError rather than continuing to completion. + Requires pydocket >= 0.16.2. + + See: https://github.com/jlowin/fastmcp/issues/2679 + """ + started = asyncio.Event() + was_interrupted = asyncio.Event() + completed_normally = asyncio.Event() + + @endpoint_server.tool(task=True) + async def interruptible_tool() -> str: + started.set() + try: + await asyncio.sleep(60) + completed_normally.set() + return "completed" + except asyncio.CancelledError: + was_interrupted.set() + raise + + async with Client(endpoint_server) as client: + task = await client.call_tool("interruptible_tool", {}, task=True) + + # Wait for the tool to actually start executing + await asyncio.wait_for(started.wait(), timeout=5.0) + + # Cancel the task + await task.cancel() + + # Wait for cancellation to propagate + await asyncio.wait_for(was_interrupted.wait(), timeout=5.0) + + # The coroutine should have been interrupted, not completed normally + assert was_interrupted.is_set(), "Task was not interrupted by cancellation" + assert not completed_normally.is_set(), ( + "Task completed instead of being cancelled" + ) diff --git a/uv.lock b/uv.lock index 08c5f65c3..c8cae8b3a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -752,7 +752,7 @@ requires-dist = [ { name = "platformdirs", specifier = ">=4.0.0" }, { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, - { name = "pydocket", specifier = ">=0.16.0" }, + { name = "pydocket", specifier = ">=0.16.2" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, @@ -1775,7 +1775,7 @@ wheels = [ [[package]] name = "pydocket" -version = "0.16.0" +version = "0.16.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -1792,9 +1792,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/d3/ff57fb8ba8180c621b17270a272ee8821a64962887c42962c5a342ab82a9/pydocket-0.16.0.tar.gz", hash = "sha256:675e829a7ea6e978fdbbe0ace342f24c8ec7cf8ed5980694215418e59ff86005", size = 286316, upload-time = "2025-12-18T15:14:53.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/b6/b9b5af725be7ffb195750e1cbe0ad4aa413858f16af258a49ad84cff82ac/pydocket-0.16.2.tar.gz", hash = "sha256:b48e59ad247cbf23fd7064cd765b75e5b870451a7778afa3b208b3aae6474c1f", size = 295623, upload-time = "2025-12-23T19:24:46.751Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/74/dcf56132e03a24e2591c5f2391902c871e5f1248e0fc175b9f1792e4afaf/pydocket-0.16.0-py3-none-any.whl", hash = "sha256:3d7d0a805bcb68a3a03d96d3c6ba8e607584ed14b0fe3879333a8fff652dda92", size = 61064, upload-time = "2025-12-18T15:14:51.506Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/81132e8ca41e11000807cc1f457a391d5a364dd1eeb7de40f2ff971301bc/pydocket-0.16.2-py3-none-any.whl", hash = "sha256:044a74deec2062c305bf1d452a1348a4e3739b3a2cf0a411ca1e11368cef5baf", size = 66812, upload-time = "2025-12-23T19:24:45.643Z" }, ] [[package]] From 18538e872b5c44d26d11201978f415f1f3e614bf Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 23 Dec 2025 18:40:07 -0500 Subject: [PATCH 2/2] Bump pydocket to 0.16.3 to fix worker cleanup race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pydocket 0.16.3 fixes a race condition in `_worker_loop` where cancellation arriving between `_worker_done.clear()` and the try block would cause `_worker_done.set()` to never run, blocking `Worker.__aexit__` forever. Also fixes: - Simplified `_docket_lifespan` cleanup (timeout wrapper no longer needed) - Fixed `nested_server` test fixture to use graceful uvicorn shutdown - Fixed uv transport tests to use local fastmcp in dev mode Closes #2679 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 3 +- src/fastmcp/server/server.py | 7 ++--- tests/client/test_streamable_http.py | 20 ++++++------ tests/client/transports/test_uv_transport.py | 32 +++++++++++++++++--- uv.lock | 8 ++--- 5 files changed, 45 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b480dc7d2..96f20d4b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "mcp>=1.24.0", "openapi-pydantic>=0.5.1", "platformdirs>=4.0.0", - "pydocket>=0.16.2", + "pydocket>=0.16.3", "rich>=13.9.4", "cyclopts>=4.0.0", "authlib>=1.6.5", @@ -187,3 +187,4 @@ reportConstantRedefinition = false [tool.codespell] ignore-words-list = "asend,shttp,te" + diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 28914943b..d11631002 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -470,12 +470,9 @@ class FastMCP(Generic[LifespanResultT]): try: yield finally: - # Cancel worker task on exit with timeout to prevent hanging worker_task.cancel() - with suppress( - asyncio.CancelledError, asyncio.TimeoutError - ): - await asyncio.wait_for(worker_task, timeout=2.0) + with suppress(asyncio.CancelledError): + await worker_task finally: _current_worker.reset(worker_token) finally: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index baf275677..0ddc071bc 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -1,6 +1,7 @@ import asyncio import json import sys +from contextlib import suppress from unittest.mock import AsyncMock, call import pytest @@ -107,8 +108,8 @@ async def nested_server(): from fastmcp.utilities.http import find_available_port - server = create_test_server() - mcp_app = server.http_app(path="/final/mcp") + mcp_server = create_test_server() + mcp_app = mcp_server.http_app(path="/final/mcp") # Nest the app under multiple mounts to test URL resolution inner = Starlette(routes=[Mount("/nest-inner", app=mcp_app)]) @@ -125,20 +126,19 @@ async def nested_server(): port=port, log_level="critical", ws="websockets-sansio", + timeout_graceful_shutdown=0, ) - # Use the simple asyncio pattern - server_task = asyncio.create_task(uvicorn.Server(config).serve()) + uvicorn_server = uvicorn.Server(config) + server_task = asyncio.create_task(uvicorn_server.serve()) await asyncio.sleep(0.1) yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/final/mcp" - # Cleanup - server_task.cancel() - try: - await server_task - except asyncio.CancelledError: - pass + # Cleanup: signal uvicorn to shutdown, then cancel the task + uvicorn_server.should_exit = True + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + await asyncio.wait_for(server_task, timeout=2.0) async def test_ping(streamable_http_server: str): diff --git a/tests/client/transports/test_uv_transport.py b/tests/client/transports/test_uv_transport.py index 020f084ba..2917045b8 100644 --- a/tests/client/transports/test_uv_transport.py +++ b/tests/client/transports/test_uv_transport.py @@ -5,10 +5,15 @@ from pathlib import Path import pytest +import fastmcp from fastmcp.client import Client from fastmcp.client.client import CallToolResult -from fastmcp.client.transports import ( - UvStdioTransport, +from fastmcp.client.transports import StdioTransport, UvStdioTransport + +# Detect if running from dev install to use local source instead of PyPI +_is_dev_install = "dev" in fastmcp.__version__ +_fastmcp_src_dir = ( + Path(__file__).parent.parent.parent.parent if _is_dev_install else None ) @@ -79,15 +84,32 @@ async def test_uv_transport_module(): main_file = module_dir / "__main__.py" _ = main_file.write_text(main_script) - client: Client[UvStdioTransport] = Client( - transport=UvStdioTransport( + # In dev installs, use --with-editable to install local source. + # In releases, use --with to install from PyPI. + if _is_dev_install and _fastmcp_src_dir: + transport: StdioTransport = StdioTransport( + command="uv", + args=[ + "run", + "--directory", + tmpdir, + "--with-editable", + str(_fastmcp_src_dir), + "--module", + "my_module", + ], + keep_alive=False, + ) + else: + transport = UvStdioTransport( with_packages=["fastmcp"], command="my_module", module=True, project_directory=Path(tmpdir), keep_alive=False, ) - ) + + client: Client[StdioTransport] = Client(transport=transport) async with client: result: CallToolResult = await client.call_tool("add", {"x": 1, "y": 2}) diff --git a/uv.lock b/uv.lock index c8cae8b3a..7293eaaf8 100644 --- a/uv.lock +++ b/uv.lock @@ -752,7 +752,7 @@ requires-dist = [ { name = "platformdirs", specifier = ">=4.0.0" }, { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, - { name = "pydocket", specifier = ">=0.16.2" }, + { name = "pydocket", specifier = ">=0.16.3" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, @@ -1775,7 +1775,7 @@ wheels = [ [[package]] name = "pydocket" -version = "0.16.2" +version = "0.16.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, @@ -1792,9 +1792,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/b6/b9b5af725be7ffb195750e1cbe0ad4aa413858f16af258a49ad84cff82ac/pydocket-0.16.2.tar.gz", hash = "sha256:b48e59ad247cbf23fd7064cd765b75e5b870451a7778afa3b208b3aae6474c1f", size = 295623, upload-time = "2025-12-23T19:24:46.751Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/c5/61dcfce4d50b66a3f09743294d37fab598b81bb0975054b7f732da9243ec/pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265", size = 297080, upload-time = "2025-12-23T23:37:33.32Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/33/81132e8ca41e11000807cc1f457a391d5a364dd1eeb7de40f2ff971301bc/pydocket-0.16.2-py3-none-any.whl", hash = "sha256:044a74deec2062c305bf1d452a1348a4e3739b3a2cf0a411ca1e11368cef5baf", size = 66812, upload-time = "2025-12-23T19:24:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087, upload-time = "2025-12-23T23:37:31.829Z" }, ] [[package]]