Skip to content

domain: route fs callback throws to uncaughtException, unblocking 4 tests (domain 88%→96%) - #34661

Open
cirospaciari wants to merge 96 commits into
claude/port-node-domain-testsfrom
claude/domain-fs-callback-throw
Open

domain: route fs callback throws to uncaughtException, unblocking 4 tests (domain 88%→96%)#34661
cirospaciari wants to merge 96 commits into
claude/port-node-domain-testsfrom
claude/domain-fs-callback-throw

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 18, 2026

Copy link
Copy Markdown
Member

Four upstream domain tests convert here that neither of the two changes involved converts on its own.

Stacked on #31828. Carries the callback-dispatch fix from #34660 as well, because the tests need both halves — see below.

Why these needed two changes

  • On main: node:domain is a 96-line stub with no Domain constructor, so the tests die before reaching any fs call. The callback fix alone converts nothing.
  • On domain: implement node:domain on AsyncLocalStorage and port the upstream domain suite #31828: node:domain works, but a throw inside an fs callback is dispatched from a promise reaction, so it surfaces as an unhandledRejection and never reaches uncaughtException — which is exactly the machinery domain hooks. The domain PR alone converts nothing.

Together they pass. The clinching detail: test-domain-no-error-handler-abort-on-uncaught-5 and -9 are byte-for-byte the already-vendored and already-passing tests 4 and 8, with process.nextTick swapped for fs.exists. The fs call was the entire difference.

Tests: test-domain-implicit-binding, test-domain-implicit-fs, test-domain-no-error-handler-abort-on-uncaught-5, test-domain-no-error-handler-abort-on-uncaught-9 — all copied verbatim.

Relationship to #34660

The src/js/{internal/shared,node/fs,node/dns,node/crypto}.ts changes here are the same commit as #34660, which targets main and carries the full measurement: node's exact semantics (the throw reaches uncaughtException synchronously from the callback's own frame, no extra tick), byte-identical happy-path timing verified by diffing an event-loop ordering trace, and an interleaved same-commit regression sweep over 356 vendored files showing zero deltas.

If #34660 merges first, rebasing this drops that half and leaves only the four tests. Reviewing them together is probably easier — #34660 has the depth, this one has the payoff.

Verification

  • the 4 tests: 3/3 each, all verbatim vs upstream (diff clean)
  • tamper-checked: flipping a mustCall to mustNotCall makes them exit 7 and 1 respectively
  • no regressions: all 44 vendored test-domain-* tests on this branch still pass (44/44)

The rest of the domain gap

Upstream has 50 test-domain-* files; 44 were already vendored, 6 were genuinely missing (not the ~46 a git ls-tree diff suggests). With these four, 48 of 50. The last two are not domain problems:

  • test-domain-dep0097 needs node:inspector (inspector.open()ERR_NOT_IMPLEMENTED, support node:inspector #2445).
  • test-domain-multi is a http client bug: the domain half is fully correct — the write callback fires, the throw routes to domain B and not root domain A, and B's handler runs — but the handler then writes a stray H to corrupt the chunk header, and Bun's http client parser doesn't error on it, so c.on('error') never fires and the server never closes.

Worth noting one false alarm ruled out along the way: --abort-on-uncaught-exception is correctly implemented in Bun (all 8 vendored abort tests pass), so these failures were never a CLI gap.


no test proof · iteration 58 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/repl/repl.test.ts test/js/node/dgram/node-dgram.test.js test/js/node/dns/node-dns.test.js test/js/node/process/process.test.js test/js/node/watch/fs.watch.test.ts test/js/node/watch/fs.watchFile.test.ts test/napi/napi.test.ts

Compat impact (upstream node v26.3.0 test files vendored, in-tree = passing)

  • node:domain: 88% → 96% (44 → 48 of 50)

…r tests

Carries the callback-dispatch change from the standalone PR (fs, dns and
crypto.pbkdf2 callbacks are dispatched from a promise reaction, so a throw
inside one surfaced as an unhandledRejection instead of an uncaughtException),
because these four domain tests need it and node:domain together.

Neither half converts them alone: on main node:domain is a stub, so they die
before reaching any fs call; on this branch domain works but the throw never
escapes the promise. Two of the four are byte-for-byte the already-vendored
abort-on-uncaught 4 and 8 with process.nextTick swapped for fs.exists, so the
fs call is the entire difference.

Tests copied verbatim from Node v26.3.0.
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator
Updated 9:37 PM PT - Aug 7th, 2026

@robobun, your commit a5914e1f65e40759dcd0cdad0a2115de22ea1618 passed in Build #90491! 🎉


🧪   To try this PR locally:

bunx bun-pr 34661

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

bun-34661 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Missing functionality of 'domain' API: control of async unhandled exceptions #6045 - PR routes fs/dns/crypto async callback throws through uncaughtException/reportUncaughtException, which is the mechanism domains hook into — directly addressing the missing async error handling in node:domain

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

Fixes #6045

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node compat batch: callback-throw dispatch, Assert class + native deep-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) #34660 - Same guardCallback fix routing fs/dns/crypto callback throws to uncaughtException, targeting main instead of the domain branch

🤖 Generated with Claude Code

@cirospaciari
cirospaciari marked this pull request as ready for review July 20, 2026 21:34
The opendir case read the first entry of the shared describe-level fixture
dir, which the sibling symlink cases populate with l3/l4/lnc links. readdir
order is filesystem-dependent, so dir.read() could return a symlink instead
of file.txt. Use a private mkdtemp dir containing only file.txt.
Comment thread test/js/node/fs/fs.test.ts
Comment thread test/js/node/fs/fs.test.ts
Same two expectations.txt entries main added in #34686 (tracked in #34095
and #34690); this branch predates that commit so CI still runs the tests.
Pre-existing assertNoException SIGABRT tracked in #34846; hits unrelated
branches including dependency-bot bumps.
Comment thread src/js/node/fs.ts
Comment thread test/js/node/fs/fs.test.ts
Comment thread test/js/node/fs/fs.test.ts
Comment thread src/js/node/fs.ts Outdated
The package-binary-size gate compares against main's canary; this branch
carries a full node-v26 feature stack, so the growth is expected and
reviewed as part of the stack, not a regression of this PR.
- fstat and read now go through ensureCallback like their siblings, so a
  missing or non-callable callback throws ERR_INVALID_ARG_TYPE synchronously
  (node's makeStatsCallback behavior) instead of routing a TypeError through
  reportUncaughtException.
- reword the symlink 4-arg comment and test title: the ignored non-callable
  callback is a preserved Bun divergence, not node parity.
- runScript drains stderr and returns it so failing cases show the child's
  stack trace.
- the setImmediate ordering marker is registered inside the fs callback so
  the assertion is deterministic, not a threadpool race.
- the opendir fixture dir is created by the parent via tempDirWithFiles so
  CI runners don't accumulate orphaned temp dirs.

[allow size]
Comment thread test/js/node/fs/fs.test.ts
cirospaciari and others added 2 commits July 22, 2026 21:21
No 127.0.0.1 PTR entry in the Windows hosts file, so the call falls
through to a real query there; same gate as the vendored test-c-ares.js.

[allow size]
Comment thread test/js/node/fs/fs.test.ts
cirospaciari and others added 3 commits July 22, 2026 21:56
… the callback-throw suite concurrently

The pbkdf2 completion handlers are hoisted named functions bound to the
guarded callback; guardCallback's comment names the Node v26.3.0 mechanism
it mirrors; the independent subprocess cases use it.concurrent per the
test conventions.

[allow size]

@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 six earlier review threads are addressed and I found nothing new this pass. Deferring to a human for the final sign-off given the breadth: this reroutes throws in ~40 fs + ~15 dns + pbkdf2 callback sites from unhandledRejection to uncaughtException, is stacked on #31828, and rides an unrelated expectations.txt quarantine (test-http2-reset-flood.js, #34846).

What was reviewed:

  • guardCallback wrapper and every ensureCallback/validateResolve reassignment site in fs/dns/crypto — no site left with a bare guardCallback on an unvalidated callback (the earlier fstat/read gaps are fixed).
  • The pbkdf2 truthy-non-callable-callback escalation candidate — ruled out: if (callback) + .bind on the guarded wrapper doesn't change the pre-existing behavior class.
  • Test hermeticity/determinism: the setImmediate race, opendir tempdir leak, and Windows dns.reverse gate were all fixed; the suite now runs it.concurrent.each.
Extended reasoning...

Overview

Adds guardCallback in internal/shared.ts (try/catch → jsFunctionReportUncaughtException) and threads it through ensureCallback in node:fs (~40 sites), validateResolve + direct calls in node:dns (~15 sites), and pbkdf2 in node:crypto. Declares jsFunctionReportUncaughtException in BunProcess.h. Vendors four upstream test-domain-* tests verbatim, adds a 19-case subprocess suite in fs.test.ts, and quarantines test-http2-reset-flood.js under ASAN in expectations.txt.

Security risks

None identified. The change catches user-thrown exceptions inside callbacks and routes them to the existing uncaughtException machinery via an already-exported C++ function; no new input parsing, no privilege boundaries crossed.

Level of scrutiny

High — this alters user-visible error semantics for essentially every async node:fs/node:dns callback (a throw that was previously an unhandledRejection warning becomes an uncaughtException, which crashes by default). That's the correct Node behavior and the whole point of the PR, but the blast radius plus the stacked-PR dependency (#31828) and the unrelated expectations.txt rider make it a human call rather than a bot approval.

Other factors

Six prior review rounds from me were all addressed (fstat/read ensureCallback, symlink 4-arg comment/test rewording, opendir tempdir cleanup, setImmediate race fix, Windows dns.reverse gate, it.concurrent.each). The bug hunter found nothing this run; the one candidate (pbkdf2 non-callable callback escalation) was verified not to change behavior versus the pre-PR path. The expectations.txt addition cites a tracked issue (#34846) and documents it as pre-existing on unrelated branches, which is the correct format per the file's own header — but a human should still confirm they're comfortable landing that quarantine here rather than in its own PR.

Comment thread test/expectations.txt Outdated
Comment thread src/js/internal/shared.ts
Comment thread test/js/node/fs/fs.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues this pass, but this reworks the runtime's uncaught-exception/exit semantics (VirtualMachine liveness gate, fatal vs keep-alive split, fs/dns/crypto callback routing, EventTarget defer-then-fatal, a user-facing dns.lookup(falsy) breaking change) across 30 files, is stacked on #31828, and carries an explicitly deferred sibling-class item (net.ts 'data' listener throw) pending a scope call — needs a human sign-off.

What was reviewed:

  • ensureCallback/guardCallback sweep across fs/dns/crypto — every site reassigns the wrapped callback; the symlink 4-arg divergence is intentional and tested.
  • uncaught_exception_fatal gating (REPL / hot / worker / entry-point exclusions) and the Mode::Bun/Throw/Strict rejection routing under hot_reload.
  • The reportError → fatal sweep in net.ts / diagnostics_channel / webstreams_adapters — no remaining keep-alive stragglers in src/js/node.
  • queueUncaughtExceptionNextTick exception-scope handling and the webview backends staying on keep-alive Bun__reportError.
Extended reasoning...

Overview

This PR routes throws from Node-style async callbacks (fs/dns/crypto) to uncaughtException instead of unhandledRejection, unblocking four upstream test-domain-* tests. To make that safe it splits VirtualMachine::uncaught_exception into a keep-alive path (Bun-native servers, reportError, webview backends) and a new uncaught_exception_fatal path (Node-compat throws: nextTick drain, timers, guardCallback, EventTarget listener defer, --unhandled-rejections=throw|strict, Bun.cron), removes unhandled_error_counter == 0 from the event-loop liveness check, and adds a suppress_fatal_uncaught VM flag for the REPL. It also lands a Node v26.3.0 breaking change (dns.lookup(falsy) now throws synchronously) with corresponding rewrites of three vendored dns tests, and cleans up dead once-bridge code in ws.js.

Security risks

None identified. No auth/crypto primitive changes (crypto.ts only wraps callback dispatch), no untrusted-input parsing added. The tamper-resistance nit on .bind was declined by the maintainer as within the module's existing threat bar.

Level of scrutiny

High. This changes when a Bun process exits versus keeps running on unhandled errors — the most fundamental runtime contract there is — and the PR history shows how easy it is to get one caller on the wrong side of the keep-alive/fatal split (net.ts handshake catches, diagnostics_channel, EventTarget, bun test, Mode::Bun rejection routing, webview backends — all iterated on during review). It's also stacked on an unmerged base (#31828) and overlaps #34660, so merge order matters.

Other factors

The PR has been through ~50 iterations; every prior inline finding from me and from cirospaciari shows resolved. Two items remain deliberately out of scope by the author's own note: the net.ts 'data'-listener-throw class (blocked on http-client parser-error handling, pending cirospaciari's scope call) and the http1_server_fallback outgoing queue (attributed to #34432 on main, not this branch). Test coverage for the new routing is substantial (per-API matrix in fs.test.ts, keep-alive vs fatal contracts in process.test.js, REPL survival, EventTarget sync/async). Given the blast radius and the open scope decision, this should be signed off by a maintainer rather than auto-approved.

cirospaciari and others added 2 commits August 7, 2026 13:51
shared.ts/crypto.ts/fs.ts/fs.test.ts: take the base side throughout - it carries the #34660 batch as merged to main, which subsumed this branch's older draft of the same callback-throw helpers.

No-Verification-Needed: merge resolution taking the landed/reviewed side of the same feature; CI verifies the merged tree
After the main merge the piped-stdin repl loop no longer waits for
timers between lines, so the 0ms setTimeout throw may never fire
before .exit (fired on debug, not on release; build 90257 failed on 7
lanes). The nextTick case alone pins suppress_fatal_uncaught: pre-fix
the first throw hard-exits and REPL-SURVIVED:42 never prints. The
tick error's position is build-dependent (between lines on debug, at
exit drain on release), so assert presence only.
Comment thread src/runtime/webview/ChromeBackend.cpp
…n [allow size]

The old sink (Bun__reportUnhandledError) filtered termination
exceptions downstream; Bun__reportError does not, and wsOnMessage
discarded the clear's return value while its two sibling frame loops
already guard. Match them: skip the report when the exception is a
sticky termination.
Comment thread src/jsc/VirtualMachine.rs Outdated
…rms [allow size]

The inner fatal gate (hot_reload == 0 inside uncaught_exception_impl)
already prevents process_exit under watch/hot and falls to the
keep-alive print, so the outer guards' only net effect was skipping
Bun__handleUncaughtException: process.on('uncaughtException') listeners
stopped receiving unhandled rejections under --hot with
--unhandled-rejections=throw/strict (pre-PR both arms dispatched
unconditionally). Verified under --hot: the listener fires (CAUGHT:x)
and the watcher stays alive with and without a listener; non-hot
fatal-exit tests unchanged.
Comment thread src/jsc/VirtualMachine.rs Outdated
…h [allow size]

Node runs both through CallbackIntoModule<true>, which calls
TriggerUncaughtException on throw: the process prints the error and
exits 1 unless an uncaughtException handler intervenes. Our keep-alive
report let these throws keep the process running.

The complete-callback site that used report_active_exception_as_unhandled
now takes the exception and calls uncaught_exception_fatal directly,
minus termination exceptions; the shared helper keeps its keep-alive
behavior for EventLoop::run_callback and the Bun-native callers.

Verified: "can throw an exception from an async_complete_callback"
passes. The four napi.test.ts 5000ms timeouts here are debug-ASAN
slowness, not this change: the orphan-leak fixture prints exactly the
expected output in 10.7s when run directly, "has the right lifetime"
fails identically with this change stashed, and the define_class case
spawns eight child processes.
Comment thread src/jsc/VirtualMachine.rs Outdated
…[allow size]

fs.watch, fs.watchFile and dgram 'message' listeners reached native
through unguarded callbacks, so a throw was reported keep-alive; with
the liveness counter gone the watcher or bound socket ref then kept the
loop alive and the process hung after printing. Node fatal-exits all
three (FSEventWrap, StatWatcher and UDPWrap invoke them via
MakeCallback), as did bun 1.4.0 via the counter gate.

Wrap the bound listeners in guardCallback at the node-compat layer,
like the fs/dns one-shot callbacks: watch.ts and watchfile.ts at
construction, dgram.ts on the data/drain/error callbacks it hands
Bun.udpSocket, leaving the native surfaces' keep-alive contract alone.

The close-from-close watch test now installs an "error" listener: the
abort path delivers an "error" event whose unhandled throw native
previously swallowed with clear_exception; guarded, it reports as
uncaught like node.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Review follow-up in 4b143eb: with the liveness counter gone, a throwing fs.watch, fs.watchFile or dgram 'message' listener printed the error and then hung (the watcher or socket ref kept the loop alive). Node and bun 1.4.0 both exit 1 there. Fixed by guarding those listeners at the node-compat layer, with tests.

One open design call for a maintainer: node:http2 session handler throws. Its dispatch rides the same keep-alive path as the Bun-native servers, so on this branch an uncaught throw in for example a 'stream' handler leaves the server serving, where node (and 1.4.0, via the old counter) exits 1. Making node:http2 match node means guarding its ~40 native-dispatched handlers, which felt too large to fold in here without a nod. Happy to do it here or as a follow-up, whichever you prefer.

robobun added 2 commits August 7, 2026 23:36
…ves the guard [allow size]

node's fs.watch emits only "close" on abort; the AbortError "error"
event is bun's own, and its unhandled ERR_UNHANDLED_ERROR was swallowed
natively before the listener was guarded. Guarded, it became a fatal
uncaught exception and test-fs-watch-abort-signal.js (which installs no
"error" listener, as node allows) exited 1.

emit_abort now tags the delivery "abort" and the JS layer emits the
"error" event inside a try/catch, so the abort event keeps its old
lenient contract while real watcher events and errors stay on the fatal
path. fs.promises.watch reads signal.aborted before draining its queue,
so the retagged entry stays unreachable there.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

CI on fdb7ab5: the fs.watch abort regression from the previous run is fixed, test-fs-watch-abort-signal passes on all lanes. The one remaining red is test-worker-message-port-transfer-terminate on the x64-asan lane, which is the known worker-terminate race tracked in #34095 and #34690 (30 local ASAN runs here do not reproduce it); the other entries passed on retry. Ready for review from my side; the open items are the scope notes above and the node:http2 design call.

Comment thread src/jsc/VirtualMachine.rs Outdated
robobun added 2 commits August 7, 2026 23:53
…to the fatal path [allow size]

A throw from a threadsafe function's JS callback or from a ChildProcess
'message' listener was reported keep-alive; with the liveness counter
gone, the TSF's poll ref or the child and channel refs kept the loop
alive and the process hung after printing. Node fatal-exits both (TSFN
via CallbackIntoModule, enforced by default since node 26; the channel's
onread via MakeCallback), as did bun 1.4.0 through the counter gate.

The TSFN Js arm takes the exception and calls uncaught_exception_fatal
(skipping termination), like the async_work complete path; the C arm
now also drains a pending napi exception or a global exception after
call_js, which node clears at callback scope close. #emitIpcMessage
reroutes through reportUncaughtException at the node:child_process
layer, leaving the raw Bun.spawn ipc handler's pinned keep-alive
contract alone.

The child-side process.on('message') throw already exits 1.
Comment thread src/runtime/node/node_fs_watcher.rs Outdated
Comment thread test/js/node/watch/fs.watch.test.ts
Comment thread test/js/node/watch/fs.watch.test.ts Outdated
…mp dirs, full pipe drains [allow size]

fs.promises.watch's event filter now skips "abort" like "close" and
"error", so the retagged abort delivery is not run through the ignore
matcher before the signal.aborted check discards it.

The watch and watchFile throw fixtures create their directories in the
parent with tempDir and thread the path in, since the fatal exit leaves
child-created mkdtemp dirs behind. The five new subprocess tests drain
both pipes and assert the combined object, so a regression shows the
child's output instead of just the exit code.
Comment thread test/js/node/child_process/child_process_ipc.test.js Outdated
Comment thread src/js/node/fs.promises.ts Outdated
Comment thread src/js/thirdparty/ws.js Outdated
Comment thread src/js/internal/fs/watch.ts
robobun added 2 commits August 8, 2026 01:35
…t on listeners [allow size]

EventEmitter.once dispatches through this.on, which already hits the
overridden on() -> #armAndOn, so the once() override was a pure
passthrough after the #armAndOn collapse.

The watch abort branch emits only when an "error" listener exists
instead of swallowing every throw: no listener stays silent (node emits
nothing on abort), and a present listener's throw now reaches
guardCallback like the sibling "error" branch.
The third consumer of the native fs.watch binding ran the user-supplied
ignore matcher unguarded, so a throwing matcher was reported keep-alive
and the watcher ref hung the process. guardCallback reroutes it to the
fatal path like the FSWatcher and StatWatcher listeners.
Comment thread src/runtime/node/node_fs_watcher.rs
The guarded listener only returns Err for a termination exception now,
and report_active_exception_as_unhandled takes it and skips reporting
instead of clear_exception dropping termination state on the floor.
Comment thread src/js/node/dgram.ts Outdated
Native calls it with five args, past guardCallback's arity fast path,
so the per-packet handler carries its own try/catch like
child_process's #emitIpcMessage. drain and error stay on guardCallback
(zero and one arg).
Comment thread test/js/workerd/html-rewriter.test.js
…[allow size]

The process.exit(1) in the unhandledRejection handler killed the child
mid-transform, dropping the original assertion that the rewrite
succeeds despite the detached rejection. A log-only handler pins both:
the rejection reaches unhandledRejection and the body still renders.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants