Skip to content

stdio: surface EPIPE from console.log/process.stdout.write as 'error' on process.stdout - #35064

Closed
robobun wants to merge 9 commits into
mainfrom
farm/c19b7920/stdio-epipe-error-event
Closed

robobun wants to merge 9 commits into
mainfrom
farm/c19b7920/stdio-epipe-error-event

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Once stdout's reader is gone (piped to a consumer that exited), console.log and process.stdout.write disagreed on whether the program failed, and neither matched Node.

Repro

// epipe.mjs  (WHICH=log bun epipe.mjs / WHICH=write bun epipe.mjs)
import { spawn } from "node:child_process";
if (process.env.__CHILD) {
  process.stdout.on("error", e => process.stderr.write("[stdout error " + e.code + "]\n"));
  process.on("exit", c => process.stderr.write("[exit " + c + "]\n"));
  const wait = ms => new Promise(r => setTimeout(r, ms));
  await wait(300);
  for (let i = 0; i < 5; i++) {
    if (process.env.WHICH === "write") process.stdout.write("x" + i + "\n");
    else console.log("x" + i);
    await wait(30);
  }
} else {
  const c = spawn(process.execPath, [import.meta.filename],
    { env: { ...process.env, __CHILD: "1" }, stdio: ["ignore", "pipe", "inherit"] });
  c.stdout.destroy();
  c.on("close", code => console.error("--- child exit=" + code + " ---"));
}
node v26.3.0 bun main bun this PR
WHICH=log [stdout error EPIPE] ×5, exit 0 listener never called, exit 0 [stdout error EPIPE] ×5, exit 0
WHICH=write [stdout error EPIPE] ×5, exit 0 [stdout error EPIPE] ×1, exit 0 [stdout error EPIPE] ×5, exit 0

Cause

console.log writes natively to fd 1 via SysQuietWriterAdapter (src/sys/lib.rs), whose write_all/flush always return Ok(()) and drop the errno. process.stdout never hears about it.

process.stdout.write on a pipe is a fs.WriteStream with autoClose:false, which leaves _writableState.autoDestroy at false. writeFast's rejection handler calls errorOrDestroy, which with autoDestroy:false goes straight to emitErrorNT; that latches on errorEmitted after the first emit, so writes 2..N never surface. Node's pipe stdio is a net.Socket (autoDestroy:true) whose _destroy is overridden to _undestroy(), so each failed write goes errorOrDestroy -> destroy -> _undestroy -> emit 'error' and the stream stays writable.

Fix

  • ProcessObjectInternals.ts: set autoDestroy:true on non-TTY stdio write streams so errorOrDestroy takes the destroy path. In the _destroy override, preserve errorEmitted across _undestroy() when no 'error' listener is attached so a write loop on a dead pipe emits at most one uncaught (Node terminates on the first; Bun doesn't, so the latch lives here).
  • SysQuietWriterAdapter: record the first write errno in a sticky field; expose via QuietWriterAdapter::take_err().
  • ConsoleObject.rs / BunProcess.cpp: after each console.* call, if an errno was recorded (and isn't EAGAIN/EINTR), hand it to process.stdout/stderr via stream.destroy(err) so 'error' fires on nextTick. When no 'error' listener is attached the failure is dropped rather than becoming an uncaught exception, matching Node's createWriteErrorHandler and test-process-external-stdio-close.

Error shape matches Node:

{"code":"EPIPE","syscall":"write","errno":-32}

Verification

New tests in test/js/node/process/process-stdio.test.ts cover all four listener × write-API combinations plus the #7251 | head loop with a listener that exits. All fail on main (except the console.log-without-listener regression guard, which matches main's silent behavior) and pass here.

Still passing: test/regression/issue/1632.test.ts, test/js/web/console/, test/js/bun/console/, test/js/node/tty.test.ts, test/js/node/child_process/child-process-stdio.test.js, test/js/node/fs/fs.test.ts -t WriteStream, and node's test-process-external-stdio-close{,-spawn}, test-console-log-stdio-broken-dest, test-stdio-{closed,undestroy,pipe-access,pipe-stderr}, test-stdout-stderr-{reading,write}, test-console-{async,sync}-write-error, test-child-process-stdout-flush{,-exit}.

Supersedes #30635 (same surface for the console path, plus the autoDestroy fix for the write path).

Fixes #7251 for the with-listener case.

console.timeEnd is intentionally excluded: it writes through the process-wide Output source adapters rather than the ConsoleObject backings forward_write_error reads, and Node routes it to stdout (Bun routes it to stderr, a pre-existing divergence). The errno those adapters record is an inert i32 that nothing reads.
The setImmediate loop with no 'error' listener still hangs (pre-existing Bun uncaughtException semantics, unchanged by this PR), but no longer spams stderr.


no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/process/process-stdio.test.ts

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change records native stdio write errors, forwards them from console output to JavaScript streams, updates stream destruction behavior, and adds EPIPE coverage for piped stdout.

Stdio EPIPE propagation

Layer / File(s) Summary
Record and consume write errors
src/sys/lib.rs, src/bun_core/lib.rs, src/bun_core/output.rs
Quiet writer adapters record the first write errno. The output sink exposes and clears the recorded value.
Forward console write errors
src/jsc/ConsoleObject.rs, src/jsc/bindings/BunProcess.cpp
Console output filters transient errors and forwards other errors to the corresponding process stream.
Preserve stdio stream error state
src/js/builtins/ProcessObjectInternals.ts
Non-TTY streams enable autoDestroy. The destroy override preserves error state when no listener exists.
Validate piped stdio behavior
test/js/node/process/process-stdio.test.ts, test/js/node/process/process-stdout-write-after-end.test.ts
Tests cover EPIPE events, listener behavior, child-process exits, repeated console writes, and writable-state reset behavior.

Possibly related PRs

  • oven-sh/bun#36066: Both changes modify native stdio write handling, but this change propagates sticky errno values while that PR addresses EAGAIN retries.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses issue #7251 by emitting EPIPE on process.stdout when a broken pipe occurs and an error listener is attached.
Out of Scope Changes check ✅ Passed The code and test changes support the stated EPIPE objectives and related Node-compatible stdio behavior.
Title check ✅ Passed The title clearly summarizes the primary change: surfacing EPIPE errors from console.log and process.stdout.write on process.stdout.
Description check ✅ Passed The description explains the problem, implementation, behavior, error shape, tests, and verification results in sufficient detail.

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Aug 1st, 2026

❌ @robobun, your commit bcecace has 1 failures in Build #87508 (All Failures):

  • Failed to create agent for 🪟 x64 - build-cpp
  • UnsupportedThe requested configuration is currently not supported. Please check the documentation for supported configurations.51199a26-909b-4f32-bdf0-a453313dfad9
  • Failed to create agent for 🐧 x64-asan - build-cpp
  • UnsupportedThe requested configuration is currently not supported. Please check the documentation for supported configurations.8adf9fbc-0a54-4759-a662-8c00088488c8
  • Failed to create agent for 🐧 x64-musl - build-cpp
  • UnsupportedThe requested configuration is currently not supported. Please check the documentation for supported configurations.5138fb2c-f3c5-4c8e-a0ea-175230a32924
  • Failed to create agent for 🪟 2019 x64 - test-bun
  • Failed to create agent for 🪟 11 aarch64 - test-bun
  • Failed to create agent for 🪟 11 aarch64 - test-bun

🧪   To try this PR locally:

bunx bun-pr 35064

That installs a local version of the PR into your bun-35064 executable, so you can run:

bun-35064 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun eslint.config.mjs | more panixs #17381 - bun eslint.config.mjs | more panics with attempt to unwrap error: EPIPE in ConsoleObject — this PR makes SysQuietWriterAdapter record the errno and forward it as an 'error' event instead of panicking

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #17381

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Re #17381: that panic (attempt to unwrap error: EPIPE in ConsoleObject.zig:2126) was in v1.2.2's Zig formatter; the current Rust SysQuietWriterAdapter already swallows the errno rather than unwrapping it, so the crash itself was fixed by the rewrite. This PR goes the other direction: it takes the swallowed errno and surfaces it as an 'error' event on process.stdout. Leaving #17381 out of the closes list since I haven't verified the original repro on Windows.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. console: surface EPIPE from console.log on process.stdout #30635 - Fixes the same issue (process EPIPE error event not emitted #7251) with the same approach (surface EPIPE from console.log on process.stdout); this PR explicitly supersedes it

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nits, I also checked: the drain_to_fd refactor's &mut self + slice-of-self.buf shape — buf is a raw *mut u8 so the slice isn't borrowed through &self and drain_to_fd only touches self.fd/self.err, no aliasing; and the new C++ DECLARE_TOP_EXCEPTION_SCOPE + tryClearException() sequence — it matches the existing pattern used throughout BunProcess.cpp for top-level event dispatch.

Extended reasoning...

This is a substantive cross-language Node-compat change (Rust writer plumbing, new C++ FFI entry, built-in JS stream _destroy/errorEmitted semantics) — not a candidate for auto-approval. The two inline findings are nits; noting the two additional concerns I examined and ruled out so a later pass doesn't re-derive them.

Comment thread src/jsc/ConsoleObject.rs
Comment thread test/js/node/process/process-stdio.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier findings are addressed in 95d5463 and nothing new turned up this pass, but this is a cross-cutting Node-compat behavior change (native console write path → C++ → process.stdout.destroy(), plus flipping autoDestroy on non-TTY stdio and reworking _destroy/_undestroy latching) that changes observable stream state — worth a human look.

Checked this run and ruled out: onStdioWriteError eagerly materializing lazy process.stdout (only reached on a recorded write errno, not per-log); the new EPIPE tests awaiting only 'close' without wiring 'error' (child errors surface via stderr JSON + exit code, so a hang isn't possible); fd_write_all_quiet becoming dead after drain_to_fd (still used by quiet_writer_write_all).

Extended reasoning...

Overview

Eight files across four layers: SysQuietWriterAdapter in src/sys/lib.rs gains a sticky err: i32 field and drain_to_fd (replacing the discarded-result fd_write_all_quiet calls); bun_core::output exposes take_err(); ConsoleObject.rs adds forward_write_error() called after message_with_type_and_level, count, and timeLog; BunProcess.cpp adds Bun__ConsoleObject__onStdioWriteError which resolves process.stdout/stderr, checks listenerCount('error'), and calls .destroy(err); ProcessObjectInternals.ts flips _writableState.autoDestroy to true for all non-TTY stdio (previously only file-backed) and reworks _destroy to preserve errorEmitted across _undestroy() when no listener is attached. Tests cover the four listener×API combinations plus the #7251 loop, and process-stdout-write-after-end.test.ts was updated because autoDestroy:true changes post-end() writableEnded/writable state.

Security risks

None identified. No untrusted-input parsing, no auth/crypto/permission surface. The C++ path calls user-overridable listenerCount/destroy on process.stdout, but exceptions are caught and cleared under a top-level scope, and it only runs after a real write errno on fd 1/2.

Level of scrutiny

Medium-high. console.log is one of the hottest user-facing paths in the runtime; the added forward_write_error runs on every call (though it's a cheap take_err() → None → return in the common case, and the &mut on the console backing is scoped before any re-entrant FFI). More importantly, changing autoDestroy and the _destroy/_undestroy/errorEmitted interaction on process.stdout/stderr is a Node-compat semantics change with observable effects on stream state (writableEnded, writable) — the kind of thing that can ripple into userland stream libraries in ways the test matrix here may not cover. The updated process-stdout-write-after-end.test.ts expectations (now writableEnded: false, writable: true post-end) illustrate exactly that ripple.

Other factors

  • Both prior review findings (missing forward_write_error in count/timeLog; undrained stdout pipe in the #7251 test) were addressed in 95d5463 and verified in the current diff.
  • The PR description lists a broad set of still-passing Node parallel tests (test-process-external-stdio-close*, test-stdio-undestroy, test-console-*-write-error, etc.), which is the right regression surface.
  • The errorEmitted latch keyed on listenerCount('error') === 0 at _destroy time is a Bun-specific divergence from Node (justified in the comment by Bun not terminating on uncaught) — a maintainer should confirm that trade-off is acceptable.
  • New tests are skipIf(isWindows); Windows EPIPE semantics remain uncovered.

Comment thread src/bun_core/output.rs Outdated
Comment thread src/js/builtins/ProcessObjectInternals.ts Outdated
Comment thread src/js/builtins/ProcessObjectInternals.ts Outdated
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (was 262 commits behind) and fixed the cargo clippy failure: clippy::undocumented_unsafe_blocks wanted the // SAFETY: comment directly above the unsafe block rather than above the enclosing let expression.

Also switched the new EPIPE describe block from describe.concurrent to sequential; the existing process-stdio block already runs nine debug+ASAN children concurrently and five more pushed the timer-paced stdin tests past their 5s timeout.

Re-verified locally on 7b14cc3:

  • bug.js | head -n 10 from process EPIPE error event not emitted #7251: prints 10 lines, EPIPE emitted EPIPE, exits 0
  • 5/5 new EPIPE tests pass (3 fail on released bun, 2 are regression guards)
  • test/js/node/process/process-stdout-write-after-end.test.ts, test/js/node/tty.test.ts, test/regression/issue/1632.test.ts, test/js/node/fs/fs.test.ts -t WriteStream
  • node parallel: test-process-external-stdio-close{,-spawn}, test-console-log-stdio-broken-dest, test-stdio-{closed,undestroy,pipe-access,pipe-stderr}, test-stdout-stderr-{reading,write}, test-console-{async,sync}-write-error, test-child-process-stdout-flush{,-exit}

Closed #30635 as superseded.

robobun added 4 commits August 1, 2026 06:51
…ror' on process.stdout

When stdout's reader has gone away (piped to a consumer that exited),
console.log and process.stdout.write disagreed on whether the program
failed and neither matched Node:

- console.log wrote natively to fd 1 via a quiet writer that swallowed
  every errno, so an attached process.stdout 'error' listener was never
  called and the process exited 0 with all output lost.
- process.stdout.write went through errorOrDestroy with the pipe stream's
  autoDestroy left at false (autoClose:false), so emitErrorNT latched on
  errorEmitted after the first emit and later writes never surfaced an
  error.

Node's pipe stdio is a net.Socket (autoDestroy:true) whose _destroy is
overridden to _undestroy(), so each failed write goes errorOrDestroy ->
destroy -> _undestroy -> emit 'error' on nextTick and the stream stays
writable. Node's console.log writes through process.stdout.write(), so
the same path carries its failures.

Fix:

- Set autoDestroy:true on non-TTY stdio write streams so errorOrDestroy
  takes the destroy path, and preserve errorEmitted across _undestroy()
  when no 'error' listener is attached so a write loop on a dead pipe
  doesn't re-emit forever (Node terminates on the first uncaught; Bun
  doesn't, so the latch lives here instead).
- Record the native console writer's first write errno in the
  QuietWriterAdapter and, after each console.* call, forward it to
  process.stdout/stderr via stream.destroy(err) when an 'error' listener
  is attached. With no listener the failure is dropped (Node's
  createWriteErrorHandler swallows it; test-process-external-stdio-close).

Fixes #7251 for the with-listener case.
…count/timeLog write errors

- process-stdout-write-after-end.test.ts asserted writableEnded:true/
  writable:false for piped stdio after end(), but Node's pipe stdio
  (net.Socket, autoDestroy:true) runs finish -> destroy -> _undestroy,
  which resets those. The write-after-end contract itself (ret:false,
  ERR_STREAM_WRITE_AFTER_END, bytes not delivered) is enforced at
  write() time before that reset and is unchanged.
- Bun__ConsoleObject__count / timeLog write through the same adapter
  backings; forward their recorded errno too so console.count() on a
  dead pipe reaches a process.stdout 'error' listener and doesn't leave
  a sticky errno for the next console.log.
- Rewrote the new EPIPE tests to spawn one child directly instead of
  two nested ones so the debug-build concurrency load is lower.
…sts sequentially

- ConsoleObject.rs: move the SAFETY comment onto the line directly above
  the unsafe block so clippy::undocumented_unsafe_blocks accepts it (it
  was above the enclosing let-expression, which the lint doesn't follow).
- process-stdio.test.ts: drop describe.concurrent on the new EPIPE block.
  The existing process-stdio describe.concurrent already runs nine
  debug+ASAN children at once; five more in parallel pushed the
  timer-paced stdin tests past their 5s default timeout.
@robobun
robobun force-pushed the farm/c19b7920/stdio-epipe-error-event branch from 7b14cc3 to 268946d Compare August 1, 2026 07:05
Comment thread src/bun_core/output.rs
Comment thread src/js/builtins/ProcessObjectInternals.ts
Comment thread src/js/builtins/ProcessObjectInternals.ts
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread src/sys/lib.rs
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found this pass — the earlier feedback (console.count/timeLog wiring, undrained pipe in the test, lazy stdout reification via getDirect) is all addressed. Deferring to a human for the final call: this changes stdio stream semantics for every Bun program (moves autoDestroy:true to all non-TTY stdio, adds a Bun-specific errorEmitted latch across _undestroy()), inverts the expected writableEnded/writable values in an existing test, and adds a new C++→JS re-entry point on the console write path.

Checked: the sticky-errno flow (recorded in drain_to_fd, cleared unconditionally in forward_write_error before the pending-exception guard); getDirect probe skips lazy stream construction; DECLARE_TOP_EXCEPTION_SCOPE + tryClearException on every JS call in the new C++ hook; EAGAIN/EINTR filtered out so transient errnos don't emit 'error'.

Extended reasoning...

Overview

Wires EPIPE (and other write errnos) from Bun's native console.* fd writes back to process.stdout/stderr as 'error' events, and fixes process.stdout.write on a dead pipe to emit 'error' per write instead of latching after the first. Touches: SysQuietWriterAdapter (sticky errno field + drain_to_fd), the OutputSink link interface, ConsoleObject.rs (forward_write_error called from message_with_type_and_level, count, timeLog), a new C++ entry Bun__ConsoleObject__onStdioWriteError in BunProcess.cpp, and the getStdioWriteStream builtin (autoDestroy:true for all non-TTY stdio + an errorEmitted-preservation branch in _destroy).

Security risks

None identified. No untrusted-input parsing; the errno flows from Bun's own syscall wrapper.

Level of scrutiny

Medium-high. ProcessObjectInternals.ts runs for every Bun process that touches process.stdout/stderr; the autoDestroy change was previously scoped to file-backed stdio and now applies to pipes/sockets too, and process-stdout-write-after-end.test.ts had its expected writableEnded/writable values inverted to match. The _destroy override adds a deliberate Bun-vs-Node divergence (preserving errorEmitted when no listener is attached) justified by Bun not exiting on uncaughtException. The new C++ hook re-enters JS from inside a console.* host call and clears any exception it raises. These are all defensible per the PR body's Node comparison, but they're design decisions on a hot, user-visible path.

Other factors

All three of my earlier inline findings were addressed in follow-up commits (95d5463, 268946d, bd3f63b). The bug hunter's one candidate this run — a transient EINTR/EAGAIN masking a later real EPIPE within one console.* call — was ruled out: forward_write_error filters EAGAIN/EINTR after take_err() has already cleared the sticky slot, so the next write's EPIPE is recorded fresh. Test coverage looks solid (four listener×API combinations plus the #7251 loop, event-driven with no sleeps), and the PR body lists a broad set of node-parallel tests re-verified. CI build #86890 is still in progress.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/ConsoleObject.rs`:
- Around line 404-405: Remove the duplicated stream-selection predicate from the
outer forwarding logic and centralize the decision in the message routing flow.
Update message_with_type_and_level_ to return or expose the selected
destination, or extract a shared helper used by both callers, so the adapter
selection remains identical whenever routing changes.

In `@test/js/node/process/process-stdio.test.ts`:
- Around line 207-211: Update the report-parsing logic around lines and
JSON.parse to assert that the JSON report line exists before parsing, and assert
the expected child output while preserving stderr and exitCode in failures. Keep
the existing result and exit validation unchanged once the report is confirmed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fb60e2b6-2814-498b-a064-8852e678aecd

📥 Commits

Reviewing files that changed from the base of the PR and between 9067083 and 9ad63a3.

📒 Files selected for processing (8)
  • src/bun_core/lib.rs
  • src/bun_core/output.rs
  • src/js/builtins/ProcessObjectInternals.ts
  • src/jsc/ConsoleObject.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/sys/lib.rs
  • test/js/node/process/process-stdio.test.ts
  • test/js/node/process/process-stdout-write-after-end.test.ts

Comment thread src/jsc/ConsoleObject.rs Outdated
Comment thread test/js/node/process/process-stdio.test.ts Outdated
Comment thread src/sys/lib.rs
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/sys/lib.rs
Comment thread src/jsc/ConsoleObject.rs
Comment thread test/js/node/process/process-stdio.test.ts
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/ConsoleObject.rs
…eLog break not return; cover stderr in EPIPE tests
Comment thread src/jsc/bindings/BunProcess.cpp
@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on every lane that ran it.

Build 87508 (bcecace): 181 jobs passed, 14 still scheduled (agent queue backlog), 1 canceled. The only hard failure is the robobun agent provisioner (AWS EC2 400 "Unsupported configuration" for 3 build-cpp lanes). All 8 test failures are marked flaky (passed on retry) and unrelated to this change. process-stdio.test.ts and process-stdout-write-after-end.test.ts passed on every lane.

Build 87011 (27ad547, the last fully-completed build): only hard failure was test/cli/install/bun-upgrade.test.ts on Windows aarch64 (main break, also fails on main build 86911, reported for triage). test-worker-message-port-transfer-terminate.js SIGABRT on one ASAN lane in 86890 did not recur and does not reproduce in 15 local ASAN runs with validateExceptionChecks.

bcecace over 27ad547 only adds the hasProcessObject() guard, routes zero-arg console.error()'s newline to fd 2, changes return to break in timeLog, and adds stderr rows to the EPIPE test matrix. Ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further findings after bcecace — the four items from my last round (hasProcessObject guard, empty-Log stderr writer, timeLog break, stderr test coverage) are all addressed. Deferring to a human because this changes user-visible process.stdout/stderr stream semantics (autoDestroy flip, _destroy→_undestroy with errorEmitted latching) and inverts the writableEnded/writable expectations in an existing test.

What was reviewed:

  • forward_write_error exception handling: take_err() clears before the pending-exception check, and the C++ side uses a top exception scope with tryClearException on every call — no exception leaks back into console.*.
  • getDirect + hasProcessObject() guard: verified neither process nor the lazy stdout/stderr stream is reified when the user never touched them.
  • drain_to_fd sticky-errno: EAGAIN/EINTR skipped at record time; adapter_write_all's buffered-slice reconstruction matches the removed buffered() helper's invariants.
  • _destroy override: checked that errorEmitted is snapshotted before _undestroy() and only re-latched when no listener is attached, so the with-listener path still fires per write.
Extended reasoning...

Overview

The PR wires EPIPE (and other write errnos) from Bun's native console.* fd writes back to process.stdout/stderr as 'error' events, and fixes process.stdout.write's one-shot error latching so repeated writes to a dead pipe each surface an error. It spans four layers: SysQuietWriterAdapter gains a sticky errno slot (src/sys/lib.rs), bun_core::output exposes take_err(), ConsoleObject.rs calls forward_write_error after each console write, a new C++ entry point Bun__ConsoleObject__onStdioWriteError in BunProcess.cpp resolves the stream and calls .destroy(err), and ProcessObjectInternals.ts flips autoDestroy on non-TTY stdio and preserves errorEmitted across _undestroy() when unlistened.

Security risks

None identified. The new C++ path only reads own properties via getDirect and calls listenerCount/destroy on the process-owned stream object; it doesn't parse untrusted input or touch auth/crypto. Exception scopes are declared and cleared on every fallible call.

Level of scrutiny

High. This is Node-compat behavior for process.stdout/stderr — a hot, user-visible surface where subtle stream-state changes (autoDestroy, writableEnded, errorEmitted) ripple into every program that pipes output. The change also inverts assertions in an existing test (process-stdout-write-after-end.test.ts: writableEnded: true→false, writable: false→true), which per REVIEW.md warrants explicit human sign-off that the new values match Node rather than weakening a guard. The Rust↔C++↔JS re-entrancy (forward_write_error → destroy() → _destroy → _undestroy) is the kind of path where a maintainer should confirm the design.

Other factors

The PR has been through several review rounds; all four of my prior findings and the earlier CodeRabbit/robobun feedback are resolved in bcecace. The console.timeEnd sibling was intentionally excluded with a stated reason (it writes through Output::Source, not the ConsoleObject adapter), which satisfies the "say so in the PR" rule. Test coverage now spans stdout×stderr × console×write × with/without listener plus the #7251 loop; the PR description lists the Node parallel tests that still pass. Given the cross-layer scope and the existing-test expectation flip, this needs a human reviewer's approval rather than mine.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #37128 reworks the stdio sinks and lists this PR as superseded (a failed console.* write on the shared sink is surfaced on process.stdout/process.stderr, the stdio streams get autoDestroy plus node's _undestroy semantics, and it tests the same four console.log / process.stdout.write x listener / no listener combinations as this PR, one 'error' per failing call with syscall: 'write'). Adding #7251 to its fixes list is suggested over there.

If something here turns out not to be covered once #37128 lands, this can be reopened.

@robobun robobun closed this Aug 13, 2026
carsteneu added a commit to carsteneu/opencode that referenced this pull request Aug 22, 2026
…mpat

bun >=1.4 surfaces EPIPE from writes into a closed stdio/IPC channel as a
fatal error instead of swallowing it (oven-sh/bun#35064). Install a
process-wide broken-pipe guard in bootstrap (uncaughtException,
unhandledRejection, stdout/stderr 'error') so the TUI/server/worker
legitimately racing channel teardown keep running as they did on bun <=1.3;
genuine uncaught errors still terminate. Also harden the LLM worker
writer (write/flush/end) to treat EPIPE/EBADF as no-ops at the source.
carsteneu added a commit to carsteneu/opencode that referenced this pull request Aug 24, 2026
First release line built and verified with bun 1.4.0 (.157): TTFD ~25%
faster than 1.3.14 in interleaved Ghostty A/B, build time down. Requires
the EPIPE guard from b071364 (oven-sh/bun#35064 surfaces EPIPE from
writes into torn-down stdio channels as fatal errors). bun.lock
normalized by the 1.4 installer (pruned unused optional coverage/legacy
entries).
carsteneu added a commit to carsteneu/opencode that referenced this pull request Aug 24, 2026
bun >=1.4 prints some internal runtime errors (e.g. EPIPE surfaced from
torn-down stdio channels, oven-sh/bun#35064) directly to the child's
stderr, bypassing process-level handlers — so they leaked into the user's
terminal. Pump the TUI server child's stderr into
<data>/opencode/log/tui-server.log instead of inheriting the tty.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

process EPIPE error event not emitted

2 participants