Repository navigation
Conversation
The worker's run loop only reported rejected promises from inside tick(). A promise rejected by a callback that auto_tick_active() ran (an immediate, a timer, an I/O callback) was reported by the next tick(); if that callback was the last thing keeping the loop alive there was no next turn, so the rejection was dropped: no 'unhandledRejection' listener ran, the parent got no 'error', and the worker exited 0, or 13 when a top-level await was still pending. The same happened to rejections raised by the work 'beforeExit' listeners schedule. Report rejected promises after each auto_tick_active() and after on_before_exit(), as run_command.rs does for the main thread before on_exit(), and stop stamping exit code 13 on a worker that stopped itself (process.exit(), uncaught error) while 'beforeExit' ran, since its exit handlers already received their code.
|
Updated 8:05 AM PT - Aug 13th, 2026
❌ @robobun, your commit 900ab8b has some failures in 🧪 To try this PR locally: bunx bun-pr 37981That installs a local version of the PR into your bun-37981 --bun |
|
Status: reproduced on main (f426a8e, debug and release) with the worker file in the PR description: the parent sees no |
WalkthroughChangesWorker rejection handling
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/web_worker.rs`:
- Around line 981-983: Move the existing “rooted by entry_promise” SAFETY
comment into an expression block directly preceding the unsafe dereference of
promise in the termination/status check, preserving the current root guarantee
and behavior.
🪄 Autofix
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: d6da09f3-98bc-4742-b0eb-5e9fd37f66ec
📒 Files selected for processing (3)
src/jsc/web_worker.rstest/js/node/worker_threads/worker_threads.test.tstest/js/web/workers/worker.test.ts
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the worker event loop's rejection-drain and exit-code ordering in spin() — subtle lifecycle semantics — a human look would still be worthwhile.
- Verified the new
handle_rejected_promises()drains mirror the main-thread pattern atrun_command.rs:1568/test_command.rs:3381, and thatauto_tick_active()deliberately omits the drain (jsc_hooks.rs:1117). - Traced non-final-turn ordering: rejections now drain right after the macrotask phase instead of at the top of the next
tick(); per-callback microtasks inauto_tick_active()still run first, so the negative-control (same-turn.catch) holds. - Checked the new
!has_requested_terminate()guard on the exit-13 stamp againstprocess.exit(0)-from-beforeExitand parentterminate()mid-beforeExit; both correctly skip the stamp.
Extended reasoning...
Overview
The PR adds two handle_rejected_promises() drain points to WebWorker::spin() in src/jsc/web_worker.rs: one after each auto_tick_active() in the main loop, and one after on_before_exit() returns. It also adds && !self.has_requested_terminate() to the exit-13 stamp condition so a worker stopped from within beforeExit (via process.exit() or an unhandled rejection arming termination) keeps the code its exit handlers already saw. Eight new tests (7 subprocess-based in worker_threads.test.ts, 1 in worker.test.ts) cover the variant matrix.
Security risks
None. This is error-reporting and exit-code control flow inside the worker's own thread; no untrusted input parsing, auth, or resource handling is touched.
Level of scrutiny
High. spin() is the worker thread's run loop; its ordering determines when 'unhandledRejection' listeners fire, when the parent's 'error' event posts, whether 'beforeExit' runs, and what exit code the parent observes. The Rust diff is only ~10 lines but shifts rejection-drain timing on every non-final turn (from the top of the next tick() to right after auto_tick_active()) and changes exit-13 stamping. I traced both and believe they are correct — the new placement matches Node's per-macrotask-phase timing and the main-thread precedent — but the interaction with on_before_exit()'s own inner tick()/auto_tick_active() loop and the unhandled_error_counter re-dispatch guard is subtle enough that a maintainer familiar with #37075 and #34193 should confirm.
Other factors
The tests are well-designed: subprocess-isolated (since bun test bypasses a worker's own unhandledRejection listener), report through parentPort rather than stdio (avoiding #34340), assert exact message/error/code triples, and include a negative control. The PR description states 6 of 7 fail on an unfixed build. Prior CodeRabbit (SAFETY comment placement) and comment-cop (long comment) feedback were addressed in commits 8ae1913 and 0a33105; the current diff has the SAFETY comment on the line directly above the unsafe block, which is the file's existing convention. No CODEOWNERS entry covers these files.
auto_tick() reports the promises its callbacks left rejected; auto_tick_active() did not, leaving them to the caller's next tick(). Every run loop pairs the two (tick(); auto_tick_active();), so a rejection raised by the callback that let the loop go idle had no next tick(): the main thread reported it only from the explicit drain after 'beforeExit' (so 'beforeExit' fired first and whatever an 'unhandledRejection' listener scheduled never ran), repl -e and the drain loop inside on_before_exit() never reported it, and a worker dropped it entirely (no listener, no 'error' event, exit 0 or 13). Drain at the end of auto_tick_active() like auto_tick() does. The worker additionally drains after on_before_exit() for rejections made by 'beforeExit' listeners themselves, as run_command.rs does, and no longer stamps exit code 13 on a worker that stopped itself during 'beforeExit'.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes core event-loop semantics — adding a rejected-promise drain to auto_tick_active() on every turn for every run loop, plus altering worker exit-code-13 stamping — a human look would still be worthwhile to confirm the original omission wasn't load-bearing.
What was reviewed:
auto_tick_active()now drains on both return paths, mirroringauto_tick(); checked callers (run_command,web_worker::spin,on_before_exit, REPL) for ordering regressions.- Worker post-
beforeExitdrain and the newhas_requested_terminate()guard on exit-13 — verifiedentry_promisestill rootspromiseacross the addedhandle_rejected_promises()call. - The two open comment-cop notes on
VirtualMachine.rs:2020/jsc_hooks.rs:1118target pre-existing doc comments that this PR shortened; they look like false positives.
Extended reasoning...
Overview
This PR adds handle_rejected_promises() to the tail of auto_tick_active() (both the idle early-return and the normal return) in src/runtime/jsc_hooks.rs, mirroring what auto_tick() already does. It updates the two doc comments that previously listed handleRejectedPromises as one of the things auto_tick_active() skips. In src/jsc/web_worker.rs it adds a post-on_before_exit() rejection drain (matching run_command.rs) and gates the exit-13 stamp on !has_requested_terminate() so a worker stopped from a 'beforeExit' listener (via process.exit() or a fatal rejection) keeps its chosen exit code. Fourteen new subprocess tests cover workers, main-thread 'beforeExit' ordering, REPL -e, and Web Worker 'error'.
Security risks
None. This is event-loop timing / error-reporting behavior; no untrusted-input parsing, auth, crypto, or path handling is touched.
Level of scrutiny
High. auto_tick_active() runs on every turn of every run loop (main thread, workers, REPL, watch mode, on_before_exit()'s inner drain). Adding a call that can enter user JS ('unhandledRejection' listeners → on_unhandled_rejection → worker termination) at a new point in the loop changes observable ordering across the whole runtime. The PR's argument that the omission "had no recorded reason" and that the no-runtime fallback already drains via tick() is persuasive, but this is exactly the kind of shared-helper semantic change where a maintainer should confirm intent — the original split predates the PR and may have encoded a deliberate difference.
Other factors
- The worker exit-13 guard change is a user-visible semantic change (
process.on('beforeExit', () => process.exit(0))+ pending TLA now exits 0 instead of 13), which the PR argues matches Node. - Test coverage is thorough (10 worker cases, 3 main-thread ordering cases, REPL, Web Worker), each verified to fail on an unfixed build per the description; the 68 upstream promise/beforeExit tests reportedly still pass.
- Two unresolved comment-cop bot comments remain on
VirtualMachine.rs:2020andjsc_hooks.rs:1118. Both point at pre-existing multi-line doc comments that this PR made shorter by removing the now-stale mention ofhandleRejectedPromises; they are not workaround justifications and look like heuristic false positives, but they are technically outstanding. - I confirmed
entry_promise(theStrong) is still live across the newhandle_rejected_promises()call inweb_worker.rs, so the subsequent(*promise).status()read remains rooted.
…rocess.exit() / uncaught error) (#38483) ### Problem - In a `worker_threads` Worker (or Web `Worker`) whose loop is kept alive by something like a `parentPort` `'message'` listener, `process.exit()` called from a `setImmediate` callback (or from a `nextTick`/microtask that callback queued), and likewise an uncaught exception thrown from one, does not end the worker promptly. The parent's `'exit'` event arrives about 680ms later on 1.4.0 release (about 900ms on a debug build); the same exit from a `setTimeout` callback or a message listener takes ~3ms. - The delay is "until something unrelated wakes the worker's loop": by default that is the idle GC timer (1s period, 30s once it has backed off after 30 idle fires). With `BUN_GC_TIMER_DISABLE=1` the worker never exits at all; the parent process hangs. - Cause: the worker's two self-stop sites, `WebWorker::exit()` (`src/jsc/web_worker.rs:1088`, reached from `Bun__Process__exit`) and the uncaught-error hook `on_unhandled_rejection` (`src/jsc/web_worker.rs:1282`), set `requested_terminate`, stop the VM handle and arm the JSC termination trap, but do not wake the loop. A parent's `worker.terminate()` goes through `VmHandle::request_termination()` (`src/jsc/VmHandle.rs:387`), which does the same three things plus `wakeup()`. - Why only immediates: `spin()` (`web_worker.rs:933`) checks `requested_terminate` after `tick()` and after `auto_tick_active()`. Immediates run at the top of `auto_tick_active()` (`src/runtime/jsc_hooks.rs:1134`), which then computes the poll timeout from the timer heaps and blocks in `tick_with_timeout()`; the flag is only re-read once that poll returns. Timer and I/O callbacks run after the poll, so their exits are seen right away. With a keep-alive and an empty timer heap, `get_timeout()` asks for an unbounded wait. ### Fix - Both self-stop sites call `handle_ref().request_termination()`, the same request a parent's `terminate()` makes: stop the handle, arm the trap, wake the loop. The wake is a write to the loop's wakeup fd (eventfd / mach port / `uv_async_send`), so the poll that follows returns at once and `spin()` sees the flag. Nothing else about the stop changes: `stop()` and `notify_need_termination()` are what these sites already did, in the same order. - Correct because the wake goes through the mechanism the parent-side stop has always used (the worker's loop is already woken from another thread on `terminate()`); waking the loop from its own thread is the same eventfd write, and a wake that turns out to be unnecessary (no keep-alive, so the poll was non-blocking anyway) is harmless, exactly as it is today for a `terminate()` that lands while the worker is idle. - Debug build, `BUN_GC_TIMER_DISABLE=1`: exit from an immediate went from never exiting to ~165ms (the same as an exit from a timer callback, i.e. plain teardown cost); with the GC timer on, ~900ms to ~165ms. Release numbers in the details below. - Verified: - `test/js/node/worker_threads/worker_threads.test.ts`, new `describe` "a worker that stops itself from an immediate exits right away" (3 rows: `process.exit()`, `process.exit()` from a `nextTick` the immediate queued, an uncaught exception). Each runs with `BUN_GC_TIMER_DISABLE=1`, so without the `src/` change the worker never exits and all three rows time out (confirmed on a build without it); with it they pass. Whole file: 132 pass. - `test/js/web/workers/worker.test.ts`, `worker-terminate-lifetime.test.ts`, `worker-terminate-funnels.test.ts`, `worker-late-completion.test.ts`, `worker_destruction.test.ts`, `worker-top-level-await.test.ts`, `worker-async-dispose.test.ts`, `worker-shutdown-post-leak.test.ts`: the only failures are identical on a build without this change (the fs `Binding` LSan report that #35159 addresses, and 5s timeouts of tests that boot many workers on a debug/ASAN build). - 24 upstream `test-worker-*` exit / uncaught / terminate tests under `test/js/node/test/parallel`: all pass. - Not covered here: a promise rejected (not thrown) inside an immediate is still only reported after the next loop wakeup, on the main thread as well. That has a different cause (rejections are not examined between the immediate phase and the poll) and is tracked separately; #37981 is adjacent to it. - #38016 edits the same two sites for a different bug (discarding queued work after the stop); the two changes compose, whichever lands second just calls both. ### Background - Worker run loop: `WebWorker::spin()` loops `tick()` (runs queued tasks and microtasks) then `auto_tick_active()` (runs the immediates queued for this turn, computes how long the loop may sleep from the timer heaps, blocks in the uSockets/libuv poll, then runs due timers). It breaks out and tears the VM down when `requested_terminate` is set; that flag is checked between those two calls, never inside them. - `requested_terminate` and `VmHandle`: a worker is stopped by setting the `WebWorker`'s `requested_terminate` flag (what `spin()` polls) and making the VM stop running script: `VmHandle::stop()` closes the gate every native-to-JS entry consults, and `notify_need_termination()` arms JSC's trap so script already on the stack unwinds with a `TerminationException`. `VmHandle::request_termination()` bundles those two with a loop wakeup, and is what the parent thread's `terminate()` calls; this PR makes the worker's own exit paths call it too. - Loop wakeup: every uSockets loop has an async handle (an eventfd on Linux, a mach port on macOS, a `uv_async_t` on Windows) registered in its poll set. `wakeup()` signals it, so a blocked poll returns immediately and a poll entered afterwards returns without blocking. It works the same whether signalled from another thread or from the loop's own thread before it polls. - Idle GC timer (`GarbageCollectionController`): a per-VM repeating timer that runs a GC every 1s (30s after the heap has been stable for 30 fires); `BUN_GC_TIMER_DISABLE=1` turns it off. It is normally what bounded this bug at about a second, which is why the new tests disable it: without it a worker stuck in the poll stays stuck, so the tests hang rather than pass slowly on a build without the fix. <details> <summary>Measurements (time from the worker's postMessage right before the stop to the parent's 'exit' event)</summary> Release 1.4.0, linux x64, worker kept alive by a `parentPort` listener, before this change: | shape | default | `BUN_GC_TIMER_DISABLE=1` | |---|---|---| | `process.exit()` from an immediate scheduled 300ms in | 679ms | never exits | | `throw` from that immediate | 679ms (code 1) | never exits | | `process.exit()` from a nextTick queued by that immediate | 680ms | never exits | | `process.exit()` from a microtask queued by that immediate | 682ms | never exits | | `process.exit()` from a `setTimeout` callback | 4ms | 3ms | | `throw` from a `setTimeout` callback | 4ms | 5ms | | `process.exit()` from a `parentPort` message listener | 4ms | 4ms | | immediate scheduled synchronously at startup | ~104ms | ~103ms (a JSC startup timer happens to fire then) | Scanning the immediate's scheduling delay with the GC timer disabled (release): 0ms to 100ms after startup the exit piggybacks on a one-shot startup timer (106ms, 79ms, 54ms, 32ms, 4ms), from 150ms on it never exits. The debug build behaves the same (last startup timer at about 150ms), which is what the 300ms in the new tests is sized against. Debug build after this change, all immediate shapes: 162ms to 195ms, the same as the timer shapes (161ms to 168ms) on that build. The `Promise.reject` shape is unchanged (910ms / never), see the last Fix bullet. </details> --------- Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
|
Independently arrived at the same change while looking at the worker shape of this (a worker whose last timer/immediate/async continuation rejects exits 0 with no Differences there, in case they are useful: it also folds in #34193's |
… 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.
|
Closing in favor of #42032. It adds the same |
… 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.
… 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.
… 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.
… 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.
Problem
setTimeout/setImmediate/I/O callback, or work a'beforeExit'listener scheduled) is reported late or not at all:node:worker_threads/ WebWorker: never. The worker'sprocess.on('unhandledRejection')listeners do not run, the parent gets no'error', and the worker exits 0 (13 when a top-levelawaitis pending). Repro (worker file, parent attaches'error'/'exit'):setImmediate(() => Promise.reject(new Error("boom"))); await new Promise(() => {});givesexit 13and nothing else on main; Node giveserror: boom,exit 1.'beforeExit'has been emitted, and what an'unhandledRejection'listener schedules (a timer, a write) never runs; Node reports it before'beforeExit'and runs that work. Work scheduled by a'beforeExit'listener that rejects gets'beforeExit'emitted a second time first.bun repl -e: never (exit 0, nothing printed).auto_tick()ends by walking the rejected-promise list (jsc_hooks.rs:1114);auto_tick_active()did not, leaving that to the caller's nexttick()(event_loop.rs:699). Every run loop istick(); auto_tick_active();(run_command.rs:1507,repl.rs:1539,VirtualMachine::on_before_exitinner loop,web_worker.rs:929), so when the callbacks of the lastauto_tick_active()are what let the loop go idle, notick()follows.run_command.rs:1568papers over it for the main thread with one drain afteron_before_exit(); the REPL and the worker loop have nothing, and the worker then reachesshutdown(), after whichunhandled_rejectionignores everything.auto_tick(), which drains) with this loop; the rest is older.Fix
auto_tick_active()walks the rejected-promise list at its end, on both of its return paths, exactly asauto_tick()does (src/runtime/jsc_hooks.rs); the two doc comments that listed the omission are updated. The walk is a[[likely]]early return when the list is empty, so steady-state cost is one call per loop turn, whichtick()already pays at least once.on_before_exit()'s inner loop, workers, watch mode) instead of adding a fourth per-caller drain, and it gives Node's timing: a rejection is processed right after the macrotask phase that produced it, before anything that phase queued, which is also whenauto_tick()andtick()already process theirs. The omission inauto_tick_active()had no recorded reason (the no-runtime fallback for it,VirtualMachine::auto_tick_active, callstick(), which drains). The main-thread drain atrun_command.rs:1568and the test runner's attest_command.rs:3381are left in place; they still cover a'beforeExit'listener that rejects synchronously.web_worker.rs: the same post-on_before_exit()drain the main thread has (for rejections made by'beforeExit'listeners themselves), and exit code 13 is no longer stamped on a worker whose stop was requested during'beforeExit'(the rejection just reported, orprocess.exit()from the listener): its'exit'handlers already ran with their code. Before,process.on('beforeExit', () => process.exit(0))plus a pending top-level await exited 13; Node exits 0.'error'event, that the worker's'exit'handler ran, and that its argument equals the code the parent received, so they hold either way; the rethrowing-listener case already exits 1 and is asserted exactly.src/change and passes with it):test/js/node/worker_threads/worker_threads.test.ts, newdescribe(10 subprocess tests: immediate and timer last turns, with and without a top-level await, listener handling / rethrowing,'beforeExit'suppressed,'beforeExit'-scheduled and'beforeExit'-made rejections, reported-before-queued-task ordering,process.exit(0)from'beforeExit', and a same-turn.catchcontrol that passes both ways); whole file 132 pass.test/js/web/workers/worker.test.ts"error event" (Web Worker kind, in-process).test/js/node/process/process.test.jsprocess.onBeforeExit: three main-thread tests (no'beforeExit'after a fatal last-turn rejection; listener-scheduled work runs before'beforeExit';'beforeExit'-scheduled rejection reported before any re-emit), each matching Node v26 output byte for byte.test/js/bun/repl/repl.test.ts-elast-turn rejection.test-promise-unhandled-*,test-promises-*,test-process-beforeexit*,test-beforeexit-event-exit,test-process-exit-code*,test-process-exception-capture*,test-microtask-*,test-next-tick-*,test-worker-*lifecycle),repl.test.ts(149),cli/hot/hot.test.tsandwatch.test.ts,node/process/(the only failures there are local-environment ones, identical on a build without this change),node-timers,setImmediate,microtask,bun:testrunner tests,event-emitter, and the worker suites.Background
promiseRejectionTrackerwhen a promise is rejected with no handler; Bun appends it to a per-global list.handleRejectedPromises()walks the list and, for each promise still unhandled, callsVirtualMachine::unhandled_rejection, which emitsprocess's'unhandledRejection'or, with no listener, calls the VM'son_unhandled_rejectionhook (main thread: print and exit 1; worker: post'error'to the parent, run the worker's'exit'handlers, request the worker's stop). Attaching a handler before the walk takes the promise off the list, so a.catchadded in the same turn's microtasks is never reported, as in Node.tick()runs the event loop's task queue and microtasks, then walks the list.auto_tick()/auto_tick_active()run immediates, poll I/O and fire timers; run loops alternatetick()with one of them.auto_tick()(used where the caller waits for something specific, e.g. the test runner) walked the list at its end;auto_tick_active()(the general run loops and the'beforeExit'drain) did not.Behaviour matrix (parent-observed for workers; stdout for the main thread)
'unhandledRejection'listener handles it'beforeExit'listener present, immediate rejects'beforeExit'schedules a rejecting immediate'beforeExit'listener rejects directly'beforeExit'callsprocess.exit(0), TLA pending'beforeExit'listener present, timer rejectsbeforeExit,exit, 1exit, 1'beforeExit'schedules a rejecting immediate (listener)bun repl -e, timer rejectsThe remaining exit-code difference on the default worker path (0 vs 1) is #34193's. A worker's own
process.stderrwrites from an'exit'handler can still be lost on these paths (#34340), so the tests report throughparentPort.Earlier revision of this PR
The first revision drained only inside the worker's loop (after each
auto_tick_active()) and after itson_before_exit(). Review pointed out that this re-implemented the missing tail ofauto_tick_active()in one of its callers while the same gap stayed inrepl -e, watch mode, the main thread's'beforeExit'ordering andon_before_exit()'s own loop, and that no test distinguished the in-loop placement from a single drain after the loop. The current revision moves the drain intoauto_tick_active()and adds the main-thread, REPL, ordering and'beforeExit'tests.