Skip to content

fix: prevent stdout truncation when piped (#20)#21

Merged
stephendolan merged 5 commits into
mainfrom
fix/stdout-truncation-on-pipe
May 21, 2026
Merged

fix: prevent stdout truncation when piped (#20)#21
stephendolan merged 5 commits into
mainfrom
fix/stdout-truncation-on-pipe

Conversation

@stephendolan
Copy link
Copy Markdown
Owner

@stephendolan stephendolan commented May 21, 2026

Summary

Closes #20.

of was truncating its JSON output to exactly 512 bytes whenever stdout was a pipe (e.g. of inbox list | cat, ssh host 'of perspective view Forecast'). Downstream consumers like MCP servers wrapping of over SSH received valid-but-truncated JSON and threw parse errors mid-object.

Root cause is the well-known Node.js footgun: writes to a pipe are asynchronous on macOS, so calling process.exit(code) terminates the process before the queued pipe buffer drains. There were two process.exit(1) call sites — one in handleError (every error response) and one in the top-level parseAsync().catch (parse failures).

Fix replaces both with process.exitCode = code and lets the event loop drain naturally. This is the documented Node.js solution.

Verification

Repro from the issue, after the fix:

node dist/cli.js inbox list | wc -c            # 10385
node dist/cli.js inbox list | cat | wc -c      # 10385  (was 512)
node dist/cli.js inbox list | tee /tmp/x.json | wc -c  # 10385  (was 512)

Error path still returns exit code 1:

node dist/cli.js project view nonexistent | cat  # PIPESTATUS[0] = 1

Tests

Added src/lib/__tests__/errors.test.ts covering every handleError branch (OmniFocusCliError, "not found" → 404, "Multiple" → 400, plain Error, non-Error values) plus a direct regression test that pipes the JSON body and parses it end-to-end.

The tests spawn handleError in a child process: setting process.exitCode inside the test runner itself makes bun's native test runner fail the suite. The child-process approach also gives a faithful end-to-end check — it actually pipes stdout the way SSH and MCP wrappers do.

Test plan

  • bun run typecheck passes
  • bun run lint passes
  • bun test passes (21/21)
  • bun run test (vitest) passes
  • bun run build succeeds
  • Manual repro of all three pipe scenarios from the issue
  • Manual verification that error exit code 1 still propagates through pipes
  • CI green

stephendolan and others added 5 commits May 21, 2026 09:30
Calling process.exit() terminates the Node.js process before the
asynchronous stdout pipe buffer drains, truncating output at ~512 bytes
when piped through cat, tee, ssh, or any other pipeline. Setting
process.exitCode and letting the event loop drain naturally is the
documented Node.js solution.

Affected every command's JSON output and every error response when run
over SSH or in a shell pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bun's native test runner (used in CI via `bun test`) reads
process.exitCode at suite end to decide whether the run succeeded.
The handleError tests deliberately set process.exitCode = 1, so any
test that didn't clean up properly would fail the whole CI run even
though every assertion passed.

Always reset process.exitCode to undefined in afterEach instead of
trying to restore a captured value, which could itself be stale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vitest's afterEach hook didn't reliably reset process.exitCode under
bun's native test runner on ubuntu CI — the suite would pass every
assertion but bun would still report exit 1 because handleError had
set the global exitCode and the hook either ran too late or not at all.

Move the save/spy/restore lifecycle into a synchronous try/finally
inside a helper so the cleanup runs as part of the test body, with no
dependency on the runner's lifecycle hooks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bun's native test runner snapshots any process.exitCode mutation
during a test and uses it as the suite's final exit code, even when
the test restores it before returning. handleError deliberately sets
process.exitCode = 1, so in-process testing was breaking CI.

Move the test to a child process. Side benefit: this is a faithful
end-to-end test of issue #20 — it pipes the child's stdout, parses
the full JSON body, and asserts the exit code, mirroring exactly how
downstream consumers like SSH/MCP wrappers consume the CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures two lessons from fixing #20:

- process.exit() truncates piped output at ~512 bytes; use exitCode.
- bun's native test runner (used in CI) treats any process.exitCode
  mutation during a test as the suite's exit code, even when reverted.
  This differs from vitest, which is what `bun run test` uses locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stephendolan stephendolan merged commit 4516e90 into main May 21, 2026
1 check passed
@stephendolan stephendolan deleted the fix/stdout-truncation-on-pipe branch May 21, 2026 13:47
@stephendolan stephendolan mentioned this pull request May 21, 2026
2 tasks
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.

Stdout truncated to 512 bytes when piped (e.g. via SSH)

1 participant