mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 20:44:17 +02:00
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:
commit
fec24a9eea
6 changed files with 89 additions and 26 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
10
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue