Skip to content

Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws - #42032

Draft
robobun wants to merge 6 commits into
robobun/ccb1615d/events-capture-rejectionsfrom
robobun/ccb1615d/worker-last-turn-rejection
Draft

robobun wants to merge 6 commits into
robobun/ccb1615d/events-capture-rejectionsfrom
robobun/ccb1615d/worker-last-turn-rejection

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #44018, fixes #44020

Problem

  • In a node:worker_threads Worker, 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.
  • Cause: auto_tick_active (src/runtime/jsc_hooks.rs) runs those callbacks but, unlike auto_tick, ran no handleRejectedPromises. unhandled_rejection() set no exit code.
  • An 'exit' listener that throws replaced the exit code with 1: process.exitCode = 4 exited 1 (node: 4).

Fix

  • auto_tick_active ends with the rejection pass. dispatch_on_before_exit runs 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.
  • A worker reports a throw from an 'exit' listener and stops at it.
  • Verified: new cases in worker_threads.test.ts, process.test.js, repl.test.ts, each pinned against node 26.

Background

  • The run loops are while alive { tick(); auto_tick_active(); }. A rejection from auto_tick_active waited for the next tick(). An idle loop has none.
  • Considered a rejection pass in the worker's spin() only: the main thread has the same gap.

Downsides

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 becomes main.

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:

door                              bun 1.4.2                  this branch = node 26
timer                             (no event)        exit 0   error:R            exit 1
queueMicrotask / nextTick tail    (no event)        exit 0   error:R            exit 1
beforeExit, beforeExit async      (no event)        exit 0   error:R            exit 1
child 'exit'                      (no event)        exit 0   error:R            exit 1
Bun.sleep().then, setImmediate    (no event)        exit 0   error:R            exit 1
kept alive (setInterval)          error:R           exit 0   error:R            exit 1
'exit' listener sees              exit:0:undefined  exit 0   exit:1:1           exit 1
own unhandledRejection listener   (no event)        exit 0   message:UR:x       exit 0
'exit' listener throws            (no event)        exit 0   error:T            exit 0
same, under process.exit(4)       error:T           exit 1   error:T            exit 4
same, bare process.exit()         error:T           exit 1   error:T            exit 0
same, two listeners               first, second     exit 0   first, error:T     exit 0

Main thread, an 'exit' listener that throws (exit status):

script                                            main   this branch = node 26
process.exitCode = 4                              1      4
process.exitCode = 0                              1      0
no exit code                                      1      1
the listener sets process.exitCode = 9            1      9
process.exit(7)                                   1      7
process.exit(0)                                   1      0
process.exit()                                    1      1
process.exitCode = 4, then process.exit()         1      4
after an unhandled rejection, listener sets 98    1      98
after an uncaught exception                       1      1
process.exit() after a reported rejection, no throw   0  1

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 -e is not a session and exits 1.

The case in Downsides: bun run stores exit code 1 for a printed error after the 'exit' listeners ran (the tail of Run::start). A throw from an 'exit' listener leaves through process_exit and 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:

  • When a rejection ends a worker, bun posts 'error' before it runs the worker's 'exit' listeners. A message posted from an 'exit' listener arrives after 'error' (node: before).
  • On the main thread, process.exit(n) runs the 'exit' listeners registered after one that throws. Tracked as separate work.
  • bun reports rejections at the end of a loop phase, node after each callback. If the callback that rejects and the callback that calls process.exit() are due in the same turn, the process exits before the report.
  • Inside 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.
  • A worker with a pending top-level await and process.exitCode = 0 exits 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 with ptrace between 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_open is not permitted), valgrind, strace and bloaty are not available on the build machine.

operation                               main      base branch   this branch
setImmediate turn                       3,436     3,411         3,414
setTimeout(0) turn                      5,236     5,204         5,206
rejection handled late, one per turn    6,387     6,355         6,361
rejection taken by a listener           6,548     6,792         6,765
text section, bytes                     80,661,004  80,660,702  80,660,958

An earlier head of this branch (ed96331) measured 3,495 per setImmediate turn 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's test-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

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:27 AM PT - Sep 26th, 2026

❌ @robobun, your commit b005757 has 2 failures in Build #120961 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42032

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

bun-42032 --bun

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

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 'exit' is emitted. The rebase will add the cases from its review, with the values of node 26.3.0:

  • A rejection first, then an 'exit' listener that throws, with process.exitCode = 0 or 4: exit 1.
  • process.exitCode = 5, then process.exitCode = undefined, then an 'exit' listener that throws: exit 1. This needs fix(process): allow clearing exitCode #42607.
  • process.exit(3) from a 'beforeExit' listener, then an 'exit' listener that throws: exit 3.
  • process.exitCode = 5 and a 'beforeExit' listener that throws: the 'exit' listeners get 1 and the process exits 1.

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.

Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/runtime/jsc_hooks.rs Resolved
@robobun
robobun force-pushed the robobun/ccb1615d/worker-last-turn-rejection branch from 8da8401 to af5a250 Compare September 8, 2026 16:59
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/runtime/jsc_hooks.rs Resolved
@robobun
robobun force-pushed the robobun/ccb1615d/worker-last-turn-rejection branch from af5a250 to ff269a0 Compare September 8, 2026 17:01
Comment thread src/runtime/jsc_hooks.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 overwrite process.exitCode, but the worker recursion-guard arm at line 1686 still writes self.exit_handler.exit_code = 1 unconditionally. With is_shutting_down = true no longer set before shutdown()'s on_exit(), a worker whose 'exit' listener throws while an uncaughtException listener also throws now reaches this line and reports exit code 1 instead of the user-chosen process.exitCode — the base branch returned early at the is_shutting_down() check and preserved it. Fix: route this write through fail_exit_code(global_object) so every unhandled-error exit_code = 1 respects process._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 the vm.is_shutting_down = true that used to precede it) → dispatch_on_exit(4) → dispatchExitInternal sets m_isExiting = true and emits 'exit'. The listener throws; innerInvokeEventListeners catches it and calls Bun__reportUnhandledError → uncaught_exception(T). On the base branch is_shutting_down() was true here and the function returned early, so exit_code stayed 4. On this branch it is false, so we proceed: is_handling_uncaught_exception = true, Bun__handleUncaughtException emits 'uncaughtException', that listener throws, innerInvokeEventListeners catches it and re-enters uncaught_exception. is_handling_uncaught_exception is true → the !is_main_thread() arm runs and writes self.exit_handler.exit_code = 1 with no Bun__Process__isExiting check. shutdown() then reads exit_handler.exit_code (now 1) into…

    Verification: normal — The PR applies its new fail_exit_code() guard (respect process._exiting) at two of the three sibling sites that write exit_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 sets process.exitCode = 4, has a…

  • 🟣 src/jsc/VirtualMachine.rs — pre-existing: fail_exit_code()'s _exiting guard does not reach the main-thread exit_on_uncaught_exception arm — a throw from an 'exit' listener on a natural drain still calls (hooks.process_exit)(global, 1), whose first line is vm.exit_handler.exit_code = code, so process.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: when Bun__Process__isExiting(global) is true, pass the current exit_handler.exit_code (not the literal 1) to process_exit, or skip this arm and fall through to fail_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 calls Bun__VirtualMachine__exitDuringUncaughtException, so exit_on_uncaught_exception = true. Then on_exit() (run_command.rs:1570) → dispatch_on_exit → dispatchExitInternal sets m_isExiting = true and emits 'exit'; the listener throws → EventEmitter::innerInvokeEventListeners catches it → Bun__reportUnhandledError → uncaught_exception. is_shutting_down() is still false (set only at line 1837, after dispatch_on_exit), so line 1708 (exit_on_uncaught_exception && is_main_thread()) is taken and (hooks.process_exit)(global, 1) runs node::process::exit (node_process.rs:71) which does vm.exit_handler.exit_code = 1, discarding the user's process.exitCode = 4, then re-enters on_exit/global_exit and exits 1. Base behaves the same, so this is pre-existing; the PR's fail_exit_code guard (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_exception arm bypasses the new _exiting guard and hardcodes exit code 1. Trace for process.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 —…

Comment thread src/jsc/VirtualMachine.rs Resolved
@robobun
robobun force-pushed the robobun/ccb1615d/worker-last-turn-rejection branch from ff269a0 to c511150 Compare September 8, 2026 17:39
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

On the two findings outside the diff: the worker recursion-guard arm (a throw from an uncaughtException listener while 'exit' listeners run) now goes through fail_exit_code too, with a test row (process.exitCode = 4 plus throwing uncaughtException and exit listeners gives error:h, exit 4, as in Node). The main-thread exit_on_uncaught_exception arm (process.exitCode = 4 then a throwing 'exit' listener on a natural drain exits 1, Node: 4) is pre-existing and left for a follow-up: matching Node there also needs its "exit code was explicitly set" distinction, since the same case without process.exitCode exits 1 in Node.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 sets exit_called, set_requested_terminate(), and request_termination() on a still-live self.vm, so behavior is a superset of the old path.
  • Dropping vm.is_shutting_down = true before vm.on_exit() in shutdown() — on_exit itself sets it after dispatch_on_exit, so the flag is still set before cleanup hooks; only the window during 'exit' listeners changed.
  • Bun__Process__isExiting — guards on hasProcessObject() 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.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

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 spin() now also checks has_requested_terminate(), like the enclosing 'beforeExit' condition. process.on("beforeExit", () => process.exit(0)) with a pending top-level await exited 13; Node exits 0.

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; worker_threads.test.ts 160/160, repl.test.ts 128/128, the process.onBeforeExit block 16/16). #37981 and #34193 are closed in favor of this PR, #33356 in favor of #42031 plus this PR.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
@robobun
robobun force-pushed the robobun/ccb1615d/events-capture-rejections branch from bf4f65d to 6acc01f Compare September 26, 2026 10:15
@robobun
robobun force-pushed the robobun/ccb1615d/worker-last-turn-rejection branch from ed96331 to e1ab619 Compare September 26, 2026 10:40

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_code now queries the VM's own global (self.global()), the REPL's interactive flag is set on the loop branch that piped and TTY sessions share, and the worker error path's request_termination() after worker.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_exit arms the hard-exit arm when an error was counted, so process_exit(code) runs before print_unhandled_version_note can overwrite the code.
  • Checked dispatch_on_before_exit's new drain + handle_rejected_promises and the two auto_tick_active tails against a stop requested mid-drain: drain_microtasks returns Stopped on a termination and the rejection pass is skipped; the on_before_exit loop already re-checks script_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.
@robobun
robobun force-pushed the robobun/ccb1615d/worker-last-turn-rejection branch from e1ab619 to f8b6c33 Compare September 26, 2026 14:04
@robobun robobun changed the title worker_threads: report uncaught errors raised in a worker's last loop turn Report the rejections of a loop turn before the loop-alive check; an error during 'exit' keeps the exit code Sep 26, 2026
@robobun robobun changed the title Report the rejections of a loop turn before the loop-alive check; an error during 'exit' keeps the exit code Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws Sep 26, 2026
Jarred-Sumner pushed a commit that referenced this pull request Sep 27, 2026
…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 -->
Jarred-Sumner pushed a commit that referenced this pull request Sep 30, 2026
…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 -->
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

#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):

  • Main thread, 7 cases: an 'exit' listener that throws replaces the exit code in effect with 1. With process.exitCode = 4, Node exits 4 and bun exits 1. The same applies to process.exit(7), process.exit(0) and a code that the listener sets.
  • Main thread, 1 case: process.exit() with no argument after a reported rejection exits 0.
  • Worker, 4 cases: a throw from an 'exit' listener is not reported to the parent, the listeners after it still run, and under process.exit(4) the code becomes 1.
  • Worker, 1 case: process.exit(0) from 'beforeExit' while a top-level await is pending exits 13.
  • REPL, 1 case: a throw from a timer during a session makes bun repl exit 1.

When #45582 and #45575 merge, I will rebase this PR onto main and cut it down to these cases.

This branch has not been deployed

No deployments
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.

2 participants