domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite - #31828
domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite#31828cirospaciari wants to merge 64 commits into
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis 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. ChangesDomain runtime and EventEmitter integration
Uncaught-exception handling
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
d620847 to
e190cb5
Compare
There was a problem hiding this comment.
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 winAssert 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
📒 Files selected for processing (51)
src/js/node/async_hooks.tssrc/js/node/domain.tssrc/js/node/events.tssrc/jsc/VirtualMachine.rssrc/jsc/bindings/BunProcess.cppsrc/jsc/bindings/BunProcess.hsrc/jsc/bindings/ZigGlobalObject.cppsrc/runtime/cli/Arguments.rstest/js/node/process/process.test.jstest/js/node/test/parallel/test-domain-abort-on-uncaught.jstest/js/node/test/parallel/test-domain-add-remove.jstest/js/node/test/parallel/test-domain-async-id-map-leak.jstest/js/node/test/parallel/test-domain-bind-timeout.jstest/js/node/test/parallel/test-domain-ee-implicit.jstest/js/node/test/parallel/test-domain-ee.jstest/js/node/test/parallel/test-domain-emit-error-handler-stack.jstest/js/node/test/parallel/test-domain-enter-exit.jstest/js/node/test/parallel/test-domain-error-types.jstest/js/node/test/parallel/test-domain-from-timer.jstest/js/node/test/parallel/test-domain-fs-enoent-stream.jstest/js/node/test/parallel/test-domain-http-server.jstest/js/node/test/parallel/test-domain-intercept.jstest/js/node/test/parallel/test-domain-load-after-set-uncaught-exception-capture.jstest/js/node/test/parallel/test-domain-multiple-errors.jstest/js/node/test/parallel/test-domain-nested-throw.jstest/js/node/test/parallel/test-domain-nested.jstest/js/node/test/parallel/test-domain-nexttick.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-0.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-1.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-2.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-3.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-4.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-6.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-7.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-8.jstest/js/node/test/parallel/test-domain-promise.jstest/js/node/test/parallel/test-domain-run.jstest/js/node/test/parallel/test-domain-safe-exit.jstest/js/node/test/parallel/test-domain-set-uncaught-exception-capture-after-load.jstest/js/node/test/parallel/test-domain-stack-empty-in-process-uncaughtexception.jstest/js/node/test/parallel/test-domain-stack.jstest/js/node/test/parallel/test-domain-throw-error-then-throw-from-uncaught-exception-handler.jstest/js/node/test/parallel/test-domain-thrown-error-handler-stack.jstest/js/node/test/parallel/test-domain-timer.jstest/js/node/test/parallel/test-domain-timers-uncaught-exception.jstest/js/node/test/parallel/test-domain-timers.jstest/js/node/test/parallel/test-domain-top-level-error-handler-clears-stack.jstest/js/node/test/parallel/test-domain-top-level-error-handler-throw.jstest/js/node/test/parallel/test-domain-uncaught-exception.jstest/js/node/test/parallel/test-domain-vm-promise-isolation.jstest/js/node/test/parallel/test-domain-with-abort-on-uncaught-exception.js
There was a problem hiding this comment.
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 winExit code assertion could be stricter for robustness.
The assertion
assert(!c)accepts any falsy exit code, includingnull(signal termination) in addition to0(normal successful exit). For a domain error-handling test, the child should exit normally with code0after 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
📒 Files selected for processing (6)
src/jsc/bindings/BunProcess.cpptest/js/node/test/parallel/test-domain-abort-on-uncaught.jstest/js/node/test/parallel/test-domain-nested-throw.jstest/js/node/test/parallel/test-domain-nested.jstest/js/node/test/parallel/test-domain-thrown-error-handler-stack.jstest/js/node/test/parallel/test-domain-top-level-error-handler-clears-stack.js
e8d4378 to
2b58181
Compare
297a234 to
0b243a8
Compare
…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.
9a76a62 to
2e035df
Compare
There was a problem hiding this comment.
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.
2e035df to
9658a11
Compare
…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.
There was a problem hiding this comment.
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
UncaughtExceptionOriginenum threading through all ~15 Rustuncaught_exceptioncall sites — each maps to the same origin its old bool did, andEntryPointRejectioncorrectly aborts likeExceptionwhile listeners see"unhandledRejection". - The unified
emit+addCatchearly-return: verifiedkCapturestill gates rejection capture and no own-propertyemitis stamped. m_domainErrorHandler/m_domainWouldClaimare WriteBarrier fields visited invisitChildrenImpl, and thesubstituteErrorout-param is stack-rooted in the Rust caller.- The
cleanupAsyncHooksDatanextTick drain and theBun__Node__AbortOnUncaughtExceptionmain-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
.todotests rather than silently omitted. - The
--abort-on-uncaught-exceptionrejection-path logic inBunProcess.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-rejectionsmodes.
| } else { | ||
| vm.setOnEachMicrotaskTick(nullptr); | ||
| // Drain so nextTick callbacks queued before this cleanup aren't dropped. | ||
| globalObject->m_nextTickQueue.get()->drain(vm, globalObject); |
There was a problem hiding this comment.
This drains the microtasks twice?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_domainWouldClaimare visited invisitChildrenImpl;substituteErrorout-param is stack-local in the caller.- The
bool→UncaughtExceptionOriginrefactor across all Rust call sites preserves the prior rejection/exception classification (EntryPointRejectionmaps to abort-like-Exception + origin-string-like-Rejection, matching the two entry-point sites that previously passedtrue). - The
isEmpty()guard beforequeue->drain()in ZigGlobalObject.cpp avoids re-draining the microtask queue. Bun__Node__AbortOnUncaughtExceptionis process-global;shouldAbortOnUncaughtException()gates onBun__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_rejection → UncaughtExceptionOrigin 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__handleUncaughtExceptiongains ~80 lines of new control flow with multipleabortOnUncaughtException()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/emitWithRejectionCaptureinto a single prototypeemitaffects every EventEmitter in the runtime; theaddCatchearly-return on!this[kCapture]is what preserves the old fast-path behaviour. - The
adopt/unadopt/currentTokenmachinery indomain.tsis a Bun-specific design (Node usescreateHookinit/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.
No-Verification-Needed: extern-declaration union merge resolution; CI verifies the merged tree
|
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. |
There was a problem hiding this comment.
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
📒 Files selected for processing (72)
src/js/node/async_hooks.tssrc/js/node/domain.tssrc/js/node/events.tssrc/js/node/http2.tssrc/js_parser_jsc/Macro.rssrc/jsc/JSGlobalObject.rssrc/jsc/VirtualMachine.rssrc/jsc/bindings/BunProcess.cppsrc/jsc/bindings/BunProcess.hsrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/virtual_machine_exports.rssrc/jsc/web_worker.rssrc/runtime/api/BunObject.rssrc/runtime/api/cron.rssrc/runtime/cli/Arguments.rssrc/runtime/cli/run_command.rssrc/runtime/napi/napi_body.rssrc/runtime/server/NodeHTTPResponse.rssrc/runtime/server/WebSocketServerContext.rssrc/runtime/server/mod.rssrc/runtime/socket/Handlers.rssrc/runtime/socket/udp_socket.rssrc/runtime/test_runner/bun_test.rstest/js/node/async_hooks/EventEmitterAsyncResource.test.tstest/js/node/async_hooks/async_hooks.node.test.tstest/js/node/domain/domain.test.tstest/js/node/events/event-emitter.test.tstest/js/node/process/process.test.jstest/js/node/test/common/index.jstest/js/node/test/parallel/test-crypto-domain.jstest/js/node/test/parallel/test-domain-abort-on-uncaught.jstest/js/node/test/parallel/test-domain-add-remove.jstest/js/node/test/parallel/test-domain-async-id-map-leak.jstest/js/node/test/parallel/test-domain-bind-timeout.jstest/js/node/test/parallel/test-domain-ee-implicit.jstest/js/node/test/parallel/test-domain-ee.jstest/js/node/test/parallel/test-domain-emit-error-handler-stack.jstest/js/node/test/parallel/test-domain-enter-exit.jstest/js/node/test/parallel/test-domain-error-types.jstest/js/node/test/parallel/test-domain-from-timer.jstest/js/node/test/parallel/test-domain-fs-enoent-stream.jstest/js/node/test/parallel/test-domain-http-server.jstest/js/node/test/parallel/test-domain-intercept.jstest/js/node/test/parallel/test-domain-load-after-set-uncaught-exception-capture.jstest/js/node/test/parallel/test-domain-multiple-errors.jstest/js/node/test/parallel/test-domain-nested-throw.jstest/js/node/test/parallel/test-domain-nested.jstest/js/node/test/parallel/test-domain-nexttick.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-0.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-1.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-2.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-3.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-4.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-6.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-7.jstest/js/node/test/parallel/test-domain-no-error-handler-abort-on-uncaught-8.jstest/js/node/test/parallel/test-domain-promise.jstest/js/node/test/parallel/test-domain-run.jstest/js/node/test/parallel/test-domain-safe-exit.jstest/js/node/test/parallel/test-domain-set-uncaught-exception-capture-after-load.jstest/js/node/test/parallel/test-domain-stack-empty-in-process-uncaughtexception.jstest/js/node/test/parallel/test-domain-stack.jstest/js/node/test/parallel/test-domain-throw-error-then-throw-from-uncaught-exception-handler.jstest/js/node/test/parallel/test-domain-thrown-error-handler-stack.jstest/js/node/test/parallel/test-domain-timer.jstest/js/node/test/parallel/test-domain-timers-uncaught-exception.jstest/js/node/test/parallel/test-domain-timers.jstest/js/node/test/parallel/test-domain-top-level-error-handler-clears-stack.jstest/js/node/test/parallel/test-domain-top-level-error-handler-throw.jstest/js/node/test/parallel/test-domain-uncaught-exception.jstest/js/node/test/parallel/test-domain-vm-promise-isolation.jstest/js/node/test/parallel/test-domain-with-abort-on-uncaught-exception.js
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.
…ption Node v26 reports it allowed in NODE_OPTIONS; the normalizing has() covers the underscore spelling.
…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.
…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.
… redundant guard note No-Verification-Needed: comment-only change
There was a problem hiding this comment.
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
UncaughtExceptionOriginenum threading through everyuncaught_exceptioncall site (Rust + C++), the newsubstituteErrorout-param, and Worker vs main-thread exit-7/abort ordering inBunProcess.cpp. - The
EventEmitterunification:addCatchnow gates onthis[kCapture], so non-captureRejections emitters don't attach a.then— behaviour preserved; the removedinit: EventEmitterself-alias would have infinitely recursed under the new constructor delegation. domain.tstoken/adopt/unadopt bookkeeping against the four non-Domain-process.domainguard sites and thecleanupAsyncHooksDatanextTick drain (guarded byisEmpty()).- New
WriteBarrierslots (m_domainErrorHandler/m_domainWouldClaim) are visited invisitChildrenImpl.
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.
Implements
node:domain(previously a ~70-line stub) as a port of Node'slib/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.txtis untouched by this PR. On linux-x64-musl,test-gc-http-client-connaborted.jsnow joinstest-net-connect-memleak.jsas 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:domainwas a stub, so any package depending on it silently did nothing. This implements it and proves it against Node's own test suite.node:domainimplemented:create()/Domain,run/bind/intercept/add/remove/enter/exit,domain.active/domain._stack, and Node's error decoration (err.domain,domainThrown,domainEmitter,domainBound).createHook: Node pairs each async resource with the active domain via the async-hooksinithook, then enters it inbefore(). Bun has nocreateHook, 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.Bun__handleUncaughtExceptionbefore the capture callback and'uncaughtException'listeners — where Node's domain hooks intoprocess._fatalException.process.domainbecomes an accessor reading the async-local active domain.EventEmitterintegration: the constructor now delegates through a Node-compatibleEventEmitter.initstatic (matching Node, and fixing userland that callsEventEmitter.init.call(this)). Emitters created inside a domain get.domainand route'error'into it with Node's stack pruning/restore semantics. The oldinit: EventEmitterself-alias is removed — with the constructor delegating throughinit, that alias would recurse infinitely.emiton the prototype: Bun had two emit implementations and assignedemitWithRejectionCaptureas an own instance property whencaptureRejectionswas set. An own property shadows any prototype override, so those emitters bypassed domains entirely. Both are now the single prototypeemitNode has, with the rejection-capture check guarded bykCapture(addCatchearly-returns when it is off). This also removes the own-emitshadowing thatHttp2Serverhad a comment working around.AsyncResourcecreated inside a domain gets the non-enumerable.domainproperty Node's init hook provides.async_hooksstays domain-agnostic: the getter is null untilnode:domainloads, so nothing touchesprocess.domainotherwise.--abort-on-uncaught-exception: implemented, including the--abort_on_uncaught_exceptionspelling V8 also accepts. Aborts after printing instead ofexit(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.AsyncResourcecreated inside a domain gets the non-enumerable.domainproperty Node's init hook provides.Drive-by fix: dropped
process.nextTickAsyncLocalStorage.enterWith()+process.nextTickat main-module scope silently dropped the tick.cleanupAsyncHooksDataunhooked 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 Buncomment explaining the omitted case (see gaps). All 44 fail on released Bun and pass on this branch. Also re-ran theevents,async-hooks, andasynclocalstorageupstream suites (29/29) and Bun's ownasync_hooks(111) andevent-emitter/process(184) tests, sinceEventEmitter.initand the nextTick drain are cross-cutting.Gaps
Six upstream tests are deliberately not vendored, all blocked on the same two missing pieces:
fscallbacks are promise reactions, so a throw inside one surfaces via the rejection path with the context already restored. Blockstest-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-existingd.run(cb)deviation intest-crypto-domain.jsis now documented rather than silent.MakeCallbackequivalent, so the DEP0097 warning has no source. Blockstest-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