console: retry on EAGAIN instead of dropping output when stdout is nonblocking - #33560
console: retry on EAGAIN instead of dropping output when stdout is nonblocking#33560robobun wants to merge 12 commits into
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe change derives synchronous process-stdio descriptors from ChangesProcess stdio reliability
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 11:57 PM PT - Aug 12th, 2026
❌ @robobun, your commit 7d5bb80 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 33560That installs a local version of the PR into your bun-33560 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
On the two bot suggestions:
|
There was a problem hiding this comment.
Thanks for addressing the exit-code nit. I don't see any bugs, but since this changes the blocking semantics of the writer behind every console.log (now blocks the JS thread on poll when fd 1/2 is nonblocking and the reader stalls), it's worth a human confirming that trade-off and layer choice.
Extended reasoning...
Overview
This PR modifies fd_write_all_quiet in src/sys/lib.rs — the low-level write loop backing the native console.log / Output writer (QuietWriter / SysQuietWriterAdapter) and ScopedLogger. On Unix it now retries on EINTR and blocks on poll(POLLOUT, -1) on EAGAIN before retrying, instead of returning false and dropping the unwritten tail. A regression test in test/js/node/process/process-stdio.test.ts reproduces the slow-pipe scenario and asserts all 1500 lines arrive. Since my prior inline comment, commit f57553a dropped the misleading pipeline exit-code assertion; that thread is resolved.
Security risks
None identified. No untrusted input parsing, no auth/crypto/permissions surface. The change only affects how stdout/stderr writes handle transient EAGAIN on the process's own fds.
Level of scrutiny
Medium-high. The diff is ~15 lines and the mechanism (poll-then-retry) is textbook, but fd_write_all_quiet sits under every console.log in every bun process. The change trades "silently drop output" for "block the JS thread until the pipe drains," which is almost certainly the right call (matches Node, and silent loss is worse than a stall), but it is a real behavioral change on a hot path. There's also a reasonable architectural question — whether the fix belongs here vs. preventing the native writer's fd from being flipped to O_NONBLOCK in the first place, or unifying the two writers — that a maintainer should ratify.
Other factors
- All callers of
fd_write_all_quietare local to this file (the quiet-writer adapter and vtable), so blast radius is contained to console/logger output. pollreturning onPOLLERR/POLLHUP(broken pipe) is handled correctly: the nextwritegetsEPIPEand hits the fall-throughErr(_) => return falsearm; a signal-interruptedpolljust loops back towrite→EAGAIN→pollagain without busy-spinning.- Windows is unchanged (
#[cfg(unix)]-gated) and the test is skipped there. - The bug-hunting system found no issues this round; my only prior nit was addressed.
- A related open PR (#30635) touches the same function for
EPIPE; the author explained they're complementary, but a human may want to coordinate the two.
|
On the blocking trade-off and layer choice, for a maintainer to weigh in:
|
|
CI status on 7d5bb80 (build 93803): 195 passed, 1 failed. The failure is |
Bun keeps a native console writer on fd 1 separate from the node-compat process.stdout stream. Materializing process.stdout dups fd 1 and sets O_NONBLOCK on the shared open file description, but the console writer kept doing a plain write(2) and silently discarded the unwritten tail on EAGAIN. On a full pipe with a slow reader this dropped most console.log output with exit code 0 and no error. fd_write_all_quiet now retries on EINTR and blocks on poll(POLLOUT) on EAGAIN, so console output is delivered in full regardless of the fd's blocking mode.
The shell pipeline's exit status is cat's, not bun's, so asserting it was 0 proved nothing about the child. The delivered-line count is the real regression guard.
Adds the fast-reader repro (a single 1 MiB console.log piped to wc -c is truncated to one pipe-buffer without the fix) and moves both cases into their own non-concurrent describe so they don't add concurrent spawn load to the borderline process.stdin tests.
f57553a to
f647c86
Compare
There was a problem hiding this comment.
No issues found — the EAGAIN→poll(POLLOUT) retry restores the same blocking semantics fd 1 already had before process.stdout flipped it, and the fall-through Err(_) arm still ends the loop on EPIPE/POLLHUP. Deferring to a maintainer since the author explicitly flagged the layer choice for sign-off and #30635 touches the same function.
What was reviewed:
posix::pollis already EINTR-retried; discarding its result is fine because the nextwrite()surfaces EPIPE if the read end closed- happy-path cost is zero — the new arms only fire on errno the old code silently dropped
- both tests deterministically overfill the 64 KiB pipe; the
sleep 0.4is the reader delay that creates the condition, not a wait-for-condition - Windows is cfg'd/skipped; EAGAIN==EWOULDBLOCK on all supported unix targets
Extended reasoning...
Overview
Adds two #[cfg(unix)] match arms to fd_write_all_quiet in src/sys/lib.rs: retry on EINTR, and on EAGAIN block via poll(POLLOUT, -1) before retrying. This is the write loop behind the native console writer (SysQuietWriterAdapter / quiet_writer_write_all). Two new regression tests in test/js/node/process/process-stdio.test.ts reproduce the O_NONBLOCK-on-fd-1 shape via a shell pipeline with a delayed reader.
Security risks
None. No untrusted input reaches the new code — it only reacts to errno from write(2) on stdout/stderr. No new allocation, no user-controlled sizes.
Level of scrutiny
Medium-high. The diff is 13 lines of native code plus tests, but it sits directly under every console.log in the runtime. The behavioral change (block instead of drop) is defensible — it's what a blocking fd 1 already does and what Node does — but it is a design choice: the alternative fixes (don't set O_NONBLOCK on the shared OFD, or unify the two writers so console goes through the buffered stream) live at different layers. The author explicitly surfaced this trade-off in the thread and asked for maintainer input, which is the right call.
Other factors
- I confirmed
posix::poll(src/sys/lib.rs:8229) is EINTR-retried internally, so the discarded return is safe; if the read end closes, poll returns with POLLERR/POLLHUP and the subsequentwrite()yields EPIPE →Err(_) => return false. - My earlier minor comment (pipeline exit status is cat's, not bun's) was addressed in 9cca594 — the vacuous
exitCodeassertion is gone and the delivered-count is the load-bearing check. - The comment-cop bot flagged the doc comment length; the author trimmed it to a two-line inline note in 735e326, which reads as a legitimate cross-crate hazard note rather than a workaround justification.
- #30635 modifies the same function's fall-through
Errarm for EPIPE. The two changes are complementary but a maintainer should coordinate the merge order. - Tests use
/bin/sh,sleep,cat,wc— all POSIX-standard, and the block isskipIf(isWindows). Thesleep 0.4is not the flaky wait-for-condition anti-pattern; it is the slow-reader mechanism itself, and the assertion is on delivered byte/line count, not timing.
There was a problem hiding this comment.
No issues found, but this changes core console-write semantics (indefinite poll(POLLOUT, -1) on EAGAIN) and you already flagged the blocking/layer trade-off for a maintainer — leaving that call to a human, especially with #30635 touching the same function.
Checked: posix::poll already retries EINTR internally, so the discarded result can't busy-spin on signals; a POLLERR/POLLHUP wake surfaces via the next write() hitting the fall-through Err arm. EAGAIN == EWOULDBLOCK on all supported Unix targets, so the single errno match is sufficient. Callers are all the console/quiet-writer path — no other consumers of fd_write_all_quiet. Tests use the harness idioms (tempDir, Buffer.alloc over .repeat) and the sleep 0.4 is the deliberate slow-reader setup, not a condition wait.
Extended reasoning...
Overview
Two files: src/sys/lib.rs adds #[cfg(unix)] EINTR-retry and EAGAIN→poll(POLLOUT, -1) arms to the fd_write_all_quiet loop (13 lines); test/js/node/process/process-stdio.test.ts adds two POSIX-only regression tests reproducing the slow-pipe data loss after process.stdout materialization flips fd 1 to O_NONBLOCK.
Security risks
None. No untrusted input parsing; the fd being polled is the process's own stdout/stderr. The only new hazard is a liveness one (indefinite block on a stalled reader), which the author correctly notes is the pre-existing behavior when fd 1 is blocking — this restores it rather than introducing it.
Level of scrutiny
Medium-high. fd_write_all_quiet backs every console.log via SysQuietWriterAdapter / quiet_writer_write_all, so a mistake here affects all console output. The fix itself is small and idiomatic (poll-then-retry is the standard write-all pattern for a possibly-nonblocking fd), but the choice to block indefinitely rather than, say, flip the fd back to blocking or route through the node stream, is a design call the author explicitly deferred to a maintainer in the thread. #30635 also modifies this function's error arm, so merge coordination is a human concern.
Other factors
- I verified
posix::poll(src/sys/lib.rs:8229) has its own EINTR loop, solet _ =on its result won't degrade to a busy-spin on signals; on any other poll failure the nextwritere-hits EAGAIN and re-polls, which is at worst a tight loop on a pathologicalpollerrno that shouldn't occur on a valid fd. - If
pollwakes withPOLLERR/POLLHUP/POLLNVALinrevents, the nextwrite(2)returns the real error (EPIPE/EBADF) and hitsErr(_) => return false, so the loop terminates. - Only EAGAIN is matched; EWOULDBLOCK is the same value on Linux/macOS/BSD, so no gap there.
- Windows is unchanged (
#[cfg(unix)]) and the tests skip Windows — appropriate sinceO_NONBLOCKon a shared open file description is the Unix-specific mechanism. - My earlier nit (pipeline exit code being
cat's, not bun's) was addressed in 9cca594; all inline threads are resolved. - Tests follow harness conventions:
tempDir,bunEnv/bunExe,Buffer.alloc(n, fill)over.repeat,await usingfor spawn cleanup, kept outside the concurrent block with a stated reason.
|
For pipes and sockets, couldn't we instead make it use sendmsg with MSG_WAIT or whatever that flag is, when the output source type is a socket/pipe (we stat on start already)? Then we don't need to poll and it will still block. |
|
Unfortunately that doesn't pan out for two reasons:
The per-call options I'm aware of on POSIX are:
Happy to switch if there's a primitive I'm missing, but as far as I can tell poll-then-retry is the portable way to block a write on a fd that someone else has flipped to nonblocking. |
Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio was reading this.fd, which FileSink::setup never writes (it stays Fd::INVALID), so the update_nonblocking(fd, false) call was silently skipped and fd 1 stayed nonblocking after process.stdout materialization. The writer's fd lives in this.writer.get_fd(). Also covers a cross-process face: a bun child with stdio:'inherit' that touches its own process.stdout flips the parent's fd 1 via the shared open file description. With this fix the child restores blocking before returning. fd_write_all_quiet's poll-on-EAGAIN remains as the writer-side guarantee when something other than bun flips the shared description.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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/runtime/webcore/FileSink.rs`:
- Around line 242-247: Update the `FileSink` writer closure to call the
`IOWriter::fd()` method instead of the nonexistent `get_fd()` method, preserving
the existing `force_sync` assignment and nonblocking update logic.
In `@src/sys/lib.rs`:
- Around line 9413-9425: Update the EAGAIN branch in the write helper to handle
the result from posix::poll: retry on EINTR, but return false for other poll
errors so repeated EAGAIN results cannot spin. Also document that this blocking
behavior applies to generic File::quiet_writer() callers, or restrict the
behavior to the intended descriptors.
In `@test/js/node/process/process-stdio.test.ts`:
- Around line 167-246: Mark the new independent tests in the “console.log after
process.stdout is materialized on a pipe” suite as concurrent, preferably by
applying the existing test harness’s concurrent describe convention. Keep the
suite outside the earlier stdin concurrent block and preserve each test’s
current setup and assertions.
🪄 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: 9939b7e1-58d8-445f-bc6e-764e8ec23cef
📒 Files selected for processing (3)
src/runtime/webcore/FileSink.rssrc/sys/lib.rstest/js/node/process/process-stdio.test.ts
posix::poll already retries EINTR; any other Err means the fd is no longer pollable, so treat it like any other I/O failure instead of re-entering the write loop.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
test/js/node/process/process-stdio.test.ts (3)
182-193: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftPropagate and assert the Bun producer’s exit status.
Each test only observes the downstream
cat/wcresult, so a Bun child can fail after producing enough output and still make the test pass. Use an explicit status channel or a reader design that preserves the producer status, then assert it after draining stdout.As per coding guidelines: subprocess tests must assert the complete observable result, not only downstream output.
Also applies to: 203-213, 231-236
🤖 Prompt for 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. In `@test/js/node/process/process-stdio.test.ts` around lines 182 - 193, Update the subprocess tests around the Bun-spawned producer to preserve and expose the producer’s exit status instead of observing only downstream cat/wc output. Drain stdout fully, then assert the Bun child exits successfully through an explicit status channel or equivalent reader design; apply the same change to the related test cases.Source: Coding guidelines
224-228: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winVerify that the inherited-stdout child actually ran.
If
spawnSyncfails or exits nonzero, the parent still writes all 20,000 lines through its original blocking descriptor, allowing this regression test to pass without exercising the intended shared-state path.Proposed fix
- spawnSync(process.execPath, ["-e", 'process.stdout.write("")'], - { stdio: ["ignore", "inherit", "ignore"] }); + const child = spawnSync(process.execPath, ["-e", 'process.stdout.write("")'], + { stdio: ["ignore", "inherit", "ignore"] }); + if (child.error || child.status !== 0) { + throw new Error(`inherited-stdout child failed: ${child.error ?? child.status}`); + }As per coding guidelines: tests must assert setup preconditions before validating the final behavior.
🤖 Prompt for 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. In `@test/js/node/process/process-stdio.test.ts` around lines 224 - 228, Update the setup in the process-stdio test around spawnSync to capture its result and assert that the inherited-stdout child completed successfully before writing the 20,000 lines. Keep the existing child command and stdio configuration, and ensure the precondition assertion fails when spawnSync errors or returns a nonzero status.Source: Coding guidelines
194-196: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAssert the emitted records, not only an aggregate count.
A missing line can be offset by a duplicate valid line in the first test, and malformed content can retain the same newline count in the parent test. Capture the output and compare each expected line in order.
As per coding guidelines: every assertion must prove the strongest relevant invariant.
Also applies to: 237-238
🤖 Prompt for 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. In `@test/js/node/process/process-stdio.test.ts` around lines 194 - 196, Update the assertions around stdout processing in the affected tests to compare the complete emitted records against the expected lines in order, rather than counting regex-matching lines or relying on newline totals. Preserve the existing output format and use the captured stdout from the relevant process execution to validate both record content and ordering.Source: Coding guidelines
🤖 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.
Outside diff comments:
In `@test/js/node/process/process-stdio.test.ts`:
- Around line 182-193: Update the subprocess tests around the Bun-spawned
producer to preserve and expose the producer’s exit status instead of observing
only downstream cat/wc output. Drain stdout fully, then assert the Bun child
exits successfully through an explicit status channel or equivalent reader
design; apply the same change to the related test cases.
- Around line 224-228: Update the setup in the process-stdio test around
spawnSync to capture its result and assert that the inherited-stdout child
completed successfully before writing the 20,000 lines. Keep the existing child
command and stdio configuration, and ensure the precondition assertion fails
when spawnSync errors or returns a nonzero status.
- Around line 194-196: Update the assertions around stdout processing in the
affected tests to compare the complete emitted records against the expected
lines in order, rather than counting regex-matching lines or relying on newline
totals. Preserve the existing output format and use the captured stdout from the
relevant process execution to validate both record content and ordering.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 496ac8f6-040b-4327-80f2-358ce909c866
📒 Files selected for processing (1)
test/js/node/process/process-stdio.test.ts
If spawnSync fails, the parent's fd 1 is never flipped and the test would spuriously pass on an unfixed build. Throw from the fixture when the child errors or exits nonzero so the regression guard can't be bypassed.
|
On the three outside-diff test comments:
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
test/js/node/process/process-stdio.test.ts (3)
232-233: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDocument the intentional one-second reader delay.
The test uses
sleep 1outside a polling loop, but the surrounding comment does not explain that this lets the pipe fill and forces the parent through the nonblocking retry path.As per coding guidelines: “comment sleeps of 50ms or more outside polling loops.”
🤖 Prompt for 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. In `@test/js/node/process/process-stdio.test.ts` around lines 232 - 233, Add a concise comment near the `sleep 1` command in the `Bun.spawn` setup explaining that the intentional one-second reader delay lets the pipe fill and forces the parent through the nonblocking retry path. Preserve the existing test behavior and command structure.Source: Coding guidelines
190-190: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSpread
bunEnvfor each new spawn.Use
{ ...bunEnv }at these sites to follow the test-harness convention and keep each subprocess environment independently owned.As per coding guidelines: “Follow existing harness conventions: spread
bunEnv.”Proposed change
- env: bunEnv, + env: { ...bunEnv },Also applies to: 210-210, 234-234
🤖 Prompt for 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. In `@test/js/node/process/process-stdio.test.ts` at line 190, Update each affected spawn configuration in the process-stdio tests to pass a shallow copy of bunEnv using the existing spread convention, including the sites around lines 190, 210, and 234, so every subprocess receives an independently owned environment object.Source: Coding guidelines
195-196: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winAssert the complete payload, not only a loose line count.
/^O\d+ x+$/accepts truncated lines, and filtering can hide missing, duplicate, or unexpected data. Compare the full output with the expected 1,500-line sequence.As per coding guidelines: “Every test assertion must be able to fail and assert the strongest invariant.”
Proposed change
- const delivered = stdout.split("\n").filter(l => /^O\d+ x+$/.test(l)).length; - expect(delivered).toBe(N); + expect(stdout.split("\n")).toEqual([ + ...Array.from({ length: N }, (_, i) => `O${i} ${pad}`), + "", + ]);🤖 Prompt for 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. In `@test/js/node/process/process-stdio.test.ts` around lines 195 - 196, Strengthen the assertion in the process-stdio test by comparing the complete stdout payload against the expected 1,500-line sequence, rather than filtering lines and counting matches with the loose /^O\d+ x+$/ pattern. Preserve the expected ordering, line contents, and exact line count so missing, duplicate, truncated, or unexpected output causes the assertion to fail.Source: Coding guidelines
🤖 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.
Outside diff comments:
In `@test/js/node/process/process-stdio.test.ts`:
- Around line 232-233: Add a concise comment near the `sleep 1` command in the
`Bun.spawn` setup explaining that the intentional one-second reader delay lets
the pipe fill and forces the parent through the nonblocking retry path. Preserve
the existing test behavior and command structure.
- Line 190: Update each affected spawn configuration in the process-stdio tests
to pass a shallow copy of bunEnv using the existing spread convention, including
the sites around lines 190, 210, and 234, so every subprocess receives an
independently owned environment object.
- Around line 195-196: Strengthen the assertion in the process-stdio test by
comparing the complete stdout payload against the expected 1,500-line sequence,
rather than filtering lines and counting matches with the loose /^O\d+ x+$/
pattern. Preserve the expected ordering, line contents, and exact line count so
missing, duplicate, truncated, or unexpected output causes the assertion to
fail.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 111d44d4-cbb7-4d1e-ad6c-292440fae740
📒 Files selected for processing (1)
test/js/node/process/process-stdio.test.ts
Bun.file(1).writer() goes through FileSink.setup (dup + O_NONBLOCK) but is not the process.stdout getter path, so ForceFileSinkToBeSynchronous never runs and fd 1 stays nonblocking. This test fails with only the FileSink fix applied (65536/1048577) and passes with the fd_write_all_quiet poll-on-EAGAIN change, giving each src/ hunk at least one test that breaks when it is reverted in isolation.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/js/node/process/process-stdio.test.ts (1)
222-230: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse an ESM import in the
.mjsfixture.
parent.mjsis an ESM file and does not test CommonJS loading. Replacerequire("node:child_process")with a top-levelimport.🤖 Prompt for 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. In `@test/js/node/process/process-stdio.test.ts` around lines 222 - 230, Update the parent.mjs fixture to use a top-level ESM import for child_process instead of require("node:child_process"), while preserving the existing spawnSync behavior and test flow.Source: Coding guidelines
🤖 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 242-258: Update the test “console.log survives a raw O_NONBLOCK on
fd 1 (isolates the writer)” to delay the pipe reader before it drains output,
following the existing slow-reader test pattern. Ensure the delayed `wc -c`
setup forces the writer to encounter EAGAIN so the assertion exercises
fd_write_all_quiet’s retry path rather than merely validating normal pipe
writes.
---
Outside diff comments:
In `@test/js/node/process/process-stdio.test.ts`:
- Around line 222-230: Update the parent.mjs fixture to use a top-level ESM
import for child_process instead of require("node:child_process"), while
preserving the existing spawnSync behavior and test flow.
🪄 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: 1baf95f4-eb1d-4f66-a889-fcbeaa96858b
📒 Files selected for processing (1)
test/js/node/process/process-stdio.test.ts
A fast wc could in principle drain between partial writes and avoid EAGAIN, which would let the isolating test pass without the poll+retry on some systems. Start wc 400ms late so the 1 MiB write is guaranteed to see EAGAIN after the first pipe-buffer.
There was a problem hiding this comment.
My earlier isolating-test concern is addressed in 73ff61e; no further issues found this pass. Deferring to Jarred on the poll-vs-alternative call he raised — the response (sendmsg is socket-only; no send-side flag overrides O_NONBLOCK) reads correct to me, but this changes the writer behind every console.log and deserves his sign-off.
What was reviewed:
fd_write_all_quiet:posix::pollalready loops on EINTR, soErr→return falseprevents an EAGAIN busy-spin; EPIPE/other errors still fall through the final arm.writer.get_fd()resolves toStreamingWriter::get_fd(src/io/PipeWriter.rs:699), not the unrelated shellIOWriter.- The delayed reader robobun said was in ebc63ae isn't on the branch (HEAD is 73ff61e), but the isolating test still pins the sys fix as-is: 1 MiB into a 64 KiB pipe hits EAGAIN before
wccan drain, and A-only was verified failing at 73ff61e.
Extended reasoning...
Overview
Two small native changes plus four POSIX-only regression tests. Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio now reads the fd from writer.get_fd() (where FileSink::setup actually stores it) instead of the never-populated this.fd, so update_nonblocking(fd, false) runs and clears the O_NONBLOCK that open_for_writing set on the shared open file description. Separately, fd_write_all_quiet in src/sys/lib.rs gains EINTR retry and a poll(POLLOUT) wait on EAGAIN so the native console writer never silently drops a partial write when fd 1/2 has been flipped nonblocking by anything (including a non-bun child on the shared OFD).
Security risks
None identified. No untrusted-input parsing, no auth/crypto, no new external surface. The new poll call is on an fd the process already owns; the only behavioral change is that a write that would previously have been truncated now blocks until writable, which restores the pre-materialization semantics of a blocking fd 1.
Level of scrutiny
High. fd_write_all_quiet sits under SysQuietWriterAdapter, which backs bun_core::Output — i.e. every console.* and scoped-log write on POSIX. A mistake here (busy-spin, deadlock, dropped bytes) would be user-visible everywhere. The FileSink change is narrower (only the process.stdout/stderr force-sync path) but still touches process-wide fd flags on a shared open file description. Both are cfg-gated to unix.
Other factors
- Jarred asked whether
sendmsg/MSG_WAITcould avoid the poll. The reply is technically sound —sendmsgreturnsENOTSOCKon pipes and there is no send-side flag that overridesO_NONBLOCK— but he hasn't acknowledged it, so the design choice is still awaiting maintainer confirmation. - My previous review flagged that neither fix had an isolating test; 73ff61e adds one that leaves
O_NONBLOCKset viaBun.file(1).writer()(bypassesForceFileSinkToBeSynchronous) and was verified to fail with only the FileSink hunk applied. That thread is resolved. - robobun told CodeRabbit the isolating test's reader was delayed in commit ebc63ae, but that commit is not on the branch (HEAD is 73ff61e, and
git log --allhas no ebc63ae). Verifiers examined this and concluded it does not weaken the test: a single 1 MiB write into a 64 KiB pipe returns a partial count thenEAGAINbeforewc -ccan drain regardless, and the A-only failure was demonstrated at 73ff61e without the sleep. Not a blocker, but worth noting the thread's stated resolution doesn't match the branch. - Tests use
/bin/shpipelines with fixed sleeps (0.4s / 1s) to force backpressure. That is the observable condition here (there is no in-process signal for "pipe filled"), and the suite is deliberately kept out of the file'sdescribe.concurrentblock after robobun reproduced it flaking the earlier stdin tests.
Given the maintainer engagement on the approach and the breadth of the code path, this should get a human sign-off rather than a bot approval.
|
On the review note that ebc63ae isn't on the branch: it is. |
|
Verified a third face of this bug against this branch: Fixture: 4 files x 100k
Serial This also settles whether the bulk |
The user asked whether bun has fixed this yet. It has not — but the fix is an identified upstream PR, which turns "Phase 2 should replace this helper" from an intention into a checkable condition. oven-sh/bun#33560, "stdio: fix O_NONBLOCK leak from process.stdout and make console writer EAGAIN-safe", is this exact defect: materializing process.stdout sets O_NONBLOCK on the shared open file description, and the PR explicitly covers the child-inheritance case where a child spawned with stdio:"inherit" flips the parent's fd 1. Adjacent and also open: #33827, #35953, #36066. Verified rather than transcribed, because writing an upstream claim into a tracked file is the class of statement this task has spent three waves correcting: fetched the PR (title verbatim, state OPEN, description matches what this repo measured independently) and the releases page (newest bun is still v1.3.14, 13 May 2026 — the version we pin and measured on, so nothing has landed and the pin is not lagging a fix). Both notes carry the check date, so "open and unmerged" reads as a fact that expires. dispatch.ts gets the deletion criterion next to the mechanism it already explains: on a bun carrying #33560, delete RESTORE_BLOCKING_PY, isPipeLike, stdioNeedsBlockingRestore, blockingRestoreFailure, restoreBlockingStdio and its call in spawnVerb — and the test that proves the deletion is safe is named too. large-output-across-the-pipe-buffer must stay green WITHOUT the helper: its three invocations push 500 KB through a pipe and assert a tail marker a truncating build cannot produce. ADR-013 gets one sentence where Phase 2 is already discussed. Comment and prose only, no executable line changed: typecheck clean and build green on bun 1.3.14 (172 modules). No suites re-run — there is nothing a comment can change for them to catch.
…hon engine Implements Phase 1 of ADR-013 (accepted 2026-08-01). The bun-compiled plainkeep-core binary is now the dispatcher: it runs the guardrail gate and multi-root resolution in-process and spawns Python once per verb, and it absorbs tab-completion, the terminal UI and the MCP server. The checked-in `plainkeep` script becomes a shim honouring PLAINKEEP_CORE=auto|require|off, so the original bash floor stays fully working. Correctness is held by a Python-owned differential oracle comparing the binary against the bash floor and the untouched Python guardrail/resolver on exit codes, stdout, stderr and the audit line, plus two fuzz harnesses and a PTY suite. It runs in CI under PLAINKEEP_REQUIRE_CORE=1. Eight tasks, each through spec and quality review. Known costs are recorded in ADR-013's consequences rather than smoothed over: piping a verb's output is ~7 ms (~8%) slower than the floor because clearing a bun O_NONBLOCK leak needs a helper process (upstream oven-sh/bun#33560; dispatch.ts names the lines to delete when it lands), the Python guardrail and resolver are now a permanent second implementation with a standing parity obligation, and building cli/ requires bun >= 1.2.21. Deferred work is tracked in docs/followups.md.
alii
left a comment
There was a problem hiding this comment.
Requesting changes. The lib.rs retry is right and is the only thing the four new tests exercise. The FileSink.rs change is a separate behavior change: process.stdout.write() to a pipe or socket now blocks the JS thread instead of returning false and emitting drain, and the body does not say so.
- FileSink.rs:247: say what it changes and pin it in a test, or land the lib.rs half on its own.
- Nothing fails without the FileSink change. What it actually fixes is what non-bun children inherit; test that.
- Tests 1 and 3 stop discriminating when bun starts slower than the reader sleep; use the single 1 MiB write shape test 2 already uses.
Clearing O_NONBLOCK in ForceFileSinkToBeSynchronousForProcessObjectStdio made process.stdout.write() to a pipe block the JS thread and return true instead of returning false and emitting drain, which is what main and node do. The console-writer retry in fd_write_all_quiet covers every reported shape on its own, so this PR now ships only that. Tests are reworked around a single 1 MiB write, which is larger than any pipe buffer and so fails deterministically on the unfixed binary without a sleeping reader: stdout, stderr, and a parent whose fd 1 was flipped by a child with inherited stdio. A fourth test pins the async write contract so a future change to the FileSink path cannot silently make it blocking.
alii
left a comment
There was a problem hiding this comment.
Looks ready to merge from this side. 7d5bb80 drops the FileSink change, so the PR is now just the fd_write_all_quiet retry plus tests; the three loss tests each deliver one pipe buffer (65536 here) against an unfixed debug build and 1048577 under node with no reader delay, the inherited-fd one goes back to 1048577 once the child stops touching its stdout, and the new async test returns false plus drain on main and node with both a fast and a 0.5s-late reader. Not rebuilt with the patch, so the fixed-side numbers in the body are unverified here; the lib.rs hunk still applies cleanly to current main even though the surrounding file has moved.
|
Thanks. On the fixed-side numbers: CI is the verification for those. The three loss tests assert exactly 1048577 and the async test asserts One housekeeping note: the earlier Changes Requested review is still the recorded state on the PR, so it will need to be re-reviewed or dismissed before the merge button unlocks. Happy to rebase if you'd rather CI run against current main first; the hunk applies cleanly as you say, so I've left the branch alone to avoid churning reviews. |
Problem
Once
process.stdoutorprocess.stderrhas been touched (reading.isTTY, anywrite, etc.),console.log/console.errorto a pipe silently drop output when the pipe is momentarily full. Exit code stays 0 and nothing is reported.The same thing happens to a bun parent that never touched its own stream, if a child it spawned with inherited stdio touched its
process.stdout: theO_NONBLOCKflag lives on the shared open file description.bun test --parallelbuilds this topology itself and loses worker output.Cause
Materializing
process.stdoutcreates the FileSink on adupof fd 1 and putsO_NONBLOCKon the shared description (open_for_writing). The native console writer behindconsole.*is a separate writer on the same fd; its write loop,fd_write_all_quietinsrc/sys/lib.rs, treated the resultingEAGAINlike any other error and returned, discarding the unwritten tail.Fix
fd_write_all_quietnow retries onEINTRand, onEAGAIN, waits forPOLLOUTand retries; apollfailure or any other write error still ends the loop as before. This restores the blocking-write semanticsconsole.*has when the fd is blocking, independent of who flipped the flag (this process, a bun child, or any other program sharing the description).process.stdout.write()itself is unchanged: it keeps returningfalseand emittingdrainon a full pipe, as on main and in node. An earlier revision of this PR also madeForceFileSinkToBeSynchronousForProcessObjectStdioclearO_NONBLOCK; that turnedprocess.stdout.write()to pipes into a blocking call and has been dropped (see alii's review). A test now pins the async contract.The flag itself is therefore still set on fd 1 / fd 2 after
process.stdout/process.stderris materialized. This PR makes bun's own console writer correct under it; a non-bun child that inherits the fd and does plainwrite(2)(alii'shead -cexample) is unchanged and out of scope here, since clearing the flag is what regressedwrite().Tests
test/js/node/process/process-stdio.test.ts, POSIX only. Each loss test writes a single 1 MiB line, which exceeds any pipe buffer, so the unfixed binary fails deterministically with no reader delay:console.logafter touchingprocess.stdout, intowc -cconsole.errorafter touchingprocess.stderr, intowc -cconsole.logafter a bun child with inherited stdout touched its streamprocess.stdout.write(1 MiB)to a pipe returnsfalseand emitsdrainVerified separately (not in CI, timing dependent): the
bun test --parallel=4fixture from the report goes from losing 29 to 71 percent of workerconsole.loglines to losing none.