Skip to content

domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite (+40 upstream tests) - #31828

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

cirospaciari wants to merge 76 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).

(An earlier revision also carried a drive-by fix for enterWith() + process.nextTick dropping the tick at main-module scope, patched in cleanupAsyncHooksData. #41190 removed that function and fixes the same case at the event-loop boundary, so the hunk is gone from this diff; the repro still passes on the merged branch.)

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 42 fail on released Bun and pass on this branch. That is the 40 added files plus the two re-synced test-domain-* files; the third re-synced file, test-crypto-domain.js, is skipped (see gaps), and test-domain-crypto.js and test-domain-ee-error-listener.js, the other two of the 44, were already on main and are untouched here. 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.

Merge note (d38da28): main refactored several uncaught-exception reporting sites into shared helpers (task::report_error_or_terminate, NapiEnv::surface_exception, run_callback_with_result). Those conflicts were resolved by taking main's structure and passing UncaughtExceptionOrigin::Exception at the three remaining direct calls (Task.rs, WebSocketServerContext.rs, socket Handlers.rs); the branch's now-redundant report_active_exception_as_unhandled helper was dropped with main. Verified by probing each path (timer throw, socket open throw without an error handler, websocket open throw) for the uncaughtException origin, plus the cron, domain, events, and process suites.

Merge note (7836e08 + 12ff462): #41190 keeps an enterWith() frame alive through the uncaught-exception dispatch of the same synchronous execution (as node does), while Bun drains nextTicks before dispatching an entry-point throw. node:domain's tick-retired pairing token then misread the still-running entry execution as a restored pairing, so process.domain = d; throw e was routed to d (node aborts under the flag, exits 1 without it, because the setter never puts d on the stack). Fix: a new UncaughtExceptionOrigin::EntryPointException (CJS entry throw; listeners still see 'uncaughtException') joins EntryPointRejection in telling node:domain that no callback boundary preceded the dispatch, and fatalErrorDispatch now follows node's updateExceptionCapture rule exactly: route to process.domain._errorHandler only while a domain on the stack has an 'error' listener, never pushing the active domain at dispatch time. All four setter cases (sync throw with and without the flag, timer, nextTick) match node v26.3.0.

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 test-crypto-domain.js is vendored byte-identical behind a documented common.skip (its callback throw takes the same rejection path).
  • No MakeCallback equivalent, so the DEP0097 warning has no source. Blocks test-domain-dep0097.js.

no test proof · iteration 41 · 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.
Description check ✅ Passed The description explains the implementation, scope, known gaps, platform differences, and verification results. It includes the required change summary and verification content, although the verificat…
Title check ✅ Passed The title clearly identifies the primary change: implementing node:domain with AsyncLocalStorage and adding the upstream domain test suite.

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 1:05 AM PT - Sep 8th, 2026

❌ @robobun, your commit 32c7a46 has 1 failures in Build #112587 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31828

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

bun-31828 --bun

@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
…e stack

After a setter-only process.domain = d, exit() is a no-op, so the loop
spun forever; node returns because its setter does not touch exports.active.

@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 the bug-hunting pass found no issues on the current revision. Given the scope — a full node:domain implementation that rewires Bun__handleUncaughtException, unifies EventEmitter.prototype.emit, adds --abort-on-uncaught-exception with real abort(), and changes exit codes (1→7) on capture-callback/domain-handler throws — a maintainer sign-off is still worthwhile.

Checked: the UncaughtExceptionOrigin bool→enum migration hits every uncaught_exception call site with the correct variant; the new m_domainErrorHandler/m_domainWouldClaim WriteBarriers are visited in visitChildrenImpl; addCatch now guards on kCapture so the collapsed single-emit preserves the no-capture fast path; the cleanupAsyncHooksData drain is gated on !queue->isEmpty(). All prior inline findings on this PR are resolved.

Extended reasoning...

Overview

This PR replaces the ~70-line node:domain stub with a full port of Node v26's lib/domain.js built on AsyncLocalStorage, and vendors 40+ upstream test-domain-* tests. It touches: src/js/node/domain.ts (rewrite), src/js/node/events.ts (constructor delegates through EventEmitter.init; two emit variants collapsed to one prototype method with addCatch gated on kCapture), src/js/node/async_hooks.ts (domain-active getter hook + .domain tag on AsyncResource), BunProcess.cpp/.h (domain error-handler dispatch slot, --abort-on-uncaught-exception with real abort(), exit-code-7 on handler throw, Worker substituteError out-param), VirtualMachine.rs (UncaughtExceptionOrigin enum replacing the is_rejection bool across ~15 call sites), ZigGlobalObject.cpp (drain pending nextTicks in cleanupAsyncHooksData), and Arguments.rs (new CLI flag + underscore alias).

Security risks

None identified. The new abort path is opt-in via CLI flag and gated to the main-thread VM. Domain error routing runs user JS but only on the pre-existing uncaught-exception path where user callbacks already run. The process.domain accessor and ALS-carried box are per-VM state (not process-global), and non-Domain values written to process.domain are filtered at every dispatch site by the _errorHandler-typeof guard.

Level of scrutiny

High. This is not a mechanical change: it rewires the process-fatal exception path (ordering of domain handler vs. capture callback vs. uncaughtException listeners vs. abort), changes the shape of EventEmitter (removes the own-property emit stamping and the init: EventEmitter self-alias), changes an observable exit code (1→7), and adds a real abort() on Windows/POSIX. The EventEmitter.init change in particular is load-bearing for every emitter subclass in the runtime. These are exactly the kinds of cross-cutting semantic changes REVIEW.md flags for maintainer review.

Other factors

Test coverage is extensive — 44 upstream domain tests plus targeted Bun-specific tests for every edge case raised in the ~30 prior review rounds (non-Domain process.domain, Worker throw-from-handler, unbalanced enter(), monitor-removes-listener, etc.). Every prior claude[bot] inline finding on this PR is marked resolved with a fix commit and a test. The maintainer (cirospaciari) has been actively shepherding the PR and just requested a final conflict-resolution merge, which robobun confirmed passing locally. The remaining known gap (unhandled-rejection → domain routing) is documented in the PR body and pinned by .todo tests. Given the breadth of runtime surface touched, deferring to a human for the final sign-off.

alii and others added 5 commits August 10, 2026 18:00
…in-tests

# Conflicts:
#	src/jsc/JSGlobalObject.rs
#	src/runtime/api/cron.rs
#	src/runtime/napi/napi_body.rs
#	src/runtime/server/WebSocketServerContext.rs
#	src/runtime/socket/Handlers.rs
…failure site

The Worker teardown merge added a call still using the old bool signature,
which failed to compile on every lane.
@robobun robobun changed the title domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite (+40 upstream tests) Aug 21, 2026
…in-tests

# Conflicts:
#	src/jsc/VirtualMachine.rs

@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 the latest revision (post-b5a1024bdc merge) and the bug-hunting pass found nothing new. Given the scope — a full node:domain rewrite, the unified EventEmitter.prototype.emit, and the reworked Bun__handleUncaughtException dispatch (domain slot, --abort-on-uncaught-exception, exit-7, worker substitute-error) — a maintainer sign-off is still warranted.

What was reviewed:

  • Bun__handleUncaughtException ordering vs Node (domainWouldClaim snapshot, monitor→capture re-read, rejection-vs-exception abort timing) — matches the differential tests in process.test.js.
  • The emitWithoutRejectionCapture/emitWithRejectionCapture merge — addCatch now guards on kCapture, so non-capture emitters are unchanged; own-emit shadowing on EventEmitterAsyncResource/Http2Server correctly removed.
  • cleanupAsyncHooksData nextTick drain — guarded on !queue->isEmpty() so it doesn't re-drain microtasks.
  • All prior inline findings (non-Domain process.domain guards, __proto__: null on the AsyncResource descriptor, configurable: true on _stack/active) confirmed applied.
Extended reasoning...

Overview

This PR replaces the ~70-line node:domain stub with a full port of Node v26's lib/domain.js, backed by Bun's AsyncLocalStorage instead of Node's createHook init/before/after hooks. It touches 71 files: the new src/js/node/domain.ts (~440 lines), a refactor of src/js/node/events.ts (unifying two emit implementations into one prototype method and introducing EventEmitter.init), a new native domain-error dispatch slot on Process (BunProcess.{cpp,h}), a new UncaughtExceptionOrigin enum threaded through ~12 Rust call sites, the --abort-on-uncaught-exception CLI flag with Node-matching abort ordering, exit-code-7 semantics for capture-callback / domain-handler throws, a nextTick-drain fix in cleanupAsyncHooksData, and 40+ vendored upstream domain tests plus Bun-specific coverage in domain.test.ts, event-emitter.test.ts, and process.test.js.

Security risks

None identified. The domain module is opt-in (nothing changes until require('node:domain') runs), the new WriteBarrier slots are visited in visitChildrenImpl, and the abort path is main-thread-gated via Bun__isMainThreadVM(). The Bun__Node__AbortOnUncaughtException global is a process-wide atomic flag consistent with the sibling --throw-deprecation flags.

Level of scrutiny

High. This is not a mechanical change: it rewrites process-global uncaught-exception dispatch ordering, changes the shape of every EventEmitter instance (no more own-emit under captureRejections), changes an exit code (1→7), and introduces an ALS-based design that intentionally diverges from Node's implementation strategy. The EventEmitter.init static replacing the init: EventEmitter self-alias is a subtle Node-compat surface change. The abort-ordering logic in Bun__handleUncaughtException (throw-time snapshot of captureAtThrow/domainClaimsAtThrow, then re-read after monitor) went through several correctness iterations in earlier review rounds. All of this warrants a maintainer's eyes even though the automated pass is now clean.

Other factors

The PR has been through ~40 iterations with extensive prior review (both mine and the comment-cop bot's), and every prior inline finding is marked resolved with a fix commit and a Node-differential verification. Test coverage is strong: 44 upstream tests pass, plus targeted Bun tests for each edge case raised in review (non-Domain process.domain, unbalanced enter(), worker throwing-handler, monitor-removes-listener, etc.). cirospaciari has been actively shepherding merges; the last activity was a conflict-fix on 2026-08-10. This clean pass is the signal that the automated side is done; final approval should come from the maintainer already engaged.

@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Checked this branch (df87d41, local debug build) against the other open node:domain PRs. I closed #30675 and #35444 in favor of this PR: their cases pass here.

Passes on this branch: the 13 tests in test/js/node/domain/domain.test.ts, all 45 vendored test-domain-*.js and test-crypto-domain.js, and 18 of the 30 cases in #40666's domain.test.ts. Ten of the twelve that fail encode behavior where node v26.3.0 agrees with this branch (process.domain is null before any run() and after an uncaught exception clears the stack, an async callback enters only its paired domain, a captureRejections emitter has no own emit). The other two are one real gap.

Gap: unhandled rejections do not reach the domain. Node delivers a rejection to the domain that was active when the promise rejected (promiseInfo.domain in lib/internal/process/promises.js). Here it goes to process.on('unhandledRejection').

const d = require("domain").create();
d.on("error", e => console.log("domain:" + e.message));
process.on("unhandledRejection", e => console.log("unhandledRejection:" + e.message));
d.run(() => { Promise.reject(new Error("boom")); });
// node v26.3.0: domain:boom
// this branch:  unhandledRejection:boom

The same holds for d.run(async () => { await x; throw err; }). Failing tests:

  • the "Unhandled rejections become errors on the domain" block of upstream test-domain-promise.js (omitted in this branch's copy)
  • the throw, warn and none cases of the test.todo matrix in domain.test.ts (node does not route under strict either, that case can go)
  • test/js/node/async_hooks/async-context/async-context-process-unhandledRejection.js from node:domain: follow domains across async boundaries #40666: the unhandledRejection listener runs with the AsyncLocalStorage store of the rejecting code. Passes on node, fails here and on main.

#40666 implements this in two parts:

  • src/jsc/bindings/PendingRejectionList.h and ZigGlobalObject.cpp: promiseRejectionTracker stores the async context (m_asyncContextData field 0) next to each pending rejected promise, and handleRejectedPromises() restores it around Bun__handleRejectedPromise. This part is node parity on its own.
  • BunProcess.cpp: Bun__handleUnhandledRejection calls the domain router before the unhandledRejection listeners. The router in domain.ts emits 'error' on the active domain, with the stack cleared, when that domain has a listener (node: promiseInfo.domain.emit('error', reason)).

Also: test-domain-implicit-fs.js and test-domain-implicit-binding.js are listed as blocked in the description, but the upstream copies vendored in #40666 pass on this branch (3 of 3 runs each).

#40666 stays open as the source for the gap above until this PR absorbs it.

@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Heads-up for the rebase: #40665 fixes the dropped-tick case this PR patches in cleanupAsyncHooksData (ZigGlobalObject.cpp), with a 10-case test block in test/js/node/async_hooks/AsyncLocalStorage.test.ts. Once it lands, the if (!queue->isEmpty()) queue->drain(...) hunk here can be dropped. The variant there also keeps node's order for a tick queued next to an enterWith() inside a later promise job (the tick runs after the sibling microtasks), which draining on every cleanup does not.

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Dedupe result: #40666 is closed in favor of this PR. Both implement node:domain on the async context with the same router slot in BunProcess.cpp. This PR matches node v26.3.0 on every case I probed except one, and its 52 vendored domain files pass unchanged. The #40666 branch (farm/eed44ee4/node-domain-async-propagation, 8ca5214) stays on origin as the source for the parts below.

What #40666 has that this PR lacks, with the files to take:

  1. Unhandled rejections reach the domain that was active at the rejection (node: promiseInfo.domain). src/jsc/bindings/PendingRejectionList.h (new) and ZigGlobalObject.cpp: promiseRejectionTracker stores the async context next to each pending promise, handleRejectedPromises() restores it around Bun__handleRejectedPromise. BunProcess.cpp: Bun__handleUnhandledRejection calls the router before the unhandledRejection listeners. domain.ts: the unhandledRejection branch of the router emits 'error' on the active domain with the stack cleared. Tests: the last block of upstream test-domain-promise.js (omitted here), test/js/node/async_hooks/async-context/async-context-process-unhandledRejection.js, and the .todo matrix in domain.test.ts. Node routes to the domain in throw, warn, warn-with-error-code and none. Under strict it goes to uncaughtException, so that .todo case is wrong as written.
  2. Three vendored tests that pass on this branch unchanged: test-domain-error-handler-throw-no-recursion.js, test-domain-implicit-binding.js, test-domain-implicit-fs.js (the last two are also in domain: route fs callback throws to uncaughtException, unblocking 4 tests (domain 88%→96%) #34661).

Do not take #40666's edits to test-domain-emit-error-handler-stack.js and test-domain-stack-empty-in-process-uncaughtexception.js. Those edited copies fail under node v26.3.0. The upstream copies here pass under node and on this branch.

If you want, I can open a stacked PR on this branch with item 1.

…in-tests

# Conflicts:
#	src/js/node/async_hooks.ts
#	src/jsc/bindings/ZigGlobalObject.cpp
… throws

After #41190 the async-context box set during the entry module's own
execution legitimately survives to the uncaught-exception dispatch, while
Bun drains nextTicks before that dispatch. The token retired by that tick
made the still-running entry execution look like a restored pairing, so
process.domain = d followed by a synchronous throw was routed to d (node:
abort / exit 1, because the setter never puts d on the stack).

- New UncaughtExceptionOrigin::EntryPointException for a CJS entry throw
  (listeners still see 'uncaughtException'); together with
  EntryPointRejection it tells node:domain that no callback boundary
  preceded the dispatch, so the pairing view is skipped.
- fatalErrorDispatch now follows node's rule exactly: route to
  process.domain._errorHandler only while a domain on the stack has an
  'error' listener, and never push the active domain at dispatch time.
  This also fixes the silent no-flag variant (bun printed "handled",
  node exits 1).
- async_hooks: the debug assertions in enterWith()/run() read the store
  through a private #peek() instead of the patchable getStore().
- web_worker: the node bootstrap failure site passes the origin enum.
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts
Comment thread src/js/node/domain.ts
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/bindings/BunProcess.cpp
Comment thread src/jsc/bindings/BunProcess.cpp
…cess.abort()

The raw abort() went through Bun's crash handler in release builds, printing
the crash banner and offering a bun.report upload for a user-requested
abort. Resetting the disposition first makes the four test-side
BUN_CRASH_REPORT_URL / BUN_ENABLE_CRASH_REPORTING workarounds unnecessary;
test-domain-throw-error-then-throw-from-uncaught-exception-handler.js and
test/common/index.js are byte-identical to upstream / main again.

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Two more node:domain differences against node v26.3.0 are still on main (511ddb3, the 96-line shim). This PR fixes both, because bind(), intercept() and add() here are node's.

  • The wrappers from d.bind(fn) and d.intercept(fn) drop the return value of fn. d.bind(x => x * 2)(21) is undefined (node: 42). d.intercept((a, b) => a + b)(null, 1, 2) is undefined (node: 3). The wrapper names are "" (node: runBound, runIntercepted).
  • d.add(timer) throws TypeError: emitter.on is not a function. Node accepts a timer, sets timer.domain and adds it to d.members.

Verified on a local debug build of 32c7a46 merged with main: both cases print the same output as node v26.3.0, and so do remove(), moving a timer between two domains, a truthy non-Error first argument to an intercept() wrapper, and this forwarding.

No test in the PR asserts either case. The vendored test-domain-intercept.js and test-domain-bind-timeout.js do not check return values, and nothing calls add() with a timer. This block fits the run() helper in test/js/node/domain/domain.test.ts. The script prints the expected line on that build and on node v26.3.0, and throws on main:

test.concurrent("bind() and intercept() return the callback's value, add() accepts a timer", async () => {
  const r = await run(`
    const domain = require("domain");
    const d = domain.create();
    const twice = d.bind(x => x * 2);
    const sum = d.intercept((a, b) => a + b);
    const t = setTimeout(() => {}, 1);
    d.add(t);
    const added = [t.domain === d, d.members.length];
    d.remove(t);
    clearTimeout(t);
    console.log(JSON.stringify([twice(21), twice.name, sum(null, 1, 2), sum.name, added, t.domain, d.members.length]));
  `);
  expect(r.stdout).toBe('[42,"runBound",3,"runIntercepted",[true,1],null,0]\n');
  expect(r.exitCode).toBe(0);
});

One note for the next merge of main: it merges without textual conflicts but does not compile. main added a Bun__ModuleGraph__handleUncaughtException guard in VirtualMachine::uncaught_exception that reads is_rejection, which this PR replaced with origin. !matches!(origin, UncaughtExceptionOrigin::Rejection) in that condition builds. Whether the two entry-point origins belong in that check is your call.

This branch has not been deployed

No deployments
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