Skip to content

node: implement process.addUncaughtExceptionCaptureCallback - #33154

Open
robobun wants to merge 3 commits into
mainfrom
farm/f437fe02/add-uncaught-exception-capture-callback
Open

node: implement process.addUncaughtExceptionCaptureCallback#33154
robobun wants to merge 3 commits into
mainfrom
farm/f437fe02/add-uncaught-exception-capture-callback

Conversation

@robobun

@robobun robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Implements process.addUncaughtExceptionCaptureCallback, added in Node.js v25.9.0 by nodejs/node#61227 and documented at https://nodejs.org/api/process.html#processadduncaughtexceptioncapturecallbackfn. Node's v26 REPL depends on it, and #31827 currently has to shim it inside the REPL because the API is missing from Bun; this lets that shim be dropped.

Note: process.removeUncaughtExceptionCaptureCallback is not a Node API (there is no way to unregister these callbacks), so it is intentionally not added.

What it does

Unlike setUncaughtExceptionCaptureCallback (one primary callback, conflicts with domain), addUncaughtExceptionCaptureCallback registers any number of auxiliary callbacks. On an uncaught exception, after uncaughtExceptionMonitor:

  1. If a primary callback is set, only it runs (auxiliary callbacks are skipped).
  2. Otherwise auxiliary callbacks run most-recent-first. One returning exactly true marks the exception handled and short-circuits.
  3. If nothing handled it, 'uncaughtException' is emitted as before.

hasUncaughtExceptionCaptureCallback() still reports only the primary callback, and registering auxiliary callbacks does not make a later setUncaughtExceptionCaptureCallback(fn) throw ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET.

Implementation

  • BunProcess.h: new Bun::WriteBarrierList<JSObject> member on Process holding the auxiliary callbacks, visited from visitChildrenImpl.
  • BunProcess.cpp: Process_addUncaughtExceptionCaptureCallback host function (validates with V::validateFunction, appends) wired into the processObjectTable LUT, and the auxiliary loop in Bun__handleUncaughtException. The loop iterates a MarkedArgumentBuffer snapshot because a callback can re-enter addUncaughtExceptionCaptureCallback and reallocate the backing vector. When a callback throws, the loop bails out immediately, matching the primary-callback path. That matters in a Worker, where Bun__Process__exit requests termination and returns instead of exiting the process.
  • packages/bun-types/overrides.d.ts: declaration on NodeJS.Process (not in @types/node@25 yet).

Verification

The dispatch test scripts produce byte-identical output to Node v26.3.0, including the ordering, the strict === true short-circuit (a truthy 1 must not stop the chain), and the primary callback taking precedence:

$ node /tmp/diff-dispatch.js > a; bun-debug /tmp/diff-dispatch.js > b; diff a b && echo IDENTICAL
IDENTICAL

Tests in test/js/node/process/process.test.js (each in its own subprocess since the callbacks cannot be unregistered): argument validation and the set/has interaction, the full dispatch-order matrix, a handled exception keeping the process alive across Bun.gc(true) with no uncaughtException listener, an unhandled one still being fatal after the callbacks run, a throwing callback aborting the process, and a throwing callback inside a Worker terminating the worker identically to the primary callback. All six fail on the released Bun and pass with this change; the existing setUncaughtExceptionCaptureCallback tests and test/js/node/test/parallel/test-process-exception-capture*.js still pass, as does test/integration/bun-types/bun-types.test.ts.

Adds the auxiliary uncaught-exception capture callback API introduced in
Node.js v25.9.0 (nodejs/node#61227). Unlike
setUncaughtExceptionCaptureCallback, multiple callbacks can be
registered. They are dispatched most-recent-first when no primary
capture callback is set; a callback that returns exactly true marks the
exception as handled and skips the remaining callbacks and the
'uncaughtException' event.
@robobun
robobun requested a review from alii as a code owner June 30, 2026 23:38
@coderabbitai

coderabbitai Bot commented Jun 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: 22 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: 2daceb10-947f-435c-8e13-2ff8797fd773

📥 Commits

Reviewing files that changed from the base of the PR and between 52a1ddf and 1052912.

📒 Files selected for processing (5)
  • packages/bun-types/overrides.d.ts
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/BunProcess.h
  • test/integration/bun-types/fixture/process.ts
  • test/js/node/process/process.test.js

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

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:28 PM PT - Jun 30th, 2026

@robobun, your commit 1052912 has 2 failures in Build #67402 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33154

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

bun-33154 --bun

Comment thread src/jsc/bindings/BunProcess.cpp
In a Worker, Bun__Process__exit requests termination and returns instead
of exiting the process, so the auxiliary-callback loop kept iterating
after a callback threw. Each subsequent call raised a
TerminationException that was logged again, and the function fell
through to return false, which fired a spurious null 'error' event on
the parent Worker handle. Bail out of the loop after the first throwing
callback, matching the primary-callback path.

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

The Worker-thread fix in 1da4876 looks right and the added parity test pins it down nicely. I'm deferring to a maintainer since this adds new Node-compat API surface and rewires the shared Bun__handleUncaughtException dispatch path.

Extended reasoning...

Overview

Implements process.addUncaughtExceptionCaptureCallback (Node v25.9.0). Touches five files: a new host function and dispatch loop in src/jsc/bindings/BunProcess.cpp, a new WriteBarrierList<JSObject> member on Process in BunProcess.h (visited from visitChildrenImpl), a type declaration in overrides.d.ts, a type-fixture line, and ~200 lines of subprocess-isolated tests in process.test.js. The existing primary-callback exception path is refactored into a shared abortOnCaptureCallbackException helper that now returns bool.

Security risks

None identified. No parsing of untrusted input, no auth/crypto/permissions surface. The only user-controlled input is the callback function, validated via V::validateFunction. The dispatch loop snapshots into a MarkedArgumentBuffer before iterating, guarding against re-entrant list mutation, and checks hasOverflowed().

Level of scrutiny

Medium-high. Bun__handleUncaughtException is the single funnel every uncaught exception in the runtime goes through, and the change restructures its control flow (the else if / else chain becomes fall-through with early returns). It also adds GC-traced state and calls user JS in a loop from an error-handling path — the kind of code where subtle mistakes (GC rooting, exception-scope discipline, Worker-vs-main-thread process.exit semantics) have outsized blast radius. The one bug the earlier review found in exactly that area (Worker-thread loop not bailing after a throw) has been fixed and pinned with a test that asserts set and add produce identical parent-observable output.

Other factors

  • New user-facing API on process: even though the semantics are externally specified by Node, additions to the compat surface are a maintainer call.
  • The refactor of the pre-existing primary-callback path into the shared helper looks behavior-preserving (same log-then-Bun__Process__exit-then-return true sequence), but it does change tested-in-production code and deserves a second pair of eyes.
  • Test coverage is thorough: arg validation, set/has non-interaction, full dispatch-order matrix including the strict === true short-circuit, GC survival, unhandled-still-fatal, throwing-callback abort, and the Worker parity case. All run in isolated subprocesses.
  • No outstanding review threads; my prior inline comment is 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 the fix in 1da4876 — the Worker bail-out now matches the primary path and the new test pins it. I didn't find anything else, but this adds a new user-facing API and reworks the Bun__handleUncaughtException control flow with a new GC-visited member, so it's worth a human pass.

Extended reasoning...

Overview

Implements process.addUncaughtExceptionCaptureCallback (Node v25.9.0). Adds a WriteBarrierList<JSObject> member on Process (visited in visitChildrenImpl), a new host function wired into the LUT, and restructures Bun__handleUncaughtException from an else-if chain into a sequential fallthrough (primary callback → auxiliary loop → 'uncaughtException' listener). The auxiliary loop snapshots into a MarkedArgumentBuffer to survive re-entrant registration and bails via the now-bool-returning abortOnCaptureCallbackException helper. Also adds the type declaration and six subprocess tests plus a Worker parity test.

Security risks

None identified. Argument validation goes through V::validateFunction; no untrusted parsing, filesystem, or network surface. The exception-handling path is process-local.

Level of scrutiny

Medium-high. This is production-critical native code: it sits on the uncaught-exception path (affects process/worker exit semantics), adds a GC-traced member, and changes the fallthrough behavior of the existing handler (the old else-if chain became sequential blocks — I verified the observable behavior is preserved for the pre-existing paths, but a maintainer should confirm the API design matches Node's intent, particularly around hasOverflowed() silently skipping all callbacks and the interaction with domain).

Other factors

My earlier inline concern (Worker-thread loop not bailing after a throw) was fixed in 1da4876 with a regression test that asserts add and set variants produce identical parent-side output. Test coverage is thorough (validation, dispatch order, strict-true short-circuit, GC survival, fatal fallthrough, throwing callback, Worker parity). The bug-hunting pass on the current revision found nothing. Still, new public API surface + JSC GC + exception-path control-flow changes in C++ warrant a human sign-off rather than a bot approval.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI status

The code is done and the new tests pass on every lane that ran them; the red on both builds is CI flake unrelated to this change.

  • Build 67388: 279 jobs passed. The 3 failures were test/js/node/test/parallel/test-net-connect-memleak.js on the two alpine x64 lanes, plus one darwin 26 aarch64 shard that hit a buildkite-agent artifact download timed out after 120s before any tests ran.
  • Build 67402 (retriggered): 281 jobs passed. test-net-connect-memleak.js on the two alpine x64 lanes again, plus test/js/bun/terminal/terminal.test.ts > creates subprocess with terminal attached timing out after 90s on darwin 14 x64. Everything else is green.

Neither failing test is related to this PR:

test/js/node/process/process.test.js, which carries the six new tests, is not in any failure annotation on either build. I already retriggered once, so this needs a maintainer to either retry the two red jobs on Buildkite or merge over the known flake.

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