fix: prevent stdout truncation when piped (#20)#21
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #20.
ofwas 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 wrappingofover 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 twoprocess.exit(1)call sites — one inhandleError(every error response) and one in the top-levelparseAsync().catch(parse failures).Fix replaces both with
process.exitCode = codeand lets the event loop drain naturally. This is the documented Node.js solution.Verification
Repro from the issue, after the fix:
Error path still returns exit code 1:
Tests
Added
src/lib/__tests__/errors.test.tscovering everyhandleErrorbranch (OmniFocusCliError, "not found" → 404, "Multiple" → 400, plainError, non-Errorvalues) plus a direct regression test that pipes the JSON body and parses it end-to-end.The tests spawn
handleErrorin a child process: settingprocess.exitCodeinside 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 typecheckpassesbun run lintpassesbun testpasses (21/21)bun run test(vitest) passesbun run buildsucceeds