Skip to content

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

Merged
Jarred-Sumner merged 190 commits into
mainfrom
claude/callback-throw-uncaught
Aug 7, 2026

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 18, 2026

Copy link
Copy Markdown
Member

Batch of node-compat fixes that unblock each other, verified against the node v26.3.0 binary throughout. Adds 98 vendored upstream tests (parallel + sequential), all passing.

Callback-throw dispatch (the original headline)

A throw inside a node-style callback in node:fs, node:dns or crypto.pbkdf2 surfaced as an unhandledRejection instead of an uncaughtException, because those callbacks dispatch from a promise reaction. guardCallback in internal/shared.ts routes the exception to the same native uncaught path timers already use, applied at the existing chokepoints (ensureCallback in fs, validateResolve in dns, pbkdf2). Happy-path timing is byte-identical to base, verified by diffing a full event-loop ordering trace. The throw reaches uncaughtException synchronously from the callback's own frame, matching node's measured semantics.

Assert class + node-correct deep equality

Node v26's Assert class (per-instance diff/strict/skipPrototype, ERR_CONSTRUCT_CALL_REQUIRED). Deep equality is confined by API ownership: the node entry points (assert strict variants, util.isDeepStrictEqual) compare prototypes and own-enumerable properties like node, while Bun.deepEquals and every expect() instantiation are verified behavior-identical to before. assert.partialDeepStrictEqual's comparison engine is implemented natively in C++ (object/array/Map/Set subset semantics, Date/RegExp/boxed-primitive/DataView arms, cycle guards), pinned by a cumulative matrix run byte-identical against node.

Intl gate fix + fallout, solved together

process.config.variables spelled the key v8_enable_i8n_support, so every vendored test gated on common.hasIntl silently skipped its Intl half. This lands the typo fix with the fallout: buffer.transcode (simdutf fast paths), node's host-parse semantics for domainToASCII/Unicode including the Unicode 16 IDNA delta node ships via ada (Bun's ICU is older; the delta is applied at the node:url entry points, cited in NodeURL.cpp), url.format/url.parse host rules, TextDecoder fatal messages, util.inspect URL formatting.

Two user-visible changes called out for review: new URL(bad) now throws node's exact TypeError: Invalid URL with code/input, and invalid-punycode xn-- hosts are rejected for special schemes (matching node, Chrome, Firefox, and WPT).

Also here

  • module.enableCompileCache() / NODE_COMPILE_CACHE: bytecode cache with atomic persist at exit (including the self-kill signal path), persist pass runs unlocked so Worker module loads never stall.
  • --watch-kill-signal: watch reloads deliver the configured signal to JS listeners before execve, on POSIX and Windows (real Windows signal numbers, not Linux discriminants).
  • --cpu-prof and --heap-prof write real V8-format profiles/snapshots with node's shared filename sequence.
  • async_hooks: AsyncResource lifecycle + tick/timer hooks behind the existing enable-gates (promise hooks need a WebKit-fork patch and are explicitly out of scope).
  • bun -pe alias, internal-module shims for vendored tests (internal/* interceptor merges native implementations over the vendored ports), require.resolve builtin handling and paths validation moved native, isURL and stringWidth(perCodePoint) native.

Verification

Interleaved same-commit sweeps with zero regressions across fs/dns/domain/pbkdf2 (356 vendored files), assert (361), url, util, expect (510), plus the bun-side suites for each subsystem. Deleted along the way rather than shipped red: test-child-process-advanced-serialization.js (passes vacuously on main via the runner's swallowed-rejection bug #34859; the real gap is #34860) and test-whatwg-url-toascii.js (WHATWG URL constructor goes through WebKit + system ICU, so distro data decides; returns with the ICU-bundling decision).


no test proof · iteration 48 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/heap-prof.test.ts test/cli/watch/watch.test.ts test/js/bun/resolve/resolve-error.test.ts test/js/bun/spawn/spawn.ipc.test.ts test/js/node/fs/fs.test.ts test/js/node/module/node-module-module.test.js test/js/node/process/process.test.js

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

  • module.enableCompileCache: 5% → 64% (1 → 14 of 22)
  • --cpu-prof: 9% → 73% (1 → 8 of 11)
  • --heap-prof: 0% → 18% (0 → 2 of 11)
  • watch mode: 0% → 30% (0 → 3 of 10)
  • node:url: 80% → 100% (12 → 15 of 15)
  • node:util: 67% → 70% (20 → 21 of 30)
  • node:fs: 98% → 99% (340 → 342 of 346)
  • node:assert: 14 → 16 vendored (upstream parallel/sequential set: 13; bun's set carries one file from an earlier upstream)

…ughtException

These callbacks are dispatched from a promise reaction, so a throw inside one
rejected the derived promise and surfaced as an unhandledRejection instead of an
uncaughtException as it does in Node, where the callback runs off the libuv
request completion. process.on('uncaughtException') and node:domain never saw
errors thrown from fs, dns, or crypto.pbkdf2 callbacks.

Add guardCallback in internal/shared and apply it at the existing callback
chokepoints (ensureCallback in fs, validateResolve in dns) plus the few sites
that bypass them. The callback keeps its exact place in the event loop; only the
throw is rerouted, through the same native path timer callbacks already use,
which is why setTimeout was never affected.

opendir and glob already hop through process.nextTick for this and are left
alone, since routing them would change their timing.
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator
Updated 12:25 PM PT - Aug 7th, 2026

@robobun, your commit 53f2e7a is building: #90220

Found by sweeping every upstream test absent from both main and all 47 open
pull requests, so none of these overlap work in flight. No source change is
needed for any of them.

  sequential/test-cpu-prof-default
  sequential/test-cpu-prof-dir-absolute
  sequential/test-cpu-prof-dir-and-name
  sequential/test-cpu-prof-dir-relative
  sequential/test-cpu-prof-drained
  parallel/test-disable-sigusr1
  parallel/test-whatwg-webstreams-adapters-to-writablestream

Each was run 6 times, checked for a skip marker, and tamper-checked: mutating a
mustCall count or an assertion makes every one of them exit non-zero, so none is
passing vacuously.

No-Verification-Needed: test-only diff, no runtime surface to drive
  test-pipe-stream
  test-pipe-unref
  test-queue-microtask-uncaught-asynchooks

All three bind unix or UDP sockets, so an earlier sweep that ran them under a
restricted sandbox recorded them as flaky and they were wrongly discarded. With
the sandbox off they are 6/6, verbatim. CI is unaffected by that restriction.

The two pipe tests are tamper-checked: mutating an assertion exits 1. The
queue-microtask one is not, because its only assertion runs inside a
process.on('exit') handler and a failure there does not change the exit code --
but that is equally true on the node v26.3.0 binary, so there is no
bun-versus-node differential. It still exercises async_hooks, queueMicrotask and
uncaughtException together and would catch a crash or a hang.
Upstream tests run with --expose-internals import node's internal modules
directly. Bun already has the mechanism (exposedInternals in
internal-for-testing.ts); this widens it to more of Bun's REAL modules under the
names node's tests use:

  internal/validators        -> src/js/internal/validators.ts
  internal/util/inspect      -> src/js/internal/util/inspect.js
  internal/util              -> { normalizeEncoding } from node_util_binding
  internal/errors            -> bun's internal/errors.ts (aggregateTwoErrors only)
  internal/event_target      -> native Event/CustomEvent/EventTarget + kWeakHandler
  internal/freelist, internal/fixed_queue, internal/assert/myers_diff

internal/net is recovered in the harness by probing net._normalizeArgs([]) for
the module-private normalizedArgsSymbol rather than minting a look-alike.
hasTemporal and hasLocalStorage are feature-detected in common/index.js; both
are false here.

Nothing above is a test-only construct: each name maps to the same code users
hit. Modules with no Bun counterpart were deliberately not shimmed -- the rest
of internal/errors is native C++ factory functions rather than a JS codes table,
so E/SystemError/hideStackFrames would be a second error hierarchy and the tests
would measure the shim rather than bun.

Adds test-global-customevent, test-net-normalize-args and
test-whatwg-encoding-singlebyte (57 subtests) with its fixtures, verbatim.
Found by re-sweeping the 754 upstream tests still absent from main and every
open pull request, against a build carrying this branch's callback-dispatch and
internal-module changes. Passes as-is, no source change.

6/6 with no skip marker, byte-identical to upstream, and tamper-checked: a
mutated assertion exits 1.

No-Verification-Needed: test-only diff, no runtime surface to drive
A `git add -A test/js/node/test` on an earlier commit swept in files that were
never meant to land here: a scratch file, a test previously rejected as vacuous
(test-gc-net-timeout), one that belongs to another branch and fails
`handle instanceof UDP` on Windows (test-dgram-create-socket-handle), one that
times out on every Windows shard (test-pipe-unref), and seven files that
duplicate the free-wins branch (test-cpu-prof-*, test-disable-sigusr1,
test-whatwg-webstreams-adapters-to-writablestream). All removed; each lives on
in the branch that owns it, or nowhere, as appropriate.

Also fixes CI-visible test-order dependence in the callback-throw suite: every
subtest shared one tempdir, so the symlink subtests' l3/l4/lnc entries could
become the first entry the Dir subtest's dir.read() returns. The Dir subtest now
uses its own directory.
Node exposes formatTime from internal/util/debuglog and its test imports it
from there under --expose-internals. Bun's copy was a closure inside the
ConsoleObject builtin, unreachable by name. It now lives in
internal/util/debuglog, required by createConsoleConstructor the same way that
function already requires internal/validators, and is exposed through
exposedInternals.

Output is unchanged: the algorithm is line-for-line the same and the module is
loaded lazily by the console.Console getter, so global console.timeEnd, which
is native, is untouched. Plain prototype methods replace the captured-$call
style because that is the internal-module convention, per neighboring modules.

Adds test-console-formatTime from Node v26.3.0, verbatim.
Deprecations, matching node's exact text and once-per-process semantics:
_tls_common and _tls_wrap emit DEP0192 (the latter previously aliased straight
to node:tls with nowhere to warn, so it is now node's re-export shim, and plain
require("tls")/require("https") stay silent). --pending-deprecation and
NODE_PENDING_DEPRECATION gate DEP0111 on process.binding() and DEP0119 on
binding("uv").errname. Bun's own builtins call process.binding() where node's
internals use internalBinding, so internal callers neither warn nor consume the
once-latch; a later user call still warns exactly once.

cpu-prof: --cpu-prof-name/-dir/-interval without --cpu-prof exit 9 with node's
message instead of warning and continuing, with an interval equal to the
default treated as a noop as node does. ${pid} is substituted in
--cpu-prof-name. The default filename is node's
CPU.<yyyymmdd>.<hhmmss>.<pid>.<tid>.<seq>.cpuprofile in local time; the old name
used epoch microseconds, and the first cut of this exposed that
bun_core::Timespec is not wall-clock (it produced 1970), so the timestamp comes
from SystemTime with localtime_r, or GetLocalTime on Windows. A self-directed
fatal signal via process.kill with no JS listener flushes the profile before
dying, as node's Kill binding does; the process still dies by the real signal.

Not converted, deliberately: DEP0169 on url.parse was removed on purpose in
PR #16641, so re-adding it would reverse a product decision; and SIGPROF is not
reserved by JSC, so node's "reserved while debugging" warning would claim a
behavior bun does not have.

Adds six upstream Node v26.3.0 tests, verbatim.
…G_SNAPSHOT

getHeapStatistics gains total_allocated_bytes so the key set matches node's
exactly. getHeapSpaceStatistics and getHeapCodeStatistics report JSC's real
totals in node's shape instead of throwing: JSC has one undivided heap, so its
used and capacity numbers appear under old_space and the other twelve V8 space
names carry zeros; the names are required by shape-dependent consumers, the
numbers are real. getCppHeapStatistics validates its argument with node's
message and returns the empty statistics node itself reports when cppgc holds
nothing.

setFlagsFromString validates and records the flags instead of throwing, and
cachedDataVersionTag derives a stable uint32 from the runtime version plus the
recorded flags, so the tag changes across a setFlagsFromString call as node's
does. startupSnapshot callbacks throw node's ERR_NOT_BUILDING_SNAPSHOT.

writeHeapSnapshot path handling is deliberately untouched: PR #34084 already
fixes it. queryObjects is not implemented; it needs real heap iteration by
prototype chain, which JSC does not expose.

Adds test-v8-stats, test-v8-version-tag, test-v8-startup-snapshot-api and
test-cppheap-stats from Node v26.3.0, verbatim.
--heap-prof-name/-dir/-interval without --heap-prof now exit 9 with node's
"<argv0>: <flag> must be used with --heap-prof" instead of warning and
continuing; --heap-prof-interval was previously not registered at all, so
`bun --heap-prof-interval 128 x.js` parsed 128 as the script. An interval
equal to node's default (512 KiB) is a noop without --heap-prof, like node.

--heap-prof now writes node's V8 sampling-heap-profile JSON
({"head": {...}, "samples": [...]}) under node's filename format
Heap.<yyyymmdd>.<hhmmss>.<pid>.<tid>.<seq>.heapprofile in local time (the old
name also hit the same non-wall-clock Timespec bug the CPU side fixed).
JavaScriptCore has no allocation-site sampler, so the profile reports the
real live-heap size on the (root) frame with no per-function attribution;
the full .heapsnapshot remains available via v8.writeHeapSnapshot() and
Bun.generateHeapSnapshot("v8"). The markdown format (--heap-prof-md) is
unchanged. Heap profiles also flush on a self-directed fatal signal via
process.kill, sharing the CPU profiler's hook.

Writing to an absolute --heap-prof-dir or --heap-prof-name (and an absolute
--cpu-prof-name) previously hit a debug assertion in AutoAbsPath::append;
both writers now use resolve-style join.

Adds two upstream Node v26.3.0 tests, verbatim. The other nine
test-heap-prof-* files assert a runAllocation call frame inside the profile
tree (or per-worker profiles), which requires allocation-stack sampling JSC
does not provide, so they are not vendored.
autofix-ci Bot and others added 5 commits July 20, 2026 19:25
The vendored harness now parses node's `// Env: A=1 B=2` comment convention:
parseTestMetadata mirrors upstream, and the test re-spawns itself with the
merged environment when any variable differs, as node's common does. Two gaps
that respawning exposed are fixed with it: the --expose-internals interceptor
now installs when the flag arrives via execArgv in a re-spawned child, and the
internal/options shim resolves --test-isolation from execArgv.

node:url exports URLPattern, which node v26 re-exports from the global.

pathToFileURL is ported from node's own JS implementation: the windows option,
UNC and extended-UNC edges, node's pre-encode character set, and posix
backslashes kept as %5C, which bun dropped. fileURLToPath attaches err.input,
the parsed URL object, on ERR_INVALID_FILE_URL_PATH. The Bun.pathToFileURL and
Bun.fileURLToPath globals are untouched.

The exposed internal/util/inspect.getStringWidth now implements node's
per-code-point east-asian-width-first algorithm, so a family emoji measures 8
and a skin-tone emoji 4 as in node. Bun.stringWidth and bun's own console
layout are deliberately unchanged; bun measures grapheme clusters and that is a
product decision, not a test's to overturn.

Adds seven upstream Node v26.3.0 tests, verbatim: the three Env-comment tests,
test-urlpattern, test-icu-stringwidth, and the two file-URL conversion tests.
A getcwd failure at startup aborted every entry point with a generic
ENOENT. Node falls back to the executable's directory
(Environment::GetCwd in env.cc) and lets process.cwd() surface the
error later.

- add bun_core::getcwd_or_exe_dir and use it for the synthetic entry
  paths (-e/-p, stdin, cron, node-emulation eval and relative scripts,
  feedback) and for the parsed startup cwd of runtime commands only;
  install/test/build keep the hard error so they never act on a tree
  found above the executable
- an absolute --cwd no longer needs a live cwd; a relative one still
  errors; the stored cwd is the post-chdir physical path so
  process.cwd(), path.resolve, and the resolver agree
- route a bare --interactive to the REPL like node's --interactive

Enables test-cwd-enoent{,-preload,-repl}.js (verbatim from Node
v26.3.0).
process.cwd() returned the resolver's cached top_level_dir, so it kept
returning a stale path after the directory was rmdir'ed. Node re-runs
uv_cwd on every cache miss and clears its cache on chdir
(lib/internal/bootstrap/switches/does_own_process_state.js).

- Bun__Process__getCwd now does a real getcwd and on failure throws
  Node's uv_cwd UVException (message, code, errno, syscall verified
  byte-identical against node v26.3.0)
- Process_functionChdir clears the cached cwd instead of repopulating
  it, so the next process.cwd() re-queries the OS
- WriteStream's internal $fastPath no longer path.resolve()s its
  discarded path, so child_process spawn keeps working from a deleted
  cwd (its stdin wiring constructs WriteStream(""))
- drop the now-unused node::path get_cwd alias

The hot path is unchanged: the cached JSString still serves repeated
process.cwd() calls; only the first call after startup or chdir pays
the syscall.

Enables test-cwd-enoent-improved-message.js (verbatim from Node
v26.3.0).
@cirospaciari cirospaciari changed the title node:fs, node:dns, node:crypto: route a throw from a callback to uncaughtException node: callback-throw dispatch to uncaughtException, DEP0192/0111/0119 warnings, cpu-prof CLI parity, internal shims, url fixes (+20 tests) Jul 20, 2026
cirospaciari and others added 7 commits July 20, 2026 13:55
A failed require or import threw ResolveMessage with bun's own message and no
code, so the ubiquitous userland pattern `err.code === 'MODULE_NOT_FOUND'`
never matched. ResolveMessage now extends Error via a new prototypeBase class
option in codegen -- the direction NodeUtilTypesModule.cpp's FIXME already
sanctions -- and its message getter returns node's exact text for runtime
import kinds: MODULE_NOT_FOUND with a Require stack and a requireStack array
for CJS, ERR_MODULE_NOT_FOUND with the specifier truncated to the package name
for bare ESM imports. The class itself is kept: Bun.build's log API documents
name and constructor as ResolveMessage, and the getter is prefix-gated so
invalid-URL, data-URL and ENAMETOOLONG texts are untouched, as is the CLI
stderr display text.

require.resolve now validates options.paths with node's exact
ERR_INVALID_ARG_TYPE and ERR_INVALID_ARG_VALUE messages.

Nine assertions across three bun-owned test files are updated to the node
shapes; each is listed in the pull request for sign-off.

Adds test-require-resolve-invalid-paths from Node v26.3.0, verbatim.
…stack/callback-throw-dispatch

# Conflicts:
#	src/js/node/dns.ts
springmin pushed a commit to springmin/bun that referenced this pull request Aug 7, 2026
…p-equality parity, Intl gate + URL/buffer fallout, compile cache, watch kill-signal, profilers (+98 tests) (oven-sh#34660)

Batch of node-compat fixes that unblock each other, verified against the
node v26.3.0 binary throughout. Adds 98 vendored upstream tests
(parallel + sequential), all passing.

A throw inside a node-style callback in `node:fs`, `node:dns` or
`crypto.pbkdf2` surfaced as an unhandledRejection instead of an
uncaughtException, because those callbacks dispatch from a promise
reaction. `guardCallback` in `internal/shared.ts` routes the exception
to the same native uncaught path timers already use, applied at the
existing chokepoints (`ensureCallback` in fs, `validateResolve` in dns,
pbkdf2). Happy-path timing is byte-identical to base, verified by
diffing a full event-loop ordering trace. The throw reaches
`uncaughtException` synchronously from the callback's own frame,
matching node's measured semantics.

Node v26's `Assert` class (per-instance `diff`/`strict`/`skipPrototype`,
`ERR_CONSTRUCT_CALL_REQUIRED`). Deep equality is confined by API
ownership: the node entry points (`assert` strict variants,
`util.isDeepStrictEqual`) compare prototypes and own-enumerable
properties like node, while `Bun.deepEquals` and every `expect()`
instantiation are verified behavior-identical to before.
`assert.partialDeepStrictEqual`'s comparison engine is implemented
natively in C++ (object/array/Map/Set subset semantics,
Date/RegExp/boxed-primitive/DataView arms, cycle guards), pinned by a
cumulative matrix run byte-identical against node.

`process.config.variables` spelled the key `v8_enable_i8n_support`, so
every vendored test gated on `common.hasIntl` silently skipped its Intl
half. This lands the typo fix with the fallout: `buffer.transcode`
(simdutf fast paths), node's host-parse semantics for
`domainToASCII/Unicode` including the Unicode 16 IDNA delta node ships
via ada (Bun's ICU is older; the delta is applied at the node:url entry
points, cited in `NodeURL.cpp`), `url.format`/`url.parse` host rules,
TextDecoder fatal messages, `util.inspect` URL formatting.

Two user-visible changes called out for review: `new URL(bad)` now
throws node's exact `TypeError: Invalid URL` with `code`/`input`, and
invalid-punycode `xn--` hosts are rejected for special schemes (matching
node, Chrome, Firefox, and WPT).

- `module.enableCompileCache()` / `NODE_COMPILE_CACHE`: bytecode cache
with atomic persist at exit (including the self-kill signal path),
persist pass runs unlocked so Worker module loads never stall.
- `--watch-kill-signal`: watch reloads deliver the configured signal to
JS listeners before execve, on POSIX and Windows (real Windows signal
numbers, not Linux discriminants).
- `--cpu-prof` and `--heap-prof` write real V8-format profiles/snapshots
with node's shared filename sequence.
- async_hooks: AsyncResource lifecycle + tick/timer hooks behind the
existing enable-gates (promise hooks need a WebKit-fork patch and are
explicitly out of scope).
- `bun -pe` alias, internal-module shims for vendored tests
(`internal/*` interceptor merges native implementations over the
vendored ports), `require.resolve` builtin handling and paths validation
moved native, `isURL` and `stringWidth(perCodePoint)` native.

Interleaved same-commit sweeps with zero regressions across
fs/dns/domain/pbkdf2 (356 vendored files), assert (361), url, util,
expect (510), plus the bun-side suites for each subsystem. Deleted along
the way rather than shipped red:
`test-child-process-advanced-serialization.js` (passes vacuously on main
via the runner's swallowed-rejection bug oven-sh#34859; the real gap is oven-sh#34860)
and `test-whatwg-url-toascii.js` (WHATWG URL constructor goes through
WebKit + system ICU, so distro data decides; returns with the
ICU-bundling decision).

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 48 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/heap-prof.test.ts test/cli/watch/watch.test.ts
test/js/bun/resolve/resolve-error.test.ts
test/js/bun/spawn/spawn.ipc.test.ts test/js/node/fs/fs.test.ts
test/js/node/module/node-module-module.test.js
test/js/node/process/process.test.js

<!-- robobun:evidence:end -->

passing)

- module.enableCompileCache: 5% → 64% (1 → 14 of 22)
- --cpu-prof: 9% → 73% (1 → 8 of 11)
- --heap-prof: 0% → 18% (0 → 2 of 11)
- watch mode: 0% → 30% (0 → 3 of 10)
- node:url: 80% → 100% (12 → 15 of 15)
- node:util: 67% → 70% (20 → 21 of 30)
- node:fs: 98% → 99% (340 → 342 of 346)
- node:assert: 14 → 16 vendored (upstream parallel/sequential set: 13;
bun's set carries one file from an earlier upstream)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
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.

3 participants