domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite (+40 upstream tests)
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.
49 of the 50 upstream test-domain-* tests now pass (45 newly vendored, plus 3 existing files re-synced to upstream). The one that does not pass is not in the tree: it is listed as a gap 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:domainimplemented: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-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. - Uncaught-exception routing: a dedicated native dispatch slot consulted by
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:node:domaintakes every emitter before its construction, through a hook ininternal/sharedthat theEventEmitterconstructor calls. The ahead-of-time texts ofnode:httpthat construct a request or a response without the constructor call it too. Node replacesEventEmitter.initfor this. Here the constructor does not go throughinit, soEventEmitter.initstays what it is on main. Emitters created inside a domain get.domainand route'error'into it with Node's stack pruning/restore semantics. The hook costs 105 interpreter instructions pernew EventEmitter()(3,480 to 3,585), and nothing in an executable compiled ahead of time that never loadsnode:domain.- One
emiton the prototype: this comes from #47381, which this branch contains until it lands.node:domainneeds it: an ownemiton an emitter that captures rejections hides theemitthatnode:domainputs on the prototype. #47381 is a draft while its design is reviewed again, and this branch follows it. AsyncResourcecreated inside a domain gets the non-enumerable.domainproperty Node's init hook provides.async_hooksstays domain-agnostic: the hook ininternal/sharedis undefined 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.- 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 two, each carrying an inline Note for Bun comment explaining the omitted case (see gaps). All 47 fail on released Bun and pass on this branch. That is the 45 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 49, 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.
Review round (a988724b0a): four findings of the review are answered so far. A throw in an fs callback reaches its domain, so test-domain-abort-on-uncaught.js is the upstream text again, test-domain-with-abort-on-uncaught-exception.js has its fs.exists case back, and five upstream files are added (test-domain-no-error-handler-abort-on-uncaught-5.js and -9.js, test-domain-implicit-fs.js, test-domain-implicit-binding.js, test-domain-multi.js). A throw of the entry module is no longer given to a domain that a callback entered since: Bun reports that throw after the queued ticks ran, and the report skipped the stand-in for node's after() hook (node printed d1 sync, this branch d2 sync). Three tests of other subjects name what the port changes: the install commands take both spellings of --abort-on-uncaught-exception, the lists of compile-trim-options.ts and of native-contract/known-changes.ts have the new names, and one program of test/aot/trimmed-is-whole ends with 0 now, because process.domain is an own accessor of process once node:domain is loaded, as in node. Still open from the review: a scope of AsyncResource or of AsyncLocalStorage.snapshot() inside domain.run() leaves the domain on the stack. The fix is in progress. Verified on the debug build: domain.test.ts 16 pass, 83 upstream files exit 0 (the 49 domain files and the async-hooks, async-local-storage, asyncresource and process-exception-capture files), test/js/node/events 136 pass.
Merge note (0bb5272b71): merged main f7607a3d35 and #47381 (d2fb1eba07). Eight files conflicted. domain.ts keeps the port and declares $Node.Domain, events.ts takes the text of #47381, and Bun__handleUncaughtException keeps main's defaultGlobalObject with this branch's origin and exit code 7. Main's new tables asked for three things: the native function that keeps the two functions of node:domain is declared in native-functions.builtins.ts, both spellings of --abort-on-uncaught-exception have a row of requirements, and the Bun.ModuleGraph hook reads the origin. The key that node:async_hooks exported for node:domain is gone, because visible-surface.test.ts lists every own key of that module. Verified on the debug build: domain.test.ts 14 pass, the 45 vendored domain files exit 0, event-emitter.test.ts 117 pass. On the release build: visible-surface.test.ts, AsyncLocalStorage.test.ts 63 pass, test/aot/servers/budgets-of-node.test.ts with the counts of #47381, and a probe that binds emitters, streams, requests and responses to the active domain prints the same under node v26.3.0, bun run and --aot.
Merge note (d38da286c4): 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 (7836e08df7 + 12ff462514): #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
One upstream test-domain-* test is not vendored, two vendored tests omit one case each, and one is skipped:
- Unhandled rejections are not routed through the domain machinery (node's
promiseInfo.domain).test-domain-promise.jsomits its one case for that, and the.todocases oftest/js/node/domain/domain.test.tshold it. - A throw in the callback of an async crypto call is reported after Bun put back the async context that was in place before the callback, so the domain of the call is not found. Main has the same order without
node:domain(anuncaughtExceptionlistener reads noAsyncLocalStoragestore there), and this PR does not change it.test-crypto-domain.jsis vendored byte-identical behind a documentedcommon.skip, and upstream'stest-crypto-domains.jsis not vendored. - Bun reports a synchronous throw of the entry module after the ticks that were queued before it, on main too.
test-domain-with-abort-on-uncaught-exception.jsomits the synchronous throw from thedomain.runcallback for that. - No
MakeCallbackequivalent, so the DEP0097 warning has no source. Blockstest-domain-dep0097.js.
An earlier revision of this text said that a throw in an fs callback does not reach its domain. It does: the five tests and two cases that were left out for it are in the tree now.
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