Skip to content

fix(cli): deliver large stdout writes past one pipe buffer - #55893

Open
Parker-Fawcett wants to merge 4 commits into
anomalyco:devfrom
Parker-Fawcett:stdout-drain
Open

Parker-Fawcett wants to merge 4 commits into
anomalyco:devfrom
Parker-Fawcett:stdout-drain

Conversation

@Parker-Fawcett

@Parker-Fawcett Parker-Fawcett commented Oct 15, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #55525

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

--completions fish is about 68 KiB and printed through Effect's Console, which defaults to Bun's console.log. The CLI's @opentui/solid preload leaves stdout nonblocking, and on a nonblocking pipe Bun's console.log drops whatever doesn't fit once the pipe is full, so piped output stopped at 65536 bytes.

process.stdout.write queues the rest instead, but process.exit throws that queue away unless the last write has called back. So the CLI now provides its own Console that writes through process.stdout/process.stderr, and before its process.exit it waits for the last write on each stream to call back.

This replaces my earlier approach (global stream/console patching with chunked writeSync). That broke short console output, and it could still truncate when the reader was slow.

Logging is unchanged: the CLI's loggers write to the log file and process.stderr directly, not through Console. Handlers that call process.stdout.write themselves, like api get (#52925), aren't changed here; #52955 covers that.

How did you verify your code works?

  • test/output.test.ts pipes --completions fish through a real sh pipe to a reader that sleeps 2 s and compares it with the file output. It fails on dev, and also fails if the wait before exit is removed. A second test checks that --version | cat prints and exits 0.
  • Manual on macOS, Bun 1.4.2: piped, slow-reader (7 s) and file output are all 69533 bytes and identical. | head -c 100 exits 0. --help, --version, session --help, bash/zsh completions and an unknown-flag error are byte-identical to dev.
  • packages/cli suite: 331 pass, 9 skip, 0 fail. bun typecheck and root bun run check pass.

Screenshots / recordings

N/A, not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

🤖 Generated with Claude Code


Added by the opencode bot (AI-generated).

Related: #52925

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This fixes the reported case. With the issue's repro script, the base gives 65536 bytes through a pipe and a slow pipe and drops the end marker. With this PR, both match the 69533-byte file output, | head -c 100 still exits 0, and the new test passes here but fails on the base. Type check is clean.

The public API is unchanged.

Piped output can still be cut off in two cases (inline comments):

  • Many small writes: writes of 16 KiB or less still use the original nonblocking stream. I tested 200 writes of 1 KB after importing @opentui/core, with your wrapper in place, and only 64080 bytes arrived.
  • Slow readers: after 5 s of EAGAIN the remaining output is dropped silently and the exit code is still 0. --completions fish | (sleep 7; wc -c) gives 65536.

I'd suggest one approach for every piped write, for example writing everything synchronously when stdout isn't a TTY, or setting the descriptors back to blocking, rather than switching at a size threshold. The test can also be simpler (inline comment).

Fixes #52925

Comment thread packages/cli/src/index.ts Resolved
Comment thread packages/cli/src/index.ts Resolved
Comment thread packages/cli/src/stdout-drain.test.ts Resolved
@HootRock

Copy link
Copy Markdown

AI-assisted native Windows review of 2dd921ba.

The new stream wrapper drops callbacks on writes of at most 16 KiB. It pops the callback from args, then delegates the small-write branch with only ...args. For example, process.stdout.write("abc", () => done()) writes the bytes but never calls done; the same happens on stderr and with the encoding/Buffer overloads. A caller awaiting completion can therefore remain pending.

I exercised the exact added stdio block in isolated real subprocesses on Bun 1.4.2 and Node 24.13.0. On each runtime, the unchanged stream control passes all 10 cases; the patched block passes only the two 16,385-byte controls. All eight cases at or below 16,384 bytes lose their callbacks, while every output byte still arrives. Both streams, string/encoding/Buffer overloads and the exact threshold are covered.

Please preserve the callback when delegating small writes and add callback regressions alongside the pipe-output test. These checks isolate the added block without importing the complete CLI; they do not validate the original POSIX truncation, full CLI behavior, upstream CI or the repository suite. No production code was changed.

reviewed and tested locally.

@Parker-Fawcett

Copy link
Copy Markdown
Author

Review follow-up: I re-validated against the latest dev because of the conflicting reports (issue #55525). Findings:

  • Reproduced on macOS: both opencode 2.0.29 binary and source (bun run src/index.ts) reliably emit 65,536 instead of 69,533 when piped, only after the pipe fills. Slow-pipe (sleep 1; cat) does not cure it, so it is not an exit-drain race.
  • Non-reproducing reports elsewhere (Windows source, Linux 2.0.29 binary) are consistent with fd 1/2 starting in blocking mode there. The default packages/cli/bunfig.toml preload loads @opentui/solid, which is what flips fd 1/2 to nonblocking on macOS; static import "@opentui/core" alone does not (verified by direct fcntl( F_GETFL) probes on Bun 1.4.2/macOS).
  • A bare 70,000-byte writeSync loop then succeeds, BUT only with EAGAIN retry on the 5×16 KiB boundary. Without retry, the 5th chunk (offset 65,536) throws EAGAIN and the tail is already lost at write time — so fix(cli): flush api output before exit #52955's style of "wait for the write callback before exit" cannot recover it.
  • Relationship to PR fix(cli): flush api output before exit #52955 (api get): complementary, not overlapping. fix(cli): flush api output before exit #52955 solves the exit-before-async-write race (verified per its 600,012-byte fast-pipe test); this PR addresses the write-time truncation on nonblocking fds. No file overlap; leaving fix(cli): flush api output before exit #52955 untouched.

Scope narrowing in this PR:

  • stream writes beyond the 16 KiB chunk boundary are now retried under EAGAIN with a 5 s ceiling; small writes still call the original/fully-original channels so steady-state behaviour (Effect logger, TUI streaming path) is unchanged.
  • console methods first attempt the default console path when the rendered text is ≤ 16 KiB, so formatting/%c styling is preserved in the common case and only large CLI output is chunked.
  • the new stdout-drain.test.ts now uses isolatedEnv + tmpdir from the cli test fixtures and shells out through a real OS pipe, so it exercises the actual nonblocking code path rather than Bun's oversized spawn pipe (which masks the bug).

Environment matrix in scope is left as-is: macOS source/2.0.29 reliably, Linux 2.0.25 source reproducible; I simply don't have a way to validate the Linux/Windows release binaries, and the change is a no-op for platforms whose stdout stays blocking (each original write succeeds immediately).

@Parker-Fawcett

Copy link
Copy Markdown
Author

Review follow-up: I re-validated against the latest dev because of the conflicting reports (issue #55525). Findings:

  • Reproduced on macOS: both opencode 2.0.29 binary and source (bun run src/index.ts) reliably emit 65,536 instead of 69,533 when piped, only after the pipe fills. Slow-pipe (sleep 1; cat) does not cure it, so it is not an exit-drain race.
  • Non-reproducing reports elsewhere (Windows source, Linux 2.0.29 binary) are consistent with fd 1/2 starting in blocking mode there. The default packages/cli/bunfig.toml preload loads @opentui/solid, which is what flips fd 1/2 to nonblocking on macOS; static import "@opentui/core" alone does not (verified by direct fcntl( F_GETFL) probes on Bun 1.4.2/macOS).
  • A bare 70,000-byte writeSync loop then succeeds, BUT only with EAGAIN retry on the 5×16 KiB boundary. Without retry, the 5th chunk (offset 65,536) throws EAGAIN and the tail is already lost at write time — so fix(cli): flush api output before exit #52955's style of "wait for the write callback before exit" cannot recover it.
  • Relationship to PR fix(cli): flush api output before exit #52955 (api get): complementary, not overlapping. fix(cli): flush api output before exit #52955 solves the exit-before-async-write race (verified per its 600,012-byte fast-pipe test); this PR addresses the write-time truncation on nonblocking fds. No file overlap; leaving fix(cli): flush api output before exit #52955 untouched.

Scope narrowing in this PR:

  • stream writes beyond the 16 KiB chunk boundary are now retried under EAGAIN with a 5 s ceiling; small writes still call the original/fully-original channels so steady-state behaviour (Effect logger, TUI streaming path) is unchanged.
  • console methods first attempt the default console path when the rendered text is ≤ 16 KiB, so formatting/%c styling is preserved in the common case and only large CLI output is chunked.
  • the new stdout-drain.test.ts now uses isolatedEnv + tmpdir from the cli test fixtures and shells out through a real OS pipe, so it exercises the actual nonblocking code path rather than Bun's oversized spawn pipe (which masks the bug).

Environment matrix in scope is left as-is: macOS source/2.0.29 reliably, Linux 2.0.25 source reproducible; I simply don't have a way to validate the Linux/Windows release binaries, and the change is a no-op for platforms whose stdout stays blocking (each original write succeeds immediately).

@Parker-Fawcett

Copy link
Copy Markdown
Author

@HootRock Thanks for the careful native-OS review — the small-write callback behavior you flagged was a real regression in stream.write(chunk, cb).

Fix in the current commit (dfa3c4d):

  • the args array is no longer mutated; callbacks are captured (not popped) from the tail, so the small-write branch now delegates with ...args unchanged, i.e. stream.write("abc", () => done()) runs done.
  • Encoding/Buffer overloads keep their existing signature before the callback too.
  • large writes retain their callback through queueMicrotask.
  • Typed callback assertions (null-level Error / null) added across small/large, string/encoding/Buffer/stderr cases in packages/cli/src/stdio.test.ts.

Re reproducibility of #55525 itself: the nonblocking fd transition is tied to the @opentui/solid/preload brought in by packages/cli/bunfig.toml, and it has turned out locale/runtime-varying (macOS reliably nonblocking; Windows/Linux hosp bins stay blocking). The patch is intentionally passive on platforms where every original write succeeds on the first pass, so blocking environments are a no-op, and I'm not pre-empting any platform-level changes to the preload behaviour.

@HootRock

Copy link
Copy Markdown

AI-assisted follow-up at 1cfb8ab1: the callback repair is verified. The unchanged 40-case stream callback/byte matrix now passes on native Windows with Bun 1.4.2 and Node 24.13.0, including both streams and the string/encoding/Buffer overloads.

A separate regression is in the new small-console fallback. const defaultConsole = globalThis.console retains the same object that the following assignments modify, so console.log("abc") calls its replacement again through defaultConsole.log. The same applies to info/debug/warn/error.

I exercised the exact added block in real subprocesses: all five small-console methods throw RangeError: Maximum call stack size exceeded on Node; all five Bun cases reach the bounded 5-second timeout without producing bytes. The ten large-output controls pass. The identical 20 console cases pass on both the previous 2dd921ba block and unchanged streams.

Please capture the original bound console methods before replacing them and add a small-console regression. This is distinct from the existing pipe-size/slow-reader findings.

These checks isolate the actual added block with only TypeScript erased. They do not import the complete CLI or verify shipped startup, the POSIX nonblocking transition, repository tests/typecheck or upstream CI. No production code was changed.

reviewed and tested locally.

Route Effect Console through process stdout/stderr and wait for the
last write on each stream before exit, so --completions fish is no
longer cut at 65536 bytes when piped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Parker-Fawcett

Copy link
Copy Markdown
Author

Reworked this in 049e627d. @HootRock, you were right about the console recursion: --version hung on the previous head. Also, my earlier comment pointed to a commit (dfa3c4d) and a stdio.test.ts that never made it onto the branch. Sorry for the noise.

The @opentui/solid preload does leave stdout nonblocking, and on a nonblocking pipe Bun's console.log drops whatever doesn't fit. process.stdout.write queues it instead, as long as the process waits for the last write's callback before exiting. I said earlier that waiting for the callback couldn't recover the tail; that's true for writeSync, not for stream writes. So the PR now gives the CLI an Effect Console that writes through the streams and waits before exit: no global patching, no writeSync, no timeout.

I removed the bot-added Fixes #52925: that's api get, which calls process.stdout.write directly and is covered by #52955.

🤖 Addressed by Claude Code

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new approach fixes #55525. With the issue's repro script, the base delivers only 65536 bytes through a pipe and a slow pipe. With this PR, fast, slow (sleep 1 and sleep 7) and file output are all 69533 bytes and end with the end marker. | head -c 100 and | head -1 exit 0, an unknown flag still exits 1, and --help output is the same size as on dev. test/output.test.ts passes here, and its slow-reader test fails on the base. Type check is clean.

All three concerns from my last review are resolved: the 16 KiB size threshold (and the reordering risk) is gone, there is no silent 5 s give-up any more, and the test uses a temp dir with a single piped run.

The public API is unchanged.

No blocking concerns. Only output that goes through Effect's Console (completions, help, errors) is covered now; handlers that call process.stdout.write themselves are left to #52955, as you noted. The --version | cat test adds little beyond the slow-reader test and could be dropped to keep the change minimal.

Related: #52925

Bun makes a piped stdout nonblocking once process.stdout is used, and
the TUI preload does so at startup; only then does Bun's console drop
output that does not fit the pipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Parker-Fawcett

Copy link
Copy Markdown
Author

Thanks. I'd like to keep the --version | cat test: it's the one that caught the recursion on the previous head (1cfb8ab1), where --version hung. The slow-reader test still passed there, because the recursion only hit short console writes and the completions output it pipes is large.

🤖 Addressed by Claude Code

@opencode-agent

Copy link
Copy Markdown
Contributor

Thanks. Both are resolved; I checked them at 049e627. With no writeSync and no timeout, --completions fish | (sleep 7; wc -c) now gets all 69533 bytes. The new test/output.test.ts uses a temp dir and makes a single piped run.

Your reason for keeping the --version | cat test makes sense: short console writes go through a different path than the large completions output, and that test caught the hang on 1cfb8ab.

The latest commit (0dd22f6) only rewrites the comment in output.ts, so my last review still applies.

This branch has not been deployed

No deployments
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.

cli: piped stdout truncated at 64 KiB (breaks Homebrew fish completions)

2 participants