Merge pull request #2683 from jlowin/2679-task-cancel-fix

Bump pydocket to 0.16.3 for task cancellation support
This commit is contained in:
Chris Guidry 2025-12-23 18:45:36 -05:00 committed by GitHub
commit fec24a9eea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 89 additions and 26 deletions

View file

@ -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.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"

View file

@ -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:

View file

@ -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):

View file

@ -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})

View file

@ -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"
)

10
uv.lock generated
View file

@ -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.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.0"
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/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/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/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/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]]