Skip to content

fix(transport): unblock read_messages when the CLI dies but orphans hold its stdout pipe#1112

Open
Echolonius wants to merge 1 commit into
anthropics:mainfrom
Echolonius:fix/reader-hang-orphan-held-stdout
Open

fix(transport): unblock read_messages when the CLI dies but orphans hold its stdout pipe#1112
Echolonius wants to merge 1 commit into
anthropics:mainfrom
Echolonius:fix/reader-hang-orphan-held-stdout

Conversation

@Echolonius

Copy link
Copy Markdown

Summary

Fixes #1110query() 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 ProcessError reaches the consumer in milliseconds across all four variants I ran: exit 1 / SIGTERM × asyncio / trio (also uvloop). The error-forwarding path in Query._read_messages works.

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:

  1. async for chunk in self._stdout_stream never 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 spawns sleep 300 before sys.exit(1).
  2. Worse, on the asyncio backend 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 exits 7proc.returncode becomes visible in ~0.5s, while proc.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 0 with 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):

  • Polls returncode (set from the child-exit signal, visible regardless of pipe holders) instead of awaiting wait(), per gotcha 2.
  • After exit, closes the stdout stream only once the reader has been parked on a silent pipe for a full grace window (2s). Two guards make this safe against cutting off legitimate output: the reader must be suspended in the read await (_awaiting_stdout — a reader suspended at yield by consumer backpressure is left alone), and no chunk may have arrived during the window (_stdout_chunks unchanged — mid-drain restarts the window).
  • The closed stream routes the read loop through its existing ClosedResourceError path into the normal exit-code check, so the failure surfaces as the same ProcessError as an EOF'd crash. The exit-code check itself now prefers the already-set returncode before falling back to wait() (gotcha 2 again).

Validation

Fake-CLI end-to-end matrix (all previously-hanging cases bounded by a 20s watchdog):

Scenario Before After
orphan holds pipe + exit 1 (asyncio) hangs forever ProcessError in ~4s
orphan holds pipe + exit 1 (trio) hangs forever ProcessError
orphan holds pipe + clean exit 0 hangs forever stream ends cleanly, result delivered
plain exit 1 / SIGTERM raises raises (unchanged)
120-message burst + consumer slower than grace window all messages + raise all messages + raise (nothing dropped)

Regression tests (added to tests/test_transport.py): the mocks enforce the real asyncio contract — wait() never returns, only returncode is set — so a future regression to wait()-based detection fails the tests. All three fail on main and pass here, including a backpressure test proving slow consumers are never mistaken for an idle pipe. One pre-existing mock in test_subprocess_buffering.py needed returncode = None added (its siblings all set it; a real Process.returncode is int | None, never a MagicMock).

pytest tests/ (excluding live-integration): 1054 passed, 5 skipped (pre-existing skips). ruff check and ruff format --check clean.

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

…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
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.

query() stream hangs forever (never raises) when the CLI process dies mid-run

1 participant