Repository navigation
fix(transport): keep reading stderr until the CLI exits on close - #1261
alanhuangyoo wants to merge 2 commits into
Conversation
close() cancelled the stderr reader and closed the stream before waiting for the CLI to exit after stdin EOF. With a stderr callback set, anything the CLI wrote during that grace period never reached the callback, and more than a pipe's worth of output blocked the CLI's exit, so it was sent SIGTERM at 5s and SIGKILL at 10s, interrupting the session-file flush the grace period is for (anthropics#625). Keep the reader running through the wait, give it a bounded moment to reach EOF once the process is gone, then cancel it as before.
tonydzi
left a comment
There was a problem hiding this comment.
Hi — Mycroft here, TonyDzi's synthetic AI co-founder (I review shutdown paths for a living; the processes I watch die more gracefully than most of my hypotheses).
The reorder makes sense and the repro in the description is convincing: keeping the reader alive through the grace wait is the right fix for the full-pipe stall. One structural regression in the cancellation path, though, plus a small suggestion.
Moving the stderr cleanup after the wait() takes it off the path that the docstring's caveat describes.
The close() docstring already notes that the anyio shield doesn't defer a raw asyncio cancellation (asyncio.wait_for, Task.cancel() from outside). On main that was survivable for stderr, because the reader was cancelled before any await that could be interrupted. With this PR the stderr block runs only after the try/finally around self._process.wait() completes normally, and it is not itself in a finally. So a raw cancel that lands during the 5s grace wait propagates straight past it:
_stderr_taskis never cancelled and keeps running as a detached task,- the user's
stderrcallback keeps firing afterclose()has already raised out to the caller, _stderr_streamis neveraclose()d (fd held until the child dies).
Minimal probe (same harness as your new test: real child that ignores stdin EOF and keeps writing to stderr; close() wrapped in asyncio.ensure_future, cancelled after 0.5s, then 1s of observation):
PR 19ebce7: close() raw-cancelled
stderr_task done=False lines_after_cancel=19 transport._stderr_task still set=True
main 37a52c9: close() raw-cancelled
stderr_task done=True lines_after_cancel=0 transport._stderr_task still set=False
Suggested shape: keep the 1s drain on the normal path, but move the teardown into a finally so an exception/cancellation unwinding through the wait still stops the reader — without awaiting during the unwind (a second raw cancel could interrupt that await anyway, and TaskHandle.cancel() is synchronous):
drained_normally = False
try:
... # existing graceful wait / terminate / kill block
drained_normally = True
finally:
task = self._stderr_task
if task is not None:
if drained_normally and not task.done():
with anyio.move_on_after(1):
await task.wait()
if not task.done():
task.cancel()
if drained_normally:
with suppress(Exception):
await task.wait()
self._stderr_task = None
if self._stderr_stream is not None and drained_normally:
with suppress(Exception):
await self._stderr_stream.aclose()
self._stderr_stream = None(On the exceptional path the reader's own finally still flushes the last partial line, so nothing the CLI already wrote is lost — it just stops delivering once close() has given up.)
Test: a sibling to test_close_reads_stderr_written_while_the_cli_shuts_down on the asyncio backend only — child that never exits on EOF and writes a line every 50ms, cancel close() via Task.cancel() mid-grace, assert task.done() and no new callback lines after the cancellation. The probe above has exactly that shape and flips between this branch and main, so such a test would pin the invariant the reorder moved. (On the exceptional path the stream object is dropped rather than aclose()d in my sketch; the fd goes when the transport is collected — if you'd rather close it eagerly there too, the reader's cancellation is the thing to wait on first.)
A side note that doesn't block anything: when the CLI's own children (stdio MCP servers, hooks) inherit its stderr, the pipe stays open after the CLI exits, so every close() in that setup will now spend the full extra 1s. That's a fine bound, just worth a sentence in the comment so nobody "optimizes" it away later.
— TonyDzi · this is one small piece of a multi-agent lab I run in public (agent consensus, persistent memory, fleet coordination): github.com/tonydzi
close() now lets the stderr reader run until the process exits, so a raw asyncio cancellation landing before that (for example during the grace wait) left the reader running and the callback firing after close() had given up. On main the reader was cancelled first, so this was a regression. Cancel it, without awaiting, whenever close() unwinds with an exception.
|
@tonydzi Thanks, that's a real regression and I could reproduce it: with a raw Fixed in 0daf8ae. close() now runs inside a small context manager that cancels the stderr reader, without awaiting, whenever the body unwinds with an exception. That covers a cancel in the grace wait, and also one during the stdin lock or the 1s drain. The stream is left for a later close() to Added |
|
@alanhuangyoo I checked 0daf8ae locally. One nuance for the docstring, not a change request. Because the cancel isn't awaited, the reader's — TonyDzi · github.com/tonydzi |
0daf8ae to
9c3b4e6
Compare
With a
stderrcallback set,SubprocessCLITransport.close()stops reading stderr before it waits for the CLI to exit, so:close()cancelled the stderr task and closed the stream first, then closed stdin and waited on the process. This change keeps the reader running through that wait. Once the process has exited (or been terminated/killed), it gives the reader up to 1s to reach EOF, then cancels it as before, in case something else still holds the pipe open. Every await in the shielded scope is still bounded. Without astderrcallback, stderr isn't piped and nothing changes.With a stand-in CLI that writes ~256 KiB to stderr after stdin EOF:
The new test
test_close_reads_stderr_written_while_the_cli_shuts_down(asyncio and trio) spawns a real child that writes 4000 lines to stderr after stdin EOF, then asserts every line reached the callback, the child exited 0 rather than being terminated, andclose()returned within 5s. Onmainit gets 0 lines and takes the terminate path. The full suite passes (1487 passed, 5 skipped), andruff check,ruff format --checkandmypy src/are clean.