Repository navigation
fix(admission): a cancelled caller keeps its slot until its worker ends (#728, slice 1) - #885
Conversation
…ds (#728, slice 1) safe_handler released the per-tool admission slot when the awaiting task was cancelled, while the asyncio.to_thread worker kept running. A new request could then start one worker more than the declared budget (issue probe: budget 4, four cancelled callers, five active workers). run_admitted acquires the slot, starts the worker and releases the slot from the worker's done callback; the caller awaits it through asyncio.shield. A worker whose caller was cancelled logs its outcome when it ends, which also retrieves its exception. The duration histogram is now observed on the worker thread, so an abandoned run is still measured. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018kyZYsPxJMjfYCgFwjAj9o
Codex Review SummaryThis 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. |
Review 1 of 3 (ADR-1119): PR #885I ran five reviews on commit What I checked in
I could not run pytest, ruff or the craftsmanship gate here because the commands needed approval. This verdict rests on reading the code and on the five reviews. Notes, no action before merge
Verdict: approve |
|
The review raised Notes only, so per ADR-1119 I am not pushing for them. Why each one stays as it is:
Generated by Claude Code |
…ancelled (#728, slice 2) (#903) A cancelled tool call leaves its asyncio.to_thread worker running; slice 1 (#885) kept its admission slot and logged its outcome, but nothing said how many such workers were still running or how the ended ones did. handlers/abandoned_work counts, per tool, the abandoned workers in flight and the outcome of each one that ended (completed / raised / cancelled). run_admitted records the abandonment, and _log_abandoned records the outcome and names in its log line how many abandoned workers of the tool remain. Refs #728 Co-authored-by: Claude <[email protected]>
…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
safe_handlerheld the per-tool admission slot withasync with admit(tool_name)aroundawait asyncio.to_thread(...). Cancelling the awaiting task exits that block and releases the slot, butasyncio.to_threadcannot stop the thread: the worker keeps running. A new request then takes the freed slot and starts one worker more than the declared budget — the probe of the issue (budget 4, four cancelled callers, five active workers).Root cause: the slot was owned by the caller, not by the work. This PR adds
run_admitted(mcp_server/handlers/admission.py): it acquires the slot, starts the worker, releases the slot from the worker's done callback, and the caller waits throughasyncio.shield. A cancelled caller still getsCancelledErrorat once, but the slot stays counted until the worker ends. A worker whose caller was cancelled logs its outcome when it ends (<tool> worker completed|raised <Exc>|was cancelled after its caller was cancelled); readingexception()there is also what retrieves it, so no "exception never retrieved" is left behind.safe_handlerusesrun_admitted; the duration histogram is now observed on the worker thread (_timed_handler_run), so an abandoned run is still measured.Refs #728 (slice 1 — the remaining acceptance items are listed below, so this PR does not close the issue).
Type of change
Test plan
tests_py/server/test_safe_handler_cancellation.py: the issue's probe through the realsafe_handler(detect_gaps, budget 4, four callers cancelled while their handlers run, then a fifth request). Without the fix it fails: after the cancellations the semaphore is[unlocked, value:4](checked locally by stashingmcp_server/). With the fix the slot stays locked, the fifth waits, and the peak of active workers is 4. Also asserts the duration andokcounter labels.tests_py/handlers/test_admission_cancellation.py(5 tests): budget 1 held by an abandoned worker until it ends; result and error reach a waiting caller and release the slot; the three abandoned outcomes are logged with their exact message.tests_py/server/test_safe_handler_error_path.py(3 tests): the error pathsafe_handlerkept (error log with and without a tool name, error counter, a failing error counter logged at debug). Added because ADR-1122 mutates the whole changed function.threading.Event/Semaphore; a 5 s guard only bounds waits a broken implementation would never satisfy, so a mutant fails instead of hanging. No verdict depends on elapsed time.GITHUB_BASE_REF=main UV_NO_SYNC=1 python scripts/ci_mutation_diff.py(mutmut 3.8.1):2 file(s), 0 failed, 0 untested; 35/35 mutants ofrun_admitted/_release_slot/_log_abandonedand 78/78 ofsafe_handler/_timed_handler_runkilled. The 4 survivors of_classify_errorare outside the changed functions.pytest tests_py/server tests_py/handlers tests_py/observability+ every other unit test file importingtool_error_handler/admission: 1853 passed, 82 skipped (PostgreSQL not reachable here).python scripts/check_craftsmanship.py: OK.ruff check/ruff format --check(0.17.3, fromuv.lock) clean on the five touched files.Audit notes
cortex_tool_duration_secondsnow measures the handler on its worker thread. Before, it measured theto_threadawait, which also counted the wait for a free executor thread and stopped at caller cancellation.was cancelled.admitis kept (tests and awith-block API); production tool calls no longer use it.torchcould not be installed through the proxy, so the dev env was installed fromuv.lockwithout the torch stack.Remaining slices for #728
Coding-standards compliance
tool_error_handler→handlers.admission, as before).# source:.Generated by Claude Code