Skip to content

domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite - #31828

Open
cirospaciari wants to merge 64 commits into
mainfrom
claude/port-node-domain-tests
Open

domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite#31828
cirospaciari wants to merge 64 commits into
mainfrom
claude/port-node-domain-tests

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jun 4, 2026

Copy link
Copy Markdown
Member

Implements node:domain (previously a ~70-line stub) as a port of Node's lib/domain.js, and vendors the upstream domain test suite verbatim from Node v26.

44 of the 50 upstream test-domain-* tests now pass (40 newly vendored, plus 3 existing files re-synced to upstream). The 6 that do not pass are not in the tree — they are listed as gaps below rather than marked failing. test/expectations.txt is untouched by this PR. On linux-x64-musl, test-gc-http-client-connaborted.js now joins test-net-connect-memleak.js as a manifestation of open issue #33044 (one object not finalized in a gc+setImmediate loop; no JS-level retention found, 10/10 on glibc); it is left un-quarantined alongside its sibling pending that issue's fix.

What this does

node:domain was a stub, so any package depending on it silently did nothing. This implements it and proves it against Node's own test suite.

  • node:domain implemented: create()/Domain, run/bind/intercept/add/remove/enter/exit, domain.active/domain._stack, and Node's error decoration (err.domain, domainThrown, domainEmitter, domainBound).
  • Async propagation without createHook: Node pairs each async resource with the active domain via the async-hooks init hook, then enters it in before(). Bun has no createHook, so the active domain rides Bun's AsyncLocalStorage/AsyncContextFrame machinery instead — snapshotted at schedule time, restored around every callback, which is the same pairing. The synchronous domain stack stays a module-global like Node's; the uncaught-exception dispatcher reconciles the two at async boundaries.
  • Uncaught-exception routing: a dedicated native dispatch slot consulted by Bun__handleUncaughtException before the capture callback and 'uncaughtException' listeners — where Node's domain hooks into process._fatalException. process.domain becomes an accessor reading the async-local active domain.
  • EventEmitter integration: the constructor now delegates through a Node-compatible EventEmitter.init static (matching Node, and fixing userland that calls EventEmitter.init.call(this)). Emitters created inside a domain get .domain and route 'error' into it with Node's stack pruning/restore semantics. The old init: EventEmitter self-alias is removed — with the constructor delegating through init, that alias would recurse infinitely.
  • One emit on the prototype: Bun had two emit implementations and assigned emitWithRejectionCapture as an own instance property when captureRejections was set. An own property shadows any prototype override, so those emitters bypassed domains entirely. Both are now the single prototype emit Node has, with the rejection-capture check guarded by kCapture (addCatch early-returns when it is off). This also removes the own-emit shadowing that Http2Server had a comment working around.
  • AsyncResource created inside a domain gets the non-enumerable .domain property Node's init hook provides. async_hooks stays domain-agnostic: the getter is null until node:domain loads, so nothing touches process.domain otherwise.
  • --abort-on-uncaught-exception: implemented, including the --abort_on_uncaught_exception spelling V8 also accepts. Aborts after printing instead of exit(1), and a throwing top-level domain 'error' handler aborts rather than being swallowed. Ordering matches Node: exceptions abort before 'uncaughtException' listeners are consulted (V8 aborts at throw time), while true promise rejections abort only after listeners decline.
  • Exit code 7 when an error is thrown from the capture callback or a top-level domain error handler (Node's internal-exception-handler run-time failure code; was exit 1).
  • AsyncResource created inside a domain gets the non-enumerable .domain property Node's init hook provides.

Drive-by fix: dropped process.nextTick

AsyncLocalStorage.enterWith() + process.nextTick at main-module scope silently dropped the tick. cleanupAsyncHooksData unhooked the on-each-microtask-tick callback without draining the pending nextTick queue, so the process could exit with callbacks still queued. It now drains before unhooking. This is pre-existing on main and unrelated to domains, but domain code hits it constantly.

How we know it works

Every vendored test is byte-identical to upstream v26 except three, each carrying an inline Note for Bun comment explaining the omitted case (see gaps). All 44 fail on released Bun and pass on this branch. Also re-ran the events, async-hooks, and asynclocalstorage upstream suites (29/29) and Bun's own async_hooks (111) and event-emitter/process (184) tests, since EventEmitter.init and the nextTick drain are cross-cutting.

Gaps

Six upstream tests are deliberately not vendored, all blocked on the same two missing pieces:

  • Unhandled rejections are not routed through the domain machinery. In Bun, fs callbacks are promise reactions, so a throw inside one surfaces via the rejection path with the context already restored. Blocks test-domain-implicit-fs.js, test-domain-implicit-binding.js, test-domain-multi.js, test-domain-no-error-handler-abort-on-uncaught-{5,9}.js. For the same reason, three vendored tests omit one case each (test-domain-abort-on-uncaught, test-domain-promise, test-domain-with-abort-on-uncaught-exception), and the pre-existing d.run(cb) deviation in test-crypto-domain.js is now documented rather than silent.
  • No MakeCallback equivalent, so the DEP0097 warning has no source. Blocks test-domain-dep0097.js.

no test proof · iteration 33 · 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

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

This pull request adds Node-compatible domains with async context propagation, EventEmitter integration, uncaught-exception routing, and abort-on-uncaught-exception support. It also updates exception-origin plumbing and adds domain and subprocess regression tests.

Changes

Domain runtime and EventEmitter integration

Layer / File(s) Summary
Domain state, APIs, and EventEmitter integration
src/js/node/domain.ts, src/js/node/async_hooks.ts, src/js/node/events.ts, src/js/node/http2.ts, src/jsc/bindings/ZigGlobalObject.cpp
Implements async-aware domain state, lifecycle APIs, callback binding, error routing, EventEmitter association, AsyncResource domain tagging, and centralized rejection capture.
Domain lifecycle and async integration tests
test/js/node/domain/*, test/js/node/async_hooks/*, test/js/node/events/*, test/js/node/test/parallel/*
Adds coverage for domain stacks, async propagation, EventEmitter routing, Promise behavior, metadata, cleanup, garbage collection, and compatibility gaps.

Uncaught-exception handling

Layer / File(s) Summary
Exception origins and abort handling
src/jsc/VirtualMachine.rs, src/jsc/bindings/BunProcess.*, src/runtime/cli/Arguments.rs, src/jsc/*, src/runtime/*
Adds explicit exception origins, domain-handler storage, abort flag parsing, handler ordering, worker substitution, exit-code handling, and updated exception-reporting call sites.
Abort regression suites
test/js/node/process/process.test.js, test/js/node/test/common/index.js, test/js/node/test/parallel/test-domain-*-abort-on-uncaught*.js
Adds subprocess coverage for abort behavior, domain handlers, capture callbacks, handler failures, crash-report suppression, and platform-specific results.

Possibly related PRs

  • oven-sh/bun#31831: Shares uncaught-exception origin and exit handling across the VM and process runtime.
  • oven-sh/bun#34121: Overlaps in async-hooks and nextTick queue cleanup.
  • oven-sh/bun#36579: Also changes uncaught-exception and rejection handling in BunProcess.cpp.
🚥 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: implementing Node-compatible domain support using AsyncLocalStorage.
Description check ✅ Passed The description includes both required sections and provides detailed implementation, verification, limitations, and test coverage information.

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

@github-actions github-actions Bot added the claude label Jun 4, 2026
@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator
Updated 4:41 PM PT - Aug 7th, 2026

@robobun, your commit a9a46e4 is building: #90336

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Missing functionality of 'domain' API: control of async unhandled exceptions #6045 - PR's full node:domain reimplementation directly fixes the reported inability to catch async exceptions via domain.on('error')
  2. domain does not catch exceptions thrown inside setTimeout() callbacks #30672 - PR's AsyncLocalStorage-based domain implementation directly fixes the exact setTimeout + domain error-catching scenario reported

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

Fixes #6045
Fixes #30672

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Implement process._fatalException with domain error routing #28665 - Implements process._fatalException with domain error routing in BunProcess.cpp and fixes domain.enter()/exit() in domain.ts — a subset of this PR's domain rewrite.
  2. domain: catch exceptions thrown inside setTimeout/setInterval/setImmediate callbacks #30675 - Rewrites domain.ts to maintain a real domain stack with timer callback wrapping and uncaught exception routing — also a subset of this PR's comprehensive AsyncLocalStorage-based implementation.

🤖 Generated with Claude Code

@cirospaciari
cirospaciari force-pushed the claude/port-node-domain-tests branch from d620847 to e190cb5 Compare June 5, 2026 01:52

@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

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)

817-825: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Assert stderr before exitCode for better test failure diagnostics.

The current assertion order makes test failures less informative. If the exit code is wrong, you won't see what stderr actually contained. As per coding guidelines, subprocess tests should assert output before exit code.

♻️ Recommended fix
   it("aborts when the uncaughtExceptionCaptureCallback throws", async () => {
-    const proc = Bun.spawn([bunExe(), join(import.meta.dir, "process-uncaughtExceptionCaptureCallbackAbort.js")], {
+    await using proc = Bun.spawn([bunExe(), join(import.meta.dir, "process-uncaughtExceptionCaptureCallbackAbort.js")], {
       stderr: "pipe",
     });
+    const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
+    expect(stderr).toContain("bar");
     // An exception thrown from the capture callback exits with code 7 like
     // node (internal exception handler run-time failure).
-    expect(await proc.exited).toBe(7);
-    expect(await proc.stderr.text()).toContain("bar");
+    expect(exitCode).toBe(7);
   });

Based on learnings: applies to **/*.test.{ts,tsx}: assert stdout/stderr BEFORE exitCode; subprocess tests must drain pipes concurrently with Promise.all.

🤖 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 817 - 825, The test
currently checks proc.exited before reading proc.stderr, which hides stderr when
the exit code assertion fails; change the assertions to drain pipes concurrently
and assert output first by awaiting both with Promise.all (e.g., await
Promise.all([proc.stderr.text(), proc.exited])) and then assert that the stderr
string contains "bar" before asserting the exit code equals 7; update the test
that uses Bun.spawn and variables proc, proc.stderr.text(), and proc.exited to
use this pattern so stdout/stderr are read concurrently and checked prior to
exit code assertion.
🤖 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 `@src/js/node/async_hooks.ts`:
- Line 283: Replace the explicit null and undefined check for the variable
`domain` with a combined loose equality check: locate the conditional `if
(domain !== null && domain !== undefined)` in src/js/node/async_hooks.ts and
change it to use `!= null` so it succinctly checks for both null and undefined
(keep the rest of the conditional block unchanged and ensure no other logic is
affected).

In `@src/js/node/domain.ts`:
- Around line 434-436: The code currently treats any falsy second argument as
missing when constructing the error payload (const er = args.length > 1 &&
args[1] ? args[1] : $ERR_UNHANDLED_ERROR()), which drops valid falsy values like
0/false/""/null/undefined; change the condition to only consider the absence of
a second argument (e.g. const er = args.length > 1 ? args[1] :
$ERR_UNHANDLED_ERROR()) so emit("error", <falsy>) is preserved; update the
assignment to variable er in src/js/node/domain.ts (the
EventEmitter.prototype.emit/error handling block) accordingly to preserve
provided values.

In `@src/jsc/bindings/BunProcess.cpp`:
- Around line 1252-1256: In the fatal branches inside
Bun__handleUncaughtException, after calling Bun__Process__exit(...) you must
return immediately so worker threads don't continue execution; add an immediate
return statement right after the Bun__Process__exit(lexicalGlobalObject, 7) call
in the branch following Bun__logUnhandledException and
Bun__Node__AbortOnUncaughtException, and do the same for the other identical
code path later (the branch around the second Bun__Process__exit call), so
neither path can fall through in worker contexts.

---

Outside diff comments:
In `@test/js/node/process/process.test.js`:
- Around line 817-825: The test currently checks proc.exited before reading
proc.stderr, which hides stderr when the exit code assertion fails; change the
assertions to drain pipes concurrently and assert output first by awaiting both
with Promise.all (e.g., await Promise.all([proc.stderr.text(), proc.exited]))
and then assert that the stderr string contains "bar" before asserting the exit
code equals 7; update the test that uses Bun.spawn and variables proc,
proc.stderr.text(), and proc.exited to use this pattern so stdout/stderr are
read concurrently and checked prior to exit code assertion.
🪄 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: c79a864b-3171-4931-903d-6fb8f19d82d8

📥 Commits

Reviewing files that changed from the base of the PR and between 91270aa and e190cb5.

📒 Files selected for processing (51)
  • src/js/node/async_hooks.ts
  • src/js/node/domain.ts
  • src/js/node/events.ts
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/BunProcess.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/runtime/cli/Arguments.rs
  • test/js/node/process/process.test.js
  • test/js/node/test/parallel/test-domain-abort-on-uncaught.js
  • test/js/node/test/parallel/test-domain-add-remove.js
  • test/js/node/test/parallel/test-domain-async-id-map-leak.js
  • test/js/node/test/parallel/test-domain-bind-timeout.js
  • test/js/node/test/parallel/test-domain-ee-implicit.js
  • test/js/node/test/parallel/test-domain-ee.js
  • test/js/node/test/parallel/test-domain-emit-error-handler-stack.js
  • test/js/node/test/parallel/test-domain-enter-exit.js
  • test/js/node/test/parallel/test-domain-error-types.js
  • test/js/node/test/parallel/test-domain-from-timer.js
  • test/js/node/test/parallel/test-domain-fs-enoent-stream.js
  • test/js/node/test/parallel/test-domain-http-server.js
  • test/js/node/test/parallel/test-domain-intercept.js
  • test/js/node/test/parallel/test-domain-load-after-set-uncaught-exception-capture.js
  • test/js/node/test/parallel/test-domain-multiple-errors.js
  • test/js/node/test/parallel/test-domain-nested-throw.js
  • test/js/node/test/parallel/test-domain-nested.js
  • test/js/node/test/parallel/test-domain-nexttick.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-0.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-1.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-2.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-3.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-4.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-6.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-7.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-8.js
  • test/js/node/test/parallel/test-domain-promise.js
  • test/js/node/test/parallel/test-domain-run.js
  • test/js/node/test/parallel/test-domain-safe-exit.js
  • test/js/node/test/parallel/test-domain-set-uncaught-exception-capture-after-load.js
  • test/js/node/test/parallel/test-domain-stack-empty-in-process-uncaughtexception.js
  • test/js/node/test/parallel/test-domain-stack.js
  • test/js/node/test/parallel/test-domain-throw-error-then-throw-from-uncaught-exception-handler.js
  • test/js/node/test/parallel/test-domain-thrown-error-handler-stack.js
  • test/js/node/test/parallel/test-domain-timer.js
  • test/js/node/test/parallel/test-domain-timers-uncaught-exception.js
  • test/js/node/test/parallel/test-domain-timers.js
  • test/js/node/test/parallel/test-domain-top-level-error-handler-clears-stack.js
  • test/js/node/test/parallel/test-domain-top-level-error-handler-throw.js
  • test/js/node/test/parallel/test-domain-uncaught-exception.js
  • test/js/node/test/parallel/test-domain-vm-promise-isolation.js
  • test/js/node/test/parallel/test-domain-with-abort-on-uncaught-exception.js

Comment thread src/js/node/async_hooks.ts Outdated
Comment thread src/js/node/domain.ts
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
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

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/test/parallel/test-domain-nested-throw.js (1)

46-48: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Exit code assertion could be stricter for robustness.

The assertion assert(!c) accepts any falsy exit code, including null (signal termination) in addition to 0 (normal successful exit). For a domain error-handling test, the child should exit normally with code 0 after handling the errors, not be terminated by a signal.

🔒 Stricter exit assertion
- child.on('exit', common.mustCall((c) => {
-   assert(!c);
+ child.on('exit', common.mustCall((code, signal) => {
+   assert.strictEqual(code, 0);
+   assert.strictEqual(signal, null);
  }));

This ensures the child exited normally with success code 0, not via signal or other abnormal termination.

🤖 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/test/parallel/test-domain-nested-throw.js` around lines 46 - 48,
The exit assertion in the child process handler currently uses assert(!c) which
allows any falsy value (including null for signal termination); update the
assertion in the child.on('exit', common.mustCall(...)) callback to assert that
the exit code is exactly 0 (e.g., use assert.strictEqual(c, 0)) so the test
verifies the child exited normally with success rather than being terminated by
a signal.
🤖 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 `@src/jsc/bindings/BunProcess.cpp`:
- Around line 1220-1223: Bun__Node__AbortOnUncaughtException is an AtomicBool in
Rust but is declared as extern "C" bool in C++, causing ABI/atomic mismatches;
add a C ABI accessor in Rust (e.g., a function named
Bun__Node__AbortOnUncaughtException_load that returns the result of
.load(Ordering::Relaxed)) and change shouldAbortOnUncaughtException() to call
that loader instead of reading Bun__Node__AbortOnUncaughtException directly so
the C++ side performs a proper atomic-safe load.

---

Outside diff comments:
In `@test/js/node/test/parallel/test-domain-nested-throw.js`:
- Around line 46-48: The exit assertion in the child process handler currently
uses assert(!c) which allows any falsy value (including null for signal
termination); update the assertion in the child.on('exit', common.mustCall(...))
callback to assert that the exit code is exactly 0 (e.g., use
assert.strictEqual(c, 0)) so the test verifies the child exited normally with
success rather than being terminated by a signal.
🪄 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: 0644b2fe-bfef-44d9-bcb4-11bf820acdd8

📥 Commits

Reviewing files that changed from the base of the PR and between e190cb5 and a8004f8.

📒 Files selected for processing (6)
  • src/jsc/bindings/BunProcess.cpp
  • test/js/node/test/parallel/test-domain-abort-on-uncaught.js
  • test/js/node/test/parallel/test-domain-nested-throw.js
  • test/js/node/test/parallel/test-domain-nested.js
  • test/js/node/test/parallel/test-domain-thrown-error-handler-stack.js
  • test/js/node/test/parallel/test-domain-top-level-error-handler-clears-stack.js

Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/js/node/domain.ts Outdated
Comment thread test/js/node/test/parallel/test-crypto-domain.js Outdated
@cirospaciari
cirospaciari force-pushed the claude/port-node-domain-tests branch from e8d4378 to 2b58181 Compare June 5, 2026 20:22
Comment thread test/js/node/events/event-emitter.test.ts Outdated
Comment thread test/js/node/events/event-emitter.test.ts Outdated
Comment thread test/js/node/test/parallel/test-domain-with-abort-on-uncaught-exception.js Outdated
@cirospaciari
cirospaciari force-pushed the claude/port-node-domain-tests branch from 297a234 to 0b243a8 Compare June 5, 2026 23:05
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/js/node/domain.ts Outdated
Comment thread src/js/node/domain.ts
…eam domain test suite

node:domain was a ~70-line stub (sync-only run/bind, no process.domain, no
uncaught-exception routing). This replaces it with a port of Node's
lib/domain.js and vendors the test-domain-* suite from the Node v26.3.0 tag.

- domain.ts: full port. Async pairing rides on AsyncLocalStorage (Bun has no
  async_hooks.createHook): the active domain is carried in an ALS box that
  AsyncContextFrame snapshots/restores around every callback. The box also
  records a token identifying the synchronous execution that wrote it; when
  a callback later observes domain state with a stale token, the paired
  domain is entered on the module-global stack — the equivalent of Node's
  before() hook. Synchronous throws that unwind to the native fatal path
  lose the ALS box (the context frame pops with the unwind), so the
  dispatcher falls back to the throw-surviving module-global stack/active.
- BunProcess.{h,cpp}: dedicated domain error-handler slot
  (jsFunctionSetDomainErrorHandler) consulted by Bun__handleUncaughtException
  before the capture callback and 'uncaughtException' listeners. Errors
  thrown from the handler/capture callback now exit with code 7 (Node's
  internal-exception-handler failure code). --abort-on-uncaught-exception
  aborts before 'uncaughtException' listeners are consulted unless a capture
  callback (or domain error handler) is installed, matching V8's throw-time
  abort semantics.
- Arguments.rs: implement --abort-on-uncaught-exception, accepting both the
  dashed and underscored spellings like V8.
- events.ts: extract the constructor body into a Node-compatible
  EventEmitter.init static so domain can wrap it; remove the old
  `init: EventEmitter` alias from the exports Object.assign — with the
  constructor delegating through EventEmitter.init, the alias made the
  constructor call itself.
- async_hooks.ts: AsyncResource instances created inside a domain get the
  non-enumerable `domain` property like Node's init hook provides.
- ZigGlobalObject.cpp: fix a pre-existing bug where process.nextTick
  callbacks queued alongside AsyncLocalStorage.enterWith() were dropped:
  cleanupAsyncHooksData unhooked the microtask-tick callback without
  draining the pending nextTick queue, so the process exited with ticks
  still queued (reproduces on stock Bun with enterWith + nextTick at main
  scope and no other event-loop work).
- process.test.js: the capture-callback-throw fixture now exits 7 (was 1).

Most of the suite is vendored verbatim; divergences are commented in-place:
- Tests throwing from fs callbacks (test-domain-implicit-binding/-fs, the
  fs cases in the abort tests) are omitted: errors thrown from fs callbacks
  surface through the unhandled rejection path in Bun, which does not yet
  route rejections through the domain machinery (same reason the unhandled
  rejection block of test-domain-promise is omitted).
- test-domain-dep0097 needs node:inspector; test-domain-multi needs raw
  res.socket writes to corrupt the wire protocol mid-response.
- test-domain-with-abort-on-uncaught-exception's synchronous throw case is
  omitted: Bun reports a main-module synchronous throw after the nextTick
  queue has drained, so the nextTick error's domain cleanup runs first.
Follow-ups from CI on the node:domain port:

- Workers: node only honors --abort-on-uncaught-exception on the main
  thread; an uncaught exception inside a Worker is forwarded to the
  parent's 'error' handler instead of aborting the process. Gate the abort
  paths in Bun__handleUncaughtException on Bun__isMainThreadVM(). Fixes
  test-worker-abort-on-uncaught-exception aborting the whole test process.

- Windows: raising SIGABRT there terminates with an ambiguous exit code
  (observed as 9), which the node test harness does not recognize as an
  abort. Call _exit(134) in place of abort() like node does;
  common.nodeProcessAborted expects exactly that value.

- Skip the four domain tests (and one case of test-domain-abort-on-uncaught)
  whose main module throws an uncaught exception that a domain then handles
  on Windows: that path leaves the process hanging there due to a
  pre-existing event loop bug, the same one tracked by the
  zeroExitWithUncaughtHandler windows-todo in
  test/js/node/process/process.test.js.
Three more entry points into the same pre-existing Windows hang: a handled
uncaught exception thrown synchronously from the main module leaves the
process hanging there (the bug tracked by the zeroExitWithUncaughtHandler
windows-todo in test/js/node/process/process.test.js).

- test-domain-abort-on-uncaught: the firstRunOnlyTopLevelErrorHandler and
  firstRunNestedWithErrorHandler cases throw synchronously from the main
  module like the already-skipped firstRun case; early-return them on
  Windows too. The async cases (nextTick/timer/immediate/netServer and the
  nested variants) are unaffected and keep running.
- test-domain-stack-empty-in-process-uncaughtexception: the throw from
  d.run() is swallowed by the process 'uncaughtException' listener — the
  exact zeroExitWithUncaughtHandler scenario.
- test-crypto-domain: d.run(cb) throws synchronously at module top level
  and the domain handles it. This passed on Windows before only because the
  old domain stub caught the error inside run() in JS instead of routing it
  through the native uncaught-exception path.
Review follow-ups on the node:domain port:

- Bun's EventEmitter.init installs the capture-rejections emit variant as an
  own instance property, which shadows the domain-aware prototype emit, so
  emitters constructed with captureRejections (or while
  EventEmitter.captureRejections is enabled globally) bypassed domain error
  routing entirely. The domain emit override is now built by a factory and
  EventEmitter.init wraps an own emit with it too.

- adopt() enters an async callback's paired domain on the module-global
  stack (node's before() hook equivalent), but nothing exited it when the
  callback returned, so the pairing leaked into unrelated callbacks
  (process.domain reported a stale domain) and repeated adoption grew the
  stack without bound. The next domain-state access from a different
  execution context now lazily undoes the previous adoption — the deferred
  equivalent of node's after() hook, which exits the paired domain along
  with anything entered above it that was never exited.

- Bun__handleUncaughtException: return immediately after the two
  Bun__Process__exit(7) calls. Bun__Process__exit is only noreturn on the
  main thread; in a Worker it requests termination and returns, so these
  fatal branches could fall through into the capture-callback /
  'uncaughtException' routing (and into toBoolean() on the call result,
  which is not meaningful when the call threw).

- test-crypto-domain.js: document why this copy diverges from upstream's
  d.run(fn, cb) (errors thrown from crypto callbacks surface through the
  unhandled rejection path, which does not yet consult domains) instead of
  presenting the synchronous throw as upstream behavior.

- Style: use `!= null` for the combined null/undefined checks.
When the main module's synchronous evaluation throws and a user
'uncaughtException' listener (or domain error handler) claims the error,
the run command grouped that case with --hot/--watch and called
tickPossiblyForever(). That arms a ref'd four-minute repeating "forever
timer" whenever the loop looks inactive — which is exactly the watcher
keep-alive semantics, not what a handled error needs.

On Windows this hung the process: the libuv-backed tick is
uv_run(UV_RUN_ONCE), which blocks until the next event — the four-minute
timer — and the ref'd timer keeps uv_loop_alive() true, so the regular
run-loop afterwards never sees the loop go idle and the process never
exits. POSIX only escaped by accident: its tick (us_loop_run_bun_tick)
happens to find the loop's wakeup eventfd already signaled and returns
immediately, and the POSIX is_active() counter is Bun-managed and never
incremented by the forever timer.

Handled entry errors now just drain the event loop once and fall through
to the regular run-loop, which finishes any work the handler scheduled and
exits when the loop is empty — same observable behavior on POSIX, no hang
on Windows. The --hot/--watch arm is unchanged.

This removes the underlying reason for the Windows skips added earlier:
- test-domain-nested, test-domain-nested-throw,
  test-domain-thrown-error-handler-stack,
  test-domain-top-level-error-handler-clears-stack,
  test-domain-stack-empty-in-process-uncaughtexception,
  test-crypto-domain: common.skip(isWindows) removed.
- test-domain-abort-on-uncaught: the three firstRun* early-returns removed.
- process.test.js: zeroExitWithUncaughtHandler and
  changeCodeInUncaughtHandler windows-todos flipped to regular tests; both
  exercise this exact path.
- event-emitter.test.ts: import the harness at module scope instead of
  require() inside the describe block, and drain the subprocess stderr
  pipes, asserting a combined { stdout, stderr, exitCode } object so
  failure diffs show the child's diagnostics.
- test-domain-with-abort-on-uncaught-exception.js: drop the fs require
  left behind when the fs.exists case was omitted.
--abort-on-uncaught-exception aborted before 'uncaughtException' listeners
for every origin, but node only does that for synchronous throws (V8 aborts
at throw time). Promise rejections that reach the uncaught-exception path
(--unhandled-rejections=strict) go through process._fatalException first;
node aborts only if it returns unhandled (TriggerUncaughtException in
node_errors.cc). So a listener that swallowed a strict-mode rejection still
SIGABRTed under the flag.

Replace the boolean is_rejection with a three-valued origin
(exception / rejection / entry-point rejection). True rejections now skip
the pre-listener abort and instead abort in the nothing-handled branch at
the bottom. The entry-point kind keeps abort-before-listeners semantics: a
synchronous throw from the main module surfaces as the rejected entry
promise and must abort like a throw (a rejected top-level await is
indistinguishable at this layer and shares the behavior), while listeners
still observe the 'unhandledRejection' origin string.

Adds two regression tests: a strict-mode rejection swallowed by an
'uncaughtException' listener exits 0 under the flag, and one with no
listeners still aborts.
The no-listener --abort-on-uncaught-exception regression test spawns a
child that SIGABRTs by design. CI lanes that collect core files at
teardown (alpine aarch64) found that child's core and flagged the test
file as crashed even though every test passed. Wrap the child in
`ulimit -c 0`, exactly like the upstream node abort tests
(test-domain-abort-on-uncaught and friends) already do for their
intentionally-aborting children.
…ters

Two residual gaps in the domain-aware EventEmitter integration:

- An emitter constructed with captureRejections before node:domain loads
  carries the un-wrapped capture emit as an own property; the wrapped
  EventEmitter.init only covers construction after load, so its 'error'
  events bypassed domain routing even after d.add(ee). add() — the only way
  such an emitter acquires a domain — now wraps an own emit too.

- The process.domain / domain.active setters wrote the context box without
  entering the callback's scheduling-time pairing first, so a callback
  whose first domain operation is a write could observe a previous tick's
  adopted entry on domain._stack (the freshened token made the stale
  globals look current). Both setters now adopt() first.

Adds a subprocess regression test for each.
@cirospaciari
cirospaciari force-pushed the claude/port-node-domain-tests branch from 9a76a62 to 2e035df Compare June 6, 2026 02:51

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

All prior feedback has been addressed and this run found no new issues, but given the scope — a novel ALS-based domain implementation with the adopt/unadopt reconciliation mechanism, changes to the native uncaught-exception/abort path and exit-code semantics, and the EventEmitter.init/emit restructuring — this warrants a human pass before merging.

Extended reasoning...

Overview

This PR replaces the ~70-line node:domain stub with a full implementation (585 lines) built on AsyncLocalStorage rather than async_hooks.createHook, and wires it into the native uncaught-exception path. It touches 67 files: the core implementation in src/js/node/domain.ts, EventEmitter restructuring (events.ts now exposes a wrappable EventEmitter.init static and drops the init: EventEmitter alias), a new domain-error-handler slot on Process (BunProcess.{h,cpp}), --abort-on-uncaught-exception support, the bool isRejection → three-valued UncaughtExceptionOrigin enum threaded through ~15 Rust call sites, exit-code 1→7 for capture-callback failures, a nextTick-drain fix in ZigGlobalObject.cpp, and a run_command.rs change splitting the handled-entry-error path from the hot-reload path. Plus 48 vendored upstream tests and several Bun-specific regression tests.

Security risks

None identified. The new jsFunctionSetDomainErrorHandler is reachable only via $newCppFunction from the builtin module (not exposed on process), the abort path is gated on an init-time CLI flag, and the EventEmitter prototype mutation is the standard Node behavior being ported. No auth/crypto/permission surfaces touched.

Level of scrutiny

High. This is production-critical exception-handling code: Bun__handleUncaughtException ordering, abort semantics, process exit codes, and the entry-point error path in run_command.rs all affect how every Bun process behaves on error. The adopt()/unadopt() deferred-after-hook mechanism in domain.ts is a novel design (not a port — Node uses createHook) that went through two rounds of correctness fixes during review (the stack-leak and write-first-setter gaps), and the PR description itself notes 18 upstream tests still failing across four buckets. The EventEmitter.init restructuring and removal of the init export alias is a user-visible API surface change.

Other factors

The author has been highly responsive — every inline comment I and CodeRabbit left across five review rounds was addressed with targeted commits and regression tests, and the reasoning in each reply was sound (e.g., correctly pushing back on the AtomicBool ABI and ERR_UNHANDLED_ERROR truthiness suggestions as matching established patterns / upstream). Test coverage is substantial. But the combination of architectural novelty, critical-path changes, known-incomplete coverage, and behavior changes (exit code 7, the events.ts init export removal) puts this well outside auto-approval territory.

@cirospaciari
cirospaciari force-pushed the claude/port-node-domain-tests branch from 2e035df to 9658a11 Compare June 8, 2026 18:59
…ception aborts

The spawnAbort helper in process.test.js (and the childShouldThrowAndAbort
helper / test-domain-*-abort-* parallel tests) deliberately SIGABRT the
child. Bun's crash handler catches SIGABRT and uploads to CI's
BUN_CRASH_REPORT_URL; the runner then pins those traces on the next
failing test file as 'crash reported', blocking its retry. On darwin 26
aarch64 this turned a known multi-run.test.ts flake into a hard failure
with '9 crashes reported'. Clear the crash-report env for these children,
matching run-crash-handler.test.ts's noReportEnv pattern.

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

No bugs found this pass, but this is a large cross-cutting change — a full node:domain rewrite plus native uncaught-exception dispatch changes in BunProcess.cpp, a unified EventEmitter.prototype.emit, the new --abort-on-uncaught-exception flag, and exit-code 1→7 semantics — so it warrants a human sign-off. There's also an outstanding request to robobun (trim source comments to ≤3 lines) that hasn't landed yet.

What was reviewed

  • The UncaughtExceptionOrigin enum threading through all ~15 Rust uncaught_exception call sites — each maps to the same origin its old bool did, and EntryPointRejection correctly aborts like Exception while listeners see "unhandledRejection".
  • The unified emit + addCatch early-return: verified kCapture still gates rejection capture and no own-property emit is stamped.
  • m_domainErrorHandler/m_domainWouldClaim are WriteBarrier fields visited in visitChildrenImpl, and the substituteError out-param is stack-rooted in the Rust caller.
  • The cleanupAsyncHooksData nextTick drain and the Bun__Node__AbortOnUncaughtException main-thread-only gate.
Extended reasoning...

Overview

This PR replaces the ~70-line node:domain stub with a ~470-line port of Node v26's lib/domain.js, layered on AsyncLocalStorage instead of createHook. It touches 72 files: the domain.ts rewrite; events.ts (constructor delegates through a new EventEmitter.init static, two emit variants collapsed into one prototype emit gated on kCapture); async_hooks.ts (AsyncResource gets a .domain tag via a lazily-installed getter); native changes in BunProcess.{cpp,h} (two new WriteBarrier slots on Process, jsFunctionSetDomainErrorHandler, a rewritten Bun__handleUncaughtException with domain dispatch + --abort-on-uncaught-exception ordering + exit-7 semantics + a substituteError out-param for Workers); a new UncaughtExceptionOrigin enum threaded through ~15 Rust call sites; a new CLI flag backed by a process-global atomic; a one-line nextTick-drain fix in ZigGlobalObject.cpp; and 40+ vendored upstream domain tests plus new Bun-specific coverage.

Security risks

None identified. The new surface is the deprecated node:domain API and a CLI flag; no auth/crypto/network parsing. The abort path calls abort() (or _exit(134) on Windows) only when the process-global flag is set on the main thread — no user-controlled input reaches it. Prototype-pollution hardening (__proto__: null on descriptors, captured primordials for ALS methods) was applied at the sites prior review rounds flagged.

Level of scrutiny

High. This is not a mechanical change: it rewrites process-wide error routing (Bun__handleUncaughtException), changes the shape of every EventEmitter instance (no more own-property emit), alters observable exit codes (1→7 for throwing capture callbacks), and introduces an abort path. Each of these is user-observable and cross-cutting. The EventEmitter.init change in particular affects every emitter in the runtime, and the domain.ts async-pairing reconciliation (adopt/unadopt/token-bumping) is novel logic with no direct Node analogue — it deserves maintainer eyes even though 44 upstream tests pass.

Other factors

  • The PR has been through many review rounds since June; every prior claude/robobun finding I could see in the timeline is marked resolved and reflected in the current diff.
  • The last timeline event is cirospaciari asking robobun to trim source comments to ≤3 lines pointing at spec/Node — that request post-dates the most recent commit-cop batch and does not appear to have been actioned yet.
  • Test coverage is extensive (44 upstream + Bun-specific + node-differential tests), and gaps (rejection→domain routing, DEP0097) are documented with .todo tests rather than silently omitted.
  • The --abort-on-uncaught-exception rejection-path logic in BunProcess.cpp (aborting rejections unconditionally before the monitor when the flag is set) is subtle enough that a maintainer should confirm it matches the intended Node ordering across all --unhandled-rejections modes.

Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
} else {
vm.setOnEachMicrotaskTick(nullptr);
// Drain so nextTick callbacks queued before this cleanup aren't dropped.
globalObject->m_nextTickQueue.get()->drain(vm, globalObject);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This drains the microtasks twice?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could in the empty-queue case: the hook fires at microtask-queue exhaustion (MicrotaskQueue.cpp performMicrotaskCheckpoint), and JSNextTickQueue::drain on an empty queue calls vm.drainMicrotasks() again before re-checking. Nothing ran twice (both queues are consume-on-run), but it was a redundant pass. Fixed in f5deb89 by guarding on isEmpty(), so the unhook path only drains when nextTick callbacks are actually queued. The drain itself is still needed: without it, new AsyncLocalStorage().enterWith(1); process.nextTick(cb) at main-module scope exits without running cb (the unhook at the line above used to drop the queued ticks; pinned by async_hooks.node.test.ts).

The onEachMicrotaskTick hook fires at queue exhaustion
(MicrotaskQueue.cpp performMicrotaskCheckpoint), and
JSNextTickQueue::drain on an empty queue re-runs vm.drainMicrotasks
before re-checking. Guard on isEmpty so the unhook path only pays for
the drain when nextTick callbacks are actually queued.
Comment thread src/jsc/bindings/ZigGlobalObject.cpp

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

No bugs found in this pass, but this warrants a human sign-off — it rewires Bun__handleUncaughtException ordering, collapses the two EventEmitter.prototype.emit paths into one, adds --abort-on-uncaught-exception with abort() semantics, changes the capture-callback-throws exit code from 1 to 7, and adds a nextTick drain to cleanupAsyncHooksData. The ALS token/adopt/unadopt reconciliation in domain.ts is a novel mapping of Node's before/after hooks and deserves eyes on the design.

What was reviewed:

  • m_domainErrorHandler/m_domainWouldClaim are visited in visitChildrenImpl; substituteError out-param is stack-local in the caller.
  • The boolUncaughtExceptionOrigin refactor across all Rust call sites preserves the prior rejection/exception classification (EntryPointRejection maps to abort-like-Exception + origin-string-like-Rejection, matching the two entry-point sites that previously passed true).
  • The isEmpty() guard before queue->drain() in ZigGlobalObject.cpp avoids re-draining the microtask queue.
  • Bun__Node__AbortOnUncaughtException is process-global; shouldAbortOnUncaughtException() gates on Bun__isMainThreadVM() so workers don't abort the process.
Extended reasoning...

Overview

Full node:domain implementation replacing the ~70-line stub, plus 40+ vendored upstream Node tests. Touches: src/js/node/domain.ts (complete rewrite, ~470 lines), src/js/node/events.ts (constructor now delegates through EventEmitter.init static; two emit implementations collapsed to one prototype method with kCapture gating in addCatch), src/js/node/async_hooks.ts (domain-active getter hook + .domain tag on AsyncResource), src/jsc/bindings/BunProcess.{cpp,h} (two new WriteBarrier slots, --abort-on-uncaught-exception dispatch ordering, exit-7 on throwing capture callback, Worker substitute-error routing), src/jsc/VirtualMachine.rs (bool is_rejectionUncaughtExceptionOrigin enum threaded through ~14 call sites), src/jsc/bindings/ZigGlobalObject.cpp (cleanupAsyncHooksData drains pending nextTicks), and src/runtime/cli/Arguments.rs (new flag, both spellings).

Security risks

None identified. The new native slot is set only via $newCppFunction (not exposed on process); domain error routing runs after the existing termination-exception guard; the abort path is gated on an explicit CLI flag and main-thread check. No parsing of untrusted external data, no filesystem/network surface added.

Level of scrutiny

High. This is not a mechanical change:

  • Bun__handleUncaughtException gains ~80 lines of new control flow with multiple abortOnUncaughtException() exit points and throw-time snapshotting semantics that were reverse-engineered from Node/V8 behaviour and pinned by differential tests. Getting the ordering wrong changes whether a process aborts, exits 1, exits 7, or continues.
  • Collapsing emitWithoutRejectionCapture/emitWithRejectionCapture into a single prototype emit affects every EventEmitter in the runtime; the addCatch early-return on !this[kCapture] is what preserves the old fast-path behaviour.
  • The adopt/unadopt/currentToken machinery in domain.ts is a Bun-specific design (Node uses createHook init/before/after) whose correctness rests on the token-staleness invariant holding across every ALS restore point.
  • Exit code 1 → 7 for a throwing capture callback is an intentional behaviour change to match Node.

Other factors

44 upstream Node tests vendored and passing, plus targeted Bun regression tests including Node differential assertions. All 55 prior inline findings from earlier review passes are resolved. The comment-cop bot flagged long comments on 08-03; cirospaciari asked robobun to trim to ≤3-line spec/Node references, and the current diff reflects that. Recent commits (f5deb89, e5fb274) since the last review pass are narrow: the drain is now guarded by !queue->isEmpty(), and intentional-abort tests suppress BUN_CRASH_REPORT_URL.

robobun and others added 3 commits August 7, 2026 04:39
No-Verification-Needed: extern-declaration union merge resolution; CI verifies the merged tree
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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

🤖 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 `@src/js/node/domain.ts`:
- Around line 200-206: Update the stack iteration in fatalErrorDispatch to skip
null or undefined entries before accessing listenerCount, matching the existing
guard in domainWouldClaim. Preserve the current listener check and _errorHandler
behavior for valid domain objects.

In `@test/js/node/domain/domain.test.ts`:
- Line 19: Update all eight independent subprocess tests in this file, including
the test named “a non-Domain process.domain does not mask the original error in
the fatal path,” to use test.concurrent instead of test so their subprocesses
run concurrently while preserving each test’s existing behavior.

In `@test/js/node/test/parallel/test-crypto-domain.js`:
- Around line 40-47: Restore the upstream asynchronous invocation in
test/js/node/test/parallel/test-crypto-domain.js:40-47 by calling the crypto
callback through the original d.run(fn, cb) flow, and track the unsupported
promise-domain behavior in Bun-owned coverage. Keep the upstream filesystem
cases unchanged in
test/js/node/test/parallel/test-domain-abort-on-uncaught.js:74-81; track only
the unsupported cases outside the vendored mirror.

In
`@test/js/node/test/parallel/test-domain-load-after-set-uncaught-exception-capture.js`:
- Around line 6-22: Update the test around the domain setup and capture callback
to execute a thrown error inside d.run() while
setUncaughtExceptionCaptureCallback is active. Use a callback that records the
received error, assert the expected handler receives the exact thrown error and
that domain handling does not incorrectly receive it, then clear the capture
callback and retain only meaningful assertions that verify the coexistence
behavior.

In `@test/js/node/test/parallel/test-domain-nested-throw.js`:
- Around line 38-40: Add a one-line inline `// BUN:` comment next to the
modified `child.on('exit', common.mustCall(...))` assertion documenting that
this is an intentional deviation from upstream; preserve the strengthened
handler and do not restore the removed `console.log('ok')`.

In `@test/js/node/test/parallel/test-domain-promise.js`:
- Around line 129-134: Update the explanatory comment above the omitted
“Unhandled rejections become errors on the domain” block to include a stable URL
referencing the relevant upstream issue, upstream commit, or Bun tracking issue.
Preserve the existing divergence explanation and point to the reference that
documents when the omitted coverage can be restored.
🪄 Autofix

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: 11d87c17-5d01-4241-8b6b-ecb02b6e8856

📥 Commits

Reviewing files that changed from the base of the PR and between 43caaf3 and c641f3f.

📒 Files selected for processing (72)
  • src/js/node/async_hooks.ts
  • src/js/node/domain.ts
  • src/js/node/events.ts
  • src/js/node/http2.ts
  • src/js_parser_jsc/Macro.rs
  • src/jsc/JSGlobalObject.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/BunProcess.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/virtual_machine_exports.rs
  • src/jsc/web_worker.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/api/cron.rs
  • src/runtime/cli/Arguments.rs
  • src/runtime/cli/run_command.rs
  • src/runtime/napi/napi_body.rs
  • src/runtime/server/NodeHTTPResponse.rs
  • src/runtime/server/WebSocketServerContext.rs
  • src/runtime/server/mod.rs
  • src/runtime/socket/Handlers.rs
  • src/runtime/socket/udp_socket.rs
  • src/runtime/test_runner/bun_test.rs
  • test/js/node/async_hooks/EventEmitterAsyncResource.test.ts
  • test/js/node/async_hooks/async_hooks.node.test.ts
  • test/js/node/domain/domain.test.ts
  • test/js/node/events/event-emitter.test.ts
  • test/js/node/process/process.test.js
  • test/js/node/test/common/index.js
  • test/js/node/test/parallel/test-crypto-domain.js
  • test/js/node/test/parallel/test-domain-abort-on-uncaught.js
  • test/js/node/test/parallel/test-domain-add-remove.js
  • test/js/node/test/parallel/test-domain-async-id-map-leak.js
  • test/js/node/test/parallel/test-domain-bind-timeout.js
  • test/js/node/test/parallel/test-domain-ee-implicit.js
  • test/js/node/test/parallel/test-domain-ee.js
  • test/js/node/test/parallel/test-domain-emit-error-handler-stack.js
  • test/js/node/test/parallel/test-domain-enter-exit.js
  • test/js/node/test/parallel/test-domain-error-types.js
  • test/js/node/test/parallel/test-domain-from-timer.js
  • test/js/node/test/parallel/test-domain-fs-enoent-stream.js
  • test/js/node/test/parallel/test-domain-http-server.js
  • test/js/node/test/parallel/test-domain-intercept.js
  • test/js/node/test/parallel/test-domain-load-after-set-uncaught-exception-capture.js
  • test/js/node/test/parallel/test-domain-multiple-errors.js
  • test/js/node/test/parallel/test-domain-nested-throw.js
  • test/js/node/test/parallel/test-domain-nested.js
  • test/js/node/test/parallel/test-domain-nexttick.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-0.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-1.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-2.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-3.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-4.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-6.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-7.js
  • test/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-8.js
  • test/js/node/test/parallel/test-domain-promise.js
  • test/js/node/test/parallel/test-domain-run.js
  • test/js/node/test/parallel/test-domain-safe-exit.js
  • test/js/node/test/parallel/test-domain-set-uncaught-exception-capture-after-load.js
  • test/js/node/test/parallel/test-domain-stack-empty-in-process-uncaughtexception.js
  • test/js/node/test/parallel/test-domain-stack.js
  • test/js/node/test/parallel/test-domain-throw-error-then-throw-from-uncaught-exception-handler.js
  • test/js/node/test/parallel/test-domain-thrown-error-handler-stack.js
  • test/js/node/test/parallel/test-domain-timer.js
  • test/js/node/test/parallel/test-domain-timers-uncaught-exception.js
  • test/js/node/test/parallel/test-domain-timers.js
  • test/js/node/test/parallel/test-domain-top-level-error-handler-clears-stack.js
  • test/js/node/test/parallel/test-domain-top-level-error-handler-throw.js
  • test/js/node/test/parallel/test-domain-uncaught-exception.js
  • test/js/node/test/parallel/test-domain-vm-promise-isolation.js
  • test/js/node/test/parallel/test-domain-with-abort-on-uncaught-exception.js

Comment thread src/js/node/domain.ts
Comment thread test/js/node/domain/domain.test.ts Outdated
Comment thread test/js/node/test/parallel/test-crypto-domain.js Outdated
Comment thread test/js/node/test/parallel/test-domain-nested-throw.js
Comment thread test/js/node/test/parallel/test-domain-promise.js Outdated
cirospaciari and others added 2 commits August 7, 2026 14:00
No-Verification-Needed: comment-only change
…m test-crypto-domain.js body behind a documented skip, run domain.test.ts subprocess tests concurrently

- fatalErrorDispatch now skips null/undefined stack entries (the _stack
  setter accepts arbitrary userland arrays), matching domainWouldClaim;
  regression test asserts the original error is still claimed by the domain.
- test-crypto-domain.js is byte-identical to node v26.3.0 again, with a
  commented common.skip: the async crypto-callback throw surfaces as an
  unhandled rejection and Bun lacks promiseInfo.domain routing (tracked by
  the .todo matrix in domain.test.ts).
- New Bun-owned test pins node's capture-callback coexistence: a domain
  with an 'error' listener claims the error before captureFn.
- BUN: markers on the nested-throw mustCall deviation; upstream blob URL
  for the omitted test-domain-promise.js block.
Comment thread src/js/node/domain.ts Outdated
Comment thread src/runtime/cli/Arguments.rs
…ption

Node v26 reports it allowed in NODE_OPTIONS; the normalizing has() covers
the underscore spelling.
Comment thread src/jsc/bindings/BunProcess.cpp
…dropping it

The JS entry passed nullptr for substituteError, so a throwing domain
handler or capture callback in a Worker was silently swallowed on the
!isMainThreadVM branch. Pass a stack-local out-param and log it.
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/js/node/domain.ts
…ocess.domain setter

An unbalanced enter() as the last domain op left currentToken equal to the
token in a later callback's ALS snapshot, so that callback read the raw
global stack (d1,d2) instead of node's restored pairing (d2) and d1 could
claim an error node surfaces as uncaughtException. setActive now queues the
same post-tick retire the setter used, which is the callback-boundary
invalidation node gets from its after() hook.
Comment thread src/js/node/domain.ts Outdated
… redundant guard note

No-Verification-Needed: comment-only change
Comment thread src/js/node/domain.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.

I reviewed this PR and didn't find any bugs. Given the scope — a full node:domain implementation that rewires Bun__handleUncaughtException, changes EventEmitter to a single prototype emit + .init static, adds --abort-on-uncaught-exception, and alters exit-code/Worker error routing — a maintainer sign-off is still warranted.

What was reviewed

  • The UncaughtExceptionOrigin enum threading through every uncaught_exception call site (Rust + C++), the new substituteError out-param, and Worker vs main-thread exit-7/abort ordering in BunProcess.cpp.
  • The EventEmitter unification: addCatch now gates on this[kCapture], so non-captureRejections emitters don't attach a .then — behaviour preserved; the removed init: EventEmitter self-alias would have infinitely recursed under the new constructor delegation.
  • domain.ts token/adopt/unadopt bookkeeping against the four non-Domain-process.domain guard sites and the cleanupAsyncHooksData nextTick drain (guarded by isEmpty()).
  • New WriteBarrier slots (m_domainErrorHandler/m_domainWouldClaim) are visited in visitChildrenImpl.
Extended reasoning...

Overview

This PR replaces the ~70-line node:domain stub with a full port of Node v26.3.0's lib/domain.js, riding on Bun's AsyncLocalStorage in place of Node's createHook init/before/after hooks. It touches 73 files: the core domain.ts implementation (~470 lines), events.ts (unifying the two emit variants into one prototype method gated on kCapture, and exposing EventEmitter.init as a wrappable static), async_hooks.ts (a domainActiveGetter hook so AsyncResource can tag .domain), BunProcess.cpp/.h (two new WriteBarrier slots on Process, a rewritten Bun__handleUncaughtException with domain dispatch + --abort-on-uncaught-exception ordering + a substituteError out-param for Worker routing), VirtualMachine.rs (a new UncaughtExceptionOrigin enum replacing the is_rejection: bool at every call site), Arguments.rs (the new CLI flag + underscore alias), ZigGlobalObject.cpp (draining pending nextTicks in cleanupAsyncHooksData), and 44 vendored upstream test-domain-* files plus Bun-specific tests.

Security risks

None identified. The change surface is error-routing and process-termination semantics, not input parsing, auth, or network boundaries. The --abort-on-uncaught-exception flag is main-thread-only (gated by Bun__isMainThreadVM()), matching Node. The new jsFunctionSetDomainErrorHandler is only reachable via $newCppFunction from the built-in domain.ts, not exposed on process.

Level of scrutiny

High. This PR rewires the process's fatal-exception path (Bun__handleUncaughtException), changes an FFI signature consumed at ~15 call sites, alters observable exit codes (1→7 for a throwing capture callback), changes Worker error propagation, and refactors EventEmitter internals that every emitter in the runtime flows through. The PR body itself deliberately pins one behaviour (hasUncaughtExceptionCaptureCallback surviving domain enter/exit) as a Bun-specific choice diverging from Node. These are exactly the cross-cutting, semantics-defining decisions a maintainer should ratify. It is well outside the "simple, mechanical, or obvious" bar for auto-approval.

Other factors

The PR has been through 30+ review iterations over two months; every prior inline finding (mine, coderabbit's, and comment-cop's) is marked resolved. Test coverage is extensive — 44 upstream tests vendored byte-identical (three with documented omissions), Bun-specific regression tests for each earlier finding, and node-differential tests pinning the abort ordering. CI was reported green modulo a tracked-on-main worker-termination ASAN abort. The remaining gaps (unhandled-rejection domain routing, DEP0097) are explicitly documented with .todo tests. Nothing in this run surfaced as a bug, but the sheer scope and the number of deliberate behavioural choices embedded here (exit code 7, abort-before-monitor ordering, EntryPointRejection as a third origin, keeping the user capture callback across domain enter/exit) mean a human should own the merge decision.

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.

4 participants