Skip to content

fix(opencode): await stdout drain so piped output is not truncated - #39577

Draft
jornado wants to merge 1 commit into
anomalyco:devfrom
jornado:fix/stdout-drain
Draft

fix(opencode): await stdout drain so piped output is not truncated#39577
jornado wants to merge 1 commit into
anomalyco:devfrom
jornado:fix/stdout-drain

Conversation

@jornado

@jornado jornado commented Jul 29, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #29330

Type of change

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

What does this PR do?

opencode db, session list, and export silently lose output past 64 KiB when piped. Exit code stays 0, so it looks like it worked.

process.stdout.write() returns false once the pipe buffer is full. The rest is queued on the stream and thrown away when the process exits. console.log does the same thing and gives you nothing to await. That is why #3049 didn't fix it: it moved export to process.stdout.write but never waited for the write to be accepted.

This adds a small writeStdout() that waits for drain when the write is refused, and routes the three commands through it. EPIPE resolves instead of waiting for a drain that will never come, so | head still works.

db also renders TSV in 64 KiB chunks now. Building the whole table as one string first was a second copy of a result set db.all() had already materialized.

Worth noting the accepted explanation on #29330 is that process.exit() in src/index.ts fires before stdout drains. I don't think that's it: removing the process.exit() and waiting 2s still truncated at exactly 65536. Backpressure is the part that matters, so I left process.exit() alone.

How did you verify your code works?

Repro needs no existing sessions:

Q="with recursive t(n) as (select 1 union all select n+1 from t where n < 5000)
   select n as n, '0123456789012345678901234567890123456789' as pad from t"

bun dev db --format json "$Q" | wc -c    # before: 65536   after: 388896
bun dev db --format json "$Q" > f; wc -c f   # 388896 both

Also checked, before -> after:

  • tsv, one 200 KB row, piped: 65541 -> 200006
  • tsv, 5000 rows through a slow reader: 65536 -> 228899
  • 19 MB result: piped output now byte-identical to a file redirect
  • | head -c 20 returns 20 bytes and doesn't hang
  • small results, empty results, db path, exit codes (0 ok / 1 bad SQL) unchanged
  • 400k rows: 1.0s / 520 MB vs 1.2-1.3s / 423-477 MB on dev. Faster, ~10% more peak RSS, db.all() dominates either way.

bun typecheck clean. bun test in packages/opencode has the same 7 failures before and after (5s timeouts in workspace/snapshot/httpapi/revert tests). New test/util/stdout.test.ts covers the large-payload, EPIPE, and short-payload cases, plus a control asserting an unawaited write still truncates so the test can't quietly stop testing anything.

Screenshots / recordings

Not a UI change.

Checklist

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

process.stdout.write() returns false once the pipe buffer is full, and the
queued remainder is discarded when the process exits. Commands that print
large results lost everything past 64 KiB while still exiting 0.

console.log has the same effect and cannot be awaited, so db and session
list move to a shared writeStdout() helper that waits for drain. export
already used process.stdout.write but never awaited it.

db renders TSV in 64 KiB chunks instead of joining the whole table, so the
rendered text is not a second copy of an already materialized result set.
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.

opencode export <id> | jq produces truncated / invalid JSON when piped (large sessions)

1 participant