Skip to content

console: surface EPIPE from console.log on process.stdout - #30635

Closed
robobun wants to merge 14 commits into
mainfrom
farm/33010463/console-epipe-process-stdout
Closed

robobun wants to merge 14 commits into
mainfrom
farm/33010463/console-epipe-process-stdout

Conversation

@robobun

@robobun robobun commented May 13, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #7251.

Repro

// bug.js
process.stdout.on('error', (err) => {
  if (err.code === 'EPIPE') {
    console.error('EPIPE emitted');
    process.exit(0);
  }
});

function test() {
  console.log('bun');
  setImmediate(test);
}

test();
bun run bug.js | head

Before: prints 10 lines then hangs forever.
After: prints 10 lines, then EPIPE emitted, exits 0 — same as Node.js.

Cause

Node.js's console.log writes through process.stdout.write(), so a write(2) failure (EPIPE when the read end closes) surfaces as an 'error' event on process.stdout via the stream's normal error machinery.

Bun's console.log uses a separate native writer (ConsoleObject.zig) that writes directly to fd 1 and swallows every error with catch {}. The process.stdout stream is never told anything went wrong, so a user 'error' listener never fires and a setImmediate loop spins forever writing to a dead pipe.

Fix

After each console.log/console.error call, check the writer adapter's recorded error. If a write failed, build a bun.sys.Error from the errno and hand it to process.stdout/process.stderr via stream.destroy(err) so the 'error' event fires on nextTick. A once('error', noop) is added first (matching Node's createWriteErrorHandler) so the common | head case without a user listener still completes quietly instead of turning into an uncaught exception. This is done at most once per stream so a tight sync loop (for (...) console.log(...)) doesn't queue unbounded destroy() calls. The emit path is skipped if a JS exception is already pending so the C++ handler's exception scopes can't swallow a user's throw.

process.stdout.write() already handled EPIPE correctly; only the console.* path was affected.

Verification

=== with listener ===
bun
...
bun
EPIPE emitted
EXIT=0

=== sync for-loop, no listener (| head) ===
done
EXIT=0

Error object shape matches Node:

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

New tests in test/js/node/process/process-stdio.test.ts fail on 1.3.13 (hang/timeout) and pass with this fix. Existing tests in test/regression/issue/1632.test.ts, test/js/web/console/, and test/js/bun/console/ still pass.

Tests are POSIX-only (describe.skipIf(isWindows)) since EPIPE-on-broken-pipe is a POSIX behavior; Windows stdio uses a different mechanism.

@robobun

robobun commented May 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented May 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds stdio write error detection and forwarding: console stdout/stderr write failures are captured, converted to JS errors, and forwarded to the corresponding process stream (destroy(err) / 'error' listener) at most once per stream.

Changes

Stdio Write Error Handling

Layer / File(s) Summary
Error state tracking in ConsoleObject
src/jsc/ConsoleObject.zig, src/jsc/ConsoleObject.rs
Adds per-stream emitted flags (stdout_write_error_emitted / stderr_write_error_emitted) and initializes them in init/init_in_place.
Write-error extraction and C-ABI emit helpers
src/jsc/ConsoleObject.zig, src/jsc/ConsoleObject.rs
Implements helpers to consume/clear sticky writer errno, ignore transient EAGAIN/EINTR, map errno -> bun.sys.Error -> JS Error, latch emitted flags, and declare the Bun__ConsoleObject__onStdioWriteError extern to synchronously forward the JS Error to the native C-ABI hook.
Console message routing and write-error forwarding
src/jsc/ConsoleObject.zig, src/jsc/ConsoleObject.rs
messageWithTypeAndLevel selects stderr vs stdout backing, clears recorded backing.err on JS exceptions (avoiding re-entry into JS), and after successful formatting takes and emits any pending stdio write error via the new helpers.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: surfacing EPIPE errors from console.log to process.stdout, which directly addresses the core issue (hang on broken pipe) and the fix implemented across multiple files.
Description check ✅ Passed The description comprehensively addresses the template requirements: explains what the PR does (surfaces EPIPE from console.log), provides detailed verification steps and test results. However, the 'How did you verify your code works?' section could be slightly more explicit about the verification methods used.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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.zig`:
- Around line 119-136: The code currently clears backing.err and sets emitted
before converting the system error to a JS error and calling
Bun__ConsoleObject__onStdioWriteError, which can drop the original write_err if
toJS() or the callback returns early; fix by deferring mutation of backing.err
and emitted until after sys_err.toJS(global) and
Bun__ConsoleObject__onStdioWriteError succeed — i.e., compute system_errno and
sys_err, call sys_err.toJS(global) and
Bun__ConsoleObject__onStdioWriteError(global, fd, js_err), and only then set
backing.err = null and emitted.* = true (or handle rollback on failure) so the
pending write_err is preserved if forwarding fails.
- Around line 101-107: The write-error handoff in messageWithTypeAndLevel_ calls
maybeEmitStdioWriteError after releasing stdout_mutex/stderr_mutex, allowing
races on console.writer_backing/console.error_writer_backing and
stdout_write_error_emitted/stderr_write_error_emitted; fix by performing the
check-and-clear (the maybeEmitStdioWriteError invocation or its internal
check/clear logic) while holding the corresponding mutex (stdout_mutex or
stderr_mutex) so the handoff is synchronized, or alternatively protect
backing.err and the *_write_error_emitted flags with atomics or a dedicated lock
and update maybeEmitStdioWriteError to use those atomics/lock to avoid races
(adjust both the branch for is_stderr and the same code paths at lines 112-123).
🪄 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: 57badac8-44aa-438f-9810-6b52c5e51567

📥 Commits

Reviewing files that changed from the base of the PR and between b9c757b and 9293ba1.

📒 Files selected for processing (3)
  • src/jsc/ConsoleObject.zig
  • src/jsc/bindings/BunProcess.cpp
  • test/regression/issue/07251.test.ts

Comment thread src/jsc/ConsoleObject.zig Outdated
Comment thread src/jsc/ConsoleObject.zig Outdated
@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 at ConsoleObject.zig with "attempt to unwrap error: EPIPE" — this PR gracefully handles EPIPE 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

Comment thread test/regression/issue/07251.test.ts Outdated
Comment thread test/regression/issue/07251.test.ts Outdated
Comment thread src/jsc/ConsoleObject.zig Outdated
Comment thread src/jsc/ConsoleObject.zig Outdated

@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: 1

🤖 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 `@test/js/node/process/process-stdio.test.ts`:
- Around line 170-180: The test currently only writes err.code back to the
sibling stream in the process.${stream}.on('error') handler; update the handler
inside the script function to serialize and write the full error object
(including at least err.code, err.errno and err.syscall) to the sibling stream
(e.g., as JSON) so the test asserts the complete error shape rather than just
code; apply the same change to the other occurrence referenced (lines ~204-229)
to ensure both stdout/stderr error paths return the full payload.
🪄 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: 9e3b01a0-1856-4b5f-b341-1e92f0316a33

📥 Commits

Reviewing files that changed from the base of the PR and between 9bab75b and be8aa22.

📒 Files selected for processing (2)
  • src/jsc/ConsoleObject.zig
  • test/js/node/process/process-stdio.test.ts

Comment thread test/js/node/process/process-stdio.test.ts
@robobun

robobun commented May 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status — diff is green, remaining failures are fleet-wide flakes

This PR's own changes pass on every lane. test/js/node/process/process-stdio.test.ts (#7251 suite) — ✅ all lanes across builds 54384 / 54401 / 54413 / 54430 / 54457. Console tests (test/js/web/console/, test/js/bun/console/) — ✅.

Build 54384 surfaced one real issue (EAGAIN surfaced as a stream error on release x64) — fixed in dfff7e5 + 43a3550 (skip EAGAIN/EINTR in the transient-errno filter). All subsequent review feedback addressed through ad30454.

Remaining red lanes on build 54457 are unrelated to ConsoleObject.rs / BunProcess.cpp / process-stdio.test.ts and are failing on other PRs' concurrent builds:

Failing test Area Also failing on builds
fetch-tcp-keepalive.test.ts (x64-asan) TCP keepalive timer state 54384, 54420, 54421, 54425, 54430, 54457
jsc-stress.test.ts mprotect failed: 487 (Windows) JSC Wasm JIT memory 54103, 54384, 54421, 54422, 54430, 54457
spawn-stdout-iterate-leak.test.ts (x64-asan, 37MB vs <32MB) RSS threshold 54430, 54457 (flaky-retry)
bun-install-registry.test.ts hoisting (Windows) bun install 54078, 54103, 54457

Already re-rolled once. All review threads (15 total) addressed and resolved. Ready for a maintainer to merge.

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
robobun added 9 commits May 14, 2026 18:19
When stdout is piped to a process that closes (e.g. `bun script.js | head`),
Bun's native console.log writer gets EPIPE from write(2) but swallowed it with
`catch {}`. Node.js routes console.log through process.stdout.write(), so the
write error surfaces as an 'error' event on process.stdout and user handlers
fire. In Bun the process just looped forever.

After a console.log/console.error write fails, forward the error to
process.stdout/process.stderr via stream.destroy(err), with a
once('error', noop) added first (matching Node's createWriteErrorHandler)
so the common `| head` case without a listener still exits cleanly. Only
done once per stream so a tight sync loop doesn't queue unbounded destroys.

Fixes #7251
So a transient failure in toJS() (pending exception) doesn't permanently
suppress the emit; a later failed write will retry.
…test.ts

- Skip the JS emit path when a JS exception is already pending so the
  C++ handler's exception scopes can't swallow the user's original throw.
  The pipe stays broken so the next console.* call retries.
- Move the #7251 tests into test/js/node/process/process-stdio.test.ts
  (this is a Node-compat gap, not a regression) and make them concurrent.
ban-words.test.ts flags global.hasException() as incompatible with strict
exception checks. We already know an exception is pending when
messageWithTypeAndLevel_ returns an error (voidFromJSError leaves it), so
just clear backing.err and return from the catch block — no need to query
the VM.
Matches Node's createWriteErrorHandler and Bun's own user-created Console
path in src/js/builtins/ConsoleObject.ts — only add the safety noop when
no one else is listening, so process.stdout.listeners('error') doesn't
briefly show an extra anonymous function when the user has their own
handler.
If a Proxy get trap on process.stdout throws during the listenerCount/once
lookup, JSObject::get() returns an empty JSValue after the exception is
cleared. On JSVALUE64 empty passes isCell(), so asCell()->isCallable() would
dereference null. Use else-if so the callable check is skipped when the
lookup threw, matching the destroy() lookup below.
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (post-Rust-rewrite) and ported:

  • src/sys/lib.rs: SysQuietWriterAdapter gains err: i32 (fits in the 64-byte opaque envelope); new fd_write_all_quiet_sticky() records the first write errno; adapter_write_all/adapter_flush route through it; quiet_writer_adapter_take_err arm added to link_impl_OutputSink!
  • src/bun_core/lib.rs: quiet_writer_adapter_take_err(&mut QuietWriterAdapter) -> i32 added to the OutputSink link-interface
  • src/bun_core/output.rs: QuietWriterAdapter::take_err() -> Option<i32> inherent wrapper
  • src/jsc/ConsoleObject.rs: stdout/stderr_write_error_emitted: Cell<bool> fields; maybe_emit_stdio_write_error() helper that wraps errno → bun_sys::Error{syscall: write} → JS and calls the C++ Bun__ConsoleObject__onStdioWriteError; message_with_type_and_level takes/clears the sticky error and either drops it (JS exception pending) or emits it
  • BunProcess.cpp hunk applies as-is

rust:check-all clean on all targets. Gate verified: without the .rs change both 'error' listener fires with EPIPE tests time out (listener never called); with it, 12/12 pass. .zig kept as reference.

@robobun
robobun force-pushed the farm/33010463/console-epipe-process-stdout branch from 9771619 to 84d1138 Compare May 14, 2026 18:46
Comment thread src/jsc/ConsoleObject.rs Outdated
robobun added 2 commits May 14, 2026 19:41
CI on release x64 intermittently saw {code:'EAGAIN', errno:-11} instead
of EPIPE from the #7251 test. EAGAIN is a transient 'would block' when
the pipe buffer is momentarily full and the fd happens to be non-blocking
before the reader closes — Node.js's stream layer retries these and never
surfaces them as 'error' events. Skip them here too; the next console.*
call will either succeed (buffer drained) or hit the real EPIPE once the
reader actually closes.

Applied to both the Rust implementation (active) and the legacy Zig
reference for consistency.
Bun__ConsoleObject__onStdioWriteError runs arbitrary JS synchronously
(lazy process.stdout getter, listenerCount, once → 'newListener',
destroy → _destroy), any of which may re-enter message_with_type_and_level
and call vm_console_mut again. Passing &mut writer_backing / &Cell<bool>
as function arguments keeps them protected (Stacked Borrows) across that
FFI call, violating vm_console_mut's documented single-borrow invariant.

Split into take_stdio_write_error (extracts errno + latches emitted with
the &mut scoped to that call) and emit_stdio_write_error (takes only the
i32 errno, no ConsoleObject borrows) so nothing derived from ConsoleObject
is live across the re-entrant boundary.
Comment thread src/jsc/ConsoleObject.rs Outdated
On macOS bun_sys::write() uses check_once! (no EINTR retry), so a signal
interrupting a console write can record EINTR into the sticky err field.
EINTR is the same transient-retry class as EAGAIN — Node.js/libuv retry
it internally and never surface it as a stream 'error'. Without this
filter it would latch emitted=true and permanently consume the
once-per-stream slot so a later real EPIPE never surfaces.
Comment thread src/jsc/ConsoleObject.rs Outdated

@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: 1

🤖 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 436-465: The helper take_stdio_write_error currently both clears
the sticky errno (backing.take_err()) and sets the one-shot latch
(stdout_write_error_emitted/stderr_write_error_emitted), causing a lost error
path when called from message_with_type_and_level just to clear errno; change
the API so clearing vs emitting are separate: either add a parameter (e.g.
set_emitted: bool) to take_stdio_write_error or create a new helper (e.g.
clear_stdio_write_error_no_latch) that only calls backing.take_err() and returns
the error without setting emitted, then update message_with_type_and_level (and
other callers that only need to clear errno) to call the no-latch variant while
leaving existing call sites that intend to emit to use the latch-setting
behavior; ensure EAGAIN/EINTR handling remains the same and emitted.set(true)
only happens when you intend to forward the error to JS.
🪄 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: ca9e0351-0364-4768-a1ea-07ee9f703e50

📥 Commits

Reviewing files that changed from the base of the PR and between dfff7e5 and 43a3550.

📒 Files selected for processing (2)
  • src/jsc/ConsoleObject.rs
  • src/jsc/ConsoleObject.zig

Comment thread src/jsc/ConsoleObject.rs
take_stdio_write_error() sets emitted=true as a side effect before
returning Some(errno). Using it with `let _ =` on the error path to
just clear the sticky errno also latches emitted without ever calling
emit_stdio_write_error, so the next console.* that hits EPIPE returns
None at the emitted.get() gate and the #7251 hang recurs.

Inline backing.take_err() on the error path (clears errno only, no
latch) to restore parity with the Zig sibling at ConsoleObject.zig:108.
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/ConsoleObject.zig Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
- Revert ConsoleObject.zig to main: per src/CLAUDE.md, .zig siblings are
  uncompiled porting references for original semantics — 'Never add new
  behavior to a .zig file.' The .rs is the only implementation that ships.
- BunProcess.cpp: comment says 'Called from ConsoleObject.zig' but the
  live caller is ConsoleObject.rs.
- ConsoleObject.rs: no-arg console.warn()/console.error() (len==0 && Log)
  was hardcoding the stdout writer regardless of level, so the EPIPE
  detection's is_stderr predicate checked the wrong backing. Use the
  level-selected writer so the newline goes to stderr (matching Node.js)
  and detection and body agree.
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #35064, which takes the same errno-recording approach for the console.* path and additionally fixes process.stdout.write's autoDestroy so the 'error' event fires per write instead of latching after the first.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #35064, which covers this fix plus the autoDestroy path for process.stdout.write() (per-write error emission).

@robobun robobun closed this Aug 1, 2026
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

1 participant