Skip to content

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

Open
cirospaciari wants to merge 80 commits into
mainfrom
ciro/worker-threads-node-tests

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 16, 2026

Copy link
Copy Markdown
Member

Ports the Node v26.3.0 worker-related upstream tests that were still missing and fixes the node:worker_threads behaviour they expose. Ten upstream files are added, byte-identical to upstream, with no expectations.txt or sanitizer exemptions; each is enabled by one of the fixes below.

upstream test fix that enables it
test-diagnostics-channel-worker-threads.js worker_threads diagnostics channel
test-worker-hasref.js async_hooks WORKER resource
test-worker-error-stack-getter-throws.js error clone survives a throwing stack getter
test-perf-hooks-worker-timeorigin.js process-wide performance.timeOrigin
test-worker-internal-modules.mjs error.code survives non-cloneable errors
test-tls-get-ca-certificates-worker-{,no-}use-system-ca.js per-thread --use-system-ca
sequential/test-cpu-prof-dir-worker.js, sequential/test-cpu-prof-worker-argv.js --cpu-prof in workers
sequential/test-worker-eventlooputil.js real eventLoopUtilization() (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 -pe alias with test-preload-worker — landed on main separately while this was open and is no longer part of this diff.)

Behaviour fixes

worker_threads diagnostics channel (worker_threads.ts). Node publishes the new Worker on the worker_threads channel at the end of the constructor; Bun never did. The channel is resolved at module load, not lazily: diagnostics_channel uses Map internally (a lazy require picks up user tampering — it broke the "tampered Map prototype" test), and dc's registry is a WeakRefMap whose subscribe() 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 WORKER resource (worker_threads.ts, async_hooks.ts, async_hooks_tick.ts). init was only ever delivered for TickObject, so hooks looking for WORKER (what why-is-node-running relies on) never fired. The Worker constructor now emits one; hasRef() follows ref()/unref() and reads back undefined once the thread has exited, ref()/unref() no-op after exit, a throwing init hook is fatal as in node. Found on the way: createHook invoked init bare, so this.disable() inside it threw — node calls it as a method on the hook.

Worker error clone survives a throwing Error.prepareStackTrace (Worker.cpp). Cloning reads stack; a throwing getter took the whole clone down and the parent got pretty-printed text in message. Node drops just the unreadable stack; the clone is retried once with an own undefined stack, only on the failure path and via setStackPropertyAlreadyMaterialized() so the getter can't run again.

performance.timeOrigin is process-wide (VirtualMachine.rs). Each worker VM took its own Instant::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.code survives a non-cloneable thrown value. A worker throwing something structured clone can't serialize fell back to message text and the parent rebuilt a bare Error, losing code — Bun's own ResolveMessage (ERR_UNKNOWN_BUILTIN_MODULE) is exactly that. code is carried alongside the message; the message text is unchanged byte for byte.

'online' fires at node's timing (WorkerMessagingProxy.cpp, web_worker.rs). Bun posted online only after the entry point's promise settled, so a worker whose top level never returns (while(true)) never reported online and terminate() on it never resolved. dispatchOnline did two separable things — the Pending→Running state 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 reports online then error like node; the bun-API Web Worker's 'open' likewise now precedes the entry point (pinned by a test in worker.test.ts).

MessagePort listeners get the port as thisinjectFakeEmitter's wrapper dropped the receiver. BroadcastChannel#ref()/unref() return the channel as in node instead of undefined. process.execArgv treats a node whole-token alias such as -pe as value-taking only ahead of the script name, so bun run -pe script.js no longer swallows the script.

--cpu-prof profiles worker threads (web_worker.rs, BunCPUProfiler.rs, jsc_hooks.rs). Node writes one profile per thread and honours a Worker's own execArgv; Bun configured the profiler on the main-thread run path only, so a process with a worker wrote one profile and execArgv: ['--cpu-prof'] did nothing. Inheritance follows node_worker.cc: no execArgv inherits 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-name and --cpu-prof-interval are 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's 0 segment is what it always was), so concurrent threads don't collide; a custom --cpu-prof-name still collides across threads, as node's does. Also fixed on the way: the sampling interval was a thread_local, so workers silently sampled at the 1000 µs default (312 → 1529 samples at --cpu-prof-interval 100), and an interval that fits u32 but not c_int panicked — it clamps.

performance.eventLoopUtilization() (perf_hooks.ts, internal/perf/event_loop_utilization.ts, usockets loop_data.h/epoll_kqueue.c/loop.c, VirtualMachine.rs, web_worker.rs) was hardcoded zeros, and worker.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's uv_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-level await counts), 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 torn idle/entry pair — the shared math lives in internal/perf/event_loop_utilization as node's does, including the deliberately unguarded NaN for two identical samples.

Two traps for anyone touching this: us_internal_loop_data_t is us_loop_t's first member and is mirrored in Rust (src/uws_sys/InternalLoopData.rs) — adding a field without the mirror shifts num_polls and 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-ca is a per-thread (per-Environment) option (web_worker.rs, VirtualMachine.rs, Arguments.rs, jsc_hooks.rs, SSLConfig.rs, ssl_config.rs, fetch.rs, usockets openssl.c/root_certs.cpp/quic.c, tls.ts). Previously a Worker's execArgv was ignored for everything but --no-addons, --no-use-system-ca did not exist, and the CA decision was a process-global boolean read by a process-wide cached store. Now:

  • Resolution follows node_worker.cc: a thread starts from its parent's resolved decision, a custom env re-derives it from that env's NODE_USE_SYSTEM_CA, and flags win — its own execArgv'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-ca is accepted on the CLI, in execArgv and in NODE_OPTIONS.
  • The decision reaches every context a thread creates: tls/net client and server contexts, the default client context fetch uses, 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 (and Bun.serve without TLS options stays plain HTTP in such a thread).
  • The store itself follows NewRootCertStore: bundled roots, plus the OS store when asked (on Linux that loader is what honours SSL_CERT_FILE/SSL_CERT_DIR; on macOS/Windows it is the OS store alone); --use-openssl-ca selects OpenSSL's default lookups instead of the bundled roots. NODE_EXTRA_CA_CERTS is added in every mode. tls.getCACertificates('default') reports the same rules, per thread.
  • ⚠️ When comparing against node, unset NODE_EXTRA_CA_CERTS — with it set, real node fails the two upstream tests exactly like an unfixed bun.

Known gaps / notes

  • --use-openssl-ca combined with --use-system-ca is rejected by the CLI, where node accepts the pair (openssl wins); pre-existing.
  • A Worker whose custom env omits NODE_USE_SYSTEM_CA while the process env sets it follows the parent's decision (node re-derives from the custom env → bundled only). Documented corner.
  • hasRef() reads back true after terminate() until the exit is delivered — the ref is held on purpose so 'exit' is observed; node reads back undefined sooner.
  • Terminating a worker while it is still loading its modules can trip a pre-existing ASSERTION FAILED: !scope.exception() || !result (objectConstructorDefineProperty via the worker preload) on debug/asan builds — the same failure the already-vendored test-worker-message-port-transfer-terminate.js shows on x64-asan. test-worker-vm-context-terminate and test-worker-abort-on-uncaught-exception-terminate, which the online fix would otherwise enable, are left out for that reason; the behaviour is covered by a Bun-side test that avoids the window.
  • Native warning output (process.emitWarning, deprecation and unhandled-rejection warnings) inside a Worker still goes to the parent's real fd 2 rather than the worker's piped stderr (node routes it to w.stderr); JS console.* is routed correctly. Rerouting emitWarning through console.error would 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.
  • Bun's async_hooks implements AsyncLocalStorage/AsyncResource only; MESSAGEPORT resources (test-worker-messageport-hasref, …-inspect-during-init-hook) remain out of scope. Also still missing from the vendored set: execArgv validation (ERR_WORKER_INVALID_EXEC_ARGV), resourceLimits, moveMessagePortToContext, data: URL MIME handling, process.env defineProperty semantics.

Found via the asan shard, not fixed here

  1. terminate() with an in-flight dns.lookup is a use-after-free (Linux only — the lib_c path): GlobalData::drop tears the Resolver down while in-flight lookups still hold an IntrusiveRc. test-worker-dns-terminate.js was removed from this PR rather than suppressed; it needs a teardown fix in dns.rs.
  2. Work landing on a terminating worker after its shutdown drain is a UAF, not just a leak. markTerminating only serializes postTaskTo; the work pool and JSC's DeferredWorkTimer enqueue directly onto concurrent_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). Both sequential/test-worker-fshandles-*-on-termination.js expose it and are left out; the same class covers a lot of test/no-validate-leaksan.txt and the already-failing "cross-thread MessagePort post during worker shutdown" case in worker_threads.test.ts.

Why online was moved the way it was

Moving the whole dispatchOnline (state flip + event) before evaluation regressed six routing tests (enqueueToWorker buffers only while not Running, so postMessage from an 'online' handler drained into a worker with no listener yet); gating delivery on an entry-evaluated flag fixed those but the early Running flip alone still broke heap-profile, terminate-unrefed and two destruction tests. Running is 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 parent online handler calling getHeapSnapshot() would see ERR_WORKER_NOT_RUNNING was wrong — postTaskToWorkerGlobalScope queues on Pending; 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

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
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator
Updated 7:18 PM PT - Aug 10th, 2026

@robobun, your commit 7e37ca010811868f0863e96593ade8184b020264 passed in Build #91827! 🎉


🧪   To try this PR locally:

bunx bun-pr 34424

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

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
@cirospaciari cirospaciari changed the title node:worker_threads: add 5 upstream Node worker tests that already pass node:worker_threads: add 8 upstream Node worker tests that already pass Jul 17, 2026
… 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
@cirospaciari cirospaciari changed the title node:worker_threads: add 8 upstream Node worker tests that already pass node:worker_threads: add 7 upstream Node worker tests that already pass Jul 17, 2026
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.
Comment thread test/no-validate-leaksan.txt Outdated
…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.
@cirospaciari cirospaciari changed the title node:worker_threads: add 7 upstream Node worker tests that already pass node:worker_threads: add 5 upstream Node worker tests that already pass Jul 17, 2026
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.
@cirospaciari cirospaciari changed the title node:worker_threads: add 5 upstream Node worker tests that already pass node:worker_threads: publish the worker_threads diagnostics channel + 6 upstream Node tests Jul 17, 2026
…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.
@cirospaciari cirospaciari changed the title node:worker_threads: publish the worker_threads diagnostics channel + 6 upstream Node tests node:worker_threads: 4 compat fixes (async_hooks WORKER, error clone, timeOrigin, diagnostics channel) + 9 upstream tests Jul 17, 2026
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.
@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun fix conflicts

@cirospaciari
cirospaciari marked this pull request as ready for review July 17, 2026 18:24
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

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.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

Worker runtime compatibility

Layer / File(s) Summary
Async hooks and worker state
src/js/node/async_hooks.ts, src/js/node/worker_threads.ts, src/js/internal/async_hooks_tick.ts, test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js, test/js/node/test/parallel/test-worker-hasref.js
WORKER init hooks, diagnostics-channel publication, and hasRef() lifecycle tracking are implemented and tested.
Shared timing origin
src/jsc/VirtualMachine.rs, test/js/node/test/parallel/test-perf-hooks-worker-timeorigin.js
Virtual machines use a process-wide timing origin, with matching worker and parent time-origin coverage.
Online-event ordering
src/jsc/bindings/webcore/Worker.h, src/jsc/bindings/webcore/Worker.cpp, src/jsc/web_worker.rs, test/js/node/worker_threads/worker_threads.test.ts
The worker emits online before loading its entry point while retaining the later running-state transition.
Error and lifecycle handling
src/jsc/bindings/webcore/Worker.cpp, test/js/node/test/parallel/test-worker-error-stack-getter-throws.js, test/js/node/test/parallel/test-worker-cleanup-handles.js, test/js/node/test/parallel/test-worker-dispose.mjs, test/js/node/test/parallel/test-worker-http2-stream-terminate.js, test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs
Error serialization retries after clearing an invalid stack, and worker cleanup, disposal, termination, and eval validation are covered.

BroadcastChannel reference chaining

Layer / File(s) Summary
Chained ref operation
src/jsc/bindings/webcore/JSBroadcastChannel.cpp
BroadcastChannel.prototype.ref() returns the wrapper after incrementing its reference count.

Node CLI short aliases

Layer / File(s) Summary
Short-alias parsing contract
src/clap/lib.rs, src/clap/streaming.rs, src/clap/comptime.rs
Clap carries configured short aliases and rewrites matching tokens before flag classification.
Node alias wiring
src/runtime/cli/Arguments.rs, src/runtime/node/node_process.rs, src/install/PackageManager/CommandLineArguments.rs
The -pe alias maps to -p for auto and run-as-node parsing, with default parser options applied elsewhere.

Worker CPU profiling

Layer / File(s) Summary
Worker exec-argv contract
src/jsc/VirtualMachine.rs, src/runtime/jsc_hooks.rs
Worker exec-argv parsing returns addon and CPU profiling options through a structured hook result.
Profiler inheritance and filenames
src/jsc/BunCPUProfiler.rs, src/runtime/cli/run_command.rs
Profiler configuration is inherited by workers, includes thread identifiers, clamps intervals, and produces worker-specific filenames.
Worker profiler integration
src/jsc/web_worker.rs, test/js/node/test/sequential/test-cpu-prof-dir-worker.js, test/js/node/test/sequential/test-cpu-prof-worker-argv.js
Worker startup applies profiling options and regression tests validate generated profiles.

Possibly related PRs

Suggested reviewers: robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the worker_threads compatibility fixes, although it lists many individual changes and is longer than preferred.
Description check ✅ Passed The description clearly explains the changes, verification evidence, upstream tests, and known gaps, despite not using the template headings exactly.

Comment @coderabbitai help to get the list of available commands.

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun heads-up — I resolved the conflicts locally, so you can drop your run to avoid a race. Your resolution started from c308af4b6b, but I pushed f1384cda99 (the 'online' timing fix) right after, so a merge built on the older head would drop it.

What conflicted and how I resolved it, in case it's useful: only src/js/node/worker_threads.ts, three hunks, all where #34338 and my changes touch the same lines. #34338 removed the #stdoutAutoPipe/#stderrAutoPipe fields and dropped the stdio port ref/unref out of ref()/unref() (ports now manage their own ref via incrementsPortRef). My side only ever added #hasRef bookkeeping there, so I took main's structure wholesale and kept just the two if (!this.#exited) this.#hasRef = ... lines plus the field. async_hooks.ts (#31825) and VirtualMachine.rs (#34293, #32498) auto-merged.

I verified the merge is a pure addition on top of main — git diff origin/main -- src/js/node/worker_threads.ts shows zero deleted lines, so nothing from #34338 or #31825 is reverted.

Also: #34338 fixed the {stdout:true} hang I'd documented in this PR's description as an open bug, so I'm dropping that section. Worth noting your fix got the part mine missed — I tried attaching the listener eagerly and it regressed ~20 tests because the listener re-refs the port; port.unref() immediately after the eager attach is the piece I didn't have.

Comment thread test/js/node/test/parallel/test-worker-memory.js Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/async_hooks.ts Outdated
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.
@cirospaciari cirospaciari changed the title node:worker_threads: 4 compat fixes (async_hooks WORKER, error clone, timeOrigin, diagnostics channel) + 9 upstream tests node:worker_threads: 6 compat fixes (online timing, async_hooks WORKER, error clone, timeOrigin, diagnostics channel, BroadcastChannel ref) + 9 upstream tests Jul 17, 2026
Comment thread src/jsc/bindings/webcore/JSBroadcastChannel.cpp
- 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

@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 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:

  • dispatchOnlineEvent split: confirmed it only posts the open event; the Pending→Running flip and fireEarlyMessages stay where they were, so message routing/queueing is unchanged.
  • Error-clone retry in dispatchErrorWithValue: gated on !serialized && !scope.exception() + dynamicDowncast<ErrorInstance>, uses putDirect + setStackPropertyAlreadyMaterialized() so the retry can't re-enter prepareStackTrace; code read 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 onlinegetHeapSnapshot() 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.
@cirospaciari cirospaciari changed the title node:worker_threads: 6 compat fixes (online timing, async_hooks WORKER, error clone, timeOrigin, diagnostics channel, BroadcastChannel ref) + 9 upstream tests node:worker_threads: 7 compat fixes (online timing, async_hooks WORKER, error clone, timeOrigin, diagnostics channel, BroadcastChannel ref, -pe) + 10 upstream tests Jul 17, 2026
@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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.
@cirospaciari

Copy link
Copy Markdown
Member Author

Another review pass; six more items verified and fixed in bb88250, two verified and left.

Fixed:

  • --use-openssl-ca had become a no-op. It only ever worked through the unconditional X509_STORE_set_default_paths, so gating that on use_system_ca silently killed the flag (SSL_CERT_FILE=x bun --use-openssl-ca trusted on 1.3.14, rejected on this branch). Now installed under that flag as well. The bare default no longer trusting SSL_CERT_FILE//etc/ssl is the intentional node-parity change from the earlier thread, but it is user-visible for anyone relying on it today and wasn't tested; both shapes are now pinned by one test, and it's probably worth a line in the description.
  • Flagless worker: reporting and connections disagreed. execArgv: [] + env NODE_USE_SYSTEM_CA=1getCACertificates('default') listed the system roots (tls.ts reads the worker's env, which is what v26's test-tls-get-ca-certificates-worker-no-use-system-ca requires) but tls.connect/fetch were built from the process default and failed. The VM option is now resolved from the same source — explicit flag, else the worker's effective env (the env option is plumbed through WebWorker__create; otherwise the inherited map) — so the two halves can't diverge; test extended with the env cases under a --no-use-system-ca parent. This is the gap noted in the earlier "genuine gap" comment.
  • Cross-thread ELU could go backwards. The park exit cleared idle_entry_ns and added to idle_ns as two ops, so a parent reading during that window got a sample smaller than one it took mid-park. Measured with a tight reader loop: min idle delta −1.14 ms before, exactly 0 across 1.3M reads after (a seq counter around the exit, clock read inside the window, idle read before elapsed). For reference the same probe on node itself shows −0.06 ms active jitter, which is the structural floor we still share.
  • eventLoopUtilization() counted from VM init. Node returns all zeros until the loop has begun and never counts the entry point's synchronous time as active; the branch returned {active: 387, utilization: 1} at top level. The main thread now stamps loop start on its first poll; a worker stamps it before its script (its bootstrap runs inside the loop — test-worker-eventlooputil asserts a positive value at a worker's top level, which caught my first attempt). Test added; vendored ELU/perf files 30/30.
  • Default profile names used the execution-context id as the tid segment (.2. for threadId 1); now the same derivation as threadId, and the inheritance test now finds one profile per thread by tid instead of an assertion that also held when the worker wrote nothing.
  • --no-use-system-ca was missing from allowedNodeEnvironmentFlags.

Verified, not changed:

  • A worker still running when the main thread exits never writes its --cpu-prof profile (process.exit() after spawning → 1 file; node writes 2). Needs the exit path to stop and join profiled workers, so left as a known gap.
  • The clone-failure retry only tolerates a throwing stack; a throwing message/name/cause still falls back to the string event. Same class as node's per-property serializer, bigger change than this PR wants.

Also re-checked and fine: the loop-data addition is mirrored in InternalLoopData.rs and the Loop.rs offset asserts still compile; 148 vendored worker/perf/ca/cpu-prof files, worker_threads (123), use-system-ca, cpu-prof, process and perf_hooks suites are green on the debug build.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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.
@cirospaciari

Copy link
Copy Markdown
Member Author

One more pass; 6fc1c9d.

The flagless default store — I'm reversing the earlier decision, please push back if you disagree. Gating X509_STORE_set_default_paths on the system-CA decision is exactly what #23735 reported against 1.3.0 (flagless fetch in a Debian container / macOS failing with UNABLE_TO_GET_ISSUER_CERT_LOCALLY), and #24350 restored the call to fix it; this branch removed it again for the flagless case, and 1.3.14 → branch reproduces the report's shape (SSL_CERT_FILE trusted → rejected). The store is now three modes: --no-use-system-ca → bundled only (the case the earlier thread was rightly worried about), nothing explicit → bundled + OpenSSL's default lookups as in every 1.3.x release, --use-system-ca/NODE_USE_SYSTEM_CA=1 → plus the OS store. The per-context tri-state request is unchanged; the resolved mode is what the store cache and the differs-from-process check key on, so a --no-use-system-ca worker in a flagless process still gets its own bundled-only context. Pinned by four SSL_CERT_FILE rows; node/tls, fetch.tls and the vendored TLS/CA files are green.

Also fixed:

  • main_use_system_ca() read Bun__Node__UseSystemCA, which the env var sets too, so NODE_USE_SYSTEM_CA was promoted into an explicit per-thread option that workers inherited ahead of their own env; only the real flag is inheritable now, and a thread with no decision stays on the process default rather than an env-derived "false" (which, with the modes above, would have silently dropped it to bundled only).
  • The worker execArgv scan stopped at the first bare token, so [...process.execArgv /* -r ./x */, "--no-use-system-ca"] or ["--conditions", "y", "--cpu-prof"] silently ignored everything after the value; bare tokens are skipped now, test added.
  • ELU used two clocks (Instant = uptime clock vs CLOCK_MONOTONIC, which diverge across sleep on macOS) — both sides now read the counter's own clock; idle accumulated before the loop counts as started (watcher waiting for its first file) is subtracted instead of producing negative active; and bun run holds the loop-start stamp until the entry point's synchronous evaluation is done. The stress probe's idle deltas are exactly monotonic; a test with an importing entry point pins the zeros.

Verified, left as-is: hasRef() reads back true after terminate() because the ref is deliberately held until the exit is delivered — a readback difference from node, not a liveness bug; NODE_USE_SYSTEM_CA truthiness on the main thread predates this branch (workers and tls.ts use node's === "1"); the CLI's --no-use-system-ca beating a later --use-system-ca differs from the worker parser's last-wins — worth picking one, but it's the CLI's documented rule; the old name-inheritance test stays alongside the per-thread one. Known corner: a worker whose custom env strips NODE_USE_SYSTEM_CA while the process env sets it still follows the process default.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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.
@cirospaciari

Copy link
Copy Markdown
Member Author

Correction to the previous comment, in efc5781: keeping OpenSSL's default lookups in the flagless store was wrong — node's NewRootCertStore never installs them on their own (bundled roots, plus the system store when asked), so the earlier gating stands and the flagless case is back to rejecting SSL_CERT_FILE; #23735-style setups are --use-system-ca / NODE_USE_SYSTEM_CA=1 territory, as in node. While there, --use-openssl-ca now follows node exactly as well: the default lookups instead of the bundled roots, system store ignored. The four SSL_CERT_FILE rows encode those rules; a flagless thread again resolves from its own env (node's per-Environment behaviour, which the vendored worker CA tests exercise), and the main thread's inheritable value stays flag-only. node/tls, fetch.tls, use-system-ca and the vendored CA files are green.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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.

  1. One follow-on from making openssl-ca exclusive: node's getCACertificates("default") skips the bundled and system sets under --use-openssl-ca to mirror the store (lib/tls.js cacheDefaultCACertificates, https://github.com/nodejs/node/blob/v26.3.0/lib/tls.js#L157), and our tls.ts has no openssl-ca branch, so under that flag it now reports the bundled roots (plus system when flagged) while the connections use neither. It was harmless while openssl-ca was additive. Fix is a binding exposing the flag plus the same gate in tls.ts and a reporting row in the test; I can push it, but since you are in these files right now I will hold unless you would rather I take it.

  2. For the record on the flagless decision, since it has flipped a few times: fix(tls) undo some changes added in root_certs #24350's diff shows 1.3.0 shipped with set_default_paths gated on the system-CA decision, [Regression] Bun 1.3.0 no longer uses system CA certificates by default, causing UNABLE_TO_GET_ISSUER_CERT_LOCALLY errors #23735 was filed against exactly that (a public site verifying via the OS store but not the bundle), and fix(tls) undo some changes added in root_certs #24350 removed the gate in response. So this is knowingly re-adopting a behavior that was reverted once under a user report, in exchange for parity; defensible, but it makes the release-notes line (flagless no longer consults SSL_CERT_FILE or the OS store; use --use-system-ca or NODE_USE_SYSTEM_CA=1) the important part, because that reporter's shape will recur on upgrade.

Comment thread src/uws/lib.rs Outdated
Comment thread src/js/node/tls.ts Outdated
robobun and others added 2 commits August 10, 2026 23:38
…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.
@cirospaciari

Copy link
Copy Markdown
Member Author

Third and last pass, ef0dc09 (rebased over robobun's 9d5b413, whose getCACertificates('default') fix I'd made identically — kept its version and test).

  • --use-system-ca also installed OpenSSL's default lookups, so on macOS/Windows SSL_CERT_FILE became trusted under the flag; node's system store there is the OS store alone (its Linux loader is what reads SSL_CERT_FILE/SSL_CERT_DIR, and ours does too), so the call is now openssl-ca only. Checked against real node on macOS: same flag, same env, both reject. The SSL_CERT_FILE row is platform-split and the WebSocket worker test is Linux-only, since that's the only platform where a file can stand in for the system store. This one's your original line — say so if you want it back, but node disagrees with it.
  • Worker inheritance carried env-derived decisions as if they were flags: a worker stored only its resolved value, so a child of a worker that got NODE_USE_SYSTEM_CA=1 via env inherited "system" even with its own env: {}, and a worker without a custom env re-read the parent's dotenv map. Now the flag part is kept separately: child starts from the parent's resolved value, a custom env re-derives, then flags (own execArgv, else parent's flags) win — node_worker.cc's order. Five-shape nested-worker test added (reporting-based, so it runs everywhere; skips like node's own tests without a system store).
  • My previous ELU deferral was wrong: holding the stamp until the entry promise settled also held it across a top-level await, so a TLA-driven app read zeros forever. Node stamps on the first loop run, which is what the first tick already did, so the deferral is gone; a TLA test pins zeros-before / counted-after and the imports test still passes.

Verified, left as-is: Web Worker's 'open' now fires before the entry evaluates (right for node workers; the bun-API open ⇒ script ran invariant is gone — a docs line or a test pinning the new order would help); the --cpu-prof-name inheritance test can't tell a worker profile from main's (the per-thread test covers the mechanism); --use-openssl-ca --use-system-ca together is refused by the CLI where node accepts the pair (pre-existing); the worker's async-hooks init dispatch duplicates ProcessObjectInternals. Build note in case you touch this: adding a $newRustFunction did not get JS2Native.cpp recompiled here — symptom is "x is not a function … instance of Object" until it's touched.

…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.
@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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.

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun clippy is red

@cirospaciari cirospaciari changed the title node:worker_threads: 15 compat fixes (eventLoopUtilization, per-Environment --use-system-ca, cpu-prof in workers, error.code across threads, online timing, MessagePort this) + 16 upstream tests 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) Aug 11, 2026
`&*(&raw const (*vm).field)` and `&(*vm).field` both borrow only the
field, so the raw-pointer round trip bought nothing and clippy denies it.
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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.

Comment thread src/jsc/VirtualMachine.rs Outdated
…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.
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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.

Comment thread test/js/node/perf_hooks/perf_hooks.test.ts
Comment thread src/runtime/bake/production.rs
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.
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.

4 participants