Repository navigation
test(admission): MCP client cancel and disconnect keep the worker's slot and land its write (#728, slice 3) - #936
Conversation
…lot and land its write (#728, slice 3) The earlier #728 tests cancel the awaiting task directly. These drive a tools/call through the MCP SDK client and server on the in-memory transport, then cancel it by notifications/cancelled, or by the client leaving. In both paths the worker keeps its admission slot until it ends, abandoned_work counts it and records it completed, its write to an SQLite file under tmp_path lands after the client stopped listening, and teardown neither waits on nor kills the worker thread. Verified red against a mutant that releases the slot at the cancel (semaphore value 4 for budget 4 while the worker runs), green on main. Refs #728 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsLJY3KEWTSwt6BCzs
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @cdeust's task in 3m 16s —— View job PR #936 review (review 1 of 3)
I traced the Blocker below in the code but could not reproduce it. This runner can't install the MCP SDK or run Python, and the Security found nothing. The test-coverage reviewer confirmed two things. First, both tests really do cancel the call on the server side: Blocker
async def _until_slot_returned() -> None:
sem = _get_semaphore(_TOOL)
while sem._value != current_budget(_TOOL): # noqa: SLF001
await asyncio.sleep(0)
# in _finish_worker:
await asyncio.wait_for(_until_slot_returned(), _GUARD)The Notes, no action before merge
Verdict: block |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 354ce42dfa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for _ in range(100): | ||
| if sem._value == current_budget(_TOOL): # noqa: SLF001 | ||
| return | ||
| await asyncio.sleep(0) | ||
| raise AssertionError(f"slot not returned: {sem!r}") |
There was a problem hiding this comment.
Wait for worker completion with a time-bounded condition
When the worker thread is still finishing after writer.ended is set, 100 iterations of asyncio.sleep(0) can expire before its completion callback releases the semaphore. The event is set inside the handler, before _run_coroutine_on_thread closes its loop and the executor future completes, so it does not synchronize that callback. Repeated direct execution of the new cancellation test reproduced slot not returned ... value:3 on the third iteration with unchanged production code. Replace the fixed iteration count with a condition wait bounded by _GUARD, allowing the worker time to finish rather than making both tests scheduling-dependent.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in b1120ca. writer.ended is set inside the handler, before the worker thread closes its loop and the executor future completes, so counting 100 loop turns was a race. The waits are now _until(condition): polling at 1 ms, bounded by _GUARD through asyncio.wait_for. They cover the slot return, the abandoned outcome and the abandoned registration. 20 consecutive runs: 20 passed.
Generated by Claude Code
| async def test_client_cancel_keeps_slot_and_write_lands(tmp_path: Path): | ||
| writer = _WritingHandler(tmp_path / "store.db") | ||
| try: | ||
| async with Client(_server(writer)) as client: |
There was a problem hiding this comment.
Use a stream-backed client to exercise protocol cancellation
With the locked MCP SDK 2.3.0, passing an MCPServer directly to the default Client(mode="auto") selects DirectDispatcher, not the in-memory transport; this was also confirmed at runtime. The SDK's _connect_inproc bypasses streams and JSON-RPC framing on this path, and _no_inbound_client_notifications explicitly documents cancellation as structural cancellation through the direct await, not a notification. Consequently, neither test exercises the claimed notifications/cancelled or transport-disconnect behavior, and protocol cancellation regressions can pass these tests. Use an explicit InMemoryTransport or mode="legacy" in both client constructions so the scenarios actually cross the protocol boundary.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in b1120ca. With mcp 2.3.0, Client(MCPServer) in the default "auto" mode goes through _connect_inproc → DirectDispatcher, so no JSON-RPC and no notifications/cancelled. Both tests now use Client(..., mode="legacy"), which puts the server behind InMemoryTransport + JSONRPCDispatcher. Checked with a probe on cancelled_request_id_from_params: the server receives notifications/cancelled for request id 2 in the cancel test. The slot-release mutant still turns both tests red.
Generated by Claude Code
…ker's slot and land its write (#728, slice 3)
|
Claude finished @cdeust's task in 1m 56s —— View job PR #936 review (review 2 of 3)
This is posted in the job comment, the same way as review 1, rather than with Review 1 Blocker: fixedThe fixed 100-turn wait in That order matches the code: Codex's second point is also addressed: both clients now use The new code
Notes, no action before merge
Verdict: approve |
…ker's slot and land its write (#728, slice 3)
|
Revue Claude 1/3 (verdict block) : le Blocker (attente bornée par 100 tours de boucle au lieu de Les trois notes sont justes et corrigées dans 0205b08 :
Generated by Claude Code |
|
Claude finished @cdeust's task in 2m 56s —— View job PR #936 review (review 2 of 3)
I couldn't run anything on this runner: Python and Review 1 Blocker: fixed
The Codex P2 finding (the default The docs notes from review 1 are fixed too. The docstring and CHANGELOG now say which test covers the session-survives check and which covers teardown, and they describe the blocked-before-write case accurately. The New BlockersNone. Notes, no action before merge
Verdict: approve |
…pletion (#728, slice 5) (#946) * docs(admission): ADR-1125, a cancelled tool call's worker runs to completion (#728, slice 5) Issue #728 asks to keep work counted "until completion or a documented cooperative cancellation boundary". Slices 1-4 (#885, #903, #902, #936, #943) implement and prove the first: the slot is released by the worker's done callback, the caller awaits through asyncio.shield, abandoned workers are reported in memory_stats.abandoned_workers. Nothing forwards the cancellation into the worker thread, so no cooperative boundary exists. ADR-1125 records those semantics, amends ADR-0329 (the slot belongs to the worker), states what a handler that wants to stop early must specify, and lists the tests that hold each point. Mirror regenerated with wiki_decision_mirror.generate_mirror. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYs8nydviS39BjwT88E * fixup! docs(admission): ADR-1125, a cancelled tool call's worker runs to completion (#728, slice 5) --------- Co-authored-by: Claude <[email protected]>
Summary
Refs #728 (slice 3; does not close the issue).
The #728 tests merged so far (#885, #902/#903) cancel the awaiting asyncio task directly. This slice drives the cancellation the way a client sends it: a
tools/callthrough the MCP SDKClientandMCPServeron the in-memory transport, while its handler (wrapped by the realsafe_handler) runs, then either the client'snotifications/cancelledor the client disconnecting. The handler writes one row to its own SQLite file undertmp_path.Pinned for both paths:
abandoned_workcounts it while it runs, then records itcompleted;Test-only: no production code changes. No root cause to fix here; the defect was fixed in #885. This slice adds the protocol-level evidence asked by acceptance item 4.
Type of change
Test plan
pytest tests_py/server/test_mcp_protocol_cancellation.py: 2 passed, 5 consecutive runs (0.9–1.2 s each).pytest tests_py/server tests_py/handlers/test_admission_cancellation.py: 29 passed.run_admittedthat releases the slot at the caller's cancel turns both tests red (semaphore value 4 == 4 - 1fails); a mutant that also drops the abandoned record fails them too.ruff checkandruff format --checkon the new file: clean.python scripts/check_craftsmanship.py: OK.threading.Event, never elapsed time; the only bound is a 5 s deadlock guard.Audit notes
Not verified:
remembercall needs the embedding model, which this environment could not install (torch download blocked). Running the same scenario againstrememberon an isolated SQLite Cortex store remains to do.uv sync --locked ... --no-install-package torch); the full suite was not run.Remaining #728 slices: the same scenario through a real mutating Cortex tool on an isolated store; a documented cooperative cancellation boundary for handlers that can stop early.
Coding-standards compliance
# source:annotations.Reviewer checklist
Generated by Claude Code