Repository navigation
Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws - #42032
Conversation
|
Updated 7:27 AM PT - Sep 26th, 2026
❌ @robobun, your commit b005757 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 42032That installs a local version of the PR into your bun-42032 --bun |
|
Status: on hold as a draft. #45582 and #45575 cover part of this PR, and its base (#42031) is closed in favor of #45597. This comment lists what is covered and the 14 cases that remain. When #45582 and #45575 merge, I will rebase this PR onto main and cut it down to those cases. #46758 depends on the rule that stays in this PR: a fatal error keeps the exit code in effect while
One of the remaining cases, reproduced on bun 1.4.2 and on main at 91e7e3b with #45575, #45582 and #45597 merged (Linux x64), compared with node 26.3.0: process.exitCode = 4;
process.on("exit", () => {
throw new Error("T");
});
// bun: exit status 1. node 26 and this branch: 4. |
eb26460 to
3650050
Compare
8da8401 to
af5a250
Compare
af5a250 to
ff269a0
Compare
There was a problem hiding this comment.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🔴
src/jsc/VirtualMachine.rs—fail_exit_code()guards the two ordinary error paths (lines 1720, 3876) so a throw during 'exit' cannot overwriteprocess.exitCode, but the worker recursion-guard arm at line 1686 still writesself.exit_handler.exit_code = 1unconditionally. Withis_shutting_down = trueno longer set beforeshutdown()'son_exit(), a worker whose 'exit' listener throws while anuncaughtExceptionlistener also throws now reaches this line and reports exit code 1 instead of the user-chosenprocess.exitCode— the base branch returned early at theis_shutting_down()check and preserved it. Fix: route this write throughfail_exit_code(global_object)so every unhandled-errorexit_code = 1respectsprocess._exiting.Extended reasoning...
Worker body:
process.exitCode = 4; process.on('uncaughtException', () => { throw new Error('h'); }); process.on('exit', () => { throw new Error('T'); });and let the loop drain.spin()→shutdown()→on_exit()(this PR removed thevm.is_shutting_down = truethat used to precede it) →dispatch_on_exit(4)→dispatchExitInternalsetsm_isExiting = trueand emits 'exit'. The listener throws;innerInvokeEventListenerscatches it and callsBun__reportUnhandledError→uncaught_exception(T). On the base branchis_shutting_down()was true here and the function returned early, soexit_codestayed 4. On this branch it is false, so we proceed:is_handling_uncaught_exception = true,Bun__handleUncaughtExceptionemits 'uncaughtException', that listener throws,innerInvokeEventListenerscatches it and re-entersuncaught_exception.is_handling_uncaught_exceptionis true → the!is_main_thread()arm runs and writesself.exit_handler.exit_code = 1with noBun__Process__isExitingcheck.shutdown()then readsexit_handler.exit_code(now 1) into…Verification: normal — The PR applies its new
fail_exit_code()guard (respectprocess._exiting) at two of the three sibling sites that writeexit_handler.exit_code = 1, but leaves the worker recursion-guard arm at src/jsc/VirtualMachine.rs:1686 unconditional, and the PR itself makes that arm newly reachable during 'exit' dispatch. Trace of the failure scenario (worker setsprocess.exitCode = 4, has a… -
🟣
src/jsc/VirtualMachine.rs— pre-existing:fail_exit_code()'s_exitingguard does not reach the main-threadexit_on_uncaught_exceptionarm — a throw from an 'exit' listener on a natural drain still calls(hooks.process_exit)(global, 1), whose first line isvm.exit_handler.exit_code = code, soprocess.exitCode = 4; process.on('exit', () => { throw new Error('T') })exits 1 (Node: 4). The worker case is fixed because!is_main_thread()falls through to line 1720; the main thread does not. Fix: whenBun__Process__isExiting(global)is true, pass the currentexit_handler.exit_code(not the literal1) toprocess_exit, or skip this arm and fall through tofail_exit_code.Extended reasoning...
Main thread, natural drain:
Run::start→on_before_exit()(line 1556) →dispatch_on_before_exit→Process__dispatchOnBeforeExit(BunProcess.cpp:860) unconditionally callsBun__VirtualMachine__exitDuringUncaughtException, soexit_on_uncaught_exception = true. Thenon_exit()(run_command.rs:1570) →dispatch_on_exit→dispatchExitInternalsetsm_isExiting = trueand emits 'exit'; the listener throws →EventEmitter::innerInvokeEventListenerscatches it →Bun__reportUnhandledError→uncaught_exception.is_shutting_down()is still false (set only at line 1837, afterdispatch_on_exit), so line 1708 (exit_on_uncaught_exception && is_main_thread()) is taken and(hooks.process_exit)(global, 1)runsnode::process::exit(node_process.rs:71) which doesvm.exit_handler.exit_code = 1, discarding the user'sprocess.exitCode = 4, then re-enterson_exit/global_exitand exits 1. Base behaves the same, so this is pre-existing; the PR'sfail_exit_codeguard (line 1720) and its doc comment ("Once 'exit' is being emitted the code is left alone") cover only…Verification: pre-existing. The main-thread
exit_on_uncaught_exceptionarm bypasses the new_exitingguard and hardcodes exit code 1. Trace forprocess.exitCode = 4; process.on('exit', () => { throw new Error('T') })on the main thread, natural drain: 1.process.exitCode = 4→Bun__setExitCode→vm.exit_handler.exit_code = 4(src/jsc/VirtualMachine.rs:676). 2.on_before_exit()→… | pre-existing —…
ff269a0 to
c511150
Compare
|
On the two findings outside the diff: the worker recursion-guard arm (a throw from an |
There was a problem hiding this comment.
Thanks — fail_exit_code now queries self.global() instead of the reporting global, which addresses the node:vm-context concern I raised. I re-reviewed and didn't find further bugs; a human look is still worthwhile given this reorders is_shutting_down relative to user 'exit' listeners and adds rejection checkpoints to the shutdown-adjacent tick paths.
What was reviewed:
worker.exit()replacing the manual terminate pair — it setsexit_called,set_requested_terminate(), andrequest_termination()on a still-liveself.vm, so behavior is a superset of the old path.- Dropping
vm.is_shutting_down = truebeforevm.on_exit()inshutdown()—on_exititself sets it afterdispatch_on_exit, so the flag is still set before cleanup hooks; only the window during 'exit' listeners changed. Bun__Process__isExiting— guards onhasProcessObject()before dereferencing, and the Rust extern signature matches (&JSGlobalObject→Zig::GlobalObject*).- New tests:
events.sort()before comparison sidesteps the acknowledged error/message ordering divergence; expected arrays are already in sorted order.
Extended reasoning...
Overview
This PR closes a family of gaps where an unhandled rejection raised during a worker's (or the main thread's) final event-loop turn was silently dropped with exit code 0. It adds a handle_rejected_promises tail to auto_tick_active (both branches), a microtask+rejection drain after dispatch_on_before_exit, a new fail_exit_code() helper gated on process._exiting via a new C++ FFI export Bun__Process__isExiting, wires unhandled_rejection to set exit code 1, switches the worker error handler to worker.exit(), and defers is_shutting_down = true until after user 'exit' listeners run in the worker path. Tests land in the existing process.test.js and worker_threads.test.ts files as a test.concurrent.each matrix of 14 end-of-life scenarios plus two main-thread cases.
Security risks
None identified. The change is behavioral (exit-code and error-reporting fidelity) and does not touch auth, crypto, network parsing, or untrusted-input validation. The new FFI export reads a boolean field on a VM-owned object with a null-guard.
Level of scrutiny
High. This PR touches VM shutdown ordering, worker termination, and the event-loop tick tail — paths where a subtle regression manifests as hangs, double-teardown, or lost error reporting rather than a clean test failure. The is_shutting_down reordering and the let _ = discards on drain_microtasks/handle_rejected_promises results in shutdown paths are the kind of change where a maintainer familiar with the historical reasons for the prior ordering should confirm nothing depended on is_shutting_down being set before 'exit' listeners in the worker (I traced on_exit and it sets the flag itself after dispatch_on_exit, so the net effect looks correct, but the surrounding invariants are dense).
Other factors
My one prior inline finding — fail_exit_code passing a possibly-NodeVMGlobalObject reporting global to Bun__Process__isExiting — was addressed in the current revision: the helper now takes no argument and calls Bun__Process__isExiting(self.global()), with a comment noting exactly this. The test coverage is thorough (each row in the PR's door matrix maps to a .each case), tests follow harness conventions (subprocess with -e, Promise.all drain, combined-object assertion, test.concurrent.each), and the PR description records local runs across the affected Node parallel tests on Linux+ASAN and Windows. The PR is stacked on #42031 and notes it supersedes three older partial fixes; that cross-PR coordination is a maintainer call.
|
Consolidation note. I pushed bb390cb to this branch while sorting out the open PRs that overlap on this path (#33356, #34193, #37981, #38116, #32814). It carries the one piece of #37981 that was missing here: the exit-13 stamp in It also folds in the tests from the superseded PRs that exercise paths this PR fixes but did not test:
All of them fail on 1.4.3 and pass on this branch (Linux x64 debug+ASAN; |
bf4f65d to
6acc01f
Compare
ed96331 to
e1ab619
Compare
There was a problem hiding this comment.
I re-reviewed the six new commits and found no bugs; since this reworks exit-code and shutdown ordering across the main thread, workers and the REPL, a human look is still worthwhile before merging.
What was reviewed:
fail_exit_codenow queries the VM's own global (self.global()), the REPL'sinteractiveflag is set on the loop branch that piped and TTY sessions share, and the worker error path'srequest_termination()afterworker.exit()stops the 'exit' listeners left after one throws — each of the earlier inline points is addressed in the code, with a test per case.- Traced the
process.exitCode = 4/ listener-sets-98 + throwing 'exit' listener cases on the main thread:on_before_exitarms the hard-exit arm when an error was counted, soprocess_exit(code)runs beforeprint_unhandled_version_notecan overwrite the code. - Checked
dispatch_on_before_exit's new drain +handle_rejected_promisesand the twoauto_tick_activetails against a stop requested mid-drain:drain_microtasksreturnsStoppedon a termination and the rejection pass is skipped; theon_before_exitloop already re-checksscript_allowed()between ticks.
Extended reasoning...
The diff touches VirtualMachine.rs (new fail_exit_code helper replacing three exit_code = 1 sites, a microtask/rejection checkpoint after 'beforeExit'), jsc_hooks.rs (auto_tick_active now runs handleRejectedPromises on both return paths), web_worker.rs (worker 'exit' listeners run before the VM is marked shutting down; error path ends via WebWorker::exit plus request_termination), two small C++ probes reading Process flags, a one-line REPL flag, and about 380 lines of subprocess tests. It touches no auth, crypto, or input-parsing surface. The hunt ran dry with no findings, the prior inline points from earlier runs are addressed in the new commits, and every behavioral change has a spawned-subprocess test, but the change alters shutdown semantics shared by the main thread, workers and the REPL, which is why a human look is recommended rather than an approval.
Still open from earlier reviews (3):
- Unresolved: 3 minor or pre-existing.
An unhandled rejection created by the callback that let a Worker's event loop drain (a timer, an immediate, a child 'exit', a 'beforeExit' listener) was never processed: the worker saw "loop not alive", ran 'beforeExit'/'exit' and tore the VM down without the end-of-turn rejection pass, so the parent got no 'error' event and exit code 0, and the worker's own 'unhandledRejection' listener never ran. - auto_tick_active ends with handleRejectedPromises like auto_tick does, so the callbacks a turn dispatched are followed by the rejection pass before the caller's loop-alive check (main thread and workers). - dispatch_on_before_exit drains microtasks and processes rejections after the listeners ran (Node: processTicksAndRejections on the way out of EmitProcessBeforeExit). An async 'beforeExit' listener's continuation now runs at all on the main thread. - unhandled_rejection sets exit code 1 like uncaught_exception does, so a worker that dies from a rejection exits 1 and 'exit' listeners see 1. Neither overrides the code once 'exit' is being emitted (process._exiting), matching Node. - The worker no longer marks its VM shutting down before running 'exit' listeners, so a throw from one is reported to the parent; its error handler ends through WebWorker::exit() so an interrupted process.exit(n) keeps n.
… await pending; fold in the cases from #37981, #34193 and #33356 A worker whose 'beforeExit' listener calls process.exit(0) while the entry module's top-level await is still pending exited 13. The exit-13 stamp now also checks that no stop was requested, as the enclosing 'beforeExit' condition does. Node exits 0 here. Tests carried over from the PRs this one supersedes: - #37981: repl -e last-timer rejection, two main-thread 'beforeExit' orderings, the top-level-await worker cases, and the reported-before-queued-task ordering. - #34193: worker-error-exit-code.test.ts (message-listener and timer sites, the worker's own 'exit' argument, listener suppression). - #33356: a main-thread listener that recovers from a 'beforeExit' rejection after a microtask hop.
Once 'exit' is being emitted (process._exiting), Node's fatal-exception handler leaves process.exitCode alone. The process then exits with process.exitCode, or with 1 when that is still undefined. A worker exits with `process.exitCode || 0`. fail_exit_code now encodes that rule: while exiting, a chosen code stands, and only a main thread with no code chosen still gets 1. The main thread's exit-on-uncaught-exception arm goes through it instead of passing a literal 1 to process_exit. On the main thread, with an 'exit' listener that throws: - process.exitCode = 4 exits 4 (was 1) - process.exit(7) exits 7 (was 1) - process.exitCode = 0 and process.exit(0) exit 0 (was 1) - a code the listener sets before it throws is kept (was 1) - a bare process.exit() and a natural exit with no code still exit 1
…ases into worker_threads.test.ts Review follow-up. A worker that exits on its own with several 'exit' listeners ran the ones registered after a listener that threw. shutdown() has already unpublished the VM when it runs the 'exit' listeners, so WebWorker::exit() requested no stop there. The error handler now requests it on the VM it was given, which ends the dispatch as process.exit() does. Node stops at the throw too. The cases of worker-error-exit-code.test.ts move into the table in worker_threads.test.ts (the message-listener and timer-throw sites were the only ones it did not have), and the file is removed.
Review follow-up. With exit code 1 now set for an unhandled rejection, `bun repl` exited 1 after any rejection reported during the session. An uncaught exception from a timer already did that. The session has reported those errors and goes on, so its exit code is process.exitCode, or 0, as in node's REPL. `bun repl -e` is not a session and still fails.
e1ab619 to
f8b6c33
Compare
…ocess as this (#44057) ### Problem - `process.exit()` calls `process.reallyExit(code)`, as Node.js does. When `process.reallyExit` is not callable, Bun throws a `TypeError` with an empty message. Node.js throws `TypeError: process.reallyExit is not a function`. - A build with assertions aborts: `ASSERTION FAILED: !message.isEmpty()` at `vendor/WebKit/Source/JavaScriptCore/runtime/Error.cpp(71)` in `JSC::createTypeError`. - The cause is `JSC::call(globalObject, reallyExitVal, args, ""_s)` at `src/jsc/bindings/BunProcess.cpp:913`. The last argument is the message for a value that is not callable. This overload also passes the function as its own `this`. Node.js passes `process`. ### Fix - The call passes `process` as `this` and the message `process.reallyExit is not a function`. - Correct because Node.js runs `process.reallyExit(process.exitCode || 0)` in JavaScript. That is a method call on `process`, and V8 gives that message. - Verified: two new tests in `test/js/node/process/process.test.js`. Both fail on release 1.4.3-canary.1. Also ran the other `process.exit()` tests and 12 exit tests from `test/js/node/test/parallel/`. ### Background - `process.reallyExit` is the raw exit of Node.js: it emits no `'exit'` event. Libraries such as signal-exit replace it. - `JSC::call` is the JavaScriptCore helper that calls a JS function from C++. - No other design applies: the two wrong values are arguments of one call. No other `JSC::call` in `src/` has an empty message. ### Downsides - A function that replaces `process.reallyExit` now sees `this === process` when `process.exit()` calls it. Before, `this` was the function itself. - Size: +53 bytes in the `BunProcess.cpp` object (`-O3`, no LTO): 37 for the message, 16 of code. Per call: no new allocation, branch, or syscall. <details><summary>Notes</summary> Repro: ```js process.reallyExit = "str"; process.exit(0); ``` | | stderr | exit code | |---|---|---| | Bun 1.4.3-canary.1+367d939d9 (release) | `TypeError: ` | 1 | | Bun 1.4.3-debug+367d939d9 | `ASSERTION FAILED: !message.isEmpty()` | 134 | | this branch (debug) | `TypeError: process.reallyExit is not a function` | 1 | | Node.js v26.3.0 | `TypeError: process.reallyExit is not a function` | 0 | The same result for each value tried: `"str"`, `undefined`, `null`, `1`, `{}`, `Symbol("x")`, and `delete process.reallyExit`. The `this` value, with `process.reallyExit = function () { console.log(this === process) }` and `process.exit(3)`: `false` before, `true` on this branch and on Node.js v26.3.0. Not changed by this PR: when nothing catches the `TypeError`, Node.js exits with the code given to `process.exit()` (0 above, 5 for `process.exit(5)`). Bun exits 1. A replaced `process.reallyExit` that throws shows the same difference. For an entry point that throws, `uncaught_exception` (`src/jsc/VirtualMachine.rs:2161`) and `exit_with_unhandled_note` (`src/runtime/cli/run_command.rs:1638`) both set exit code 1. Related to #42032, which changes the first place for an `'exit'` listener that throws. The new test catches the error in the child, so it does not depend on that exit code. History: #16026 added the call with the comment `// process.reallyExit(exitCode);`. Search for other call sites: `JSC::call`, `call`, `profiledCall` and `construct` with an empty message literal in `src/` and `packages/`. `BunProcess.cpp:913` is the only one. Size measurement: compiled `BunProcess.cpp` from `origin/main` and from this branch with the release flags of the build (`-O3 -march=nehalem`), without `-flto=thin` so that the object is native code. `size -A`: `.text` of `Bun::Process_functionExit` 712 to 728 bytes, `.rodata.str1.1` 10594 to 10631 bytes, `.data` and `.bss` unchanged. On a debug build without the fix, the first new test fails: the child exits with code 134 and the assertion is in its stderr. Tests run on the debug build of this branch: - `test/js/node/process/process.test.js -t "process.exit\(\)"`: 6 pass. - `test/js/node/process/process.test.js`, whole file: 179 pass, 5 skip, 3 fail. `process` fails because the container sets no `USER`. `signal > simple case works` and `signal > process.emit will call signal events` time out at 5 s in the whole-file run, with and without the fix. Each passes in 2.5 s when it runs alone. - `test/js/node/test/parallel/`: `test-process-exit-code-validation.js`, `test-process-exit-code.js`, `test-process-exit-from-before-exit.js`, `test-process-exit-handler.js`, `test-process-exit-recursive.js`, `test-process-exit.js`, `test-process-really-exit.js`, `test-worker-exit-code.js`, `test-worker-nested-on-process-exit.js`, `test-worker-on-process-exit.js`, `test-worker-process-exit-async-module.js`, `test-crypto-op-during-process-exit.js`: all exit 0. Not run: macOS, Windows. The changed line has no platform-specific code. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process.test.js <!-- robobun:evidence:end -->
…allback (#44250) ### Problem - With nothing else alive, a JS stream piped into a `FileSink` on a pipe stops at the first full drain. Un-awaited `Bun.write(Bun.stdout, new Response(stream))` delivers 4,194,304 of 8,388,608 bytes, exits with code 0, and never settles. - `FileSink::on_write` (`src/runtime/webcore/FileSink.rs:366`) ran its microtask checkpoint before it resumed the pump. The run loop does not count the microtask the pump queues. `on_close` had no checkpoint. ### Fix - `completion_scope()` opens an event-loop scope while a stream is piped in. Its exit is the checkpoint. `on_write` and `on_close` open it before they enter JS. - Correct because a callback from the loop owes a checkpoint for the JS it enters. - Verified: `test/js/bun/util/filesink.test.ts`, seven new tests, 0 of 7 on main. Each scope has a test that fails without it. Also 15 more Linux suites. - Self-reviewed: 6 concerns raised, 5 addressed as proposed, 1 otherwise (Notes). ### Background - The pump (`readStreamIntoSink`) feeds the sink from a JS stream and waits for `ready()` after a short write. A microtask checkpoint runs queued promise reactions. - Considered a drain in the run loop after each poll callback: every loop turn pays. A scope around every `FileSink` poll callback charges plain writers too. ### Downsides - A sink written from script pays 7 more instructions per `on_write` and per `on_close`. `.text` grows by 256 bytes. - An un-awaited `Bun.write` whose stream fails now exits with code 1. Main exits 0 in 9 of 10 runs. - After `proc.unref()`, a stream stdin that can always produce now arrives in full. Main cuts it. <details><summary>Notes</summary> All numbers are from release builds of main ad60a9b and of this branch at 028985b on Linux x64, unless a line says debug. **Repro** (no `beforeExit` listener, no timer, no top-level await): ```js // bun repro.mjs | (sleep 0.4; wc -c) expected 8388608, main prints 4194304 const first = new Uint8Array(4 * 1024 * 1024).fill(97); const chunk = new Uint8Array(64 * 1024).fill(98); let pulls = 0; Bun.write(Bun.stdout, new Response(new ReadableStream({ pull(c) { if (++pulls === 1) return c.enqueue(first); if (pulls <= 65) return c.enqueue(chunk); c.close(); }, }))); ``` **Why main loses the step.** The run loop is `while vm.is_event_loop_alive() { vm.tick(); vm.auto_tick_active(); }` (`src/runtime/cli/run_command.rs`). `auto_tick_active` runs the poll callback. `on_write` drops the loop ref of the poll because the buffer is empty, and `src.ready()` makes the pump read the next chunk. The chunk steps, close steps and error steps of that read are queued with `queueReactionJob` (`src/jsc/bindings/webcore/streams/JSReadRequest.cpp`). The callback returns, nothing is alive, and the loop ends before the next `tick()`. One ref'd timer gives the loop another turn and hides the bug. **The fix itself**, 20 runs per shape. A run counts when every byte arrived and the reaction of the promise ran. | Shape | main | this branch | | --- | --- | --- | | chunks arrive after the pump parked, to a piped stdout | 0 of 20 | 20 of 20 | | the same into a FIFO | 0 of 20 | 20 of 20 | | stream closes while the pump is parked | 2 of 20 | 20 of 20 | | stream fails while the pump is parked | 1 of 20 | 20 of 20 | | direct stream, closed with bytes still buffered | 0 of 20 | 20 of 20 | | small chunk, sink ended against a full socket | 0 of 20 | 20 of 20 | | direct stream that awaits each `flush()` (control) | 20 of 20 | 20 of 20 | **Each scope is needed** (debug+ASAN builds, the seven new tests): | Build | Failing tests | | --- | --- | | main | 7 | | this branch | 0 | | this branch without the scope in `on_write` | 5: chunks to stdout, chunks to FIFO, `beforeExit` count, close, error | | this branch without the scope in `on_close` | 1: sink closed with no source parked | **Microtask checkpoints and `enter()` calls per callback**, from debug logs (`BUN_DEBUG_ALL=1`), constant over 3 runs. The column for main is from a debug build of 1313ca6, where `FileSink.rs` is the same file as on ad60a9b. | Callback | main | this branch | | --- | --- | --- | | drain that resumes the pump | 1 checkpoint, 1 `enter()` | 1 checkpoint, 2 `enter()` | | drain that leaves bytes in the buffer | 0, 0 | 0, 0 | | close, a source had parked | 1, 1 | 1, 3 | | close, no source parked | 1, 1 | 2, 2 | | sink written from script, drain or close | 1, 1 | 1, 1 | On main the one checkpoint of a drain runs before the pump is resumed. On this branch it runs after. **`beforeExit` emissions** for the 8 MiB pump with a listener installed, 10 runs: main 1 to 21, this branch 1 in 10 of 10, Node v26.3.0 1 in 10 of 10. **Cost for a sink written from script.** One `on_write(65536, Drained)` call: 46 instructions on main, 53 on this branch, no `call` instruction in either. One `on_close` call: 28 and 35, 2 `call` instructions in both. Counted with gdb `nexti` on release builds with symbols, identical over 8 calls. Binary size with `size`: `.text` 80,667,862 to 80,668,118, `data` and `bss` equal, the file keeps its size of 80,836,168 bytes. `on_write` grows from 1,302 to 1,530 bytes and `on_close` from 1,860 to 1,983. **Not measured:** `write(2)` calls that return `EAGAIN` per drain. `strace`, `perf` and `valgrind` are not installed where I measured, and under gdb `catch syscall` the writer is so slow that the pipe never fills. **A stream that fails with no handler**, 10 runs. Main: exit code 0 and nothing on stderr in 9 runs, `error: boom` and exit code 1 in 1 run. This branch: `error: boom` and exit code 1 in 10 runs. The same stream awaited at top level gives `error: boom` and exit code 1 on both builds. **`proc.unref()` and a stream stdin**, 10 runs per cell. The child starts to read only after the pump has taken the first chunk. | Stream | main | this branch | | --- | --- | --- | | can always produce | 4,194,304 of 8,388,608 | 8,388,608 | | all data queued and closed before the spawn | 219,264 in 9 of 10 runs | 219,264 in 9 of 10 runs | | produces one chunk, then waits on a promise that never settles | 4,194,304, parent exits | 4,194,304, parent exits | | can always produce, no `unref()` | 8,388,608 | 8,388,608 | This change does not touch `Writable::unref`, which clears the loop ref of the stdin writer also while it holds accepted bytes (second row). What `unref()` must mean for a writer that owes bytes is a decision for a maintainer and relates to #33533. No test here uses `unref()`. **Sites that this change leaves alone, and why.** | Site | Reason | | --- | --- | | `on_ready` | The POSIX writer never calls it. It is the `on_writable` slot on Windows, where the unfixed build has no failing case. | | `EndOfFile` arm of `on_write` | No test reaches it with a piped stream on POSIX. | | Windows settle for a borrowed fd, in `end_writer` and in the abort handler | No failing case on Windows. #42819 rewrites that code. | | `on_auto_flush` | It runs inside the deferred task queue, where `EventLoop::exit()` does not drain. The task that `run_pending_later` queues just before the resume keeps the loop alive for the next `tick()`. | | Other sinks and other poll owners | Not examined here. | **Other PRs that touch the same lines.** - #43761 added a ref guard at the top of `on_close` and merged while this PR was open. This branch contains it: guard first, scope second. Its stdin test passes and the tests here pass. - #42032 adds a checkpoint after the `beforeExit` dispatch. On unfixed code with that hunk, six of the seven tests pass, because the loop passes through that dispatch after each drain. The seventh fails: `beforeExit` fires more than once for one stream (8 times in my run). With this change and that hunk together all seven pass. **Self-review.** Concerns raised and what I did: 1. A ref guard in `on_close` repeated #43761 without its tests. The proposal was to stack this PR on #43761. I removed the guard and did not stack: the scope does not read the sink when it ends, so it does not need the guard. #43761 has merged since. 2. A `cfg(windows)` scope in `end_writer` had no failing test and sits in code that #42819 rewrites. Removed. 3. A scope and a guard in `on_ready`, and an `EndOfFile` clause in `on_write`, had no failing test. Removed. 4. A gate on `VM::is_entered()` had no failing test. I built the change without the gate and ran a script that closes the sink inside the `Bun.write` call with a microtask already queued. The order of the microtask did not change, because a script frame already runs inside an entered scope. Removed. 5. The predicate also matched a `stdin: "pipe"` sink that script never read, and shell pipes. It now tests only for a piped stream. 6. The tests would pass on unfixed code once #42032 lands. Added the test that counts `beforeExit`. **Platforms.** On Windows Server 2019 x64 a debug build of main passes every portable shape, also with a first chunk of 64 MiB and of 256 MiB where the child waits 0.6 to 2.3 s for the reader. So on Windows the five portable tests guard against a regression only. The first revision of the FIFO test read its end with `Bun.file(fd).bytes()`. On macOS that read never finished, 8 of 8 attempts, although the child had exited. The test now reads with `read(2)` in a poll with a deadline, and passes on macOS. **Suites** on the debug build of this branch, 60 s per test, all pass: `filesink` (77 after the merge with main), `bun-write` (86), `spawn-stdin-readable-stream` (4 files, 55), `spawn-streaming-stdin`, `spawn-stdin-destroy`, `spawn-stdin-pipe-fd-leak`, `readablestream-helpers`, `streams` (624), `compression`, `sync-pull-fast-path`, `native-source-onclose-leak`, `direct-readable-stream`, `bunshell` (436). </details> <!-- robobun:evidence:begin --> --- **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/bun/util/filesink.test.ts <!-- robobun:evidence:end -->
|
#42031, the base of this PR, is closed in favor of #45597. Two more open PRs cover part of this one, so I converted it to a draft. I merged #45597, #45582 and #45575 into main at 91e7e3b and ran the 41 test cases of this PR on that build. 27 pass and 14 fail. Node 26.3.0 gives the expected result for each of the 14. Covered:
Not covered (the 14 cases):
When #45582 and #45575 merge, I will rebase this PR onto main and cut it down to these cases. |
Fixes #44018, fixes #44020
Problem
node:worker_threadsWorker, an unhandled rejection from the callback that let the loop drain (timer, immediate, child'exit','beforeExit') was dropped: no'error'event, exit code 0. Node reports'error'and exits 1.auto_tick_active(src/runtime/jsc_hooks.rs) runs those callbacks but, unlikeauto_tick, ran nohandleRejectedPromises.unhandled_rejection()set no exit code.'exit'listener that throws replaced the exit code with 1:process.exitCode = 4exited 1 (node: 4).Fix
auto_tick_activeends with the rejection pass.dispatch_on_before_exitruns a microtask checkpoint and that pass after the listeners.fail_exit_code(src/jsc/VirtualMachine.rs) is the one place where a fatal error sets exit code 1. While'exit'is emitted, the code in effect stands. With none chosen, the main thread exits 1 and a worker 0.'exit'listener and stops at it.worker_threads.test.ts,process.test.js,repl.test.ts, each pinned against node 26.Background
while alive { tick(); auto_tick_active(); }. A rejection fromauto_tick_activewaited for the nexttick(). An idle loop has none.spin()only: the main thread has the same gap.Downsides
Bun.servehandler error,process.exitCode = 0and a throwing'exit'listener,bun runexits 0 (main: 1).setImmediateturn: +2 instructions against the base branch (3,411 to 3,414, spread 5). Text: +256 bytes.Notes
Stacked on #42031 (base branch
robobun/ccb1615d/events-capture-rejections). That PR makes the rejection reporter run the ticks and microtasks of the listeners after each batch, and gives it an inlined check for a turn with no rejected promise. Once it merges, the base of this PR becomesmain.Worker matrix (worker body, events seen by the parent, exit code). Each of the 24 bodies tried gives the same events and exit code on this branch and on node 26:
Main thread, an
'exit'listener that throws (exit status):bun repl: a session reports its errors and goes on, so they do not decide its exit code (process.exitCode, or 0, as in node's REPL). bun 1.4.2 exited 1 after an uncaught exception in a session and 0 after a rejection.bun repl -eis not a session and exits 1.The case in Downsides:
bun runstores exit code 1 for a printed error after the'exit'listeners ran (the tail ofRun::start). A throw from an'exit'listener leaves throughprocess_exitand never reaches that store. main passed a literal 1 there. This branch passes the code in effect, which the program set to 0. #38116 stores the 1 before the'exit'listeners run, and then the code in effect is 1. The two PRs merge with no conflict.Known differences that remain, unchanged by this PR:
'error'before it runs the worker's'exit'listeners. A message posted from an'exit'listener arrives after'error'(node: before).process.exit(n)runs the'exit'listeners registered after one that throws. Tracked as separate work.process.exit()are due in the same turn, the process exits before the report.bun test, a worker's uncaught error is taken by the test runner. worker_threads: run worker uncaughtException/unhandledRejection listeners under bun test #36445 covers it.process.exitCode = 0exits 13. node 26 does the same.Measurements. Release builds of main (36cd151), the base branch (b005757) and this branch (f8b6c33) on the same machine. Sections from
size. Instructions are user-space instructions of the main thread for one operation, counted exactly by single-stepping withptracebetween two marker system calls, as the difference of a run with 500 and a run with 1,500 operations.BUN_JSC_useJIT=0, one GC marker and no concurrent GC make the count repeatable: the spread between two runs is 5 instructions or less.perf(perf_event_openis not permitted),valgrind,straceandbloatyare not available on the build machine.An earlier head of this branch (ed96331) measured 3,495 per
setImmediateturn by the same method. The reporter made two calls per pass then. It is one inlined load and compare now.Suites run locally (Linux x64 debug+ASAN):
test/js/node/worker_threads/(all files),test/js/web/workers/worker.test.ts,process.test.js,event-emitter.test.ts,repl.test.ts, and a sweep of node'stest-process-*,test-promise*,test-timers-*,test-worker-*,test-events*,test-stream-*tests with no result change.Closed PRs that each addressed one face of this, superseded by this one: #37981, #34193, #32866. #40132 changes the signature of
WebWorker::exit(). The PR that lands second needs a small rebase.no test proof · iteration 10 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process.test.js, test/js/bun/repl/repl.test.ts