Skip to content

process: match Node exit code semantics; fix node:events captureRejections - #32814

Closed
robobun wants to merge 6 commits into
mainfrom
farm/d857ffd4/process-exit-semantics
Closed

robobun wants to merge 6 commits into
mainfrom
farm/d857ffd4/process-exit-semantics

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

From a differential fuzz report comparing Bun against Node (v26.3.0) on process-lifecycle semantics. The report had five divergences; this PR fixes three (the exit-code ones) plus two node:events bugs found while verifying it. The other two are deliberately not in this PR: #1 (a worker thread's exit suppressing the parent's exit event) is the same root cause and fix as the already-open #32264, and #4 (unhandled rejections never reaching the uncaughtException machinery in the default mode) was implemented here, caused two bun --hot regressions in review, and was backed out (details below).

Repro

// 1. exit code > 255 / negative: the getter and the event arg lose the raw value
process.exitCode = -1;  // node getter: -1   bun: 255
process.exitCode = 300; // node exit/beforeExit arg: 300   bun: 44

// 2. nullish assignment does not reset (node resets to unset, status 0)
process.exitCode = 5; process.exitCode = null; // node getter: undefined, status 0   bun: 5, status 5
process.exitCode = 5; process.exit(null);      // node: status 0   bun: status 5

// 3. an exit listener's write is ignored by process.exit(N)
process.on("exit", () => { process.exitCode = 9; });
process.exit(2);  // node: status 9   bun: status 2

Cause

process.exitCode was backed by a u8 on the VM (exit_handler.exit_code) and masked to 0-255 at assignment time, so the raw value was gone by the time the getter or the beforeExit/exit events read it. Node stores it in an int32 slot and masks only at the actual exit(2) (it wraps at the int32 boundary too: 2**31 reads back as -2147483648 on Node v26.3.0, which a test pins). Process_functionExit also read the code once before emitting exit, so a listener reassigning it was ignored (Node re-reads), and a nullish assignment was a no-op (Node resets to unset). Process_functionReallyExit still narrowed the code back to a u8, which only mattered for workers: the parent's worker.on("exit", code) saw 44 for process.exit(300) while the process.exitCode = 300 spelling produced 300. Both now produce the raw 300, matching Node.

node:events captureRejections (verified against Node stage by stage)

  • EventEmitter.captureRejections = true (the global) was ignored by instances whose EventEmitter constructor never ran (the util.inherits pattern), because Bun chooses the capturing vs non-capturing emit at construction. The global setter now keeps EventEmitter.prototype.emit in sync (without clobbering a user-installed prototype wrapper), and addCatch gates on emitter[kCapture] at emit time like Node. That gate is load-bearing: Node does not retroactively capture for an already-constructed default emitter, and a test covers that negative contract.
  • addCatch only handled real Promises ($isPromise). Node duck-types .then on any non-nullish listener return value inside a try/catch, so plain thenables are captured and a throwing then getter is routed to the error event. Both cases were silently dropped.

--unhandled-rejections=throw double error report

The throw arm's unhandled path called uncaught_exception() (which reports the error and sets the exit code) and then still fell through to the default printer, so a truly unhandled rejection under --unhandled-rejections=throw was reported twice. The fall-through is gone. Covered by a fail-before test.

Backed out: routing the default unhandled-rejection mode through uncaughtException

Node's default raises an unhandled rejection as an uncaught exception, so uncaughtExceptionMonitor and a uncaughtException listener can handle it. Bun's default mode skips that machinery entirely; bun --unhandled-rejections=throw already matches Node exactly (verified identical in every listener combination). Merging the default into the throw arm was the obvious fix and this PR originally shipped it, but review and verification found that uncaught_exception() carries invariants only its existing callers satisfy, and the default path violates them:

  • bun --hot calls on_before_exit() after every drain, which arms the one-shot exit_on_uncaught_exception guard that nothing resets, so the second reload's error hard-exited the watcher.
  • A second, distinct --hot regression in hot.test.ts's sourcemap tests (verified green with main's src/ and red with this PR's via a src-for-src swap).
  • In a worker, that guard's process_exit hook returns instead of exiting, and the guard then panic!s the whole process.
  • UnhandledRejectionScope callers (HTMLRewriter) that override on_unhandled_rejection get bypassed by an installed uncaughtException listener and leak exit_code = 1.

All four are latent today on the opt-in throw/strict arms (which already call uncaught_exception()); the change made them the default. Changing the default needs uncaught_exception() hardened for the --hot loop, workers, and capture scopes first, which is its own PR. The review threads on src/jsc/VirtualMachine.rs have the full analysis of each.

Intentionally out of scope

  • The parent's process.on("exit") being suppressed after a worker_threads Worker ran: process: add regression tests for per-VM 'exit' event guard #32264 (same processIsExiting process-global static, already open).
  • The default unhandled-rejection routing, per the section above.
  • Worker exit codes outside 0..65535 are still wrong in the parent (process.exit(-1) in a worker reports 65535; Node reports -1): the worker exit code reaches node:worker_threads through a CloseEvent whose code is an unsigned short. That transport predates this PR (it previously masked to a u8) and needs a non-CloseEvent channel.

Verification

12 new tests in test/js/node/process/process.test.js and 5 in test/js/node/events/event-emitter.test.ts; 13 of the 17 fail on the unfixed build (the other 4 are negative contracts or regression guards that must not change). Every expected value was taken from Node v26.3.0. test/cli/hot/hot.test.ts (all 4 sourcemap tests) and the --hot error-reload probe were re-verified green against the final head.

Node parallel tests re-verified against the debug build (all pass)

test-event-capture-rejections.js, test-process-exit-code-validation.js, test-process-exit{,-recursive,-handler,-from-before-exit}.js, test-process-beforeexit{,-throw-exit}.js, test-promise-unhandled-{default,error,flag,throw,throw-handler,silent,silent-no-hook,warn-no-hook,issue-43655}.js, test-promises-{unhandled-rejections,unhandled-proxy-rejections,unhandled-symbol-rejections,warning-on-unhandled-rejection}.js, test-promise-handled-rejection-no-warning.js, test-worker-{on-process-exit,nested-on-process-exit,exit-from-uncaught-exception,uncaught-exception-async}.js, test-unhandled-exception-with-worker-inuse.js, test-util-callbackify.js, test-queue-microtask.js, all test-event-emitter-*.js, plus test/js/node/promise/reject-tostring.test.ts and test/js/bun/spawn/exit-code.test.ts.

…tics

Store process.exitCode as an int32 instead of a masked u8 so the getter
and the beforeExit/exit event arguments keep the raw value (-1, 300),
reset it on a nullish assignment (including process.exit(null) and
process.exit(undefined)), and re-read it after the exit listeners run
so a listener can override the code passed to process.exit(). Only the
final exit(2) masks to 0-255.

Route the default unhandled-rejection mode through the same path as
--unhandled-rejections=throw, which is Node's default: with no
unhandledRejection listener the rejection is raised as an uncaught
exception so uncaughtExceptionMonitor and uncaughtException listeners
run, and an uncaughtException listener keeps the process alive. This
also removes a duplicate error report on the throw-mode fatal path.

events: read EventEmitter.prototype[kCapture] at emit time instead of
baking the choice in at construction, so the global
EventEmitter.captureRejections setting applies to instances whose
constructor never ran (util.inherits), and duck-type .then on any
non-nullish listener return value like Node so plain thenables are
captured and a throwing `then` getter is routed to the error event.
@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:24 AM PT - Jun 27th, 2026

❌ @robobun, your commit 3a986dd has some failures in Build #65373 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 32814

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

bun-32814 --bun

@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ce3800bc-9857-499b-bd0c-a69f2125a8d9

📥 Commits

Reviewing files that changed from the base of the PR and between fa4c013 and c400c21.

📒 Files selected for processing (4)
  • src/js/node/events.ts
  • src/jsc/VirtualMachine.rs
  • test/js/node/events/event-emitter.test.ts
  • test/js/node/process/process.test.js

Walkthrough

The PR updates EventEmitter rejection capture, exit-code representation and propagation across VM/runtime bindings, unhandled-rejection handling in the VM, and tests for thenables, exit-code semantics, and rejection routing.

Changes

Process and event handling

Layer / File(s) Summary
Thenable capture and prototype sync
src/js/node/events.ts, test/js/node/events/event-emitter.test.ts
addCatch now inspects thenables defensively, emitWithRejectionCapture forwards any non-nullish listener result, and EventEmitter.captureRejections synchronizes the prototype emit path when it still uses the internal default. Tests cover thenables, throwing then getters, and inherited emitters.
Exit-code plumbing across VM and runtime
src/jsc/VirtualMachine.rs, src/jsc/web_worker.rs, src/runtime/hw_exports.rs, src/runtime/node/node_process.rs, src/runtime/jsc_hooks.rs, src/jsc/bindings/headers.h
ExitHandler.exit_code and the related C ABI and hook signatures now use i32; OS exit calls mask through os_exit_code(), and worker shutdown copies the stored exit code directly.
Process exit binding behavior
src/jsc/bindings/BunProcess.cpp
Bun__Process__exit and the process exit bindings now pass int32_t codes, process.exit() only stores a code when given an argument, exit dispatch uses the current stored code, and nullish exit-code assignments reset the observable state.
Unhandled rejection throw handling
src/jsc/VirtualMachine.rs, test/js/node/process/process.test.js
The VM Mode::Throw branch drains microtasks inside the arm after uncaught_exception succeeds, then returns without falling through to the later default handling path.
Exit-code behavior tests
test/js/node/process/process.test.js
runInlineFixture captures stderr, and the process tests add coverage for --unhandled-rejections=throw, raw int32 process.exitCode values, nullish resets, process.exit() argument handling, exit-listener overrides, and worker-thread propagation of out-of-range codes.

Suggested reviewers

  • Jarred-Sumner
  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the two main change areas: process exit-code semantics and node:events captureRejections.
Description check ✅ Passed The description is detailed and covers the PR purpose, rationale, out-of-scope items, and verification, though it doesn't use the exact template headings.
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.

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: 3

🤖 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/events/event-emitter.test.ts`:
- Around line 849-853: The subprocess tests around the `proc`/`proc2` assertions
are not reading `stderr`, so the pipe can block and failures won’t show stderr
in the expectation. Update both tests to drain `stderr` alongside `stdout` and
`exited`, then include the drained stderr in the final object asserted by
`toEqual` (use `.trim()` on the collected output to ignore harmless noise).
Locate the changes in the `Promise.all` blocks that currently only await
`proc.stdout.text()`/`proc.exited` and adjust the corresponding `expect` calls
to compare the combined stdout/stderr result.

In `@test/js/node/process/process.test.js`:
- Around line 998-1008: The new inline subprocess cases in runInlineFixture
leave stderr unread, which can cause hangs or missed failures. Update the
runInlineFixture helper so it drains proc.stderr concurrently by including
proc.stderr.text() in the same Promise.all as the other pipe reads, and ensure
the subprocess test cases that use it continue to await all streams through that
helper.
- Around line 879-890: The spawned-process test currently relies on setTimeout
and only reads stdout, which can make ordering flaky and leave stderr
unconsumed. Update the test around the proc fixture to use a turn-based callback
like setImmediate instead of setTimeout in the spawned script, and in the
assertion path read stdout, stderr, and proc.exited together in a single
Promise.all so both pipes are drained concurrently. Keep the change localized to
the test that exercises process behavior and its spawned fixture.
🪄 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: 9f7c7484-9e7b-4a28-862b-219a889d74cb

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and 1ef0ab3.

📒 Files selected for processing (8)
  • src/js/node/events.ts
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/web_worker.rs
  • src/runtime/hw_exports.rs
  • src/runtime/node/node_process.rs
  • test/js/node/events/event-emitter.test.ts
  • test/js/node/process/process.test.js

Comment thread test/js/node/process/process.test.js Resolved
Comment thread test/js/node/process/process.test.js Resolved
@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Top level await causes promise rejections inside global.setTimeout to be ignored, allowing execution to continue #22546 - PR change Support import assertions #4 (Mode::Bun now routes unhandled rejections through uncaughtException machinery and drains the event loop) directly addresses why the process continues running instead of terminating after an unhandled rejection in a setTimeout callback
  2. Bun unexpected logging and twice running #17382 - The core repro is that throwing inside a resolved promise/setTimeout doesn't exit the process, which is the same class of bug fixed by the Mode::Bun unhandled rejection routing changes
  3. Crash through uncaught exception in a JS VM #24069 - The crash is a recursive uncaughtException panic; PR change Support import assertions #4 fixes the unhandled rejection routing to properly share the uncaughtException path and removes duplicate error output in the Throw arm

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #22546
Fixes #17382
Fixes #24069

🤖 Generated with Claude Code

Comment thread src/runtime/node/node_process.rs Resolved
Comment thread src/jsc/bindings/BunProcess.cpp Resolved
Process_functionReallyExit masked process.exit(N)'s code to a u8 before
handing it to Bun__Process__exit, so in a worker the parent's
worker.on("exit", code) saw 44 for process.exit(300) while the
process.exitCode = 300 spelling correctly produced 300. Node passes the
raw int32 to the parent for both. The main thread is unaffected: the OS
status is still masked to 0-255 at the final exit(2).

Also drain the subprocess stderr pipes in the new tests, replace the
setTimeout in the rejection-routing fixture with setImmediate, and pin
the int32 wrap boundary (process.exitCode = 2**31 reads back as
-2147483648, matching Node).
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 1daeb95 addressing the review feedback:

  • All subprocess tests (and the shared runInlineFixture helper) now drain stderr concurrently and assert it, and the rejection-routing fixture uses setImmediate instead of a timer.
  • Process_functionReallyExit no longer narrows process.exit(N)'s code to a u8 before handing it to Bun__Process__exit, so in a worker the parent's worker.on("exit", code) now sees 300 for both process.exit(300) and process.exitCode = 300 (it was 44 for the former). Good catch. Worker codes outside 0..65535 are still wrong because the value is transported to node:worker_threads through a CloseEvent whose code is an unsigned short; that pre-dates this PR and is out of scope (noted in the description).
  • The other review finding (that the getter should keep values above the int32 range) turned out not to be a divergence: Node v26.3.0 also backs process.exitCode with an Int32Array slot and wraps at the int32 boundary, so static_cast<int32_t> matches it exactly. Verified empirically in the resolved thread, and a new test pins process.exitCode = 2 ** 31 reading back as -2147483648.

On the suggested issue links: I reproduced and tested each before claiming anything.

The parameter widening has to touch this declaration, and the trailing
note was a TODO marker. Keep the durable part (the attribute is
deliberately left as a comment because the real [[noreturn]] trips
ASan) as a plain why-comment on its own lines.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/node/process/process.test.js (1)

12-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Have the helper pin stderr for clean-path fixtures.

runInlineFixture() now drains stderr, but it still treats it as informational only. That means the new success-path callers at Line 1028 and Line 1155 would keep passing if Bun starts printing warnings or duplicate errors, so this helper is not asserting the strongest invariant yet. Add an expectedStderr parameter (defaulting to "") and assert it before the exit-code check.

Suggested fix
-async function runInlineFixture(script, expectedStdout = null, expectedCode = 0) {
+async function runInlineFixture(script, expectedStdout = null, expectedCode = 0, expectedStderr = "") {
   using dir = tempDir("process-test", {
     "index.js": script,
   });
@@
   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

   if (expectedStdout !== null) {
     expect(stdout).toBe(expectedStdout);
   }
+  if (expectedStderr !== null) {
+    expect(stderr).toBe(expectedStderr);
+  }
   expect(exitCode).toBe(expectedCode);

   return { stdout, stderr, exitCode };
 }

As per coding guidelines, subprocess tests should assert the strongest invariant, and based on learnings, clean subprocess cases in this repo are expected to pin stderr: "".

🤖 Prompt for 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.

In `@test/js/node/process/process.test.js` around lines 12 - 31,
runInlineFixture() currently reads stderr but does not verify it for clean-path
cases, so success fixtures can still pass if Bun emits unexpected warnings or
errors. Update the helper to accept an expectedStderr parameter defaulting to an
empty string, and assert stderr against it alongside the existing stdout and
exitCode checks. Then update the success-path callers that rely on
runInlineFixture() to explicitly pin stderr to "" so the process tests enforce
the strongest invariant.

Sources: Coding guidelines, Learnings

🤖 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/events/event-emitter.test.ts`:
- Around line 852-853: The EventEmitter test is still asserting an exact empty
stderr string, which is brittle in debug/ASAN lanes. Update the assertion in the
stdout/stderr/exitCode check to normalize stderr first (for example by using
stderr.trim()) or omit stderr from the equality if it is not part of the
behavior under test. Keep the existing Promise.all drain and the focus on the
relevant symbols in this test block, such as proc.stderr.text() and the expect({
stdout, stderr, exitCode }) assertion, and apply the same fix to the other
matching assertion pair as well.

---

Outside diff comments:
In `@test/js/node/process/process.test.js`:
- Around line 12-31: runInlineFixture() currently reads stderr but does not
verify it for clean-path cases, so success fixtures can still pass if Bun emits
unexpected warnings or errors. Update the helper to accept an expectedStderr
parameter defaulting to an empty string, and assert stderr against it alongside
the existing stdout and exitCode checks. Then update the success-path callers
that rely on runInlineFixture() to explicitly pin stderr to "" so the process
tests enforce the strongest invariant.
🪄 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: ca1cc2f8-1310-45f6-a611-37e0cf04eacc

📥 Commits

Reviewing files that changed from the base of the PR and between 1ef0ab3 and fa4c013.

📒 Files selected for processing (7)
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/headers.h
  • src/runtime/jsc_hooks.rs
  • src/runtime/node/node_process.rs
  • test/js/node/events/event-emitter.test.ts
  • test/js/node/process/process.test.js

Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/js/node/events.ts Resolved
… report

The previous commit routed the default mode (Mode::Bun) through the
uncaughtException machinery. That regressed bun --hot (the reused VM's
armed exit_on_uncaught_exception guard killed the watcher on the second
reload's error, plus a second sourcemap regression in hot.test.ts) and
also reaches a worker-thread panic path and bypasses HTMLRewriter's
rejection-capture scope. uncaught_exception() carries invariants that
only its existing callers satisfy, so the default mode is restored to
its previous behavior; changing it needs that function hardened first.

The --unhandled-rejections=throw arm keeps one fix: it no longer falls
through to a second error report after uncaught_exception has already
printed one and set the exit code.

Also from review: the EventEmitter.captureRejections setter no longer
clobbers a user-installed prototype emit, the new subprocess tests
normalize stderr with .trim() and use setImmediate, and the note on the
Bun__Process__exit declaration (a line the parameter widening has to
touch) is reworded from a TODO marker into the why-comment it expressed.
@robobun robobun changed the title process: match Node's exit code and default unhandled-rejection semantics process: match Node exit code semantics; fix node:events captureRejections Jun 27, 2026
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Scope change in 4e2609a, summarizing for anyone following along rather than reading the threads.

The default unhandled-rejection routing (originally fix #4 of this PR: make Bun's default mode raise an unhandled rejection as an uncaught exception so uncaughtExceptionMonitor / uncaughtException listeners run, like Node) is backed out. The --unhandled-rejections=throw arm already implements Node's behavior exactly, and merging the default into it looked like a one-line win, but review plus verification surfaced four independent problems, all rooted in uncaught_exception() carrying invariants that only its existing callers happen to satisfy:

  1. bun --hot arms a one-shot "the exit sequence started" guard on every drain and never resets it, so the second reload's error killed the watcher. Reproduced, and initially fixed.
  2. A second, distinct bun --hot regression in hot.test.ts's sourcemap tests (verified by swapping only src/ between main and this branch). I could not produce a mechanism-level explanation for this one, and I am not going to ship a runtime change with an unexplained regression inside it.
  3. The same guard in a worker thread reaches a panic! that aborts the whole process.
  4. UnhandledRejectionScope callers (HTMLRewriter) get their capture handler bypassed by an installed uncaughtException listener, and leak exit_code = 1.

All four already exist, latently, on the opt-in --unhandled-rejections=throw/strict arms; the change made them the default. The honest conclusion is that changing the default is a real change to uncaught_exception()'s contract, not a match-arm merge, and it needs that function hardened for the --hot loop, workers, and the capture scopes first, in its own PR. The review threads on src/jsc/VirtualMachine.rs carry the full analysis to start from.

What ships here is the part that is independently proven and has no CI regressions: the whole process.exitCode int32 contract (#1/#2/#3 of the report, 12 tests), the two node:events captureRejections bugs (5 tests), and the --unhandled-rejections=throw double error report fix (fail-before test). The final head is green on hot.test.ts's sourcemap tests, the --hot error-reload probe, and the full node parallel sweep listed in the description.

Comment thread src/jsc/VirtualMachine.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.

Thanks for updating the title/description — I have nothing further to flag, but this touches core process-lifecycle paths (exit-code ABI widening across the C++/Rust boundary, unhandled-rejection dispatch, worker exit propagation) and went through a mid-review scope change, so a maintainer should sign off.

Extended reasoning...

Overview

This PR widens process.exitCode storage from u8 to i32 end-to-end (Rust ExitHandler, C++ Bun__getExitCode/Bun__setExitCode/Bun__Process__exit/Process__dispatchOn{Before,}Exit signatures, headers.h, web_worker.rs, hw_exports.rs, jsc_hooks.rs, node_process.rs), reworks Process_functionExit/setProcessExitCodeInner/Process_functionReallyExit for Node-matching nullish-reset / re-read-after-exit-listener / raw-int32 semantics, fixes the --unhandled-rejections=throw double-report fall-through in VirtualMachine::unhandled_rejection, and fixes two node:events captureRejections divergences (thenable duck-typing in addCatch, prototype-emit sync in the global setter). 17 new tests across two files; an extensive Node-parallel test sweep is listed as re-verified.

Security risks

None identified. No auth/crypto/permissions/network surface; the changes are Node-compat semantics for exit codes and error routing. The FFI signature widening (u8→i32) is the closest thing to a hazard, but every declaration site on both sides of the boundary was updated in lockstep and the diff is internally consistent.

Level of scrutiny

High. This is core runtime: process exit, worker-thread exit-code propagation, and the unhandled-rejection dispatch path. The PR already surfaced four real regressions during review (two bun --hot breakages, a worker-thread panic!, and an UnhandledRejectionScope bypass) that led the author to back out the Mode::Bun routing change in 4e2609a. The remaining diff is well-scoped and well-tested, but cross-language ABI changes plus a mid-review scope reduction are exactly the kind of thing a maintainer should eyeball before merge. The robobun build status on 4e2609a also showed failures (Build #65346), with a subsequent merge-from-main on top — CI on the final head should be confirmed green.

Other factors

All seven of my prior inline findings and all coderabbit findings are resolved; the author updated the PR title/description per my last comment, and the description now accurately documents what was backed out and why. The bug-hunting system found nothing on this run. I'm deferring rather than approving solely because of the criticality of the touched paths and the FFI signature changes, not because of any open concern with the code itself.

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status, for whoever merges this. Build 65373 is now final: out of 287 jobs, 285 passed and exactly 1 failed. The single failure is BuildKite infrastructure, not a test.

The diff is done and fully reviewed: every review thread from claude[bot] (7) and CodeRabbit (4) is resolved, and the latest reviews from both report nothing further to flag.

The one red job is a recurring artifact-download timeout on the macOS 26 aarch64 test agents. In two consecutive builds, on two different agents, the :darwin: 26 aarch64 - test-bun job failed with the exact same error before running a single test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).
    at getExecPathFromBuildKite (scripts/runner.node.mjs:2182:13)

I already spent this PR's one ci: retrigger re-roll on it (commit 3a986dd, which is the run above), so I will not keep pushing retrigger commits. Re-running that one job (or another full build once the darwin artifact downloads recover) should go green; everything the diff actually touches was re-verified locally against the final head and is listed in the description.

Earlier CI history for completeness: build 65346's 3 Windows failures were an upstream test broken at this branch's original base (internal-sourcemap-roundtrip.test.ts asserting forward-slash source map paths before #32797 fixed the bundler on main); merging main resolved them, and they have not reappeared.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

A fresh differential fuzz report landed on the captureRejections half of this PR (a listener returning a rejecting thenable is silently swallowed), which this PR already fixes in src/js/node/events.ts. No change needed here.

While confirming that, the same bug turned out to exist in the browser node:events polyfill, src/node-fallbacks/events.js, which this PR does not touch. Its gate is even stricter (result.then === Promise.prototype.then, so cross-realm promises are dropped too), and a throwing then getter escapes emit() instead of reaching the 'error' event. Fixed separately in #33369 so the two diffs stay disjoint.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

The "intentionally out of scope" note at the bottom mentions that worker exit codes outside 0..65535 stay wrong in the parent because they ride CloseEvent.code (an IDL unsigned short). Branch farm/8df9b345/worker-exit-code-i32 adds that non-CloseEvent channel on top of the same u8->i32 widening: an int32_t m_exitCode on WebCore::Worker set in the close task, a Worker.prototype.exitCode getter, and src/js/node/worker_threads.ts reading this.#worker.exitCode instead of e.code. With both halves, process.exit(-1) / process.exitCode = 70000 in a worker deliver -1 / 70000 to the parent's exit event (was 255 / 112; would be 65535 / 4464 with only the i32 widening).

The i32 widening itself overlaps this PR's VirtualMachine.rs / BunProcess.cpp / headers.h / node_process.rs / hw_exports.rs / jsc_hooks.rs / web_worker.rs hunks; the Worker.h / Worker.cpp / JSWorker.cpp / worker_threads.ts parts are additive. Happy to fold those into this PR or open a follow-up once this lands, whichever is easier.

Jarred-Sumner pushed a commit that referenced this pull request Jul 18, 2026
## What

`process.on('beforeExit', ...)` fired after a default-fatal uncaught
exception. Node never emits `'beforeExit'` in that case: its
fatal-exception path is effectively `process.exit(1)`, and the docs say
`'beforeExit'` is "not emitted for conditions causing explicit
termination, such as calling process.exit() or uncaught exceptions".

## Repro

```js
process.on('beforeExit', () => console.log('BEFOREEXIT'));
process.on('exit', c => console.log('EXIT', c));
setTimeout(() => { throw new Error('boom'); }, 1);
```

| | stdout | exit |
|---|---|---|
| Node v26.3.0 | `EXIT 1` | 1 |
| Bun (before) | `BEFOREEXIT` then `EXIT 1` | 1 |
| Bun (after) | `EXIT 1` | 1 |

## Cause

`VirtualMachine::uncaught_exception()` has two paths when no
`'uncaughtException'` listener handled the error:

- If `exit_on_uncaught_exception` is set, it hard-exits via
`process_exit(global, 1)`. But that flag is only armed inside
`Process__dispatchOnBeforeExit`, i.e. *after* `beforeExit` has already
fired.
- Otherwise it records the fatal state (`unhandled_error_counter += 1`,
`exit_handler.exit_code = 1`), prints the error and returns. The main
drain loop in `Run::start` then falls out (because
`is_event_loop_alive()` sees the nonzero counter) and unconditionally
calls `vm.on_before_exit()`.

## Fix

Gate both dispatch sites in `on_before_exit()` on
`unhandled_error_counter == 0` so `'beforeExit'` only fires on a natural
drain:

- Entry: skip entirely when we arrived via a fatal throw. Arm
`exit_on_uncaught_exception` ourselves here since the skipped
`Process__dispatchOnBeforeExit` was its sole setter; without it a throw
from an `'exit'` listener after a fatal throw would fall through and let
subsequent `'exit'` listeners run.
- Re-dispatch inside the drain loop: skip when work scheduled by a
`beforeExit` listener itself threw. On the main thread
`exit_on_uncaught_exception` already hard-exits this case; the guard
covers workers where that flag's hard-exit is main-thread-only.

Controls verified:
- With `process.on('uncaughtException', ...)` installed: `beforeExit`
still fires, exit 0. (New regression-guard test.)
- After a fatal throw, a throw from the first `'exit'` listener still
stops subsequent `'exit'` listeners. (New regression-guard test; would
regress without the flag-arming.)
- `test/cli/hot/hot.test.ts` including "should recover from errors"
passes; the watcher loop is unaffected since it proceeds to
`tick_possibly_forever()` either way, and `exit_on_uncaught_exception`
was already armed by the previous unconditional dispatch.
- All `test-process-beforeexit*` / `test-process-exit*` /
`test-worker-*beforeexit*` / `test-promises-unhandled-*` Node parallel
tests pass.

### Side effect: unhandled rejections

`unhandled_error_counter` is also bumped on the unhandled-rejection path
(`Mode::Bun` fallthrough, `Mode::Throw`), so this guard now also skips
`'beforeExit'` when an unhandled rejection is processed before the drain
loop exits (e.g. `Promise.reject(...); setImmediate(() => {});`). That
is a Node-parity improvement on that path, but it is not the whole
story: Bun's default mode still processes some rejections only at
`handle_rejected_promises()` *after* `on_before_exit()`, so
`setTimeout(() => Promise.reject(...), 1)` is unchanged. The full fix
(routing the default unhandled-rejection mode through
`uncaughtException`) is #32814's backed-out scope and needs the
watcher/worker hardening first; no test pins the partial behavior here.

## Verification

```
$ USE_SYSTEM_BUN=1 bun test test/js/node/process/process.test.js -t "is skipped after a fatal"
(fail) process.onBeforeExit > is skipped after a fatal uncaught exception
  + "beforeExit
  + exit 1
  "

$ bun bd test test/js/node/process/process.test.js -t "is skipped after a fatal"
(pass) process.onBeforeExit > is skipped after a fatal uncaught exception
```

Found while investigating #34627; reproduces with or without top-level
`await`.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/process/process.test.js

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap note. The node:events captureRejections half of this PR is covered by #42031 (kCapture checked at emit time so emitters whose constructor never ran follow the global default, thenables duck-typed with then read once, a throwing then getter emitted as 'error'). This PR's event-emitter tests pass on that branch.

What stays unique here is the exit-code half: process.exitCode kept as an int32 (raw value from the getter and in the 'beforeExit'/'exit' arguments, masked only at exit(2)), the nullish reset, process.exit(undefined)/process.exit(null), the unmasked worker exit code, and the --unhandled-rejections=throw double report. Those 10 tests still fail on current canary and on #42031/#42032. The remaining cases already pass on main (an 'exit' listener overriding process.exit(N), bare process.exit() keeping an assigned code, the throw mode routing guard).

Suggestion: when rebasing (this conflicts with main), drop src/js/node/events.ts and its tests so the PR is only the exit-code semantics.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-06-27, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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