Skip to content

worker_threads: exit code 1 when a Worker dies from an unhandled rejection - #34193

Closed
robobun wants to merge 2 commits into
mainfrom
farm/2a8a417c/worker-rejection-exit-code
Closed

robobun wants to merge 2 commits into
mainfrom
farm/2a8a417c/worker-rejection-exit-code

Conversation

@robobun

@robobun robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:worker_threads Worker that dies from an unhandled promise rejection in an event-loop turn (a parentPort 'message' listener, a timer callback) reports exit code 0 to the parent, and its own process.on('exit') listeners receive 0. Node reports 1. The 'error' event is correct. A synchronous throw from the same places already exits 1.
  • Cause: the worker's last-resort error hook on_unhandled_rejection (src/jsc/web_worker.rs:1163) never sets the exit code. The throw path reaches it through VirtualMachine::uncaught_exception, which sets exit_handler.exit_code = 1 first (src/jsc/VirtualMachine.rs). The rejection path reaches it through VirtualMachine::unhandled_rejection, which in the default mode does not. The hook then runs the worker's 'exit' listeners and shutdown() posts the untouched 0.

Fix

  • Set exit_handler.exit_code = 1 in on_unhandled_rejection, after the "stop already requested" early return and before the worker's 'exit' listeners run. Both entry paths now report the same code, and an 'exit' listener can still change it, as in Node.
  • A rejection that a process.on('unhandledRejection') listener claims never reaches the hook (Bun__handleUnhandledRejection returns first), so that case keeps exit code 0.
  • Verified: test/js/node/worker_threads/worker-error-exit-code.test.ts (6 cases: rejection and throw from a message listener and from a timer callback, the worker's own 'exit' argument, and suppression by an 'unhandledRejection' listener). Without the fix the three rejection cases fail with code: 0.

Background

  • A worker's uncaught error ends in on_unhandled_rejection: it posts 'error' to the parent, runs the worker's process.on('exit') listeners through ExitHandler::dispatch_on_exit, and requests the stop. spin() then calls shutdown(), which reads exit_handler.exit_code and reports it to the parent as the 'exit' code.
  • VirtualMachine::unhandled_rejection is what JSC's rejected-promise list drains into at the end of an event-loop turn. VirtualMachine::uncaught_exception is the path for a thrown exception (and for the entry module's own rejection).
Notes
  • Measured on canary 1.4.3-canary.1+f42e98025 (one day after 1.4.2) against Node v26.3.0: the message-listener rejection still exits 0 (5/5 runs of the original repro), the timer-callback rejection with an interval keeping the loop alive exits 0. Both go through this hook. Both exit 1 with this change.
  • Not covered here, different cause: a rejection from the callback that lets the worker's loop go idle (setTimeout(() => Promise.reject(e), 20) with nothing else alive) is never reported at all (no 'error', the worker's 'unhandledRejection' listeners do not run, exit 0). The worker loop does not walk the rejected-promise list after its last turn. event loop: report promise rejections left by the turn that let the loop go idle #37981 fixes that; its worker test asserts code is not.toBe(13) rather than 1 because of the bug fixed here, so the two changes are complementary.
  • Fail-before on the current base was checked with the unfixed debug build of f42e98025 (src/jsc/web_worker.rs is identical between f42e98025 and this PR's base): 3 of 6 tests fail with code: 0. The passing run with the fix was done on the earlier base of this PR; the change is the same single assignment.
  • Also ran test/js/web/workers/worker.test.ts and test/js/node/worker_threads/worker-top-level-await.test.ts on the earlier base.

@robobun

robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:07 PM PT - Sep 6th, 2026

❌ @robobun, your commit 864ac3e has 5 failures in Build #111810 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34193

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

bun-34193 --bun

@coderabbitai

coderabbitai Bot commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Worker error exit codes

Layer / File(s) Summary
Set unhandled rejection exit code
src/jsc/web_worker.rs
on_unhandled_rejection now sets the worker exit code to 1 before error handling.
Validate worker failure exit behavior
test/js/node/worker_threads/worker-error-exit-code.test.ts
Subprocess tests cover unhandled rejection, synchronous throws, exit callback propagation, and suppression by an unhandledRejection handler.
🚥 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 identifies the primary change: worker threads now report exit code 1 when terminated by an unhandled rejection.
Description check ✅ Passed The description explains the problem, root cause, fix, expected behavior, and verification steps. It does not use the template headings exactly, but it provides the required information and is substan…

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 `@test/js/node/worker_threads/worker_threads.test.ts`:
- Around line 574-576: Reorder the subprocess assertions in the worker test
helper around the stdout parsing and exit-code check, including the
corresponding block near the second referenced location: validate or parse
stdout first, then assert exitCode last while preserving the existing stderr
behavior for failures.
🪄 Autofix (Beta)

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: 35be9d5b-ffdf-41dc-9cb6-ab23f75ea6eb

📥 Commits

Reviewing files that changed from the base of the PR and between 3fb557b and c78eff6.

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

@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 — one-line Node.js compat fix with thorough test coverage.

Checked VirtualMachine::unhandled_rejection in Mode::Bun: it falls through to the hook without setting exit_code, while uncaught_exception sets it at :1436 — confirms the root cause.
Verified process.on('unhandledRejection') short-circuits at handle_unhandled() before the hook, so the new assignment can't clobber the handled case.
Tests spawn subprocesses (isBunTest short-circuit noted), drain pipes concurrently, and mirror the existing run() helper pattern in the same file.

Extended reasoning...

Overview

One-line change in src/jsc/web_worker.rs: on_unhandled_rejection now sets vm.exit_handler.exit_code = 1 at the top, so a worker killed by an unhandled promise rejection reports exit code 1 to the parent (matching Node.js and the existing sync-throw path). Four new subprocess tests in test/js/node/worker_threads/worker_threads.test.ts cover the rejection path, the sync-throw control, the worker's own process.on('exit') argument, and that a registered unhandledRejection handler still suppresses the nonzero exit.

Security risks

None. This is a pure exit-code-reporting change on an existing error path; no auth, crypto, parsing, or user-input handling is touched.

Level of scrutiny

Low-to-moderate. I traced the two entry paths in VirtualMachine.rs:

  • uncaught_exception (sync throw) sets exit_code = 1 at :1436 before calling on_unhandled_rejection — the new line is a redundant re-assignment on that path.
  • unhandled_rejection in Mode::Bun (:3354) calls handle_unhandled(); if a JS unhandledRejection handler claims it, it returns early and never reaches the hook (so the 4th test's suppression case is unaffected). If unclaimed, it falls through to :3414 and invokes the hook without setting exit_code — exactly the gap this PR closes.

The unconditional = 1 matches how uncaught_exception already behaves (also unconditional), and dispatch_on_exit runs afterward so process.on('exit') handlers can still mutate process.exitCode — consistent with Node.

Other factors

  • Tests are well-designed: spawned (with a comment explaining the isBunTest short-circuit), test.concurrent, pipes drained via Promise.all, and the combined { exitCode, stderr } assertion mirrors the existing SHARE_ENV run() helper in the same file. CodeRabbit's assertion-ordering nit was withdrawn as matching file convention.
  • PR description documents fail-before verification and Node v26 cross-checks for the unchanged paths (clean exit, process.exit(N), natural finish).
  • No outstanding unresolved review comments.

@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: 2

🤖 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 `@test/js/node/worker_threads/worker-error-exit-code.test.ts`:
- Around line 21-23: Reorder assertions in the worker subprocess tests so the
parsed stdout result is validated before stderr and exitCode. Update the helper
around run() to parse and assert the expected worker result first, passing the
expected result into run() if needed; apply the same ordering to the direct
subprocess test at test/js/node/worker_threads/worker-error-exit-code.test.ts
lines 21-23 and 61-63.
- Around line 66-72: Update the unhandledRejection test’s worker script so its
handler closes or unreferences parentPort instead of calling process.exit(0).
Let the worker terminate naturally, while preserving the expected { err: null,
code: 0 } result and the existing rejection-handling scenario.
🪄 Autofix (Beta)

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: bae1ec07-26b5-4ed4-a966-85fbe5684c1c

📥 Commits

Reviewing files that changed from the base of the PR and between c2137cf and 79e0cf3.

📒 Files selected for processing (1)
  • test/js/node/worker_threads/worker-error-exit-code.test.ts

@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 — one-line Node compat fix with solid coverage.

Checked VirtualMachine::unhandled_rejection (Mode::Bun falls through to the hook without setting exit_code) vs uncaught_exception (sets it at :1436 before the hook) — the asymmetry is real and this closes it.
Verified the process.on('unhandledRejection') suppression path returns before the hook is reached, so the new assignment can't leak into the handled case.
The assignment precedes dispatch_on_exit, so user process.on('exit') handlers still see and can override the code — same ordering as the sync path.
Tests cover rejection, sync-throw control, worker-side process.on('exit') arg, and handler suppression; CodeRabbit feedback (combined-object assertion, parentPort.close() over process.exit(0)) was applied.

Extended reasoning...

Overview

One production line in src/jsc/web_worker.rs: vm.exit_handler.exit_code = 1 at the top of the worker's on_unhandled_rejection hook, plus a 3-line comment explaining why. A new 80-line test file covers four cases (rejection → exit 1, sync throw → exit 1 as control, worker's own process.on('exit') receives 1, and unhandledRejection handler suppresses to 0).

Mechanism verified

I read both entry paths in VirtualMachine.rs:

  • uncaught_exception (sync throw): sets exit_handler.exit_code = 1 at :1436, then calls the hook at :1437.
  • unhandled_rejection in Mode::Bun: if Bun__handleUnhandledRejection returns handled, returns early (so the suppression test is sound — the hook is never reached and exit_code stays 0); otherwise falls through to :3414 and calls the hook without setting exit_code. That is exactly the gap this line closes.

Setting it inside the hook is idempotent for the sync path (already 1) and fixes the async path. It runs before ExitHandler::dispatch_on_exit, so the worker's own process.on('exit') handlers see 1 and can still mutate process.exitCode — matching the existing comment at that call site ("code 1; they may change process.exitCode").

Security risks

None. No parsing, no untrusted input, no allocation, no new control flow — a single field write on an already-owned struct.

Level of scrutiny

Medium file (worker lifecycle in web_worker.rs is delicate), but the change itself is trivially low-risk: an unconditional u8 assignment on a path that already runs and already terminates the worker. It only makes the async-rejection path consistent with the sync-throw path, which has done the same assignment for a long time.

Other factors

  • All CodeRabbit threads are resolved: the combined {stdout, stderr, exitCode} assertion pattern was adopted, and the suppression test now uses parentPort.close() so it exits naturally rather than via process.exit(0).
  • Tests are subprocess-spawned (comment explains isBunTest short-circuit), use bunExe()/bunEnv, drain pipes concurrently, and follow the directory's standalone-file convention.
  • PR description documents fail-before verification and Node v26 parity checks for the unchanged paths.
  • No prior claude[bot] review on 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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/runtime/napi/napi_body.rs:969-971 — Commit 26716d3 ("ci: retrigger") looks like a botched push: it reverted the actual fix (vm.exit_handler.exit_code = 1 in on_unhandled_rejection at src/jsc/web_worker.rs) and instead introduced unrelated N-API null-argument guards across napi_body.rs and napi.cpp. The net PR diff now has zero changes to web_worker.rs, so the two new rejection tests (worker-error-exit-code.test.ts:30 and :44) will fail with code: 0. Please restore the web_worker.rs change and drop the N-API hunks (or split them into their own PR).

    Extended reasoning...

    What happened

    The PR history shows the fix was landed and then silently undone:

    • c78eff6 added the fix: vm.exit_handler.exit_code = 1; at the top of on_unhandled_rejection in src/jsc/web_worker.rs.
    • 26716d3 ("ci: retrigger") removed exactly those 4 lines from web_worker.rs and simultaneously added N-API null-argument guards to src/jsc/bindings/napi.cpp and src/runtime/napi/napi_body.rs.

    git diff 1e351180..HEAD --stat (net diff vs merge base) confirms:

     src/jsc/bindings/napi.cpp                          |  2 +
     src/runtime/napi/napi_body.rs                      | 24 +++++++
     .../worker_threads/worker-error-exit-code.test.ts  | 80 ++++++++++++++++++++++
    

    There is no web_worker.rs in the changed-files list. The current on_unhandled_rejection at src/jsc/web_worker.rs:1461-1467 does not set exit_code.

    Why the tests will fail

    Step through the "unhandled promise rejection exits 1" test at line 30:

    1. The worker script does Promise.reject(new Error("task-reject")) inside the message handler.
    2. VirtualMachine::unhandled_rejection fires. In Mode::Bun it calls the worker's on_unhandled_rejection hook without touching exit_handler.exit_code (this is the exact asymmetry the PR description identifies as the bug).
    3. Because 26716d3 removed the vm.exit_handler.exit_code = 1 line, the hook still doesn't set it either.
    4. The hook dispatches 'error', runs process.on('exit'), and arms termination; shutdown() reads the untouched exit_code (0) and posts it to the parent.
    5. Parent prints {"err":"Error: task-reject","code":0}.
    6. The test asserts stdout === JSON.stringify({ err: "Error: task-reject", code: 1 }) → fails.

    The same applies to the test at line 44, which asserts { workerExitArg: 1, code: 1 } — both values will be 0. The PR description itself says "Fail-before verified by stashing src/ and rebuilding: the two rejection tests fail with code: 0" — that's exactly the state this branch is now in.

    The unrelated N-API changes

    The same commit added is_empty() / NAPI_CHECK_ARG guards to napi_is_array, napi_get_array_length, napi_strict_equals, napi_is_error, napi_is_arraybuffer, napi_is_dataview, napi_get_dataview_info, napi_is_date, napi_detach_arraybuffer, and node_api_create_buffer_from_arraybuffer. These are entirely unrelated to worker_threads exit codes, aren't mentioned anywhere in the PR title/description/review, and have no accompanying tests. They look like they belong on a different branch and got mixed in during a bad rebase or force-push. Per the repo guidelines ("don't ride file-wide standardization on a focused bugfix"), they should be dropped from this PR regardless.

    Fix

    Revert commit 26716d3 in its entirety — that both restores the web_worker.rs fix and removes the stray N-API hunks. If the N-API guards are wanted, open a separate PR for them with tests.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Already caught and fixed in da2c1de (pushed right after 26716d3). The net PR diff is back to just src/jsc/web_worker.rs (+4, the exit_code = 1 assignment) and the new test file; the N-API hunks are gone. gh pr diff 34193 --name-only confirms.

@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 — one-line fix aligning worker unhandled-rejection exit code with Node.

What was reviewed:

  • Verified VirtualMachine::uncaught_exception sets exit_code = 1 before invoking the hook (VirtualMachine.rs:1436) and unhandled_rejection in Mode::Bun does not — the PR's cause analysis is accurate.
  • Confirmed the hook is only reached when no process.on('unhandledRejection') handler claimed the rejection (handle_unhandled() returns early first), so the new assignment can't fire in the suppressed case.
  • Checked that a user-set process.exitCode being overwritten to 1 here matches the existing sync-throw path behavior, and that dispatch_on_exit still lets exit handlers change it afterward.
  • Tests follow repo subprocess conventions (combined-object assertion, describe.concurrent, stderr masked on success per ASAN guidance); all CodeRabbit threads resolved.
Extended reasoning...

Overview

The PR touches two files: a 1-line assignment (+3 comment lines) in src/jsc/web_worker.rs's on_unhandled_rejection hook, and a new 80-line test file at test/js/node/worker_threads/worker-error-exit-code.test.ts. The fix sets vm.exit_handler.exit_code = 1 at the top of the worker's unhandled-rejection hook so a Worker killed by an unhandled promise rejection reports exit code 1 to the parent (matching Node.js), instead of 0.

Security risks

None. This changes the value of an integer exit code reported through an existing event; no new inputs, no parsing, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-moderate. The Rust change is a single scalar assignment on a path that already existed and already runs user callbacks; it introduces no new control flow, allocations, or FFI calls. I traced both entry paths to the hook: uncaught_exception (VirtualMachine.rs:1436) already sets exit_code = 1 immediately before invoking it, so this line is redundant-but-harmless there; unhandled_rejection in Mode::Bun (VirtualMachine.rs:3354) calls handle_unhandled() first and returns early if a JS unhandledRejection handler claimed it, so the hook (and thus this assignment) only runs when the rejection is truly unhandled — the suppression test correctly exercises the early-return path, not this line. The one edge case (a user-set process.exitCode being clobbered to 1) already happens on the sync-throw path and matches Node; dispatch_on_exit runs afterward so exit handlers can still override.

Other factors

Test coverage is thorough for the change: the rejection path (the actual fix), a sync-throw control, the worker's own process.on('exit') argument, and suppression via an unhandledRejection handler. Tests are spawned subprocesses (necessary because isBunTest short-circuits the in-process rejection path), use the combined {stdout, stderr, exitCode} assertion convention, and run under describe.concurrent. All CodeRabbit review threads are resolved — the author addressed the "don't force process.exit(0) in the suppression test" concern by switching to parentPort.close() so the worker exits naturally. The commit history shows an accidental N-API hunk was reverted in da2c1de; the final diff is clean. No CODEOWNERS entries cover these paths. No prior claude[bot] reviews on this PR.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI on da2c1de: the new worker-error-exit-code.test.ts passed on every lane. Remaining red is unrelated and also failing on main:

  • test-worker-message-port-transfer-terminate.js (debian x64-asan): pre-existing JSC exception-scope assertion in JSObject::getOwnPropertyDescriptor; this PR only writes a u8 field and touches no exception scopes.
  • bun-install-registry.test.ts, es-module-lexer.test.ts, 30205.test.ts, spawn.test.ts: known flakes on Windows/debian lanes.

Diff is the one-line exit_code = 1 assignment in web_worker.rs::on_unhandled_rejection plus the new test file. Ready for a maintainer.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Two notes from re-deriving this on current main (b9ef885), where it still reproduces (setTimeout(() => {}, 300); setTimeout(() => Promise.reject(new Error("x")), 1) in a worker gives 'error' + exit 0; node exits 1):

  • on_unhandled_rejection in src/jsc/web_worker.rs now starts with an if !vm.script_allowed() { return; } (a stop already requested by terminate() or the worker's own process.exit()). When rebasing, the exit_code = 1 assignment should go after that return, otherwise a rejection that surfaces while the worker is already stopping (a cancelled lookup or request) turns a process.exit(0) into exit 1. It still needs to come before dispatch_on_exit so 'exit' listeners see 1 / process.exitCode === 1 and can override it, as in node.
  • Without event loop: report promise rejections left by the turn that let the loop go idle #37981 the rejection in a worker's last task is not reported at all, so that case keeps exiting 0 even with this change; the two compose (this PR covers the keep-alive case on its own).

A rebased version of both with tests for the 'exit'-listener-sees-1 / override-to-42 / keep-alive rows is at main...farm/5d8321fa/worker-last-task-rejection, for reference only.

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

One case this PR leaves different from Node: a rejection while the worker entry is still loading (a top-level Promise.reject(...)). The handler now sets 1 and runs the worker's 'exit' listeners, but the stop it requests makes load_entry_point_for_web_worker return Err(WorkerTerminated), and spin() then writes exit_code = 1 again. A process.exitCode that an 'exit' listener assigned is lost:

// worker.js
process.on("exit", code => { console.log("listener", code); process.exitCode = 42; });
Promise.reject(new Error("boom"));
// parent: worker.on("exit", c => console.log("exit", c))
// node v26.3.0: listener 1, exit 42
// this PR:      listener 1, exit 1

A rejection from a later turn already keeps the listener's value, and the observe_entry path has a comment that says it must not clobber it. A two-line change in spin() makes the load-failure path agree (on branch robobun/5bc78732/exit-code-unhandled-rejection, 20d3618, src/jsc/web_worker.rs):

            Err(_) => {
                // process.exit() may have run during load; don't clobber its code. Nor a
                // nonzero one: an uncaught error during load sets 1 and then runs the
                // 'exit' listeners, which may change process.exitCode.
                if !self.exit_called.load(Ordering::Relaxed) && vm.exit_handler.exit_code == 0 {
                    vm.as_mut().exit_handler.exit_code = 1;
                }

The == 0 guard (rather than unhandled_error_counter == 0) keeps exit code 1 for a worker spawned under bun test, where the isBunTest path runs the handler without setting the code.

…ction

A node:worker_threads Worker killed by an unhandled promise rejection
reported exit code 0 to the parent's 'exit' event and to its own
process.on('exit') listeners. Node reports 1. A synchronous throw
already exits 1.

The sync-throw path goes through VirtualMachine::uncaught_exception,
which sets exit_handler.exit_code = 1 before invoking the worker's
on_unhandled_rejection hook. A rejection reported from an event-loop
turn goes through VirtualMachine::unhandled_rejection, which in the
default mode invokes the same hook with the code still 0. The hook
dispatches 'error', runs the worker's 'exit' listeners and requests the
stop, so shutdown() posted 0 to the parent.

Set exit_code = 1 in the hook, before the 'exit' listeners run, so both
paths report the same code.
@robobun
robobun force-pushed the farm/2a8a417c/worker-rejection-exit-code branch from da2c1de to 535ab29 Compare September 6, 2026 21:02
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.

LGTM — one-line fix that makes the worker's unhandled-rejection hook write exit_code = 1 before dispatching the worker's own process.on('exit'), matching the sync-throw path and Node.

What was reviewed:

  • Traced both entry paths into on_unhandled_rejection: uncaught_exception already writes exit_code = 1 (redundant but harmless), and unhandled_rejection in Mode::Bun falls through without writing it — the gap this closes.
  • Placement is after the script_allowed() guard, so terminate() still wins and reports its own code; and before dispatch_on_exit, so the worker's exit listeners see 1 (the pre-existing comment there already claimed "code 1").
  • Checked the unhandledRejection-handler suppression test: handle_unhandled() returns early before the hook runs, so exit_code is never touched — the test's expectation of code 0 is sound.
  • Tests spawn subprocesses (correct — isBunTest short-circuits the in-process rejection path), drain pipes concurrently, and assert combined {stdout, stderr, exitCode}.
Extended reasoning...

Overview

The PR adds a single assignment vm.exit_handler.exit_code = 1 inside on_unhandled_rejection in src/jsc/web_worker.rs, plus a new test file with six subprocess-spawned cases covering unhandled rejections and synchronous throws from both parentPort message handlers and timer callbacks, the worker's own process.on('exit') receiving code 1, and suppression via a process.on('unhandledRejection') listener. The branch was force-pushed on 2026-09-06 with two fresh commits (the fix, then a comment shortening in response to a bot inline), superseding whatever the 2026-07-15 reviews saw.

Security risks

None. This changes only the exit code reported when a worker thread dies from an unhandled rejection. No untrusted input parsing, no auth, no crypto, no memory management changes.

Level of scrutiny

Low. The Rust change is one line whose correctness is verifiable by tracing the two callers of the hook in VirtualMachine.rs: uncaught_exception (line ~1712) already sets exit_code = 1 before invoking the hook, so the new assignment is a no-op there; unhandled_rejection in the default Mode::Bun arm falls through to the hook without setting it, which is exactly the reported divergence from Node. Placement after the !vm.script_allowed() early return means a prior terminate() keeps its own exit code, and placement before ExitHandler::dispatch_on_exit(vm) means the worker's process.on('exit') listeners observe 1 and can still override via process.exitCode — both confirmed against the surrounding code, and the pre-existing comment at the dispatch_on_exit call already documented "code 1".

Other factors

The test file follows the harness conventions the review guide calls out: bunExe()/bunEnv, describe.concurrent for independent subprocess tests, concurrent draining of stdout/stderr/exited, combined-object assertion, and -e for single-file fixtures. The isBunTest short-circuit at VirtualMachine.rs:3779 is real and justifies spawning rather than testing in-process. Fail-before evidence in the PR description shows the rejection tests fail with code: 0 on a build without the fix. The changed paths are not covered by .github/CODEOWNERS. No outstanding CHANGES_REQUESTED reviews; all third-party inline threads are resolved by a non-author, and the github-actions inline on line 1180 was addressed by the follow-up "shorten the comment" commit.

@robobun

robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status after the rebase onto main (head 864ac3e):

  • Local: test/js/node/worker_threads/worker-error-exit-code.test.ts passes 6/6 with the fix on the rebased tree. Without the src/ change, 3 of 6 fail with code: 0 (rejection in a message listener, rejection in a timer callback, the worker's own 'exit' argument).
  • CI build 111810: 176 of 181 jobs passed. The new test file is green on every lane. The 5 red jobs are unrelated to this diff: structured-clone-fastpath.test.ts memory bound on ubuntu aarch64 (reported separately as a main break), test-cluster-primary-error.js timeout on debian x64-asan, a darwin aarch64 test job that timed out downloading the build artifact, and retried flakes (webview-chrome, in-process-cron, filesink, bun-patch, test_worker_terminate, fetch-backpressure, grpc test-tonic, node-http-connect).

The diff is the single exit_code = 1 assignment in on_unhandled_rejection plus the test file. Ready for a maintainer.

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 sets exit code 1 for an unhandled rejection in VirtualMachine::unhandled_rejection, so the worker's 'exit' listeners and the parent's 'exit' event both see 1, and it now includes this PR's worker-error-exit-code.test.ts unchanged (bb390cb). All 6 cases 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