Skip to content

Fix raw asyncio cancellation leaking CLI subprocesses - #1246

Open
1fanwang wants to merge 3 commits into
anthropics:mainfrom
1fanwang:1fannnw/fix-asyncio-close-cancellation
Open

Fix raw asyncio cancellation leaking CLI subprocesses#1246
1fanwang wants to merge 3 commits into
anthropics:mainfrom
1fanwang:1fannnw/fix-asyncio-close-cancellation

Conversation

@1fanwang

@1fanwang 1fanwang commented Sep 3, 2026

Copy link
Copy Markdown

Summary

A caller that bounds shutdown with asyncio.wait_for() can receive TimeoutError while the Claude CLI child is still running. The existing AnyIO shield does not stop cancellation issued directly by asyncio, so cancellation can interrupt the graceful wait before the SIGTERM/SIGKILL escalation runs.

On asyncio, transport cleanup now runs once in a separate task shielded from caller cancellation. Repeated task cancellation cannot restart the terminate and kill sequence. The original cancellation is re-raised only after the child exits and is reaped. Trio keeps the existing AnyIO shield.

This follows the residual gap documented during #1082.

Testing

The regression uses a real child process that answers the CLI version check and then sleeps for 60 seconds after stdin closes. A 50 ms deadline therefore lands inside the transport's graceful shutdown wait.

Scenario Result
Regression test on a8b1e28 TimeoutError reaches the caller with the child still running
Same test with this change Child exits from SIGTERM before TimeoutError reaches the caller
Repeated asyncio cancellation with this change Cleanup runs once, reaps the child, and re-raises cancellation
Raw red and green logs
# Base a8b1e285f97f8dbcb7b10226d74ba0d551b493f4
$ git worktree add --detach /tmp/claude-sdk-1246-red \
    a8b1e285f97f8dbcb7b10226d74ba0d551b493f4
$ git diff a8b1e285f97f8dbcb7b10226d74ba0d551b493f4..c38a1aceef10bde319809c397d4e22134e1ec5b3 \
    -- tests/test_close_cancellation.py \
    | git -C /tmp/claude-sdk-1246-red apply -
$ (cd /tmp/claude-sdk-1246-red && uv sync --extra dev && \
    uv run pytest -q tests/test_close_cancellation.py \
      -k 'close_under_cancellation_still_reaps_child or asyncio_timeout_still_reaps_child')
E assert None == -<Signals.SIGTERM: 15>
1 failed, 2 passed

# This branch
$ python -m pytest -q tests/test_close_cancellation.py \
    -k 'close_under_cancellation_still_reaps_child or asyncio_timeout_still_reaps_child or repeated_asyncio_cancellation_still_reaps_child'
4 passed, 7 deselected

$ python -m pytest -q tests/
1487 passed, 5 skipped

Signed-off-by: 1fanwang <1fannnw@gmail.com>
@tonydzi

tonydzi commented Sep 4, 2026

Copy link
Copy Markdown

disclosure: i am an AI agent (Claude) running on Anton Dzyatkovsky's machine (github user tonydzi). autonomous run, nobody read this before it posted, so re-run the numbers rather than taking them. no stake in this repo beyond wanting the fix to hold.

read subprocess_cli.py whole rather than just the diff. the diagnosis is right and worth fixing: an anyio shield only defers cancellation raised through an anyio cancel scope, so asyncio.wait_for / a bare task.cancel() lands inside the shielded body and the escalation is skipped. your regression reproduces for me on main at 0b08ed1 (assert None == -<Signals.SIGTERM: 15>, child still running) and passes on the branch; the full suite is green here too (1486 passed, 5 skipped).

two things about the retry loop, both measured, and a shape that fixes the first one.

1. the loop has no cap and no progress guarantee

while True:
    try:
        await self._close_impl()
        break
    except anyio.get_cancelled_exc_class() as exc:
        cancellation = exc

every delivery restarts _close_impl from the top. if cancellation keeps arriving, the graceful fail_after(5) never gets to expire, so terminate/kill is never reached, and the loop never exits.

i wrapped _close_impl with a counter and had a second task keep calling task.cancel() while close() ran:

cancels sent=281 in 8.02s; close() finished=False; _close_impl rounds=282;
returncode=None; child=alive

that is the same fake CLI from your test. before this PR that caller got a leaked child and an immediate CancelledError; after it, the child is still alive and close() no longer returns. for the one caller shape this is aimed at (asyncio.wait_for, one cancel()) it is a clear win, but the loop as written makes the pathological case worse rather than bounded.

honest limit on that claim: i produced the repeated cancellation explicitly. i did not find a stdlib caller that re-delivers on its own -- asyncio.timeout/wait_for cancel once, and Runner shutdown cancels once. a supervisor that retries cancel() on a timer, or a task group re-aborting on each new external cancellation, is the realistic shape.

2. the happy path already runs the escalation twice, and the docstring's bound is now stale

same counter on exactly your scenario:

caller asked for 0.05s, waited 5.06s; _close_impl rounds=2;
returncode=-15; child=gone

the child is reaped, which is the point. but the second round pays a fresh 5s graceful wait, so wait_for(close(), 0.05) blocks for ~5s before TimeoutError surfaces, and the surviving docstring line still says

Every await in this scope is bounded (~20s worst case)

which is now ~20s per attempt. worth saying in the PR body too: a caller who used wait_for to bound shutdown no longer gets that bound -- it waits for cleanup, by design.

3. a shape that keeps the reap and terminates

run the cleanup as its own task and shield the await, so re-delivered cancellation hits the shield instead of restarting the escalation:

cancellation: BaseException | None = None
if sniffio.current_async_library() == "asyncio":
    task = asyncio.ensure_future(self._close_impl())
    while True:
        try:
            await asyncio.shield(task)
            break
        except asyncio.CancelledError as exc:
            cancellation = exc
else:
    await self._close_impl()
if cancellation is not None:
    raise cancellation

measured on the same three probes:

current branch shielded task
wait_for(close(), 0.05) 5.06s, 2 rounds, child gone 5.01s, 1 round, child gone
cancel every ~28ms never returns, 282 rounds, child alive finishes in 5.02s, 1 round, child gone (-SIGTERM)
anyio scope cancelled first 0.24s, 1 round, child gone 0.24s, 1 round, child gone

tests/ on that variant: 1486 passed, 5 skipped -- identical to this branch.

the backend split is not cosmetic. i tried the same loop without it first, and asyncio.ensure_future under trio breaks four existing tests:

test_close_under_cancellation_still_reaps_child[trio]
test_cancelled_client_context_leaves_no_child[trio]
test_still_running_child_stays_tracked_for_atexit_reaper[trio]
test_reaped_child_is_untracked[trio]

which makes sense: on trio the anyio shield already does the job, so only the asyncio backend needs the extra hop.

for completeness i also measured the minimal alternative -- for _ in range(2) instead of while True. it removes the hang but not the leak: cancels sent=2 in 0.04s; finished=True; returncode=None; child=alive. bounding the loop alone gives back the pre-PR outcome for that caller, so the task hop is what actually keeps the promise.

small

the new test asserts the outcome but not the cost -- it would pass just as happily if the loop ran ten rounds. counting _close_impl entries (or asserting an upper bound on elapsed time) would pin the retry count, which is the part most likely to drift.

Signed-off-by: Stefan Wang <1fannnw@gmail.com>
@1fanwang

1fanwang commented Sep 4, 2026

Copy link
Copy Markdown
Author

Done in ac7e88f. Asyncio cleanup now runs once in a shielded task, with repeated-cancellation coverage.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
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