Fix raw asyncio cancellation leaking CLI subprocesses - #1246
Conversation
Signed-off-by: 1fanwang <1fannnw@gmail.com>
|
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 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 guaranteewhile True:
try:
await self._close_impl()
break
except anyio.get_cancelled_exc_class() as exc:
cancellation = excevery delivery restarts i wrapped that is the same fake CLI from your test. before this PR that caller got a leaked child and an immediate honest limit on that claim: i produced the repeated cancellation explicitly. i did not find a stdlib caller that re-delivers on its own -- 2. the happy path already runs the escalation twice, and the docstring's bound is now stalesame counter on exactly your scenario: the child is reaped, which is the point. but the second round pays a fresh 5s graceful wait, so
which is now ~20s per attempt. worth saying in the PR body too: a caller who used 3. a shape that keeps the reap and terminatesrun 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 cancellationmeasured on the same three probes:
the backend split is not cosmetic. i tried the same loop without it first, and 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 -- smallthe new test asserts the outcome but not the cost -- it would pass just as happily if the loop ran ten rounds. counting |
Signed-off-by: Stefan Wang <1fannnw@gmail.com>
|
Done in ac7e88f. Asyncio cleanup now runs once in a shielded task, with repeated-cancellation coverage. |
Signed-off-by: 1fanwang <1fannnw@gmail.com>
Summary
A caller that bounds shutdown with
asyncio.wait_for()can receiveTimeoutErrorwhile 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.
Raw red and green logs