fix(transport): unblock read_messages when the CLI dies but orphans hold its stdout pipe#1112
Open
Echolonius wants to merge 1 commit into
Open
Conversation
…old its stdout pipe The stdout read loop treated pipe EOF as the only process-death signal. Helper processes spawned by the CLI inherit its stdout pipe, so when the CLI exits without reaping them (crash or deliberate nonzero exit) EOF never arrives and read_messages blocks forever: consumers hang with no ProcessError and no end-of-stream (anthropics#1110). On the asyncio backend process.wait() is gated on the same pipes, so the trailing exit-code check would hang identically. Add a process-exit watcher that polls returncode (visible regardless of pipe holders) and closes the stdout stream once the reader has been parked on a silent pipe for a full grace window, routing the read loop through its existing ClosedResourceError path into the exit-code check. Chunk-count and parked-on-read guards ensure a reader mid-drain or suspended at yield by consumer backpressure is never cut off, so output written before death is always delivered. Fixes anthropics#1110
This was referenced Jul 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #1110 —
query()hanging forever when the CLI process dies mid-run — at what experiments show is the actual root cause: the read loop treats stdout EOF as the only process-death signal, and EOF never arrives when helper processes spawned by the CLI outlive it holding its stdout pipe.Root cause (reproduced deterministically)
I first tried to reproduce #1110 as filed, with a protocol-faithful fake CLI (completes the initialize handshake, emits messages, then dies without a result). The plain scenario does not hang — the trailing
ProcessErrorreaches the consumer in milliseconds across all four variants I ran:exit 1/ SIGTERM × asyncio / trio (also uvloop). The error-forwarding path inQuery._read_messagesworks.The hang needs one extra ingredient: the dying CLI leaving behind a child process that inherited its stdout pipe (MCP servers, shell helpers — anything the CLI didn't reap on its crash/rate-limit exit; likewise
pkill -f "claude.*stream-json"kills only the CLI, not its helpers). Then:async for chunk in self._stdout_streamnever sees EOF — the orphan holds the write end — so the read loop never reaches its exit-code check, and no error is ever emitted. Consumer hangs forever. Reproduced deterministically with a fake CLI that spawnssleep 300beforesys.exit(1).await self._process.wait()is gated on the same pipes: asyncio's subprocess transport only resolves waiters once every pipe protocol reports disconnected. Probe: a process that orphans a pipe-holding child and exits7—proc.returncodebecomes visible in ~0.5s, whileproc.wait()still hasn't returned after 8s. So even a naive "wait for exit, then act" watcher would hang in exactly the scenario it exists for.This also affected successful runs: a clean
exit 0with a surviving helper hung the stream after the result message was delivered.The fix
A process-exit watcher task on the transport (same
spawn_detached+ cancel-in-close()pattern as the stderr reader):returncode(set from the child-exit signal, visible regardless of pipe holders) instead of awaitingwait(), per gotcha 2._awaiting_stdout— a reader suspended atyieldby consumer backpressure is left alone), and no chunk may have arrived during the window (_stdout_chunksunchanged — mid-drain restarts the window).ClosedResourceErrorpath into the normal exit-code check, so the failure surfaces as the sameProcessErroras an EOF'd crash. The exit-code check itself now prefers the already-setreturncodebefore falling back towait()(gotcha 2 again).Validation
Fake-CLI end-to-end matrix (all previously-hanging cases bounded by a 20s watchdog):
ProcessErrorin ~4sProcessErrorRegression tests (added to
tests/test_transport.py): the mocks enforce the real asyncio contract —wait()never returns, onlyreturncodeis set — so a future regression towait()-based detection fails the tests. All three fail onmainand pass here, including a backpressure test proving slow consumers are never mistaken for an idle pipe. One pre-existing mock intest_subprocess_buffering.pyneededreturncode = Noneadded (its siblings all set it; a realProcess.returncodeisint | None, never aMagicMock).pytest tests/(excluding live-integration): 1054 passed, 5 skipped (pre-existing skips).ruff checkandruff format --checkclean.Relation to #1111
#1111 addresses a different theory (the awaited error-send blocking). Its two tests pass on unfixed
main, and the plain CLI-death path already propagates errors, which is what pointed this investigation at the missing ingredient above. Details in a comment on that PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01T2Pw4KuUi6nNhKwZKMnRxj