Skip to content

event loop: report promise rejections left by the turn that let the loop go idle - #37981

Closed
robobun wants to merge 4 commits into
mainfrom
farm/c299516b/worker-final-turn-rejections
Closed

robobun wants to merge 4 commits into
mainfrom
farm/c299516b/worker-final-turn-rejections

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A promise rejected by the callback that lets the event loop go idle (the last setTimeout/setImmediate/I/O callback, or work a 'beforeExit' listener scheduled) is reported late or not at all:
    • node:worker_threads / Web Worker: never. The worker's process.on('unhandledRejection') listeners do not run, the parent gets no 'error', and the worker exits 0 (13 when a top-level await is pending). Repro (worker file, parent attaches 'error'/'exit'): setImmediate(() => Promise.reject(new Error("boom"))); await new Promise(() => {}); gives exit 13 and nothing else on main; Node gives error: boom, exit 1.
    • main thread: only after '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).
  • Cause: auto_tick() ends by walking the rejected-promise list (jsc_hooks.rs:1114); auto_tick_active() did not, leaving that to the caller's next tick() (event_loop.rs:699). Every run loop is tick(); auto_tick_active(); (run_command.rs:1507, repl.rs:1539, VirtualMachine::on_before_exit inner loop, web_worker.rs:929), so when the callbacks of the last auto_tick_active() are what let the loop go idle, no tick() follows. run_command.rs:1568 papers over it for the main thread with one drain after on_before_exit(); the REPL and the worker loop have nothing, and the worker then reaches shutdown(), after which unhandled_rejection ignores everything.
  • The worker's top-level-await case is new since Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075, which replaced the entry wait (it spun with 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 as auto_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, which tick() already pays at least once.
  • Why this is the right layer: it fixes every owner of a run loop at once (main thread, REPL, 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 when auto_tick() and tick() already process theirs. The omission in auto_tick_active() had no recorded reason (the no-runtime fallback for it, VirtualMachine::auto_tick_active, calls tick(), which drains). The main-thread drain at run_command.rs:1568 and the test runner's at test_command.rs:3381 are 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, or process.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.
  • Not changed here: a worker killed by a rejection still reports the hook's exit code (0) where Node reports 1; that pre-existing difference is worker_threads: exit code 1 when a Worker dies from an unhandled rejection #34193. The new tests assert the '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.
  • Verified (debug/ASAN build; every new test fails on a build without the src/ change and passes with it):
    • test/js/node/worker_threads/worker_threads.test.ts, new describe (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 .catch control 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.js process.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 -e last-turn rejection.
    • Unchanged and passing: 68 upstream tests (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.ts and watch.test.ts, node/process/ (the only failures there are local-environment ones, identical on a build without this change), node-timers, setImmediate, microtask, bun:test runner tests, event-emitter, and the worker suites.

Background

  • Rejected-promise list: JSC calls promiseRejectionTracker when 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, calls VirtualMachine::unhandled_rejection, which emits process's 'unhandledRejection' or, with no listener, calls the VM's on_unhandled_rejection hook (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 .catch added 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 alternate tick() 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.
  • Exit code 13: Node's code for "the entry module's top-level await never settled"; a worker applies it when its loop drains with the entry promise still pending and nothing chose another code.
Behaviour matrix (parent-observed for workers; stdout for the main thread)
case main before this PR node v26
worker: immediate rejects, TLA pending no error, exit 13 error, exit handler ran, exit 0 error, exit 1
worker: same, 'unhandledRejection' listener handles it listener never runs, 13 listener runs, 13 same as PR
worker: same, listener rethrows listener never runs, 13 listener, error, exit 1 same as PR
worker: timer rejects, no TLA no error, exit 0 error, exit 0 error, exit 1
worker: 'beforeExit' listener present, immediate rejects beforeExit, no error, 0 error, no beforeExit, 0 error, no beforeExit, 1
worker: immediate posts a port message then rejects task, then listener listener, then task same as PR
worker: 'beforeExit' schedules a rejecting immediate beforeExit x2, no error beforeExit x1, error beforeExit x1, error, 1
worker: 'beforeExit' listener rejects directly no error, 0 error, 0 error, 1
worker: 'beforeExit' calls process.exit(0), TLA pending 13 0 0
main: 'beforeExit' listener present, timer rejects beforeExit, exit, 1 exit, 1 same as PR
main: listener handles it and sets a timer beforeExit, listener; timer never fires listener, timer, beforeExit, 0 same as PR
main: 'beforeExit' schedules a rejecting immediate (listener) beforeExit, beforeExit, listener beforeExit, listener, beforeExit same as PR
bun repl -e, timer rejects nothing listener runs n/a

The remaining exit-code difference on the default worker path (0 vs 1) is #34193's. A worker's own process.stderr writes from an 'exit' handler can still be lost on these paths (#34340), so the tests report through parentPort.

Earlier revision of this PR

The first revision drained only inside the worker's loop (after each auto_tick_active()) and after its on_before_exit(). Review pointed out that this re-implemented the missing tail of auto_tick_active() in one of its callers while the same gap stayed in repl -e, watch mode, the main thread's 'beforeExit' ordering and on_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 into auto_tick_active() and adds the main-thread, REPL, ordering and 'beforeExit' tests.

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.
@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 AM PT - Aug 13th, 2026

❌ @robobun, your commit 900ab8b has some failures in Build #94443 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37981

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

bun-37981 --bun

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on main (f426a8e, debug and release) with the worker file in the PR description: the parent sees no 'error' and the worker exits 13 (0 without the top-level await); the same scripts under Node v26 report the error. The same missing drain also shows on the main thread ('beforeExit' emitted before the rejection is reported, listener-scheduled work dropped) and in bun repl -e (nothing reported). Fix: auto_tick_active() now reports rejected promises at its end like auto_tick() (src/runtime/jsc_hooks.rs), plus the worker's post-'beforeExit' drain and exit-13 guard in src/jsc/web_worker.rs. New tests in test/js/node/worker_threads/worker_threads.test.ts, test/js/web/workers/worker.test.ts, test/js/node/process/process.test.js and test/js/bun/repl/repl.test.ts fail on a build without the src/ change and pass with it. PR: #37981.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Worker rejection handling

Layer / File(s) Summary
Process final-turn rejections
src/jsc/web_worker.rs
spin() handles rejected promises after active ticks and before-exit callbacks. Termination checks control subsequent processing and top-level-await exit-code logic.
Validate worker rejection outcomes
test/js/node/worker_threads/worker_threads.test.ts, test/js/web/workers/worker.test.ts
Tests cover final-turn rejection reporting, listener behavior, exit codes, scheduling, explicit exit, same-turn handling, and web worker error events.

Possibly related PRs

  • oven-sh/bun#32649: Both changes cover termination and promise processing around process.exit() and on_before_exit.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary event-loop change to report promise rejections from the final idle turn.
Description check ✅ Passed The description explains the problem, fix, behavior, scope, and extensive verification results, covering the template requirements.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between ea0f16a and 8016412.

📒 Files selected for processing (3)
  • src/jsc/web_worker.rs
  • test/js/node/worker_threads/worker_threads.test.ts
  • test/js/web/workers/worker.test.ts

Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/jsc/web_worker.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.

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 at run_command.rs:1568 / test_command.rs:3381, and that auto_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 in auto_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 against process.exit(0)-from-beforeExit and parent terminate() 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'.
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/runtime/jsc_hooks.rs Resolved
@robobun robobun changed the title worker: report promise rejections left by the worker's last loop turn event loop: report promise rejections left by the turn that let the loop go idle Aug 13, 2026

@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 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, mirroring auto_tick(); checked callers (run_command, web_worker::spin, on_before_exit, REPL) for ordering regressions.
  • Worker post-beforeExit drain and the new has_requested_terminate() guard on exit-13 — verified entry_promise still roots promise across the added handle_rejected_promises() call.
  • The two open comment-cop notes on VirtualMachine.rs:2020 / jsc_hooks.rs:1118 target 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:2020 and jsc_hooks.rs:1118. Both point at pre-existing multi-line doc comments that this PR made shorter by removing the now-stale mention of handleRejectedPromises; they are not workaround justifications and look like heuristic false positives, but they are technically outstanding.
  • I confirmed entry_promise (the Strong) is still live across the new handle_rejected_promises() call in web_worker.rs, so the subsequent (*promise).status() read remains rooted.

dylan-conway added a commit that referenced this pull request Aug 14, 2026
…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>
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

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 'error'): handle_rejected_promises() at both ends of auto_tick_active() plus the post-on_before_exit() drain in spin(). Rebased onto today's main (b9ef885) it still applies as-is and behaves as described here; the branch is at main...farm/5d8321fa/worker-last-task-rejection for reference only (no competing PR).

Differences there, in case they are useful: it also folds in #34193's exit_code = 1 (placed after the script_allowed() early return that on_unhandled_rejection has on current main, so a rejection surfacing after process.exit() / terminate() does not overwrite the chosen code), which makes the exit-13 guard this PR adds unnecessary, and the worker tests assert node's exit codes exactly ('exit' listeners see 1 and process.exitCode === 1, a listener can still set 42, parent with no 'error' listener exits 1). With both changes every row of the worker matrix above matches node v26 byte for byte. Also re-ran the upstream test-promise-unhandled-* / test-promises-* (18) and test-worker-* (105) files on a debug build with the combined change: all pass.

robobun added a commit that referenced this pull request Sep 8, 2026
… 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.
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #42032. It adds the same handleRejectedPromises pass at the end of auto_tick_active and a ticks-and-rejections pass after the 'beforeExit' listeners, and it sets exit code 1 for a worker that dies from a rejection (the 0-vs-1 rows in this PR's matrix). The exit-13 guard for process.exit() from 'beforeExit' and the tests from here that #42032 did not have (repl -e, the two main-thread 'beforeExit' orderings, the top-level-await worker cases, reported-before-queued-task) were pushed to #42032 in bb390cb. This PR's 15 new tests all pass on that branch.

@robobun robobun closed this Sep 8, 2026
robobun added a commit that referenced this pull request Sep 26, 2026
… 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.
robobun added a commit that referenced this pull request Sep 26, 2026
… 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.
robobun added a commit that referenced this pull request Sep 26, 2026
… 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.
robobun added a commit that referenced this pull request Sep 26, 2026
… 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.
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.

1 participant