Skip to content

Implement process._fatalException with domain error routing - #28665

Closed
robobun wants to merge 1 commit into
mainfrom
farm/90f740ca/fix-fatal-exception-domain
Closed

robobun wants to merge 1 commit into
mainfrom
farm/90f740ca/fix-fatal-exception-domain

Conversation

@robobun

@robobun robobun commented Mar 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #28664

Problem

process._fatalException was mapped to a no-op stub (Process_stubEmptyFunction) that returned undefined. This meant errors passed to it were silently ignored, and the node:domain module could not intercept them.

Reproduction:

require('domain')
  .create()
  .on('error', (e) => console.error('domain error:', e))
  .run(() => {
    setImmediate(() => {
      process._fatalException(new Error('CRASH!!!'));
    });
  });

Expected: domain error: Error: CRASH!!!
Actual: No output, exit code 0.

Root Cause

  1. process._fatalException was a stub returning undefined
  2. domain.enter()/exit() were no-ops that never set process.domain
  3. domain.run() only caught synchronous errors via try/catch

Fix

BunProcess.cpp: Replaced the stub with Process_fatalException that:

  • Checks process.domain for an active domain with error listeners
  • Sets domainThrown: true and domain properties on the error object
  • Emits the error on the domain if one is active
  • Falls back to existing uncaughtException/captureCallback handling
  • Returns a boolean indicating whether the error was handled

domain.ts: Fixed domain tracking:

  • enter() properly sets process.domain and pushes to domain stack
  • exit() restores the previous domain from the stack
  • run() keeps the domain active for async callbacks scheduled during execution

Verification

bun bd test test/regression/issue/28664.test.ts  → 6 pass, 0 fail
USE_SYSTEM_BUN=1 bun test test/regression/issue/28664.test.ts → 0 pass, 6 fail

@robobun

robobun commented Mar 30, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:39 AM PT - Mar 30th, 2026

❌ @robobun, your commit ad79007 has 3 failures in Build #42811 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28665

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

bun-28665 --bun

@coderabbitai

coderabbitai Bot commented Mar 30, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a domain-aware implementation of process._fatalException, expands the domain module with explicit stack, enter/exit and member tracking plus timer callback context propagation, and adds regression tests validating domain routing and fallback uncaught-exception behavior.

Changes

Cohort / File(s) Summary
Fatal Exception Handler
src/bun.js/bindings/BunProcess.cpp
Replaced stub _fatalException with Process_fatalException (arity 1). Inspects process.domain, sets exception.domainThrown/exception.domain when applicable, calls domain.listenerCount("error") and domain.emit("error", exception) if handlers exist, otherwise delegates to Bun__handleUncaughtException/Bun__reportUnhandledError.
Domain State & Lifecycle
src/js/node/domain.ts
Added module _stack and exported domain._stack and domain.active. Domain instances gain members, explicit enter()/exit() methods; run() uses enter()/exit() with try/finally. add()/remove() manage emitter.domain and d.members; dispose() removes members and calls exit(). Patched setImmediate, setTimeout, setInterval to enter/exit captured process.domain around callbacks and route exceptions via process._fatalException.
Regression Tests
test/regression/issue/28664.test.ts
New tests spawn Bun child processes to verify that process._fatalException delivers errors to an active domain error handler (with domainThrown: true and domain set), returns true when handled, falls back to process-level uncaughtException when no domain, and that nested domain.enter()/exit() restore previous domain.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: implementing process._fatalException with domain error routing, which directly addresses the core objective of making fatal exceptions work with Node's domain module.
Description check ✅ Passed The PR description is comprehensive and follows the template structure with 'What does this PR do?' (Problem, Root Cause, Fix sections) and 'How did you verify your code works?' (Verification section with test results).
Linked Issues check ✅ Passed All core requirements from issue #28664 are met: process._fatalException now routes errors into active domains [BunProcess.cpp], domain.enter()/exit() properly maintain process.domain [domain.ts], and existing uncaughtException fallback behavior is preserved with domain context management working correctly.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing process._fatalException and domain error routing; the modifications to BunProcess.cpp, domain.ts, and the new regression test suite are all necessary and related to the stated objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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 the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/bindings/BunProcess.cpp`:
- Around line 3493-3510: The code calls countValue.toNumber(globalObject) and
uses the result without checking for exceptions; change it to call toNumber into
a local numeric result (e.g., double or JSValue numeric form) and immediately
invoke the existing exception check (RETURN_IF_EXCEPTION(scope, {})) before
using the value in the comparison; update the comparison that uses
countValue.toNumber(globalObject) to use this checked local result so any
exception from countValue.toNumber (Symbol/getter throws) is handled before
entering the domain emit logic.

In `@src/js/node/domain.ts`:
- Around line 91-100: The d.run implementation never calls d.exit() when fn()
completes successfully; update d.run (the function named "d.run") so that
d.exit() is invoked after the synchronous call to fn() in all cases (use a
finally-style flow), keeping the existing error handling that calls
emitError(err) on exceptions; ensure the order is: d.enter(), try/catch to call
emitError(err) on thrown errors, then always call d.exit() before returning this
to match Node.js domain behavior.

In `@test/regression/issue/28664.test.ts`:
- Around line 1-3: Add a single-line GitHub issue URL comment at the very top of
the test file (above the existing imports of "test, expect" and "bunEnv,
bunExe") for traceability; the comment should be a single line like //
https://github.com/<org>/<repo>/issues/28664 (replace with the correct org/repo
if needed).
🪄 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: 5a3bfd77-edeb-4f1f-bc20-c88d18b60238

📥 Commits

Reviewing files that changed from the base of the PR and between 17616ae and 8dbe5f3339301535382d5dbde1da74ec5958e8c0.

📒 Files selected for processing (3)
  • src/bun.js/bindings/BunProcess.cpp
  • src/js/node/domain.ts
  • test/regression/issue/28664.test.ts

Comment thread src/bun.js/bindings/BunProcess.cpp
Comment thread src/js/node/domain.ts
Comment thread test/regression/issue/28664.test.ts Outdated
Comment thread src/js/node/domain.ts Outdated
Comment thread src/bun.js/bindings/BunProcess.cpp Outdated

@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 the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/28664.test.ts`:
- Around line 132-157: Update the test in the existing "domain.enter/exit
properly sets process.domain" block to exercise nested domains: create two
domain objects (e.g., d1 and d2) in addition to the existing d, call d1.enter();
d2.enter(); then d2.exit(); and assert that process.domain is restored to d1
(not undefined) before finally exiting d1 and asserting undefined; modify the
inline script passed to Bun.spawn (the string under cmd "-e") to include these
extra enter/exit calls and corresponding console.log checks so the test
validates nested-domain stack restoration by referencing process.domain, d1, and
d2.
- Around line 85-130: Add a new test that spawns Bun and calls
process._fatalException without any domain or uncaughtException handlers to
cover the unhandled path; run the same Bun.spawn pattern as the existing tests
but with no listeners, capture stdout/stderr/exit code, assert stdout contains
"result: false", assert the process reported the unhandled path (e.g., stderr
contains "Bun__reportUnhandledError" or other unhandled-error text), and check
the exit code indicates failure (exitCode !== 0) to satisfy the guideline to
verify error scenarios for process._fatalException and
Bun__reportUnhandledError.
🪄 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: b2b3db8f-5156-41fe-9cd1-161eb1b19f31

📥 Commits

Reviewing files that changed from the base of the PR and between 8dbe5f3339301535382d5dbde1da74ec5958e8c0 and 15a02441cdbca43f57b9551c2128e0f289a46e5b.

📒 Files selected for processing (1)
  • test/regression/issue/28664.test.ts

Comment thread test/regression/issue/28664.test.ts
Comment thread test/regression/issue/28664.test.ts
Comment thread src/js/node/domain.ts Outdated
Comment thread src/js/node/domain.ts Outdated

@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 the current code and only fix it if needed.

Inline comments:
In `@src/js/node/domain.ts`:
- Around line 81-95: The add/remove logic can leave duplicate listeners or
detach the wrong domain; fix d.add and d.remove so ownership is checked and old
domains are cleaned up: in d.add(emitter) return immediately if emitter.domain
=== d to avoid duplicate listeners; if emitter.domain exists and !== d, call the
old domain's remove(emitter) (or otherwise remove the emitter from the old
domain's members and listener) before attaching emitError, then set
emitter.domain = d and push to d.members; in d.remove(emitter) only perform
removal when emitter.domain === d (removeListener("error", emitError), set
emitter.domain = null, and splice from d.members) so you never clear another
domain's ownership. Ensure you reference the existing symbols: d.add, d.remove,
emitError, emitter.domain, and d.members.
- Around line 9-52: The current patch only wraps
setImmediate/setTimeout/setInterval so async callbacks scheduled via
process.nextTick, queueMicrotask, and promise continuations still lose
process.domain; update the module to also wrap process.nextTick,
globalThis.queueMicrotask, and monkey-patch Promise.prototype.then/catch/finally
(using saved originals like _origNextTick, _origQueueMicrotask, and
_origPromiseThen) to call _wrapWithDomain for function callbacks when
process.domain is present; while doing this also fix the invocation bugs in
_wrapWithDomain/_patched* by using fn.apply/Function.prototype.call on the
original functions (not .$apply/.$call) and call the saved originals (e.g.,
_origSetImmediate.call(this, callback, ...args)) so domain enter/exit is
reliably propagated for microtasks and promise continuations as well as timers.
- Around line 147-150: The dispose implementation currently clears d.members but
leaves each emitter still wired to the domain; update d.dispose (the function
paired with d.add and the emitError handler) to iterate over d.members and for
each member call member.removeListener('error', emitError') and delete
member.domain (or set to undefined) to unregister the domain from the emitter,
then call this.removeAllListeners(), clear the members array, and finally call
d.exit(); this ensures emitters no longer reference the disposed domain and the
emitError listener is removed.
🪄 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: fd0bcbf7-fd5a-48b9-91e5-07f8e58ebbfa

📥 Commits

Reviewing files that changed from the base of the PR and between 15a02441cdbca43f57b9551c2128e0f289a46e5b and 1d991d5f6320e19ee9f9d35c1764da87e0cb948e.

📒 Files selected for processing (3)
  • src/bun.js/bindings/BunProcess.cpp
  • src/js/node/domain.ts
  • test/regression/issue/28664.test.ts

Comment thread src/js/node/domain.ts Outdated
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts Outdated

@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

♻️ Duplicate comments (1)
src/js/node/domain.ts (1)

11-13: ⚠️ Potential issue | 🟠 Major

Monkey-patching the globals is still import-order dependent.

src/js/node/timers.promises.ts:7-9 snapshots globalThis.setImmediate, setTimeout, and setInterval at module load. If that module is loaded before node:domain, it keeps the unwrapped originals and bypasses the new domain propagation entirely. This needs to live at a shared timer entry point, or timers.promises.ts needs the same wrapping.

Also applies to: 50-52

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/js/node/domain.ts` around lines 11 - 13, The global timer snapshots
(_origSetImmediate, _origSetTimeout, _origSetInterval) in domain.ts make
propagation order-dependent; fix by centralizing the timer wrappers so all
modules use the same wrapped originals: either move the snapshot/wrapping logic
into a shared timer entry point and import that from
src/js/node/timers.promises.ts, or export the wrapped functions (or a setup
function) from domain.ts and have timers.promises.ts call/import them so it does
not capture unwrapped globals; update references to the original globals in both
modules to use the shared wrappers to ensure domain propagation regardless of
import order.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/node/domain.ts`:
- Around line 65-78: The emitError function must not coerce falsy error payloads
into a synthetic error; remove the e ||= $ERR_UNHANDLED_ERROR() line and stop
replacing 0/false/""/undefined before emitting. Instead only attach metadata
when the payload is an object or function (adjust the typeof check to include
"function" if needed), keep ObjectDefineProperty(e, "domain", ...) and
e.domainThrown = thrown === true, then call d.emit("error", e) unchanged so the
original exception value is forwarded to d.emit.
- Around line 15-23: The wrapper _wrapWithDomain currently exits the domain in a
finally block which clears process.domain before native uncaught-exception
handling sees it; change the implementation so exceptions are routed through the
domain before exit by replacing the plain finally-only approach with
try/catch/finally: inside catch, invoke the domain's error routing (e.g., call
activeDomain.emit('error', err) or the domain's error handler) then rethrow the
error, and only call activeDomain.exit() in the finally block after routing;
reference _wrapWithDomain, activeDomain.enter(), activeDomain.exit(), and the
wrapped function call (fn.$apply) when making the change.

---

Duplicate comments:
In `@src/js/node/domain.ts`:
- Around line 11-13: The global timer snapshots (_origSetImmediate,
_origSetTimeout, _origSetInterval) in domain.ts make propagation
order-dependent; fix by centralizing the timer wrappers so all modules use the
same wrapped originals: either move the snapshot/wrapping logic into a shared
timer entry point and import that from src/js/node/timers.promises.ts, or export
the wrapped functions (or a setup function) from domain.ts and have
timers.promises.ts call/import them so it does not capture unwrapped globals;
update references to the original globals in both modules to use the shared
wrappers to ensure domain propagation regardless of import order.
🪄 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: 57b0e4e8-ddbf-4c48-9210-521b9b3f1962

📥 Commits

Reviewing files that changed from the base of the PR and between 1d991d5f6320e19ee9f9d35c1764da87e0cb948e and f6b96d05770b5e8910e1fdd47baed9b1f7f5692b.

📒 Files selected for processing (1)
  • src/js/node/domain.ts

Comment thread src/js/node/domain.ts Outdated
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts

@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 the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/28664.test.ts`:
- Around line 196-198: The assertion fails because the test checks
process.domain with strict equality to undefined after calling d1.exit(); update
the check to use loose null comparison so it accepts both null and undefined
(change the console.log/expect that uses "=== undefined" to use "== null" or
equivalent). Locate the lines around the call to d1.exit() and the subsequent
console.log/expect that reference process.domain and replace the strict ===
undefined check with == null to match the other tests and the domain
implementation.
🪄 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: 4801a22e-4577-44d0-b006-cb4e9b883e0a

📥 Commits

Reviewing files that changed from the base of the PR and between f6b96d05770b5e8910e1fdd47baed9b1f7f5692b and e0133482215ecfec8c5f0121222b36b039c41ad6.

📒 Files selected for processing (2)
  • src/js/node/domain.ts
  • test/regression/issue/28664.test.ts

Comment thread test/regression/issue/28664.test.ts
Comment thread src/bun.js/bindings/BunProcess.cpp
Comment thread src/js/node/domain.ts Outdated
@robobun
robobun force-pushed the farm/90f740ca/fix-fatal-exception-domain branch from a1a1d25 to 4f25883 Compare March 30, 2026 13:04
Comment thread src/js/node/domain.ts Outdated
Comment thread src/bun.js/bindings/BunProcess.cpp Outdated
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts
@robobun
robobun force-pushed the farm/90f740ca/fix-fatal-exception-domain branch 2 times, most recently from 480c95b to 2d31b97 Compare March 30, 2026 13:52
Comment on lines +1 to +30
// https://github.com/oven-sh/bun/issues/28664
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";

test("process._fatalException routes errors to active domain", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
require('domain')
.create()
.on('error', (e) => console.log('domain error: ' + e.message))
.run(() => {
setImmediate(() => {
process._fatalException(new Error('CRASH!!!'));
});
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toContain("domain error: CRASH!!!");
expect(exitCode).toBe(0);
});

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.

🔴 All 8 tests in test/regression/issue/28664.test.ts call process._fatalException() directly rather than exercising the natural throw-inside-timer path, leaving the _wrapWithDomain catch block in domain.ts with zero test coverage. Add a test that does d.run(() => { setImmediate(() => { throw new Error('real throw'); }); }) to cover the primary integration path that was added during review.

Extended reasoning...

Missing test coverage for _wrapWithDomain catch block

The _wrapWithDomain function in src/js/node/domain.ts contains a catch block that is the primary integration path for domain error routing — it catches errors thrown inside domain-wrapped timer callbacks (setImmediate/setTimeout/setInterval) and routes them through process._fatalException(err) while the domain is still active. This block was added iteratively during PR review (commit ee4adc0) because reviewers noted that without it, process.domain would be cleared by the finally block before the native error handler could inspect it.

All 8 tests in test/regression/issue/28664.test.ts bypass this catch block entirely. Tests 1 and 2 do call setImmediate inside a d.run() context (so _wrapWithDomain IS invoked and wraps the callback), but the callback explicitly calls process._fatalException(new Error(...)) — the try block returns normally without throwing, so the catch block is never entered. Tests 3–6 call process._fatalException() without any timer, and tests 7–8 test enter()/exit() without exceptions.

The untested code path is:

function _wrapWithDomain(fn: any, activeDomain: any) {
  return function (this: any) {
    activeDomain.enter();
    try {
      return fn.$apply(this, arguments);
    } catch (err) {          // ← NEVER REACHED by any test
      if (\!process._fatalException(err)) {
        throw err;
      }
    } finally {
      activeDomain.exit();
    }
  };
}

Step-by-step proof of the gap:

  1. Test 1 calls d.run(() => { setImmediate(() => { process._fatalException(new Error('CRASH\!\!\!')); }); })
  2. d.run wraps the outer callback; on scheduling, setImmediate's callback is also wrapped by _wrapWithDomain
  3. When the setImmediate fires, _wrapWithDomain's wrapper calls activeDomain.enter(), then invokes the original callback
  4. Inside the callback, process._fatalException() is called explicitly — this routes to the domain handler and returns true
  5. The try block returns normally (the call to _fatalException returns a boolean; no exception is thrown)
  6. The catch block is never evaluated; the finally block runs and calls activeDomain.exit()

A minimal test covering the untested path would be:

const d = require('domain').create();
d.on('error', (e) => console.log('caught: ' + e.message));
d.run(() => {
  setImmediate(() => {
    throw new Error('real throw');  // caught by _wrapWithDomain's catch block
  });
});

Without this test, any regression in the catch block (wrong condition on _fatalException's return value, wrong variable name, missing err argument, accidentally swapped then/else) would pass the entire test suite undetected. The catch block was added specifically to handle the case reviewers flagged — not testing it means the core improvement from the PR review has no verification.

Comment thread src/bun.js/bindings/BunProcess.cpp
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts
Comment on lines 107 to 116
d.bind = function (fn) {
return function () {
var args = Array.prototype.slice.$call(arguments);
try {
fn.$apply(null, args);
return fn.$apply(this, args);
} catch (err) {
emitError(err);
emitError(err, true);
}
};
};

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.

🔴 d.bind() and d.intercept() do not call d.enter()/d.exit() around the wrapped function's execution, so process.domain is wrong inside bound/intercepted callbacks; additionally, the wrapper returned by d.bind() is missing a .domain property, breaking any code that inspects bound.domain. Both issues cause incorrect domain association for errors thrown inside callbacks created with these methods.

Extended reasoning...

Missing d.enter()/d.exit() in d.bind() and d.intercept()

The Node.js domain module contract is that any callback wrapped by d.bind() or d.intercept() should execute with that domain active (i.e., process.domain === d) for the duration of the call. The current implementation at lines 107–130 of src/js/node/domain.ts calls fn directly without ever invoking d.enter() before execution or d.exit() in a finally block after. This means process.domain retains whatever value it had before the bound callback was called — which could be null, a different domain, or a stale reference.

Comparison to correct patterns in the same file

Both d.run() (lines 147–157) and _wrapWithDomain() (lines 15–29) implement the correct enter/finally-exit pattern. d.run() calls d.enter() before fn(), catches errors with emitError(), then always calls d.exit() in a finally block. _wrapWithDomain() does the same for timer-patched callbacks. d.bind() and d.intercept() conspicuously omit this pattern despite being the primary public API for domain-aware callbacks.

Missing wrapper.domain = d on the d.bind() return value

Node.js's Domain.prototype.bind explicitly stamps the returned wrapper function with bound.domain = this (set via ObjectDefineProperty with enumerable: false). Code that calls d.bind(fn) and then inspects the returned function's .domain property — or that later removes the binding by checking emitter.domain — will get undefined instead of d. This is a compatibility gap versus Node.js's documented behavior. Note: d.intercept() does not set .domain on its wrapper in Node.js either, so this specific sub-issue is scoped to d.bind() only.

Concrete proof of the enter/exit bug

Step 1: create domain d1, enter it (process.domain === d1). Step 2: create domain d2, call var cb = d2.bind(fn). Step 3: exit d1 (process.domain === null). Step 4: call cb(). Inside fn, process.domain is still null — not d2 as expected. Any throw inside fn calls emitError(err, true), which emits on d2 correctly, but process.domain is wrong throughout fn's execution, so any nested async scheduling (setTimeout, etc.) inside fn would capture null as the active domain rather than d2.

How to fix

In d.bind(), wrap the fn. call with d.enter() before and d.exit() in a finally block (matching the d.run() pattern), and add ObjectDefineProperty(bound, 'domain', { configurable: true, enumerable: false, value: d, writable: true }) on the returned wrapper. In d.intercept(), add the same d.enter()/d.exit() wrapping around the fn. call in the non-error branch.

Comment on lines +3498 to +3511
if (count > 0) {
// Domain has error listeners - emit the error on the domain
JSValue emitFn = domainObj->get(globalObject, Identifier::fromString(vm, "emit"_s));
RETURN_IF_EXCEPTION(scope, {});

if (emitFn && emitFn.isCallable()) {
auto emitCallData = JSC::getCallData(emitFn);
MarkedArgumentBuffer emitArgs;
emitArgs.append(jsString(vm, String("error"_s)));
emitArgs.append(exception);
JSC::call(globalObject, emitFn, emitCallData, domainObj, emitArgs);
RETURN_IF_EXCEPTION(scope, {});
return JSValue::encode(jsBoolean(true));
}

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 async domain error path in Process_fatalException passes null directly to d.emit("error", null) without a null guard, while the synchronous path through JS emitError() has an explicit if (\!e) e = $ERR_UNHANDLED_ERROR() guard. This creates a behavioral split: throw null inside a d.run() callback works correctly in sync code but causes handlers to receive null in async callbacks (e.g., setImmediate), leading to TypeError: Cannot read properties of null when the handler accesses err.message.

Extended reasoning...

What the bug is and how it manifests

PR #28665 adds domain error routing via a new C++ Process_fatalException implementation. However, the async and sync code paths have an asymmetry: the synchronous path (via emitError() in domain.ts) has a null guard that converts falsy errors into proper ERR_UNHANDLED_ERROR objects, but the async path (via C++ Process_fatalException) has no such guard and passes the raw value straight to d.emit('error', exception).

The specific code path that triggers it

When code throws a falsy value (e.g., null) inside an async callback that was scheduled while a domain was active, the path is:

  1. _wrapWithDomain wraps the async callback (e.g., the setImmediate handler)
  2. The callback throws null
  3. _wrapWithDomain's catch block calls process._fatalException(null)
  4. C++ Process_fatalException receives null as exception
  5. The if (exception.isObject()) guard is skipped (null is not an object in JSC)
  6. emitArgs.append(exception) appends the raw null
  7. d.emit("error", null) fires with null as the error argument
  8. The domain error handler receives null and any access like err.message throws a TypeError

Why existing code doesn't prevent it

The if (exception.isObject()) check in Process_fatalException only guards property-stamping (adding domainThrown and domain properties). It does NOT check whether the exception is falsy before emitting. The null guard that Node.js relies on — if (\!e) e = $ERR_UNHANDLED_ERROR() — exists only in the JS emitError() function, which is only called on the synchronous path.

What the impact would be

Any code that (intentionally or by accident) does throw null, throw undefined, throw 0, or throw "" inside an async callback (timer, I/O, promise, etc.) while a domain is active will cause domain error handlers to receive that falsy value instead of a proper Error. This silently breaks the Node.js contract for domain error handlers and can cause cascading TypeErrors in handlers that expect an Error-like object.

How to fix it

Add a null/falsy guard at the top of Process_fatalException, mirroring the JS guard:

// At the start of Process_fatalException, after getting exception:
if (\!exception || exception.isNull() || exception.isUndefined() || exception.isFalse() ||
    (exception.isNumber() && exception.asNumber() == 0) ||
    (exception.isString() && exception.getString(lexicalGlobalObject)->length() == 0)) {
    // Replace with ERR_UNHANDLED_ERROR equivalent
    exception = ...;
}

Or more simply, mirror the JS: convert the exception to a proper Error if it is falsy before emitting.

Step-by-step proof

Consider this program:

const domain = require('domain');
const d = domain.create();
d.on('error', (err) => {
  console.log('got error:', err);       // prints 'null' instead of an Error
  console.log(err.message);             // throws TypeError: Cannot read properties of null
});
d.run(() => {
  setImmediate(() => { throw null; });  // async throw of null
});
  • In Node.js, d.on('error') would receive an ERR_UNHANDLED_ERROR wrapping the null (sync path also does this via emitError)
  • In Bun with this PR: setImmediate is wrapped by _wrapWithDomain, its catch block calls process._fatalException(null), C++ emits null directly, and the handler crashes on err.message
  • The same throw null inside a synchronous d.run(() => { throw null; }) would correctly produce ERR_UNHANDLED_ERROR because the sync path goes through JS emitError() which has the guard

Comment thread src/js/node/domain.ts
process._fatalException was a no-op stub that returned undefined.
Now it properly:
- Checks process.domain for an active domain with error listeners
- Sets domainThrown and domain (non-enumerable) properties on the error
- Emits the error on the domain if one is active
- Falls back to uncaughtException/captureCallback handling
- Returns a boolean indicating whether the error was handled

Also fixes the domain module:
- enter()/exit() properly set process.domain with stack tracking
- run() uses try/catch/finally with proper domain lifecycle
- Async timer wrapping propagates domain context through callbacks
- add()/remove()/dispose() manage emitter ownership correctly
- bind()/intercept() forward this, return values, and thrown status
- splice(index, 1) fix for exit() stack management

Fixes #28664
@robobun
robobun force-pushed the farm/90f740ca/fix-fatal-exception-domain branch from 2d31b97 to ad79007 Compare March 30, 2026 14:50
Comment on lines +3498 to +3510
if (count > 0) {
// Domain has error listeners - emit the error on the domain
JSValue emitFn = domainObj->get(globalObject, Identifier::fromString(vm, "emit"_s));
RETURN_IF_EXCEPTION(scope, {});

if (emitFn && emitFn.isCallable()) {
auto emitCallData = JSC::getCallData(emitFn);
MarkedArgumentBuffer emitArgs;
emitArgs.append(jsString(vm, String("error"_s)));
emitArgs.append(exception);
JSC::call(globalObject, emitFn, emitCallData, domainObj, emitArgs);
RETURN_IF_EXCEPTION(scope, {});
return JSValue::encode(jsBoolean(true));

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.

🔴 Process_fatalException returns early after routing to a domain error listener without ever emitting the 'uncaughtExceptionMonitor' event, violating Node.js compatibility. In Node.js, 'uncaughtExceptionMonitor' is an unconditional passive observer that must fire before any domain or uncaughtException routing — it cannot be suppressed by a domain handler. Any monitoring tool using process.on('uncaughtExceptionMonitor', ...) alongside a domain error handler will silently fail to receive notifications in Bun.

Extended reasoning...

What the bug is and how it manifests

In Process_fatalException (BunProcess.cpp), when a domain has error listeners and handles the exception, the function calls domain.emit('error', exception) and immediately returns jsBoolean(true) at line 3510. The 'uncaughtExceptionMonitor' event is only emitted inside Bun__handleUncaughtException (lines 1184-1187), which is only reached on the fallthrough path (no domain handled the error, line 3517). This means for any exception handled by a domain, uncaughtExceptionMonitor is never fired.

The specific code path that triggers it

  1. process._fatalException(err) is called while a domain is active
  2. Process_fatalException finds process.domain set to an object domain
  3. domain.listenerCount('error') > 0, so the error is emitted on the domain
  4. The function returns jsBoolean(true) immediately
  5. Bun__handleUncaughtException is never called — uncaughtExceptionMonitor never fires

Why existing code doesn't prevent it

The uncaughtExceptionMonitor emission is entirely encapsulated inside Bun__handleUncaughtException. There is no unconditional pre-routing emission. The domain-handling path at lines 3498-3510 is a pure early-return that bypasses Bun__handleUncaughtException entirely.

What the impact would be

This is a Node.js compatibility regression. APM tools (Sentry, Datadog, New Relic) and monitoring frameworks rely on uncaughtExceptionMonitor to track ALL uncaught exceptions regardless of how they are ultimately handled. A program that registers both a domain error handler and an uncaughtExceptionMonitor listener will never receive monitor events in Bun when exceptions are caught by domains. The monitor is specifically designed to be unsuppressible — it fires even when uncaughtException or domain handlers are present.

Step-by-step proof

In Node.js, uncaughtExceptionMonitor fires first in the fatalException function before any domain routing takes place. Bun's implementation never reaches the emission for domain-handled errors.

How to fix it

Before the domain check in Process_fatalException, explicitly fire the uncaughtExceptionMonitor event if there are listeners, mirroring how Bun__handleUncaughtException does it but unconditionally — before any domain routing logic.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this PR predates the Rust rewrite and modifies source files that no longer exist on main (Zig sources removed, src/bun.js/ reorganized into src/jsc/). It cannot merge as-is.

If the underlying issue is still present, it will need a fresh fix against the current tree.

@robobun robobun closed this Jun 26, 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.

Bun does not handle process._fatalException inside a domain

1 participant