Skip to content

fix(runtime): process.stdout/stderr.write must invoke its completion callback — pi print-mode exit code (#6672) - #6691

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/6672-async-catch-mutation
Jul 19, 2026
Merged

fix(runtime): process.stdout/stderr.write must invoke its completion callback — pi print-mode exit code (#6672)#6691
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/6672-async-catch-mutation

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Which mechanism (a/b/c) was real

None of the three the issue hypothesized. The issue framed this as an async
catch's mutation of a shared output object not being visible at state-commit
(a: aliasing, b: threading/continuation-context, c: commit ordering). A runtime
trace disproves all three: with probes on the pi bundle, output carries
stopReason === "error" at every stage — the catch, the stream consumer,
the state-store reducer (message_endstate.messages.push), and
runPrintMode's read of session.state.messages[last]. The mutation is fully
visible. The assistantMsg.stopReason === "error" branch runs, exitCode = 1,
and runPrintMode reaches return exitCode.

The divergence is downstream of all of that: after return exitCode, the
caller's continuation (const exitCode = await runPrintMode(...); if (exitCode !== 0) process.exitCode = exitCode) never runs — so the process exits with
process.exitCode still 0.

Root cause

process.stdout.write(chunk[, encoding][, callback]) (and stderr) never
invoked the optional completion callback.
Perry's write stubs took a single
argument and dropped the callback entirely.

pi's flushRawStdout awaits exactly that callback:

await new Promise((resolve, reject) => {
  getRawStdoutWrite()(text, (error) => { if (error) reject(error); else resolve(); });
});

With the callback dropped, that promise never resolves. It sits inside
runPrintMode's finally, so runPrintMode's returned promise never settles,
the awaiting caller never resumes, the event loop drains empty, and natural exit
reads process.exitCode === 0 where Node exits 1. (#6666's natural-exit
process.exitCode consultation is a necessary prerequisite — the value it would
consult was simply never set.)

Minimal reproduction (identical to Node except rc):

async function main() {
  await new Promise<void>((resolve) => { process.stdout.write("", () => resolve()); });
  console.error("REACHED continuation");   // never printed under the bug
  process.exitCode = 1;                     // never runs -> rc 0 not 1
}
main();

Fix

crates/perry-runtime/src/os_process_streams.rs: the stdout/stderr/stdin write
stubs now take (chunk, arg2, arg3) and register ABI arity 3 (Direct dispatch
would otherwise size the call to the call site and drop the trailing callback).
After writing, the trailing function argument — write(chunk, cb) or
write(chunk, encoding, cb) — is scheduled on the next tick (matching
Node's async, no-error-on-success completion contract) via js_queue_next_tick.

Tests

Two byte-identical-to-node fixtures under test-files/:

  • test_gap_6672_stream_write_callback.ts — awaits the callback across stdout,
    stderr, the 3-arg overload, a return-through-awaiting-finally, async ordering
    (never synchronous), and a plain no-callback write.
  • test_gap_6672_exit_code_after_stream_flush.ts — the pi print-mode shape
    reduced to essence (process.exitCode set by a continuation that only runs
    after an awaited stdout flush; Node exits 1), with a stored expected-output +
    expected-exit=1.

cargo test -p perry-runtime --lib -- --test-threads=1: 1426 passed, 0
failed
. cargo fmt clean on the touched file. (An unrelated pre-existing
codegen test, executable_exit_releases_collection_side_allocations_last, fails
identically on the base commit and current main; this change does not touch
perry-codegen.)

GATE 2a — full pass

Fresh pi bundle recompiled with the fix and run against the intended bogus-key
401:

HOME=$(mktemp -d) ./pi-native -p --provider anthropic --model claude-3-5-haiku-latest --api-key sk-ant-bogus-diff "hi"
node v26.3.0 perry (this fix)
stdout (empty) identical
stderr (req_id masked) model-warning + deprecation + 401 …authentication_error… byte-identical
exit code 1 1

The fix flips perry's exit code 0 → 1, completing GATE 2a rc parity.

Fixes #6672

https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9

Summary by CodeRabbit

  • Bug Fixes

    • Restored Node-style completion callback support for process.stdout.write and process.stderr.write, including overloads with encoding and ensuring callbacks are dispatched after the write.
    • Prevented awaited writes from hanging and fixed exit-code behavior after stdout flushing.
    • process.stdin.write now also safely accepts an optional completion callback.
  • Tests

    • Added new parity and regression coverage for callback timing, overload handling, flush/exit-code propagation, and expected output.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8091401e-9c24-43c1-a3a3-ade50891c10f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Process stream write stubs now accept optional trailing callbacks, schedule callable callbacks asynchronously, and preserve callback arguments through dispatch. New tests validate stdout/stderr overloads, callback timing, async continuations, and exit-code parity.

Changes

Stream write callback support

Layer / File(s) Summary
Runtime callback plumbing
crates/perry-runtime/src/os_process_streams.rs
Stdout, stderr, and stdin write stubs accept three arguments, schedule trailing callable callbacks on the next tick, and register matching dispatch arity.
Callback and exit-code regression coverage
test-files/test_gap_6672_*.ts, test-parity/expected*/test_gap_6672_exit_code_after_stream_flush.txt
Tests cover callback overloads, asynchronous ordering, try/finally continuation, callback-free writes, and the expected exit code and output.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller as process.stdout.write
  participant Stub as process_stdout_write_stub
  participant Queue as js_queue_next_tick
  Caller->>Stub: pass chunk and optional callback
  Stub->>Stub: write and flush stdout
  Stub->>Queue: schedule callback
  Queue-->>Caller: invoke callback asynchronously
Loading

Possibly related issues

  • Issue 6666 — Both changes address process.exitCode behavior in an asynchronous stream-flush continuation, though Issue 6666 focuses on natural-exit handling rather than callback propagation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: stream write callbacks now run, restoring pi exit-code behavior.
Description check ✅ Passed The description covers the summary, root cause, fix, issue reference, and testing, even if it doesn't use the exact template headings.
Linked Issues check ✅ Passed The code now invokes stdout/stderr write callbacks and restores continuation and exit-code behavior required by #6672.
Out of Scope Changes check ✅ Passed The added tests and fixtures directly support the callback and exit-code fix, with no obvious unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 2 commits July 19, 2026 16:24
…back (PerryTS#6672)

Node's stream.write(chunk[, encoding][, callback]) calls the optional
trailing callback once the chunk is handled. Perry's write stubs took a
single arg and ignored the callback, so
  await new Promise(r => process.stdout.write(x, r))
never resolved: the promise hung, its awaiter never resumed, and the event
loop drained and exited with the continuation (and any process.exitCode it
would set) left unrun.

That is the pi print-mode exit-code divergence: pi's flushRawStdout awaits
this callback, so on a request error the natural-exit code stayed 0 where
Node exits 1 (GATE 2a rc divergence).

The write stub now takes (chunk, arg2, arg3), registers arity 3 so dispatch
pads/truncates to those three (Direct dispatch would otherwise size the call
to the call site and drop the trailing callback), and schedules the trailing
function arg on the next tick — matching Node's async, no-error-on-success
completion contract.

Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9
…after-flush parity

Two byte-identical-to-node fixtures for the process.stdout/stderr.write
completion-callback fix:
- test_gap_6672_stream_write_callback: awaits the callback across stdout,
  stderr, the 3-arg write(chunk, encoding, cb) overload, a return-through-
  awaiting-finally, async ordering (never sync), and a plain no-callback
  write. Under the bug the awaited continuations never run and the transcript
  diverges from node.
- test_gap_6672_exit_code_after_stream_flush: the pi print-mode shape reduced
  to essence — process.exitCode set by a continuation that runs only after an
  awaited stdout flush; node exits 1, and the stored expected-output +
  expected-exit=1 assert Perry now matches.

Claude-Session: https://claude.ai/code/session_01JuiiePQfrXhAFD9fuCygB9
@proggeramlug
proggeramlug force-pushed the fix/6672-async-catch-mutation branch from dfd357d to b4beb84 Compare July 19, 2026 14:24
@proggeramlug

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@proggeramlug
proggeramlug merged commit a323f6d into PerryTS:main Jul 19, 2026
2 checks passed
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.

runtime: async catch's mutation of a shared object not visible at state-commit → pi one-shot exits 0 not 1 (GATE 2a rc divergence) — pi wall #10

1 participant