node:worker_threads: per-thread --use-system-ca, real eventLoopUtilization, --cpu-prof in workers, node's online timing, error.code / stack-getter / timeOrigin fixes, async_hooks WORKER resource, worker_threads dc channel (+10 upstream tests) - #34424
Conversation
Ports five Node.js v26.3.0 test/parallel worker tests that are absent from the suite and pass against current main, byte-identical to upstream: test-worker-dns-terminate.js terminate() with an in-flight dns.lookup test-worker-http2-stream-terminate.js terminate() with in-flight http2 streams test-worker-memory.js RSS does not grow across worker churn test-worker-unsupported-eval-on-url.mjs eval:true rejected for a URL filename test-worker-cleanup-handles.js handles are cleaned up on worker exit Verified on a debug build of main (3/3 runs each), and again with BUN_JSC_validateExceptionChecks=1 to match the asan shard. No source changes: these cover behaviour Bun already implements, so they guard against regressions rather than fix anything. Note test-worker-unsupported-eval-on-url.mjs fails on 1.3.14 and passes on main - the ERR_INVALID_ARG_VALUE message now matches Node exactly. No-Verification-Needed: test-only change, no runtime surface to drive
|
Updated 7:18 PM PT - Aug 10th, 2026
✅ @robobun, your commit 7e37ca010811868f0863e96593ade8184b020264 passed in 🧪 To try this PR locally: bunx bun-pr 34424That installs a local version of the PR into your bun-34424 --bun |
…dy pass parallel/test-worker-dispose.mjs await using / Symbol.asyncDispose sequential/test-worker-fshandles-error-on-termination.js terminate() with open FileHandles sequential/test-worker-fshandles-open-close-on-termination.js These are the first test-worker-* files under test/js/node/test/sequential/; discovery is glob-based so no manifest change is needed, and the runner already sets BUN_FEATURE_FLAG_NO_ORPHANS=1 for that directory. Verified 5/5 each under the runner's own env (bun run --config bunfig.node-test.toml, NO_ORPHANS, BUN_JSC_validateExceptionChecks=1) and on a release binary. Both fshandles tests are self-contained: no ports, no chdir, no shared files, so they cannot interfere with other sequential tests. Slowest is ~4.5s on a debug build against a 20s budget. No-Verification-Needed: test-only change, no runtime surface to drive
… from LeakSan The x64-asan shard surfaced two pre-existing bugs in these new tests: test-worker-dns-terminate.js hits a heap-use-after-free (READ of size 4, thread T6) when a worker is terminated with a dns.lookup in flight: GlobalData::drop tears the Resolver down by value while in-flight DNSLookups still hold an IntrusiveRc, so the later deref reads the freed RefCount (Cell<u32>). Dropping the test - this needs a real fix in the DNS teardown, and it cannot be suppressed: worker VMs are destroyed on exit regardless of BUN_DESTRUCT_VM_ON_EXIT (VirtualMachine.rs), and ASAN_OPTIONS cannot hide a UAF. Linux-only; macOS uses lib_info rather than lib_c. test-worker-fshandles-open-close-on-termination.js is a genuine leak (ConcurrentTask boxed for a VM being torn down) and is delisted from LeakSanitizer, which is what no-validate-leaksan.txt actually controls. No-Verification-Needed: test-only change, no runtime surface to drive
The asan shard flagged the sibling too, on a different allocation site: an in-flight AsyncFSTask<Open> rather than JSC deferred work. Same class - terminating a worker with work still in flight leaks the ConcurrentTask boxed for it, because the VM is torn down before the task runs. Both tests pass; only the exit-time leak check fails.
…g them Reverts the no-validate-leaksan.txt entries and removes both sequential/test-worker-fshandles-*-on-termination.js. Needing a LeakSanitizer exemption means the test is not passing: terminating a worker with work still in flight leaks the ConcurrentTask boxed for it (an AsyncFSTask<Open>, or JSC deferred work via DeferredWorkTimer) because the VM is torn down before the task runs. That is a real leak and should be fixed in the teardown rather than hidden, so these two tests stay out until it is. What remains is 5 parallel tests that pass with no exemptions.
Node publishes the newly-constructed Worker on the 'worker_threads'
diagnostics channel at the end of the Worker constructor
(lib/internal/worker.js). Bun never did, so dc.subscribe('worker_threads')
was silently dead.
Resolve the channel at module load rather than lazily in the constructor:
diagnostics_channel keys its registry off a Map, so a lazy require would
build the channel out of whatever user code had tampered with by then
(this broke worker_threads.test.ts's tampered-Map-prototype test), and a
module-load strong ref also pins the channel against its WeakRefMap
registry so subscribe() cannot race a GC. http2.ts and _http_client.ts
already require diagnostics_channel at module scope.
Verified against the node v26.3.0 binary: identical payload ({ worker }),
no publish without subscribers, no fire when subscribing after
construction, one publish per Worker in order, unsubscribe stops it, and
a nested worker publishes on its own thread's channel. All 36 node
test-diagnostics-channel-*.js pass; vendored test-worker* is 105/2 vs
104/3 before.
…d timeOrigin Three independent Node compat fixes, each with its upstream v26.3.0 test. All three verified against the node v26.3.0 binary. async_hooks WORKER init (test-worker-hasref.js) Bun delivered `init` for TickObject only, so a hook watching for WORKER resources never fired. Emit one from the Worker constructor into the same tickInitHooks array, exposing hasRef(): it follows ref()/unref() and reads back undefined once the thread has exited, as node's handle does. ref() and unref() no-op after exit rather than resurrecting it (lib/internal/worker.js nulls kHandle before emitting 'exit'). A throwing init hook is fatal, as in node, mirroring the TickObject site in ProcessObjectInternals. createHook also invoked `init` bare, so `this` was undefined inside it. Node calls init as a method on the AsyncHook instance (lib/internal/async_hooks.js), which is why `this.disable()` inside init works there and threw here. Worker error clone (test-worker-error-stack-getter-throws.js) Cloning an Error reads `stack`, so a throwing Error.prepareStackTrace took the whole clone down and the error surfaced as Bun's pretty-printed text. Node drops just the unreadable stack (lib/internal/error_serdes.js TryGetAllProperties). Retry once with an own undefined stack, only on the failure path, only for an ErrorInstance, using setStackPropertyAlreadyMaterialized so the retry does not re-run the getter. performance.timeOrigin (test-perf-hooks-worker-timeorigin.js) Each worker VM called Instant::now(), so a worker's timeOrigin drifted from the main thread's by the spawn delay (~265ms with a forced gap; node: 0.000). timeOrigin is the process start and every thread reports the same one, so capture it once. This also makes Bun.nanoseconds() match its own documented "nanoseconds since the process started". Vendored test-worker* goes 105/2 -> 113/3 (the 3 are pre-existing and unrelated); bun's worker_threads suite stays at its 2 baseline failures; async_hooks and perf_hooks unchanged.
Node's BroadcastChannel#ref() returns `this` so it chains (lib/internal/worker/io.js); Bun's returned undefined while unref() already returned the channel, so the two disagreed in the same file. This is the other half of #19810, which fixed the identical return-value bug in unrefBody and left ref() behind. jsRef()/jsUnref() are mirrors — both void, both guarding m_hasRef around an event-loop ref count — so refBody now matches unrefBody exactly. Verified against node v26.3.0: `bc.ref() === bc` and `bc.unref() === bc` on both, and util.inspect(bc.ref()) is byte-identical. test/js/web/broadcastchannel 16/0.
|
@robobun adopt |
|
@robobun fix conflicts |
|
Head is 7e37ca0. One self-inflicted detour since the last update: my 09f3e89 (initializing the bake production VM as the main thread, for a review nit about the inherited CA flag) broke bun build --app, caught by build 91809 on asan, because a main-thread init takes the process's single initial context id and a production build creates more globals; 7e37ca0 restores bake's init shape, passes the flag explicitly instead, and documents why at the site. Verified on the bake suites and the CA file locally. Everything else in 91809 was retry-passed flake. Otherwise unchanged: root-store rules are node's end to end, the ELU test arms its timer inside the loop, the open-event order is pinned, clippy is clean, and the by-design gaps are listed in Ciro's comments. All review threads resolved. CI running for 7e37ca0; ready for maintainer sign-off. |
Node reports 'online' once the worker thread has bootstrapped, before user
code (lib/internal/worker.js). Bun posted it only after the entry-point
promise settled, so a worker whose top-level never returns never reported
online at all:
new Worker('while(true);', { eval: true })
node: ONLINE fired | terminate() -> 1
bun : no online, ever
dispatchOnline did two separable things: the Pending->Running flip under
m_pendingTasksMutex, which also gates message routing, and a postTaskToParent
of the open event. Only the second belongs before the entry point, so split
them: dispatchOnlineEvent() posts the event and is called ahead of the load;
dispatchOnline() keeps the state flip exactly where it was, leaving message
routing and fireEarlyMessages untouched. Moving both regresses the suite.
Removes a comment claiming the flip must precede the post or a parent 'online'
handler calling getHeapSnapshot() would see ERR_WORKER_NOT_RUNNING. It cannot:
postTaskToWorkerGlobalScope queues on Pending and returns true, rejecting only
for Closing/Closed, as JSWorker.cpp already documents. Verified by driving it —
getHeapSnapshot() from inside the online handler resolves.
Also fixes online-before-error: a worker whose entry throws now reports
["online","error"] like node, where it previously reported only ["error"].
Verified against node v26.3.0: spinning worker goes online and terminate()
resolves to 1; online fires exactly once; ordering vs a worker message and vs a
throwing entry both match. Failure set of the worker suite is unchanged
(strict subset of baseline); the new test fails 0/3 without the fix, 3/3 with.
Known gap left: a worker with an unresolvable specifier still skips 'online'
(node fires it) — the event goes out after entry resolution.
|
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:
WalkthroughChangesWorker runtime compatibility
BroadcastChannel reference chaining
Node CLI short aliases
Worker CPU profiling
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
@robobun heads-up — I resolved the conflicts locally, so you can drop your run to avoid a race. Your resolution started from What conflicted and how I resolved it, in case it's useful: only I verified the merge is a pure addition on top of main — Also: #34338 fixed the |
Only src/js/node/worker_threads.ts conflicted, in three hunks where #34338 ("don't hang when captured stdout/stderr is never consumed") and this branch touch the same lines. #34338 removed the #stdoutAutoPipe/#stderrAutoPipe fields and moved the stdio port ref/unref out of ref()/unref() — ports now manage their own ref via makePortReadable's incrementsPortRef. This branch only added #hasRef bookkeeping there, so main's structure is taken wholesale and only the two `if (!this.#exited) this.#hasRef = ...` lines and the field are kept. async_hooks.ts (#31825) and VirtualMachine.rs (#34293, #32498) auto-merged. `git diff origin/main -- src/js/node/worker_threads.ts` is a pure addition: zero deleted lines, so nothing from #34338 or #31825 is reverted. Verified on the merge result: test-worker-hasref, test-worker-error-stack- getter-throws, test-perf-hooks-worker-timeorigin, test-diagnostics-channel- worker-threads and the new "online fires before the entry point finishes" all pass; #34338's own repro still exits 0 like node; BroadcastChannel ref()/unref() and the 'online' timing fix both still match node v26.3.0.
- drop test-worker-memory.js: RSS ratio assertion fails on macOS aarch64
(builds 74530 and 74665), same bar as the fshandles tests
- emit the WORKER async_hooks init before the diagnostics_channel publish
so the observable order matches node (AsyncWrap fires mid-constructor)
- hasRef() starts false when Bun's { ref: false } option is passed
- update the stale async_hooks_tick header comment (WORKER now flows there)
- test that BroadcastChannel ref()/unref() return the channel
There was a problem hiding this comment.
All five earlier findings are addressed in d7e1659 (test-worker-memory dropped, init/dc order swapped, #hasRef seeded from options.ref, async_hooks_tick header updated, BroadcastChannel ref/unref return test added) and I found nothing new. Deferring to a maintainer because the dispatchOnlineEvent/dispatchOnline split changes the Worker state-machine timing across threads, and the process_origin() OnceLock makes origin_timer process-global — both are the kind of lifecycle change worth a human sign-off, and build #74680 is still running.
What was reviewed:
dispatchOnlineEventsplit: confirmed it only posts the open event; the Pending→Running flip andfireEarlyMessagesstay where they were, so message routing/queueing is unchanged.- Error-clone retry in
dispatchErrorWithValue: gated on!serialized && !scope.exception()+dynamicDowncast<ErrorInstance>, usesputDirect+setStackPropertyAlreadyMaterialized()so the retry can't re-enterprepareStackTrace;coderead below still runs. process_origin():OnceLock<(Instant, u64)>is thread-safe; first VM (main thread) initialises it before any worker VM is created.#emitAsyncHooksInit: snapshot-before-iterate matches node's tmp_array; throwing hook →process.exit(1)matches the existing TickObject site.
Extended reasoning...
Overview
Six Node-compat behaviour fixes across the worker stack plus eight vendored upstream tests and two Bun-side tests. Source changes: src/js/node/worker_threads.ts (diagnostics_channel publish, async_hooks WORKER init, #hasRef bookkeeping), src/js/node/async_hooks.ts (init hook this binding), src/js/internal/async_hooks_tick.ts (comment), src/jsc/VirtualMachine.rs (process-global origin_timer/origin_timestamp via OnceLock), src/jsc/bindings/webcore/Worker.{cpp,h} (split dispatchOnlineEvent from dispatchOnline, error-clone retry on throwing prepareStackTrace), src/jsc/web_worker.rs (call WebWorker__dispatchOnlineEvent before entry-point load), src/jsc/bindings/webcore/JSBroadcastChannel.cpp (ref() returns this).
Security risks
None identified. No auth/crypto/permissions surface. The error-clone retry mutates a worker-thread ErrorInstance whose thread is terminating; the diagnostics_channel is resolved at module load so it isn't built from tampered primordials (which the PR calls out and which the existing tampered-Map test would have caught).
Level of scrutiny
High. The dispatchOnlineEvent split touches the Worker state machine documented in Worker.h — the PR description records two rejected attempts that regressed the suite, and the final shape moves only the event post while leaving the Pending→Running flip and fireEarlyMessages in place. That reasoning checks out against the code (postTaskToWorkerGlobalScope queues on Pending, so a parent's online→getHeapSnapshot() still works), but it is a deliberate change to cross-thread lifecycle timing and a removed comment previously defended the opposite ordering. The process_origin() change makes per-VM state process-global, which is correct per Node/WHATWG but changes the meaning of origin_timer for every worker VM.
Other factors
All five of my previous inline findings were addressed in d7e1659 and the threads are resolved. The reviewer who set the bar for this PR ("we should not add any tests that dont pass here") has adopted it and resolved the merge; build #74680 on the latest commit is still in progress, so CI status on the final shape (particularly the online-timing change across all shards) isn't confirmed yet. Given the scope — C++/Rust worker lifecycle across threads — and the active maintainer engagement, this should get a human approval rather than a bot one.
`bun -pe "1+1"` printed a ReferenceError for `e`. `-p` is declared
`-p, --print <STR>` and the short parser accepts attached values, so `-pe X`
read as `-p` carrying the value `e` and evaluated that identifier. Node has the
same ambiguity and resolves it the same way: `-pe` is not a short at all, it's a
whole-token alias applied before short parsing (AddAlias, node_options.cc).
Adds an alias table to ParseOptions, applied in StreamingClap::parse_next_arg —
the one place a token is classified as a flag. Option values and `--` targets
are pulled straight off the iterator and never pass through it, so they stay
verbatim; node scopes its own lookup to the option-name branch for the same
reason. Only AutoCommand/RunAsNodeCommand pass the node table: `bun run -pe` and
`bunx -p` (where -p means --package) are unaffected.
process.execArgv re-parses argv against a set built from AUTO_PARAMS to find
value-taking flags. An alias is not a param, so `-pe` missed the set and the
code string was dropped from execArgv; the set now also takes any alias whose
target takes a value, derived from the same table rather than hardcoded.
Verified against node v26.3.0, matching byte-for-byte:
bun -pe '1+1' -> 2
bun -p '1+1' / -e 'console.log(3)' -> 2 / 3 (unchanged)
bun -- -pe -> Script not found "-pe" (not "-p")
bun -e -pe -> evaluates "-pe" (value intact)
bun script.js -pe x -> ["-pe","x"] (argv intact)
bun -pe 'process.execArgv' -> ["-pe","..."]
test-preload-worker.js goes 0/6 -> 6/6; it needed only this.
Not addressed: `-pe=x`. Node rejects `-pe=3+3` and `-p=1+1` alike ('=' splitting
is long-flags-only there); bun evaluates them. That divergence predates this and
is orthogonal.
|
Two additions to the for-the-record list. quic.c matches it: 153e900 only moved the H3 context from the stale argless call to an explicit us_default_use_system_ca(), so it is the same one-line seed as the others when wanted. And one more case in the same family, distinct from the WebSocket fix: a worker with no CA flag of its own but NODE_USE_SYSTEM_CA=1 via the env option. Node resolves that per Environment (HandleEnvOptions), and our reporting follows it (the vendored no-use-system-ca test pins that), but the VM option is unset for that worker, so everything 28ea0a8 and the SSLConfig stamping seed from it still resolves to the process default. Reproduced just now on the debug build with SSL_CERT_FILE as the system store: getCACertificates reports the extra root, tls.connect in the same worker fails with UNABLE_TO_VERIFY_LEAF_SIGNATURE. Closing it means resolving the option from the worker env at VM init rather than a seed, so it seems like the same follow-up bucket rather than this PR. |
…estore --use-openssl-ca, make ELU monotonic and loop-relative, name profiles by threadId
--use-openssl-ca ("use OpenSSL's default CA store") only ever worked
because the default store installed OpenSSL's default lookups
unconditionally; gating those on use_system_ca turned the flag into a
no-op. Install them under that flag too, and pin all three shapes
(bare default, --use-openssl-ca, --use-system-ca) with SSL_CERT_FILE.
A worker with its own execArgv but no CA flag reported the system roots
whenever its env said NODE_USE_SYSTEM_CA=1 (tls.ts reads the worker's
env, as node's per-Environment option does) while its contexts were built
from the process default, so getCACertificates('default') and the
connections it describes disagreed. Resolve the VM's option from the same
source: an explicit flag, else the worker's effective env (its own `env`
option, plumbed through WebWorker__create, or the inherited map).
us_loop_idle_ns read idle_ns and idle_entry_ns separately while the park
exit cleared one and added to the other, so a cross-thread reader could
observe the entry cleared before the park was added and return less than
an earlier sample (a negative ELU delta). Guard the exit with a sequence
counter, read the clock inside the validated window, and read idle before
elapsed so the derived active never dips; the field is mirrored in
InternalLoopData.rs.
eventLoopUtilization() measured from VM init, so it never returned node's
all-zero result before the loop began and permanently counted bootstrap
plus the entry point's synchronous time as active. Stamp the loop start on
the first poll instead; a worker stamps it before its script, whose
bootstrap runs inside the loop (test-worker-eventlooputil asserts a
positive value at a worker's top level).
The default profile name used the execution context id as its tid segment
while worker.threadId is that id minus one, so the file for threadId 1 was
named .2.; use the same derivation. Add --no-use-system-ca to
allowedNodeEnvironmentFlags alongside its siblings, and replace the
worker inheritance test's assertion, which also held when the worker
wrote nothing, with one that finds a profile per thread by tid.
|
Another review pass; six more items verified and fixed in bb88250, two verified and left. Fixed:
Verified, not changed:
Also re-checked and fine: the loop-data addition is mirrored in |
|
Confirmed bb88250 against my pieces: the openssl-ca restore extends the gating in place, the reporting binding is untouched, and the env-at-VM-init resolution closes the flagless case I reproduced. The --use-openssl-ca regression was mine: when I gated set_default_paths I checked for SSL_CERT_FILE consumers and missed that the flag had no other implementation, so thanks for catching it. The two-op park exit was also a window I left when I made those stores seq_cst, the sequence counter is the right fix. Build 91628 on the previous head had zero failed jobs before this superseded it. |
…ore, inherit only real CA flags, scan the whole worker execArgv, measure ELU on one clock Gating X509_STORE_set_default_paths on the system-CA decision re-opened #23735: a flagless process (the common container case) lost the OpenSSL default lookups that #24350 restored, and every default context built for fetch/tls/WebSocket/quic failed issuer lookups that 1.3.x resolved. Model the store as three modes: --no-use-system-ca is bundled only, nothing explicit keeps the default lookups, --use-system-ca / NODE_USE_SYSTEM_CA=1 adds the OS store. The per-context request stays a tri-state; the resolved mode is what the store cache and the "differs from the process" check key on. A thread with no decision of its own now stays on the process default instead of an env-derived Some(false), which would have dropped it to bundled only. Bun__Node__UseSystemCA is also set by the env var, so main_use_system_ca() promoted NODE_USE_SYSTEM_CA into an explicit per-thread option that every worker inherited ahead of its own env; only the flag is inheritable now. parse_worker_exec_argv stopped at the first bare token, so any flag after another option's separate value (`-r ./x`, `--conditions y`) was silently ignored; bare tokens are skipped instead, since execArgv carries no positionals. eventLoopUtilization() derived elapsed from std::time::Instant and idle from CLOCK_MONOTONIC, which disagree across system sleep on macOS; both now use the counter's own clock. Idle accumulated before the loop is considered started (a watcher waiting for its first file) is subtracted rather than charged against a shorter elapsed, and `bun run` holds the stamp until the entry point's synchronous evaluation is done, as node's loopStart is.
|
One more pass; 6fc1c9d. The flagless default store — I'm reversing the earlier decision, please push back if you disagree. Gating Also fixed:
Verified, left as-is: |
|
No pushback on the reversal, agreed. The flagless gating was mine and it was the wrong call: Bun's shipped default plus a fixed regression (#23735, restored in #24350) outranks node parity there, and the earlier review thread had flagged exactly that shape, which I dismissed without checking the issue history. The explicit --no-use-system-ca case was the only bug the gating needed to fix, and BUNDLED mode keeps that. Read 6fc1c9d through the CA surface: renaming every getter to the mode vocabulary means a stale boolean caller now fails to compile instead of passing a bool as a mode, which is the defense the quic.c incident wanted, and quic.c itself is on us_default_ca_mode(); us_default_ca_mode / us_resolve_ca_mode / the gate / the 3-slot cache and its is_shared loop / differs_from_process (resolved modes) are all consistent with the three modes as described; and the scenario that was red on every Linux lane still resolves the same way (--no worker BUNDLED rejects the SSL_CERT_FILE root, --use worker SYSTEM trusts it) while the flagless process trusts it again. The rewritten gate comment is accurate too, node cited only for the --use-openssl-ca arm. Nothing to add on the left-as-is list. |
The previous commit kept OpenSSL's default lookups in the flagless default store. Node's NewRootCertStore does not: the default store is the bundled roots, plus the system store when asked, and --use-openssl-ca selects the default lookups instead of the bundled roots. Restore the two-variant store and apply the openssl rule exactly, so a flagless process no longer trusts SSL_CERT_FILE and --use-openssl-ca no longer carries the bundled roots along. A thread with no flag of its own again resolves from its env, as node's per-Environment option does; the main thread's inheritable value stays flag-only.
|
Correction to the previous comment, in efc5781: keeping OpenSSL's default lookups in the flagless store was wrong — node's |
|
Read efc5781 through the CA surface: the builder matches NewRootCertStore (openssl-ca gives the lookups instead of bundled, system ignored; otherwise bundled plus system when asked; extras always, lookups riding along with use_system_ca as documented), quic.c and the two-slot cache follow the boolean signature again, and the four rows pin all four shapes. Two notes.
|
…under --use-openssl-ca efc5781 made --use-openssl-ca exclusive in the store (OpenSSL's lookups instead of the bundled roots, system roots ignored), but the reporting path still listed the bundled roots, and the system roots when enabled, so under that flag it described roots connections do not trust. Gate both sets on a new process-wide getUseOpensslCA binding, the shape of node's cacheDefaultCACertificates. A reporting matrix alongside the connection one pins all four flag shapes; the openssl-ca row reported 121 roots before this change.
…porting, flag-only inheritance; stamp ELU on the first loop run
--use-system-ca also installed OpenSSL's default lookups, so on macOS and
Windows SSL_CERT_FILE became trusted under the flag; node's system store
there is the OS one only, and on Linux the system loader already reads
SSL_CERT_FILE / SSL_CERT_DIR, so the extra call is dropped.
tls.getCACertificates('default') still listed the bundled (and system)
roots under --use-openssl-ca after the store stopped using them; it now
reports only the extra certificates, as node's does.
A Worker stored only its resolved decision, so a decision a parent
worker took from its env was inherited by its children as if it were a
flag, and a worker with no env of its own re-read the parent's dotenv
map. Node starts a child from the parent's resolved value, re-derives
from a custom env, and then applies only flags; the flag part is now
kept separately for inheritance.
Holding the loop-start stamp until the entry point's promise settled
held it across a top-level await as well, so anything awaited at the top
level read zeros forever; node stamps on the first loop run, which the
first tick already does, so the deferral is removed.
|
Third and last pass, ef0dc09 (rebased over robobun's 9d5b413, whose
Verified, left as-is: Web |
…oint 'open' now reports the thread coming up (node's 'online'), not that the script has run: it is delivered before anything the entry posts, and it fires for an entry that never returns. Both fail on the previous order.
|
On the --use-system-ca lookups: no, I do not want them back. They were only there so SSL_CERT_FILE could stand in for the system store on every platform, which is a test convenience and not a semantic; with our Linux loader reading SSL_CERT_FILE itself the Linux rows still work, and the platform split you made is the honest version. openssl-ca-only matches NewRootCertStore exactly. Took the open-event item from your left-as-is list in b6f0c62: two tests in worker.test.ts pin that open is delivered before anything the entry posts and that it fires for an entry that never returns; both fail on the released build (the first deterministically, the second by never firing), both pass here and the ordering one is stable across repeated runs. While in that file, the #37075 flood test ("does not starve the parent's event loop") fails on this debug build because it only gives the worker about 30ms to boot before asserting a message arrived, unrelated to this branch; handed off separately rather than touched here. Thanks for the JS2Native.cpp note; the binding change did rebuild cleanly on this side. |
|
@robobun clippy is red |
this) + 16 upstream tests`&*(&raw const (*vm).field)` and `&(*vm).field` both borrow only the field, so the raw-pointer round trip bought nothing and clippy denies it.
|
On it. The two clippy errors are the deref_addrof pair in get_elu from ef0dc09 (clippy's own suggestion would move an AtomicU64 out of the raw deref, so the fix binds the raw field pointers and reborrows them); the full rust:clippy script is clean here with that. Holding the push a few minutes because the ELU test you added ("zero before the loop starts and counts only loop time after") fails on this Linux debug build in a way that looks real, not environmental: when the first loop turn is itself the park, that park is not counted (timer-first script reports idle 0, active 4ms; the same script with a setImmediate ahead of the timer reports idle 20ms). Instrumenting where the start stamp actually lands; will push both together or report back if it is something for you. |
…timer inside the loop The ELU test armed its 20ms timer at the top level, so on a debug build the post-entry work before the first poll (about 24ms here) outlasted it and the first poll returned without parking, reporting idle 0 with the accounting itself correct. Arming it from the first loop turn keeps what the test checks and guarantees a park.
|
Following up on the ELU note: the accounting is correct, the test was fragile. With the start stamp confirmed to land in auto_tick_active before the first park, the remaining question was the numbers, and a 500ms top-level timer reports idle 476ms on this debug build, i.e. about 24ms of post-entry work (the GC pass and friends in Run::start) runs before the first poll. A 20ms timer armed at the top level is therefore already due when the loop first polls, the poll returns without parking, and idle is legitimately 0; armed from inside the loop the same timer reports idle 20ms. So debug lanes would have hit that assertion intermittently. 3fcfde3 arms the test's timer from a first setImmediate turn, which keeps what it checks (top-level spin excluded, loop time counted) and guarantees a park; perf_hooks is 13/13 here. Saw your a24ad08 land the clippy fix meanwhile, so mine dropped out in the rebase and 3fcfde3 carries only that test change plus the use_system_ca_flag doc links from the latest review nit; rust:clippy is clean on the full workspace at this head. |
init() now derives use_system_ca_flag from is_main_thread, so setting the field after the fact left the bake VM's flag unset; pass it in the options as run/test/repl do, which also registers it as the main-thread VM and gives it the main-thread context id, and drop the post-init assignment init now covers.
… thread 09f3e89 broke bun build --app: a main-thread init takes the process's single initial script execution context id, and a production build creates further globals in the same process, so the second one tripped the initialIdentifier assertion (and would collide on id 1 in release). The bake VM's non-main init shape is load-bearing; say so at the site, restore it, and pass use_system_ca_flag explicitly, which is all the review finding needed.
Ports the Node v26.3.0 worker-related upstream tests that were still missing and fixes the
node:worker_threadsbehaviour they expose. Ten upstream files are added, byte-identical to upstream, with noexpectations.txtor sanitizer exemptions; each is enabled by one of the fixes below.test-diagnostics-channel-worker-threads.jsworker_threadsdiagnostics channeltest-worker-hasref.jsWORKERresourcetest-worker-error-stack-getter-throws.jsstackgettertest-perf-hooks-worker-timeorigin.jsperformance.timeOrigintest-worker-internal-modules.mjserror.codesurvives non-cloneable errorstest-tls-get-ca-certificates-worker-{,no-}use-system-ca.js--use-system-casequential/test-cpu-prof-dir-worker.js,sequential/test-cpu-prof-worker-argv.js--cpu-profin workerssequential/test-worker-eventlooputil.jseventLoopUtilization()(this test previously hung, so it could not be vendored)(A first batch of tests that already passed —
http2-stream-terminate,unsupported-eval-on-url,cleanup-handles,dispose, and the-pealias withtest-preload-worker— landed onmainseparately while this was open and is no longer part of this diff.)Behaviour fixes
worker_threadsdiagnostics channel (worker_threads.ts). Node publishes the new Worker on theworker_threadschannel at the end of the constructor; Bun never did. The channel is resolved at module load, not lazily:diagnostics_channelusesMapinternally (a lazy require picks up user tampering — it broke the "tampered Map prototype" test), and dc's registry is aWeakRefMapwhosesubscribe()swaps the prototype in place, so a module-load strong ref is what keeps the published channel from being re-created underneath the publisher. Verified against the node binary: identical payload, one publish per Worker, none without subscribers, nested workers publish on their own thread's channel.async_hooks
WORKERresource (worker_threads.ts,async_hooks.ts,async_hooks_tick.ts).initwas only ever delivered forTickObject, so hooks looking forWORKER(whatwhy-is-node-runningrelies on) never fired. The Worker constructor now emits one;hasRef()followsref()/unref()and reads backundefinedonce the thread has exited,ref()/unref()no-op after exit, a throwing init hook is fatal as in node. Found on the way:createHookinvokedinitbare, sothis.disable()inside it threw — node calls it as a method on the hook.Worker error clone survives a throwing
Error.prepareStackTrace(Worker.cpp). Cloning readsstack; a throwing getter took the whole clone down and the parent got pretty-printed text inmessage. Node drops just the unreadablestack; the clone is retried once with an ownundefinedstack, only on the failure path and viasetStackPropertyAlreadyMaterialized()so the getter can't run again.performance.timeOriginis process-wide (VirtualMachine.rs). Each worker VM took its ownInstant::now(), so a worker's origin drifted by the spawn delay (264 ms measured vs node's 0). Every thread now reports the process origin;Bun.nanoseconds()becomes "since the process started" as documented.error.codesurvives a non-cloneable thrown value. A worker throwing something structured clone can't serialize fell back to message text and the parent rebuilt a bareError, losingcode— Bun's ownResolveMessage(ERR_UNKNOWN_BUILTIN_MODULE) is exactly that.codeis carried alongside the message; the message text is unchanged byte for byte.'online'fires at node's timing (WorkerMessagingProxy.cpp,web_worker.rs). Bun postedonlineonly after the entry point's promise settled, so a worker whose top level never returns (while(true)) never reported online andterminate()on it never resolved.dispatchOnlinedid two separable things — thePending→Runningstate flip that also gates message routing, and posting the event — and moving both regresses message-routing, heap-profile and destruction tests (measured; details in the discussion below). Only the event moves: it is posted before the entry loads, the state flip stays where it was. Consequences that fall out: a worker whose entry throws or fails to resolve reportsonlinethenerrorlike node; the bun-API WebWorker's'open'likewise now precedes the entry point (pinned by a test inworker.test.ts).MessagePort listeners get the port as
this—injectFakeEmitter's wrapper dropped the receiver.BroadcastChannel#ref()/unref()return the channel as in node instead ofundefined.process.execArgvtreats a node whole-token alias such as-peas value-taking only ahead of the script name, sobun run -pe script.jsno longer swallows the script.--cpu-profprofiles worker threads (web_worker.rs,BunCPUProfiler.rs,jsc_hooks.rs). Node writes one profile per thread and honours a Worker's ownexecArgv; Bun configured the profiler on the main-thread run path only, so a process with a worker wrote one profile andexecArgv: ['--cpu-prof']did nothing. Inheritance followsnode_worker.cc: noexecArgvinherits the parent's settings, an explicit list (even[]) replaces them, and a worker's own--cpu-prof,--cpu-prof-md,--cpu-prof-dir,--cpu-prof-nameand--cpu-prof-intervalare honoured, including flags that follow another option's separate value. Default file names carry the real thread id (CPU.<date>.<time>.<pid>.<threadId>.<seq>; the main thread's0segment is what it always was), so concurrent threads don't collide; a custom--cpu-prof-namestill collides across threads, as node's does. Also fixed on the way: the sampling interval was athread_local, so workers silently sampled at the 1000 µs default (312 → 1529 samples at--cpu-prof-interval 100), and an interval that fitsu32but notc_intpanicked — it clamps.performance.eventLoopUtilization()(perf_hooks.ts,internal/perf/event_loop_utilization.ts, usocketsloop_data.h/epoll_kqueue.c/loop.c,VirtualMachine.rs,web_worker.rs) was hardcoded zeros, andworker.performance.eventLoopUtilization()a stub. The loop already brackets its park, so idle accounting is two clock reads on ticks that were going to sleep anyway; Windows uses libuv'suv_metrics_idle_time(UV_METRICS_IDLE_TIME, which node enables unconditionally). Semantics match node's: the main thread reports zeros until its loop first runs (an entry point's synchronous top level sees{0,0,0}; the time spent in a top-levelawaitcounts), a worker counts from before its script starts, and a worker's ELU is readable from the parent. Elapsed and idle come from the same clock (they diverged across sleep on macOS), idle accumulated before the loop is considered started is not charged, and the mid-park counter is published through a seqlock so a reader never sees a tornidle/entrypair — the shared math lives ininternal/perf/event_loop_utilizationas node's does, including the deliberately unguardedNaNfor two identical samples.Two traps for anyone touching this:
us_internal_loop_data_tisus_loop_t's first member and is mirrored in Rust (src/uws_sys/InternalLoopData.rs) — adding a field without the mirror shiftsnum_pollsand the loop silently stops parking; and a counter folded in only when a park ends reads stale mid-park, which libuv solves the same way (publish the park's entry time).--use-system-cais a per-thread (per-Environment) option (web_worker.rs,VirtualMachine.rs,Arguments.rs,jsc_hooks.rs,SSLConfig.rs,ssl_config.rs,fetch.rs, usocketsopenssl.c/root_certs.cpp/quic.c,tls.ts). Previously a Worker'sexecArgvwas ignored for everything but--no-addons,--no-use-system-cadid not exist, and the CA decision was a process-global boolean read by a process-wide cached store. Now:node_worker.cc: a thread starts from its parent's resolved decision, a customenvre-derives it from that env'sNODE_USE_SYSTEM_CA, and flags win — its ownexecArgv's, or the parent's flags when it has none. The main thread's inheritable value is flag-only (the env var is not promoted into a flag).--no-use-system-cais accepted on the CLI, inexecArgvand inNODE_OPTIONS.tls/netclient and server contexts, the default client contextfetchuses,WebSocket, and QUIC. Default stores are built once per decision and shared; a thread whose decision differs from the process default gets its own default client context (andBun.servewithout TLS options stays plain HTTP in such a thread).NewRootCertStore: bundled roots, plus the OS store when asked (on Linux that loader is what honoursSSL_CERT_FILE/SSL_CERT_DIR; on macOS/Windows it is the OS store alone);--use-openssl-caselects OpenSSL's default lookups instead of the bundled roots.NODE_EXTRA_CA_CERTSis added in every mode.tls.getCACertificates('default')reports the same rules, per thread.NODE_EXTRA_CA_CERTS— with it set, real node fails the two upstream tests exactly like an unfixed bun.Known gaps / notes
--use-openssl-cacombined with--use-system-cais rejected by the CLI, where node accepts the pair (openssl wins); pre-existing.envomitsNODE_USE_SYSTEM_CAwhile the process env sets it follows the parent's decision (node re-derives from the custom env → bundled only). Documented corner.hasRef()reads backtrueafterterminate()until the exit is delivered — the ref is held on purpose so'exit'is observed; node reads backundefinedsooner.ASSERTION FAILED: !scope.exception() || !result(objectConstructorDefinePropertyvia the worker preload) on debug/asan builds — the same failure the already-vendoredtest-worker-message-port-transfer-terminate.jsshows on x64-asan.test-worker-vm-context-terminateandtest-worker-abort-on-uncaught-exception-terminate, which theonlinefix would otherwise enable, are left out for that reason; the behaviour is covered by a Bun-side test that avoids the window.process.emitWarning, deprecation and unhandled-rejection warnings) inside a Worker still goes to the parent's real fd 2 rather than the worker's pipedstderr(node routes it tow.stderr); JSconsole.*is routed correctly. ReroutingemitWarningthroughconsole.errorwould change main-thread warning formatting, so the fix is routing the native console client at the worker's stdio — left for a separate change. Related:process.listenerCount('warning')is 0 (node 1) and a user'warning'listener suppresses the default print.async_hooksimplementsAsyncLocalStorage/AsyncResourceonly;MESSAGEPORTresources (test-worker-messageport-hasref,…-inspect-during-init-hook) remain out of scope. Also still missing from the vendored set:execArgvvalidation (ERR_WORKER_INVALID_EXEC_ARGV),resourceLimits,moveMessagePortToContext,data:URL MIME handling,process.envdefinePropertysemantics.Found via the asan shard, not fixed here
terminate()with an in-flightdns.lookupis a use-after-free (Linux only — thelib_cpath):GlobalData::droptears theResolverdown while in-flight lookups still hold anIntrusiveRc.test-worker-dns-terminate.jswas removed from this PR rather than suppressed; it needs a teardown fix indns.rs.markTerminatingonly serializespostTaskTo; the work pool and JSC'sDeferredWorkTimerenqueue directly ontoconcurrent_tasks, the worker thread is detached rather than joined, and the VM box is freed raw — so draining later only narrows the window (tried and discarded; it also frees the node but not the payload). The durable fix is at the poster side (drain-and-join, or a refcount taken by posters). Bothsequential/test-worker-fshandles-*-on-termination.jsexpose it and are left out; the same class covers a lot oftest/no-validate-leaksan.txtand the already-failing "cross-thread MessagePort post during worker shutdown" case inworker_threads.test.ts.Why
onlinewas moved the way it wasMoving the whole
dispatchOnline(state flip + event) before evaluation regressed six routing tests (enqueueToWorkerbuffers only while notRunning, sopostMessagefrom an'online'handler drained into a worker with no listener yet); gating delivery on an entry-evaluated flag fixed those but the earlyRunningflip alone still brokeheap-profile,terminate-unrefedand two destruction tests.Runningis overloaded three ways (online / deliverable / bookkeeping-valid), which is why only the event is posted early here and the flip is untouched. The removed comment claiming a parentonlinehandler callinggetHeapSnapshot()would seeERR_WORKER_NOT_RUNNINGwas wrong —postTaskToWorkerGlobalScopequeues onPending; confirmed by running it.no test proof · iteration 43 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/cpu-prof.test.ts test/js/node/process/process.test.js test/js/node/tls/test-use-system-ca.test.ts