Skip to content

fix(cli): flush stdout before exiting on a signal (OX-L8) - #58

Merged
ojassug merged 1 commit into
mainfrom
fix/ox-l8-stdout-flush
Sep 6, 2026
Merged

fix(cli): flush stdout before exiting on a signal (OX-L8)#58
ojassug merged 1 commit into
mainfrom
fix/ox-l8-stdout-flush

Conversation

@ojassug

@ojassug ojassug commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes OX-L8, the last open oxaudit item that was a defect rather than a decision. Lifted from the audit/lane-a2 worktree — then rewritten, because the fix it carried does not work.

The item, and why it was left open

process.exit() discards whatever a stream still holds, so tokendamper mcp exiting the instant stop() returned lost any JSON-RPC frame still buffered.

It was recorded rather than fixed for a stated reason: "delivering SIGINT to exercise that is not something the suite can do here", so a change would ship unverified — and the fix contemplated at the time, dropping the forced exit to let the loop drain, risked tokendamper mcp hanging on Ctrl+C instead. Both halves are answered here.

Measured first, because the platform decides whether this bug exists

Node's stdout is synchronous for pipes on Windows and Linux, asynchronous on macOS, and the buffer only overflows on a large frame. A child writing 1 MB and exiting the way this handler does:

Platform Delivered
Windows 1,000,046 / 1,000,046 — complete
Linux 146,176 / 1,000,046 — truncated

Then end to end through the real product on Linux — a 900 kB MCP response with SIGINT delivered mid-stream:

Bytes received Final frame
Without fix 365,696 did not parse
With fix 900,819 complete

lane-a2's fix does not work

The other worktree's change was await server.stop(). stop() is (): void, so the await yields one microtask — and a microtask does not run the I/O loop that drains a pipe. Measured: 146,176 / 1,000,046, byte for byte identical to no fix at all.

Porting it as-is would have shipped something that reads as a fix in the changelog and changes nothing. That is the failure mode this review keeps finding, so it is recorded rather than repeated.

What actually works

Ask the stream. An empty write queues behind everything already pending, so its callback is the stream reporting that its bytes reached the OS.

The 2 s cap answers the hang objection: the exit is still forced, merely deferred, so a consumer that never reads cannot wedge shutdown. unref keeps the timer from holding the loop open.

The test

test/unit/cli/sigint-flush.test.ts pins the ordering deterministically — it holds the write callback rather than waiting on a clock, so it cannot pass by timing accident. Three cases: the flush defers the exit, the cap fires when no flush is reported, and the exit happens exactly once when both occur.

All three fail against the unfixed handler, verified by reverting main.ts and re-running (expected "spy" to not be called at all, but actually been called 1 times). A green from a test that cannot detect the defect would have been worthless here.

Checks

99 test files / 918 tests pass, 2 skipped (POSIX-only). Typecheck and lint clean. CLI-only change, off the optimize route; no corpus run applies.

🤖 Generated with Claude Code

The last open oxaudit item that was a defect rather than a decision. Lifted
from the audit/lane-a2 worktree, then rewritten - because the fix it carried
does not work.

OX-L8 was recorded rather than fixed, for a stated reason: SIGINT could not be
exercised from the suite, so any change would ship unverified, and the fix
contemplated at the time - dropping the forced exit and letting the loop drain
- risked tokendamper mcp hanging on Ctrl+C instead. Both halves are answered
here.

Measured first, because the platform decides whether the bug exists. Node's
stdout is synchronous for pipes on Windows and Linux, asynchronous on macOS,
and the buffer only overflows on a large frame. A child writing 1 MB and
exiting the way this handler does: Windows 1000046/1000046 complete, Linux
146176/1000046 truncated. Then end to end through the real product on Linux - a
900 kB MCP response with SIGINT delivered mid-stream - 365696 bytes arrived and
the final frame did not parse, against 900819 bytes and a whole frame with the
fix.

lane-a2's fix was `await server.stop()`. It does not work: stop() is (): void,
so the await yields one microtask, and a microtask does not run the I/O loop
that drains a pipe. Measured at 146176/1000046 - byte for byte identical to no
fix at all. Porting it as-is would have shipped something that reads as a fix
in the changelog and changes nothing, which is the failure mode this review
keeps finding.

What works is asking the stream: an empty write queues behind everything
pending, so its callback is the stream reporting its bytes reached the OS. The
2 s cap answers the hang objection - the exit is still forced, merely deferred,
so a consumer that never reads cannot wedge shutdown.

test/unit/cli/sigint-flush.test.ts pins the ordering deterministically by
holding the write callback rather than waiting on a clock. All three cases fail
against the unfixed handler, verified by reverting main.ts and re-running - a
green from a test that cannot detect the defect would have been worthless here.

99 files / 918 tests pass, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ojassug
ojassug merged commit fafb9d9 into main Sep 6, 2026
3 checks passed
@ojassug
ojassug deleted the fix/ox-l8-stdout-flush branch September 6, 2026 17:22
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