FileSink: release the event loop keep-alive when flush() drains the buffer - #38641
Conversation
…uffer A write() below the coalescing threshold buffers its bytes; on_write then marks the event loop alive until they are flushed, and on_auto_flush releases that once it drains them. flush_from_js drained them too but left the mark in place until the next deferred-task drain, so console.write() (write()+flush() on a stdout FileSink) called from a 'beforeExit' listener with stdout on a pipe counted as newly scheduled work and 'beforeExit' was emitted again, forever. Release the keep-alive in flush_from_js when the flush emptied a non-empty buffer, on the success and on the error path alike.
|
Warning Review limit reached
Next review available in: 16 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Status: fix pushed, waiting on CI. Reproduced on the released binary (linux x64) with The |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The 10-line Rust change mirrors what on_auto_flush already does after a drain, is gated conservatively, and disable_keeping_process_alive is flag-idempotent so a redundant release is harmless. Because it touches FileSink's event-loop keep-alive accounting — subtle runtime lifecycle code — a human look would still be worthwhile.
What was reviewed:
flush_from_jsgate: only releases when the buffer transitioned from non-empty to empty; thePending/partial-drain path leaveshas_pending_data()true so the keep-alive stays (covered by the could-not-drain test).- Error arm: POSIX
flush()resetsoutgoingonErr, so the release fires there too — matches the EPIPE test. - Idempotency:
FilePoll::disable_keeping_process_alivedecrements byHasIncrementedActiveCount as u32, so a lateron_auto_flush'supdate_ref(false)on the same sink is a no-op. - Windows test concern (Bun.stdout.writer() on a pipe being async) was raised and refuted.
Extended reasoning...
Overview
The PR fixes an infinite beforeExit loop when console.write() (or any Bun.file(fd).writer() write()+flush()) is called from a beforeExit listener with stdout/stderr on a pipe. The fix is ~10 lines in src/runtime/webcore/FileSink.rs: after flush_from_js's w.flush() returns, if the buffer went from non-empty to empty, call self.update_ref(false) to release the event-loop keep-alive that on_write had set. This is exactly what on_auto_flush already does after its own drain, so the change makes the explicit-flush path consistent with the deferred-flush path. Two test files add five subprocess-based regression tests (console.write, Bun.stdout/stderr.writer(), EPIPE error arm, and a could-not-drain case that verifies the keep-alive is not released when the flush left bytes buffered).
Security risks
None. This is process-lifecycle bookkeeping (uSockets loop active count) with no untrusted-input parsing, no auth/crypto, no memory-safety change. The added tests use local subprocesses and createSocketPair() from bun:internal-for-testing; no network access.
Level of scrutiny
Higher than a mechanical change: FileSink's keep-alive accounting is subtle and has been the source of leak/hang fixes before (the file already carries several such fixes with detailed comments). Getting it wrong causes either premature process exit (releasing too eagerly) or hangs (never releasing). I traced the change against the underlying FilePoll::disable_keeping_process_alive (idempotent via HasIncrementedActiveCount flag) and the POSIX flush() implementation (resets outgoing on both full-drain and error, leaves it non-empty on Pending/partial), and the gate is correct on every arm. The interaction with an explicit user sink.ref() is unchanged relative to the existing on_auto_flush path (both call update_ref(false) after a drain), so no new inconsistency is introduced.
Other factors
The PR description is unusually thorough — root cause traced through on_write → update_ref(evtloop, has_pending_data) → VirtualMachine::on_before_exit, Windows behavior explained (has_pending_data() stays true while a uv_write is in flight so the release still happens in on_write on completion), and the --hot watcher-loop wakeup called out as a separate pre-existing issue. Test coverage is strong: the could-not-drain test specifically guards against over-release (an unref'd timer only gets to run because pending bytes hold the loop open), and each fixture is designed so the pre-fix binary produces count: 2 and the fixed one produces count: 1. The bug-hunting system found no issues; one candidate (Windows async Bun.stdout.writer()) was raised and refuted. Given the change touches process-lifecycle-critical code, I'm deferring rather than approving, but I have no specific concerns to flag.
Problem
process.on("beforeExit", () => console.write("x\n"))with stdout on a pipe never exits:beforeExitis re-emitted over and over andxis printed until the process is killed (timeout 2 bun -e '...' | wc -lprints several hundred thousand lines).process.stdout.writeandconsole.login the same listener print once and the process exits, as in Node.write()+flush()onBun.stdout.writer(),Bun.stderr.writer()or any pipe/socket-backedBun.file(fd).writer()from the listener.console.write()is exactly that (writeinsrc/js/builtins/ConsoleObject.ts).src/runtime/webcore/FileSink.rs: on POSIX awrite()below the coalescing threshold is only buffered.on_writethen turns the loop keep-alive on because bytes are pending (update_ref(evtloop, has_pending_data)), and the deferredon_auto_flushturns it off after draining them.flush_from_js(the JSflush()) drains the same bytes but leaves the keep-alive on, so until the next deferred-task drain the sink still tells the loop it has pending work.VirtualMachine::on_before_exit(src/jsc/VirtualMachine.rs) therefore sees the loop alive after the listener returns, ticks (the auto-flush turns the keep-alive off), and, as it must when a listener really did schedule work, emitsbeforeExitagain. The listener writes again and the cycle repeats.Fix
flush_from_jsturns the keep-alive off when its flush emptied a non-empty buffer, on the success and the error arm alike. This is whaton_auto_flushalready does after its own drain.process.stdout.writecase that already behaved).flush()with nothing buffered a no-op, so it does not touch the explicitref()/unref()state, which shares the same flag.has_pending_data()stays true while auv_writeis in flight (the release keeps happening inon_writeon completion), and its stdout/stderr sinks are synchronous.epoll_ctlto everyconsole.write(). Under--hotthat poll still wakes the watcher loop once, and that loop re-dispatchesbeforeExiton every wakeup (once a second even with an empty listener); that is a separate pre-existing run-loop issue, tracked separately.test/js/bun/console/console-write.test.ts(the reportedconsole.write()case) andtest/js/bun/util/filesink.test.ts, "FileSink flush() from a 'beforeExit' listener":Bun.stdout.writer()andBun.stderr.writer()on pipes, the EPIPE error arm (child's stdout is a socket whose peer is closed), and the could-not-drain case above (child's stdout is a socket whose send buffer the test filled first). Each fixture writes from the firstbeforeExitonly and reports how many it saw: the first four report 2 on the released binary and 1 with this change; the last passes before and after, as intended.filesink.test.ts,test/js/bun/console/,test/js/node/process/process.test.js(existingbeforeExitkeep-alive tests),test/js/bun/io/bun-write.test.js,test/js/web/fetch/blob-write.test.ts,test/js/bun/spawn/spawn-stdin-readable-stream.test.ts.Background
Bun.file(...).writer(),Bun.stdout.writer()andconsole.write(). On POSIX it wraps aPosixStreamingWriter(src/io/PipeWriter.rs) that coalesces writes smaller than a page into a buffer instead of issuing onewrite(2)per call;flush()pushes that buffer out.FilePoll.enable_keeping_process_alive/disable_keeping_process_aliveadd to or remove from the uSockets loop'sactivecount, andVirtualMachine::is_event_loop_aliveis true while that count (or a queue of pending tasks) is non-zero. The poll being registered with epoll/kqueue is separate from this count.flush();on_auto_flushis that callback.beforeExit(same in Node): emitted when the loop drains; if a listener makes the loop alive again, the runtime runs the loop and emitsbeforeExitagain when it drains again. The bug was the sink claiming to have made the loop alive while it had nothing pending.