Skip to content

fix(admission): a cancelled caller keeps its slot until its worker ends (#728, slice 1) - #885

Merged
cdeust merged 1 commit into
mainfrom
fix/728-admission-worker-ownership
Nov 6, 2026
Merged

cdeust merged 1 commit into
mainfrom
fix/728-admission-worker-ownership

Conversation

@cdeust

@cdeust cdeust commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Summary

safe_handler held the per-tool admission slot with async with admit(tool_name) around await asyncio.to_thread(...). Cancelling the awaiting task exits that block and releases the slot, but asyncio.to_thread cannot 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 through asyncio.shield. A cancelled caller still gets CancelledError at 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); reading exception() there is also what retrieves it, so no "exception never retrieved" is left behind. safe_handler uses run_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

  • Bug fix (non-breaking change which fixes an issue)

Test plan

  • New tests_py/server/test_safe_handler_cancellation.py: the issue's probe through the real safe_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 stashing mcp_server/). With the fix the slot stays locked, the fifth waits, and the peak of active workers is 4. Also asserts the duration and ok counter labels.
  • New 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.
  • New tests_py/server/test_safe_handler_error_path.py (3 tests): the error path safe_handler kept (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.
  • Orderings use 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 of run_admitted/_release_slot/_log_abandoned and 78/78 of safe_handler/_timed_handler_run killed. The 4 survivors of _classify_error are outside the changed functions.
  • pytest tests_py/server tests_py/handlers tests_py/observability + every other unit test file importing tool_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, from uv.lock) clean on the five touched files.

Audit notes

  • Behaviour change, deliberate: cortex_tool_duration_seconds now measures the handler on its worker thread. Before, it measured the to_thread await, which also counted the wait for a free executor thread and stopped at caller cancellation.
  • If the event loop itself cancels the worker task (shutdown), the slot is released while the thread may still run; the loop is going away, and the log line says was cancelled.
  • admit is kept (tests and a with-block API); production tool calls no longer use it.
  • Not verified: real MCP cancellation/disconnect (item 4 below); the full suite on PostgreSQL; torch could not be installed through the proxy, so the dev env was installed from uv.lock without the torch stack.

Remaining slices for #728

  1. Define and report in-flight mutation status for work completing after client abandonment (beyond the log line): e.g. a counter or a receipt the client can query.
  2. Exercise real MCP cancellation/disconnect with isolated stores and verify side effects and teardown.
  3. A documented cooperative cancellation boundary for handlers that can stop early.

Coding-standards compliance

  • Layer direction preserved (tool_error_handler → handlers.admission, as before).
  • No file over 300 lines, no function over 40; test constants carry # source:.

Generated by Claude Code

…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
@cdeust
cdeust marked this pull request as ready for review November 6, 2026 00:28
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T00:29:30.176501Z a359db4 Draft marked ready
🔒 Security Review ✅ Completed 2026-11-06T00:31:12.983839Z a359db4 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 1m 33s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Nov 6, 2026

Copy link
Copy Markdown

Review 1 of 3 (ADR-1119): PR #885

I ran five reviews on commit a359db4: code quality, performance, test coverage, documentation and security. None of them found a Blocker.

What I checked in run_admitted (mcp_server/handlers/admission.py:89-97):

  • No slot leak or double release. There is no await between sem.acquire(), ensure_future and add_done_callback. _release_slot runs exactly once, whether the worker succeeds, fails or is cancelled.
  • No unretrieved exception. _log_abandoned checks cancelled() before it calls exception().
  • Context reaches the worker thread. contextvars propagate through ensure_future and to_thread.
  • Metrics are safe on the worker thread. Timer now records there, and metrics.py updates under a threading.Lock.
  • Project rules hold. Layer direction, file and function size limits, and the # source: markers are fine.
  • No security regression. tool_name is always a literal at the call sites, and the abandoned-worker log contains only the exception type name.

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

  • tests_py/server/test_safe_handler_cancellation.py:85-89: the first test returns once fifth completes, while the three other released threads may still be in _timed_handler_run. If they record their duration after the fixture's metrics.reset(), the exact count ... 1 check at line 100 can flake. Fix: wait until handlers.active == 0 and the semaphore is free before returning.
  • admission.py:107: the claim that reading exception() here is "what retrieves it" is inaccurate, because asyncio.shield's inner callback already retrieves it when the outer future is cancelled. ADR-0329 still describes admit as the production wrapper, though it has no production caller now.
  • tests_py/handlers/test_admission_cancellation.py:105: peak is always 1 there, because second never calls blocking. The real check is the locked() assert at line 93.

Verdict: approve

cdeust commented Nov 6, 2026

Copy link
Copy Markdown
Owner Author

The review raised Notes only, so per ADR-1119 I am not pushing for them. Why each one stays as it is:

  • Possible flake in test_duration_is_recorded_for_a_caller_that_waits: it cannot happen with the pinned pytest-asyncio (1.4.1). Each test function runs on its own asyncio.Runner. Runner.close() calls shutdown_default_executor(), which joins the worker threads of the first test, _timed_handler_run included, before the next test's setup runs metrics.reset(). 15 consecutive local runs of the file: 15 passes.
  • "Reading exception() here is what retrieves it": right, the wording is inaccurate, since shield's inner callback already retrieves it. It does not change the behaviour; I will fix the docstring in the next code push, if there is one.
  • peak == 1 in test_cancelled_caller_holds_slot_until_worker_finishes: right, the discriminating assertion is locked(), and peak is redundant there. I am leaving it as it is.

Generated by Claude Code

@cdeust
cdeust merged commit 9455b59 into main Nov 6, 2026
32 checks passed
@cdeust
cdeust deleted the fix/728-admission-worker-ownership branch November 6, 2026 00:52
github-actions Bot pushed a commit that referenced this pull request Nov 6, 2026
…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]>
github-actions Bot pushed a commit that referenced this pull request Nov 7, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants