Skip to content

worker_threads: run worker uncaughtException/unhandledRejection listeners under bun test - #36445

Closed
robobun wants to merge 1 commit into
mainfrom
claude/farm/b7d286e3/worker-isbuntest-main-thread-only
Closed

robobun wants to merge 1 commit into
mainfrom
claude/farm/b7d286e3/worker-isbuntest-main-thread-only

Conversation

@robobun

@robobun robobun commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

A node:worker_threads Worker that throws synchronously emits 'exit' with code 1 when run standalone, but code 0 when run inside bun test. Node gives 1 in both contexts. Separately, a worker's own process.on('uncaughtException') / process.on('unhandledRejection') listeners are never called under bun test.

// under bun -e: exit code 1.  under bun test: exit code 0.
import { Worker } from 'node:worker_threads';
const w = new Worker('throw new Error("X")', { eval: true });
w.on('error', () => {});
w.on('exit', c => console.log('exit code:', c));
// under bun -e / node: prints HANDLER CALLED, exit 0.
// under bun test: handler never runs, parent 'error' fires, exit 0.
const w = new Worker(`
  process.on('uncaughtException', e => console.error('HANDLER CALLED:', e.message));
  setImmediate(() => { throw new Error('X') });
`, { eval: true });

Cause

isBunTest is a process-global atomic, so it is true inside a worker VM spawned by a test. Both VirtualMachine::uncaught_exception and VirtualMachine::unhandled_rejection have a short-circuit when isBunTest is set: increment unhandled_error_counter, call (self.on_unhandled_rejection)(...), return. That short-circuit skips Bun__handleUncaughtException / Bun__handleUnhandledRejection (so the worker's own process.on(...) listeners never run) and skips the exit_handler.exit_code = 1 write. The short-circuit exists so jest's main-thread reporter captures errors instead of the user's process listeners swallowing them, but in a worker VM on_unhandled_rejection is the worker error dispatcher in web_worker.rs, not jest's, and the test runner has no visibility into the worker's error counter.

For the sync-throw case the entry promise is a JSInternalPromise, so the rejection is not tracked via promiseRejectionTracker; spin() sees Rejected, calls uncaught_exception, gets handled == true back, and continues with exit_code still 0. (An async Promise.reject at top level accidentally gave 1 because the rejection trips handle_rejected_promises inside wait_for_promise_with_termination, which makes load_entry_point_for_web_worker return Err(WorkerTerminated) and hit spin()'s Err arm.)

Fix

Gate both isBunTest short-circuits on self.is_main_thread(). Worker VMs under bun test now take the normal path: the worker's own uncaughtException/unhandledRejection listeners run, and an unhandled error sets exit_code = 1 before dispatching to the parent, same as standalone.

Verification

Three new tests in the error event describe of worker_threads.test.ts run the Worker directly inside the test process (the bug only reproduces under bun test): sync-throw exit code 1, worker's uncaughtException listener is called, worker's unhandledRejection listener is called. All three fail on main and pass with the fix; all three match Node's output.

Also re-enabled the entry throws case in the getHeapSnapshot rejects-ERR_WORKER_NOT_RUNNING table, which had been omitted with a comment explaining that isBunTest made it resolve instead of reject under bun test.

Full worker_threads.test.ts (95 tests), test/js/web/workers/worker.test.ts, and the vendored test-worker-*uncaught*/test-worker-*exit* Node tests pass.

Related

#34193 sets exit_code = 1 in the worker's last-resort on_unhandled_rejection for the standalone unhandled-rejection path; this PR fixes the bun test-specific divergence. They compose without conflict.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/worker_threads.test.ts
bun test v1.4.0 (54d3761fa)

test/js/node/worker_threads/worker_threads.test.ts:
(pass) support eval in worker [1905.66ms]
(pass) all worker_threads module properties are present [25.09ms]
(pass) markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent [27.32ms]
(pass) all worker_threads worker instance properties are present [166.23ms]
(pass) threadId module and worker property is consistent [208.09ms]
(pass) receiveMessageOnPort works across threads [1723.32ms]
(pass) receiveMessageOnPort works as FIFO [14.76ms]
(pass) you can override globalThis.postMessage [1689.70ms]
(pass) support require in eval [1679.69ms]
cwd /workspace/bun
realpath test/js/node/worker_threads/fixture-argv.js
(pass) support require in eval for a file [1701.24ms]
(pass) support require in eval for a file that doesnt exist [1677.94ms]
(pass) support worker eval that throws [1669.97ms]
(pass) execArgv option > inherits the parent's execArgv when falsy or unspecified [6811.29ms]
(
... (truncated)

release without fix: BUILD FAILED (no junit output)
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/worker_threads/worker_threads.test.ts:

# Unhandled error between tests
-------------------------------
SyntaxError: Export named 'markAsUncloneable' not found in module 'node:worker_threads'.
-------------------------------


 0 pass
 1 fail
 1 error
Ran 1 test across 1 file. [166.00ms]
__F:-1:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/worker_threads.test.ts
bun test v1.4.0 (54d3761fa)

test/js/node/worker_threads/worker_threads.test.ts:
(pass) support eval in worker [1766.45ms]
(pass) all worker_threads module properties are present [23.31ms]
(pass) markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent [23.73ms]
(pass) all worker_threads worker instance properties are present [167.57ms]
(pass) threadId module and worker property is consistent [195.10ms]
(pass) receiveMessageOnPort works across threads [1706.00ms]
(pass) receiveMessageOnPort works as FIFO [12.75ms]
(pass) you can override globalThis.postMessage [1678.83ms]
(pass) support require in eval [1658.70ms]
cwd /workspace/bun
realpath test/js/node/worker_threads/fixture-argv.js
(pass) support require in eval for a file [1686.67ms]
(pass) support require in eval for a file that doesnt exist [1680.47ms]
(pass) support worker eval that throws [1667.32ms]
(pass) execArgv option > inherits the parent's execArgv when falsy or unspecified [6734.60ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     54d3761faf
  features     baseline

22 deps, 108 codegen, 1171 objects in 783ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [13.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [6.00ms]
[4/1234] gen .bind.ts → GeneratedBindings.cpp
[5/1234] fetch zlib
[zlib] up to date
[6/1234] fetch tinycc
[tinycc] up to date
[7/1234] gen ErrorCode+*.h
[8/1234] gen bindgenv2
[9/1234] fetch picohttpparser
[picohttpparser] up to date
[10/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs                          | 12 ++++-
 test/js/node/worker_threads/worker_threads.test.ts | 59 +++++++++++++++++++---
 2 files changed, 63 insertions(+), 8 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/jsc/VirtualMachine.rs                               6      2      0
test/js/node/worker_threads/worker_threads.test.ts      2      4      0

self-review · no surviving concerns

30 concerns were raised and did not survive verification.

…ners under bun test

The process-global isBunTest flag made a worker VM's uncaught_exception
and unhandled_rejection short-circuit into the worker's last-resort error
dispatcher, skipping Bun__handleUncaughtException/Bun__handleUnhandledRejection
and the exit_code = 1 write. So under 'bun test' a worker that threw
synchronously emitted 'exit' with code 0 (Node and standalone Bun give 1),
and its own process.on('uncaughtException'/'unhandledRejection') listeners
were never consulted.

The isBunTest short-circuit exists so jest's main-thread reporter captures
errors; in a worker VM on_unhandled_rejection is the worker error dispatcher,
not jest's, and the test runner has no visibility into the worker's error
counter. Gate the short-circuit on is_main_thread() so workers under
'bun test' take the normal path and behave the same as they do standalone.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7ff38055-4d96-4623-a515-8373154d7690

📥 Commits

Reviewing files that changed from the base of the PR and between c08f665 and 54d3761.

📒 Files selected for processing (2)
  • src/jsc/VirtualMachine.rs
  • test/js/node/worker_threads/worker_threads.test.ts

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

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

LGTM — narrowly scopes the isBunTest error short-circuit to the main-thread VM so worker error handling matches standalone behaviour.

What was reviewed:

  • Confirmed is_main_thread() is self.worker.is_none() and that a worker VM's on_unhandled_rejection is the web_worker.rs dispatcher, not jest's — so the short-circuit was indeed skipping Bun__handleUncaughtException and the exit_code = 1 write for workers.
  • Checked these are the only two Rust-side isBunTest reads; no sibling sites left unfixed.
  • Tests run in-process (required — the bug only reproduces under bun test), assert exact event sequences and exit codes, and the re-enabled getHeapSnapshot "entry throws" case was previously omitted for exactly this reason.
Extended reasoning...

Overview

Two-line fix in src/jsc/VirtualMachine.rs: adds && self.is_main_thread() to the isBunTest short-circuit in both uncaught_exception and unhandled_rejection. Three new in-process tests in worker_threads.test.ts covering sync-throw exit code, worker-side uncaughtException, and worker-side unhandledRejection; one previously-skipped test.each row re-enabled.

Security risks

None. Pure control-flow gating on an existing predicate; no new inputs, no allocation, no FFI surface change.

Level of scrutiny

Low-to-moderate. The change is a two-token predicate addition at each of two sites; the surrounding fall-through path is the same code that already runs for workers outside bun test. I verified is_main_thread() returns self.worker.is_none() (VirtualMachine.rs:970) and that web_worker.rs installs its own on_unhandled_rejection on the worker VM (web_worker.rs:953, 1496), which confirms the PR description's mechanism: under bun test the short-circuit was calling the worker error dispatcher (fine) but skipping Bun__handleUncaughtException/Bun__handleUnhandledRejection (so worker process.on listeners never ran) and skipping exit_handler.exit_code = 1.

Other factors

  • Grepped for other isBunTest reads in Rust — these are the only two, so the whole class is covered.
  • The tests are correctly placed in the existing error event describe, run the Worker in-process (with a comment explaining why spawning would defeat the repro), assert combined {events, code} objects, and wire error into the observed events so a regression would show up as an extra entry rather than a hang.
  • The re-enabled getHeapSnapshot "entry throws" row is a good signal: its exclusion comment described exactly this bug, and removing the workaround comment while re-enabling the case is the right cleanup.
  • PR description traces the mechanism end-to-end (JSInternalPromise → spin() → uncaught_exception returning handled=true → exit_code never set) and notes composition with #34193.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Oct 29, 2026
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