node:diagnostics_channel: sync with Node 26 + subsystem channels - #32628
node:diagnostics_channel: sync with Node 26 + subsystem channels#32628cirospaciari wants to merge 59 commits into
Conversation
Port lib/diagnostics_channel.js from Node v26.3.0: adds boundedChannel()/ BoundedChannel, Channel.prototype.withStoreScope (store scopes built on DisposableStack), copy-on-write subscriber lists so unsubscribing during publish doesn't skip later subscribers, TracingChannel rebuilt on BoundedChannel with hasSubscribers, no-subscriber early exit, non-thenable warnings and custom-thenable passthrough, and a WeakReference that holds the channel strongly while it has active subscriptions so subscriptions survive GC. Add AsyncLocalStorage.prototype.withScope (RunScope), used by the new store-scope API. The scope updates the async context directly and restores the previous store on dispose instead of going through enterWith, so it does not arm the end-of-tick context cleanup. Publish the diagnostics channels Node provides in the corresponding subsystems: - worker_threads: 'worker_threads' on Worker construction - child_process: 'child_process' on ChildProcess construction and the 'child_process.spawn' tracing channel around spawning - net: 'net.client.socket', 'net.server.socket' and the 'net.server.listen' tracing channel - http server: 'http.server.request.start', 'http.server.response.created', 'http.server.response.finish' Sync all 67 test-diagnostics-channel-* tests from Node v26.3.0 (32 new, 8 updated). 59 pass; the remaining 7 newly-added tests depend on features Bun does not implement yet (module loader channels, Web Locks, v8.queryObjects, net.Socket-backed http server sockets) and are listed in test/expectations.txt with reasons.
|
Updated 7:11 PM PT - Aug 7th, 2026
✅ @robobun, your commit 416dd52d965e5ecc0e30984af73ad9bfe39168ca passed in 🧪 To try this PR locally: bunx bun-pr 32628That installs a local version of the PR into your bun-32628 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
Wrap the CommonJS require path in the "module.require" tracing channel the
way Node's wrapModuleLoad does: start/end/error events carry
{ parentFilename, id } (and result on success).
To keep require() free for processes that never use diagnostics_channel,
the channel lives in a small internal holder module
(internal/require_tracing) that node:diagnostics_channel fills in when it
is first loaded. overridableRequire only checks that holder; when nobody
subscribed it tail-calls the real implementation, which moved to a new
overridableRequireImpl builtin (registered as a lazy global builtin getter
like requireESM, so its source is still not parsed during global object
construction).
Enables test-diagnostics-channel-module-require(-error).js; their
expectations entries are removed.
Add v8.queryObjects(ctor[, options]) backed by a JSC heap scan: after a full synchronous GC, every live object whose prototype chain contains ctor.prototype is collected (walking prototypes structurally so no proxy traps or getters run during heap iteration). The JS wrapper mirrors Node's: validates the constructor and options, supports the 'count' and 'summary' formats, and emits the same one-time ExperimentalWarning. Vendors Node v26.3.0's test-v8-query-objects.js, which passes verbatim, and removes the expectations entry for test-diagnostics-channel-memory-leak.js, which now passes too.
Wrap dynamic import() in the "module.import" tracing channel like Node's
ESM loader: start/end fire synchronously around starting the import and
asyncStart/asyncEnd/error fire when the promise settles, with
{ parentURL, url } context.
The channel handling lives in JS: internal/require_tracing becomes
internal/module_tracing and now holds both the module.require and
module.import channels (installed by node:diagnostics_channel when it
loads) plus a traceImport() helper that runs the import through
TracingChannel.tracePromise. moduleLoaderImportModule moves its body into
a static helper and only diverts through traceImport when
node:diagnostics_channel has been loaded and the channel has subscribers,
so dynamic import is unchanged for everything else.
test-diagnostics-channel-module-import-error.js passes and its
expectations entry is removed. test-diagnostics-channel-module-import.js
still has an entry: the transpiler rewrites the statically-analyzable
import("http") literal to "node:http" before the loader sees it, so the
published url differs from Node's for that one case.
The tracing branch in moduleLoaderImportModule used a block-scoped ThrowScope; destroying it unreleased simulates a throw, so falling through to the real import (which declares its own scope) tripped JSC's exception check validation and aborted under BUN_JSC_validateExceptionChecks (the ASAN CI job). Declare the scope at function level and release it through RELEASE_AND_RETURN for the tail call instead, and drop the redundant check after toBoolean(), which cannot throw.
|
CI note: in build #64127 (the exception-scope fix commit), |
Create net.client.socket / net.server.socket / net.server.listen at module load like _http_client.ts (and Node's net.js) instead of lazily inside Socket.prototype.connect / listen / onconnection. This drops the per-call lazy-init branch and keeps diagnostics_channel's module evaluation out of the first connect() call, where the extra allocation work sat right in the window that GC-sensitive tests like test-net-connect-memleak.js measure.
|
Follow-up on the Alpine Digging into history: the test does not appear in the Alpine x64 shard logs of the last five main builds — the vendored-test sharding never places it on Alpine there. This PR adds 34 vendored test files, which shifts the shard boundaries so the test now runs on Alpine, where its single-
|
The vendored test asserts the implicit 'connect' once-listener closure is collected after a single gc(). On the Alpine (musl) CI runners that collection is intermittently not observed within one cycle, so the assertion fails; nothing holds the closure structurally and the test passes on glibc Linux, macOS, Windows and FreeBSD, where it keeps running. The test only started landing on the Alpine shards because this branch adds test files and shifts the shard boundaries. Scoped with the existing MUSL ABI modifier; tracked in #20627.
|
Resolution for the Alpine flake: e6ec58c adds a |
test_function and test_instance_data fail on the release-ASAN CI runners (the spawned bun exits 1 with no output, 4/4 in-job attempts) while passing on every other platform and on a local ASAN debug build. They only started running on the ASAN shards because the growing test list shifted the shard boundaries. Scoped to the existing ASAN modifier; tracked in #32642.
|
Two node-api suites (test_function, test_instance_data) turned out to fail consistently on the release-ASAN runners once the resharding placed them there — same exposure pattern as the Alpine memleak test, no involvement of this PR's code, and they pass locally on an ASAN debug build. Filed #32642 and quarantined them with |
…vative GC
Both tests create an object with a napi finalizer, null the JS reference,
call global.gc() once, and expect common.mustCall() to observe the
finalizer before process exit. JSC's GC conservatively scans the native
stack, so when require()'s call depth changes (as this PR's
overridableRequire wrapper does) a stale pointer to the just-created
object can survive in an unscrubbed stack slot on the release-ASAN
layout, the object is not collected by a single gc() and the mustCall
check fails at exit with 'Expected exactly 1, actual 0' on stdout.
Move the object creation into an IIFE so the creating frame is gone
before gc() runs, and retry gc() a bounded number of times. This mirrors
what test_finalizer/test.js in the same directory already does for the
same reason ('to be compatible with non-V8 JS engines').
Also capture the spawned test's stdout in the node-napi harness and
print it when the test fails, so the mustCall diagnostic is visible in
CI instead of presenting as a bare 'exit code 1'.
Removes the [ ASAN ] expectations entries for these suites; both pass
5/5 on a local release-ASAN build with the full CI environment.
Closes #32642
|
Pushed ee6a68d to address #32642: the two quarantined node-api suites weren't a pre-existing ASAN issue, they were tripped by the extra One optional follow-up left as a note on #32642: the no-subscriber fast path in |
|
CI status: every test job in build #64232 passed (285/286). The single failed job is |
|
Third retry of the darwin-26 aarch64 test job hit the same |
|
It'd be a huge W when this is done eventually 👀 |
…annel-node26 # Conflicts: # src/js/builtins/BunBuiltinNames.h # src/js/node/child_process.ts
…response.created
Remove the three [ FAIL ] expectations entries this branch added by deleting
the vendored tests behind them instead of shipping known-failing files:
- test-diagnostics-channel-web-locks.js: needs the Web Locks API
(navigator.locks), which Bun does not implement.
- test-diagnostics-channel-http.js: asserts the socket passed to the
http.server.* channels is a net.Socket; Bun's node:http server hands out a
Duplex wrapper. Fixing that belongs to the node:http compat work.
- test-diagnostics-channel-module-import.js: asserts the published url is the
literal specifier ('http'); Bun's transpiler rewrites import("http") to
"node:http" before the loader publishes module.import.
Also drop the unrelated [ MUSL ] quarantine of test-net-connect-memleak.js.
Deleting test-diagnostics-channel-http.js left http.server.response.created
with no coverage, so add a test for it to the diagnostics_channel suite.
Add the missing exception check after constructEmptyArray in queryObjects.
…le scope Requiring node:diagnostics_channel lazily from the Worker and ChildProcess constructors meant the module was evaluated after user code had already run. "postMessageToThread survives a tampered Map prototype" clobbers Map.prototype and then constructs a Worker, so diagnostics_channel loaded onto the tampered prototype and threw out of the constructor. Node creates both channels at module scope (lib/internal/worker.js, lib/internal/child_process.js), which is also what net.ts and _http_server.ts already do here. Do the same, so the channels are built while the module graph loads rather than on first use. Also route diagnostics_channel's own Map operations through the $-prefixed intrinsics instead of the user-overridable prototype methods.
|
All five review points addressed in 078e4bb (swap CI: build #90247 passed 196/196; build #90391 is running on 416dd52. All review threads resolved. Note: #34209 (net channels) and #34212 (module tracing) are later robobun PRs that implement strict subsets of what this PR already covers; once this lands those can be closed. |
…ms, bool gate for import tracing
- overridableRequire is back to the direct implementation (no per-call
wrapper); when tracing:module.require:* gains subscribers,
internal/module_tracing swaps Module.prototype.require to a tracing
wrapper via the existing setter, and swaps back when the count returns
to zero. Hooked via a new Bun-internal _onSubscribersChanged callback on
Channel.
- moduleLoaderImportModule gates the tracing path on a new
hasModuleImportSubscribers bool on Zig::GlobalObject (set from JS via
jsSetHasModuleImportSubscribers), and the tracing body is extracted to
tryTraceModuleImport().
- net.ts / _http_server.ts / child_process.ts / worker_threads.ts no
longer require("node:diagnostics_channel") at module scope; channels are
created lazily on first use (first connect/listen, first ChildProcess,
first Worker, first non-upgrade request), matching the pattern dgram.ts
already uses.
- baseRequire is now captured once (??=) so a user wrapper installed between subscribe cycles that delegates to tracingRequire cannot recurse. - The 15s-timeout comment now names http2/_http_client (which still load dc at module scope on main) instead of net/_http_server (now lazy).
In Node http.Server inherits listen() from net.Server and so publishes on the net.server.listen tracing channel. Bun's http.Server is Bun.serve-backed with its own listen()/[kRealListen], so it now mirrors asyncStart/asyncEnd/error at the same lifecycle points net.ts uses. https.Server shares the prototype so it is covered too.
typeof null is "object", so the object branch read null.start and threw a bare TypeError. With the null guard the constructor falls through to channelFromMap, which throws the typed error, matching boundedChannel(null).
With ||, falsy-but-present formats ("", 0, false) silently defaulted to
"count" instead of reaching the format validation and throwing
ERR_INVALID_ARG_VALUE.
This reverts commit 4eb58ac.
v8.ts: union — keep this branch's queryObjects alongside main's landed serialize/deserialize and snapshot stubs. No-Verification-Needed: keep-both merge resolution of independent additions; CI verifies the merged tree
…guments) The no-subscriber path allocated an arguments object on every require(); $overridableRequire reads exactly (id, options) and handles an undefined options identically, so $call with the named params is equivalent.
…y, self-include - require(id) forwards 1 arg so the native $require keeps its skip-options gate (argumentCount distinguishes the arities again). - Drop the test-net-connect-memleak.js expectations entry; the file was removed on main and the entry matched nothing. - NodeDiagnosticsChannel.cpp includes its own header like NodeV8Module.cpp, so declaration and definition compile in one TU.
connectionListenerHTTP1 (emit('connection', foreignSocket) and http2's
allowHTTP1 ALPN fallback) constructs req/res and emits 'request' without
publishing request.start/response.created/response.finish; in Node both
entry paths converge on parserOnIncoming which publishes all three.
dc.channel() returns the per-name singleton so these are the same channel
objects _http_server.ts uses. Accepted upgrades still return early before
any publish, matching the native dispatch path.
|
Closed #27881 ( |
Summary
lib/diagnostics_channel.jsfrom Node v26.3.0:boundedChannel()/BoundedChannelexports,Channel.prototype.withStoreScope, copy-on-write subscriber lists (sync unsubscribe during publish no longer skips subscribers),TracingChannelrebuilt onBoundedChannel(hasSubscribers, early exit when unsubscribed, non-thenable warning, custom-thenable passthrough), and aWeakReferencethat keeps a channel alive while it has active subscriptions so they survive GC.worker_threads(Worker construction)child_process+child_process.spawntracing channel (spawn success/ENOENT/EACCES paths)net.client.socket,net.server.socket,net.server.listentracing channelhttp.server.request.start,http.server.response.created,http.server.response.finishtest-diagnostics-channel-*files from Node v26.3.0 (29 new, 8 updated to current upstream content). Every vendored file added or modified here is byte-identical to Node v26.3.0 upstream.Also implemented, because vendored tests in this sync depend on them:
module.requiretracing channel around CommonJSrequire()(test-diagnostics-channel-module-require(-error)pass)module.importtracing channel around dynamicimport()(test-diagnostics-channel-module-import-errorpasses)v8.queryObjects(), required bycommon/gc.js'scheckIfCollectableByCounting(Node'stest-v8-query-objects.jsis vendored and passes;test-diagnostics-channel-memory-leakpasses)cleanupAsyncHooksDatanow drains the nextTick queue when it exists:als.enterWith(x); process.nextTick(cb)previously droppedcbon an otherwise-idle tick, which with async_hooks,events,http,http2,perf_hooks: port Node.js async compatibility tests and fix the gaps they surface — ALS run/disable + withScope/defaultValue, http client ALS across reused agent sockets, http2 ALS context, AsyncResource.bind, EventEmitterAsyncResource, timerify (+22 tests) #31825'senterWith-basedRunScopeon main broketest-diagnostics-channel-bind-store.js(its transformer-error path schedulesuncaughtExceptionviaprocess.nextTick). Covered by a new test intest/js/node/async_hooks/AsyncLocalStorage.test.ts.Rather than vendor known-failing tests behind
[ FAIL ]entries, the three upstream tests Bun cannot pass are not vendored at all. They are known gaps, to add when the blocking feature lands:test-diagnostics-channel-web-locksnavigator.locks) is not implementedtest-diagnostics-channel-module-importurlis the literal specifier ('http'); Bun's transpiler rewritesimport("http")to"node:http"before the loader publishesmodule.import, so Bun publishes'node:http'. The channel itself works;test-diagnostics-channel-module-import-errorcovers it.This PR also adds tests for the
http.server.response.createdchannel and for the upgrade-gating of all threehttp.server.*channels totest/js/node/diagnostics_channel/diagnostics_channel.test.ts. They fail underUSE_SYSTEM_BUN=1and pass on this build.http.server.request.startandhttp.server.response.finishstay covered by the vendoredtest-diagnostics-channel-http-server-start.js.Why the napi test changes are here
test_functionandtest_instance_datacreate an object with a napi finalizer, null the JS reference, callglobal.gc()once, and expect the finalizer before exit. JSC's GC conservatively scans the native stack, so this PR'soverridableRequirewrapper (which changesrequire()'s call depth) can leave a stale pointer to the object in an unscrubbed stack slot on the release-ASAN layout, and onegc()does not collect it. The tests now create the object inside an IIFE and retrygc()a bounded number of times, mirroring whattest_finalizer/test.jsin the same directory already does for the same reason. The napi harness also captures the spawned test's stdout on failure so themustCalldiagnostic is visible in CI instead of a bare exit code 1.Test plan
test-diagnostics-channel-*tests pass, run CI-style (bun-debug run --config=bunfig.node-test.toml,BUN_GARBAGE_COLLECTOR_LEVEL=1)test-async-local-storage-*,test-async-hooks-*,test-als-*andtest-asyncresource-*vendored suites pass after thecleanupAsyncHooksDatafixtest/js/node/diagnostics_channel/andtest/js/node/async_hooks/AsyncLocalStorage.test.tspass; the newhttp.server.response.createdtest and theenterWith+process.nextTicktest fail underUSE_SYSTEM_BUN=1test/js/node/test/parallel/diffs clean againstraw.githubusercontent.com/nodejs/node/v26.3.0Linked issues
Fixes #32472
Fixes #29586
Fixes #27805
Notes:
node:child_processAPI (spawn/fork/exec/execFile) publishes these channels, matching Node.Bun.spawnitself intentionally does not publish them. Overlaps with open PRs child_process: publish diagnostics_channel events for node:child_process spawn/fork/exec/execFile #30080 (child_process channels), http: publish to http.server.* diagnostics_channel channels #29588 (http.server.* channels) and fix: add hasSubscribers property to TracingChannel #27881 (TracingChannel.hasSubscribers), which each implement a slice of the same surface; this PR covers those slices as part of the full Node 26 sync.tracingChannel().hasSubscribersnow exists (covered bytest-diagnostics-channel-tracing-channel-has-subscribers.js).[review] gate passed · iteration 33 · 60 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 35 passed · 2 rejected · iteration 33
evidence per changed file