Skip to content

Stdio: keep a bounded tail of a child's stderr - #13

Merged
odrobnik merged 5 commits into
mainfrom
claude/stderr-capture
Sep 23, 2026
Merged

odrobnik merged 5 commits into
mainfrom
claude/stderr-capture

Conversation

@odrobnik

Copy link
Copy Markdown
Contributor

Why

ProcessLaunch.inheritStderr offers two choices: pass the child's stderr through to ours, or drop it. Neither helps a caller diagnosing a child that dies. The explanation is almost always the last thing the child wrote — and .discard throws it away, while .inherit mixes it into this process's own stderr where a library cannot read it back.

Prompted by porting acpx's cold-start stderr capture (0.12.1) to SwiftACP. Today a failed agent is reported as Codex process has exited with code 1 with no way to say why, which has already cost real debugging time there — the agent's own stderr is exactly what explains it.

What

ProcessLaunch.stderr: StderrDisposition — .inherit, .discard, .capture(maxBytes:) — implemented by both stdio transports, each exposing capturedStandardError().

  • Everything is read; only the tail is kept. Draining matters as much as capturing: a child whose stderr nobody reads can block on a full pipe or take EPIPE. Bytes past the limit are read and dropped rather than left unread. One of the tests covers exactly this — a child that writes 2000 stderr lines under a 64-byte limit still finishes its work.
  • Nothing breaks. inheritStderr stays as a two-way view over the new property, the old initializer keeps working, and .discard remains the default.
  • The tail decodes lossily, on purpose. Keeping the last bytes means the first may be half a character; a diagnostic with one replacement character beats no diagnostic. That is why the optional_data_string_conversion rule is waived at that one line, with the reason in place.

Verification

All tests pass with and without the Subprocess trait, swiftlint --strict clean on 64 files.

Six new tests across both transports: a dying child's message is captured, only the tail survives a small limit, a chatty child is not stalled, and nothing is captured by default.

`ProcessLaunch.inheritStderr` offered two choices — pass the child's stderr
through to ours, or drop it. Neither helps a caller diagnosing a child that dies:
the explanation is usually the last thing the child wrote, and with `.discard`
it is gone while with `.inherit` it is mixed into this process's own stderr,
where a library cannot read it back.

`ProcessLaunch.stderr: StderrDisposition` adds `.capture(maxBytes:)` beside
`.inherit` and `.discard`. Both stdio transports implement it and expose
`capturedStandardError()`.

- Everything is read, only the tail is kept. Draining matters as much as
  capturing: a child whose stderr nobody reads can block on a full pipe or take
  `EPIPE`, so the bytes past the limit are read and dropped rather than left.
- `inheritStderr` remains, as a two-way view over the new property, and the old
  initializer keeps working — existing callers need no change, and `.discard`
  stays the default.
- Decoding the tail is deliberately lossy: keeping the *last* bytes means the
  first may be half a character, and a diagnostic with one replacement character
  beats no diagnostic.

Prompted by porting acpx's cold-start stderr capture (0.12.1) to SwiftACP, where
"agent exited with code 1" is reported today with no way to say why — the agent's
own stderr is exactly what explains it.

Tests: a dying child's message is captured, only the tail is kept under a small
limit, a chatty child still completes its work (the drain, not just the buffer),
and nothing is captured by default. Both transports covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T10:28:08.997682Z db7af62 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: db7af626e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/JSONRPCStdio/ProcessTransport.swift Outdated
odrobnik and others added 4 commits September 23, 2026 13:51
They were appended after the file's closing `#endif`, so they sat outside
`#if Subprocess` — where `StdioTransport`, `ProcessLaunch` and `LineFraming` are
not imported. The default build compiles that file, so every platform failed on
missing types while the trait-enabled build I ran locally was fine.

Verified both ways now: `swift test` and `swift test --traits Subprocess`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From review: a child can exit with bytes still queued in its stderr pipe, and
`terminationHandler` has no specified ordering against the readability drain — so
`await waitForExit(); capturedStandardError()` could return a tail missing the
child's last line, which is usually the one that explains the exit. The original
test polled, which hid the question rather than answering it.

`waitForExit()` now resumes only once a captured stderr has reached EOF: the
capture marks `awaitingStderrEOF`, an empty read clears it and releases any
waiters, and termination holds them until then. Nothing changes for `.inherit`
or `.discard`.

Honest about the evidence: I could not reproduce the race on macOS. The test
reads the tail straight after `waitForExit()` with no polling, and it passes
without this change too — even with ~800 KB of backlog, the drain wins here. It
is kept because the guarantee is what callers depend on, and the ordering is
unspecified rather than safe; the comment on the test says exactly that rather
than implying it reproduces a failure.

The `Subprocess` transport does not share the race: its stderr drain and message
pump are in one task group, so `run` returns — and the inbound stream finishes —
only after both complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Linux CI timeout was mine. `waitForExit()` blocked until a captured stderr
reached EOF, and that EOF is not guaranteed: on Linux a child that writes one
line and exits immediately never delivered the empty read, so
`processTransportCapturesTheStderrTail` hung and took the whole test process with
it — the job was cancelled after 25 minutes with `swift-test` and the xctest
bundle left as orphans. The rest of the run had already passed, which is why the
job showed no failing test.

Two fixes, both of which the previous commit should have had:

- **The wait is bounded.** Once the child has exited, EOF gets a short grace
  period (250 ms) and then waiters are released regardless. A tail missing its
  last bytes is a far better outcome than a caller that never wakes — and EOF can
  legitimately never arrive when a grandchild inherited stderr and holds the write
  end open.
- **`close()` tears the capture down.** It cancels the readability handler and
  closes the read end. Leaving a handler installed keeps its dispatch source alive
  past the transport, which on Linux can keep the process itself from exiting —
  quite possibly the second half of what the runner saw.

The guarantee the review asked for still holds in the normal case: EOF arrives
straight after exit, and `capturedStandardError()` read after `waitForExit()`
sees the child's last words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@odrobnik
odrobnik merged commit 622fac5 into main Sep 23, 2026
6 checks passed
@odrobnik
odrobnik deleted the claude/stderr-capture branch September 23, 2026 15:23
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.

1 participant