From 7f82a21471b2d595669258b0e632314f5105d64a Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 16:47:48 -0700 Subject: [PATCH 001/137] node:worker_threads: add 5 upstream Node worker tests that already pass 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 --- .../parallel/test-worker-cleanup-handles.js | 29 +++++++++ .../parallel/test-worker-dns-terminate.js | 14 +++++ .../test-worker-http2-stream-terminate.js | 63 +++++++++++++++++++ .../node/test/parallel/test-worker-memory.js | 51 +++++++++++++++ .../test-worker-unsupported-eval-on-url.mjs | 6 ++ 5 files changed, 163 insertions(+) create mode 100644 test/js/node/test/parallel/test-worker-cleanup-handles.js create mode 100644 test/js/node/test/parallel/test-worker-dns-terminate.js create mode 100644 test/js/node/test/parallel/test-worker-http2-stream-terminate.js create mode 100644 test/js/node/test/parallel/test-worker-memory.js create mode 100644 test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs diff --git a/test/js/node/test/parallel/test-worker-cleanup-handles.js b/test/js/node/test/parallel/test-worker-cleanup-handles.js new file mode 100644 index 000000000000..0ed3c747807b --- /dev/null +++ b/test/js/node/test/parallel/test-worker-cleanup-handles.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const fs = require('fs'); +const { Server } = require('net'); +const { Worker, isMainThread, parentPort } = require('worker_threads'); + +if (isMainThread) { + const w = new Worker(__filename); + let fd = null; + w.on('message', common.mustCall((fd_) => { + assert.strictEqual(typeof fd_, 'number'); + fd = fd_; + })); + w.on('exit', common.mustCall(() => { + if (fd === -1) { + // This happens when server sockets don’t have file descriptors, + // i.e. on Windows. + return; + } + assert.throws(() => fs.fstatSync(fd), { code: 'EBADF' }); + })); +} else { + const server = new Server(); + server.listen(0); + parentPort.postMessage(server._handle.fd); + server.unref(); +} diff --git a/test/js/node/test/parallel/test-worker-dns-terminate.js b/test/js/node/test/parallel/test-worker-dns-terminate.js new file mode 100644 index 000000000000..da9d543c3b0b --- /dev/null +++ b/test/js/node/test/parallel/test-worker-dns-terminate.js @@ -0,0 +1,14 @@ +'use strict'; +const common = require('../common'); +const { Worker } = require('worker_threads'); + +const w = new Worker(` +const dns = require('dns'); +dns.lookup('nonexistent.org', () => {}); +require('worker_threads').parentPort.postMessage('0'); +`, { eval: true }); + +w.on('message', common.mustCall(() => { + // This should not crash the worker during a DNS request. + w.terminate().then(common.mustCall()); +})); diff --git a/test/js/node/test/parallel/test-worker-http2-stream-terminate.js b/test/js/node/test/parallel/test-worker-http2-stream-terminate.js new file mode 100644 index 000000000000..128a79c3186d --- /dev/null +++ b/test/js/node/test/parallel/test-worker-http2-stream-terminate.js @@ -0,0 +1,63 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); +const { duplexPair } = require('stream'); +const { parentPort, Worker } = require('worker_threads'); + +// This test ensures that workers can be terminated without error while +// stream activity is ongoing, in particular the C++ function +// ReportWritesToJSStreamListener::OnStreamAfterReqFinished. + +const MAX_ITERATIONS = 5; +const MAX_THREADS = 6; + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + + function spinWorker(iter) { + const w = new Worker(__filename); + w.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'terminate'); + w.terminate(); + })); + + w.on('exit', common.mustCall(() => { + if (iter < MAX_ITERATIONS) + spinWorker(++iter); + })); + } + + for (let i = 0; i < MAX_THREADS; i++) { + spinWorker(0); + } +} else { + const server = http2.createServer(); + let i = 0; + server.on('stream', (stream, headers) => { + if (i === 1) { + parentPort.postMessage('terminate'); + } + i++; + + stream.end(''); + }); + + const [ clientSide, serverSide ] = duplexPair(); + server.emit('connection', serverSide); + + const client = http2.connect('http://localhost:80', { + createConnection: () => clientSide, + }); + + function makeRequests() { + for (let i = 0; i < 3; i++) { + client.request().end(); + } + setImmediate(makeRequests); + } + makeRequests(); +} diff --git a/test/js/node/test/parallel/test-worker-memory.js b/test/js/node/test/parallel/test-worker-memory.js new file mode 100644 index 000000000000..9b115053aab5 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-memory.js @@ -0,0 +1,51 @@ +'use strict'; +const common = require('../common'); +if (common.isIBMi) + common.skip('On IBMi, the rss memory always returns zero'); + +const assert = require('assert'); +const util = require('util'); +const { Worker } = require('worker_threads'); + +let numWorkers = +process.env.JOBS || require('os').availableParallelism(); +if (numWorkers > 20) { + // Cap the number of workers at 20 (as an even divisor of 60 used as + // the total number of workers started) otherwise the test fails on + // machines with high core counts. + numWorkers = 20; +} + +// Verify that a Worker's memory isn't kept in memory after the thread finishes. + +function run(n, done) { + console.log(`run() called with n=${n} (numWorkers=${numWorkers})`); + if (n <= 0) + return done(); + const worker = new Worker( + 'require(\'worker_threads\').parentPort.postMessage(2 + 2)', + { eval: true }); + worker.on('message', common.mustCall((value) => { + assert.strictEqual(value, 4); + })); + worker.on('exit', common.mustCall(() => { + run(n - 1, done); + })); +} + +const startStats = process.memoryUsage(); +let finished = 0; +for (let i = 0; i < numWorkers; ++i) { + run(60 / numWorkers, common.mustCall(() => { + console.log(`done() called (finished=${finished})`); + if (++finished === numWorkers) { + const finishStats = process.memoryUsage(); + // A typical value for this ratio would be ~1.15. + // 5 as a upper limit is generous, but the main point is that we + // don't have the memory of 50 Isolates/Node.js environments just lying + // around somewhere. + assert.ok(finishStats.rss / startStats.rss < 5, + 'Unexpected memory overhead: ' + + util.inspect([startStats, finishStats])); + } + })); +} diff --git a/test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs b/test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs new file mode 100644 index 000000000000..d5ff6a8548d2 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-unsupported-eval-on-url.mjs @@ -0,0 +1,6 @@ +import '../common/index.mjs'; +import assert from 'assert'; +import { Worker } from 'worker_threads'; + +const re = /The property 'options\.eval' must be false when 'filename' is not a string\./; +assert.throws(() => new Worker(new URL(import.meta.url), { eval: true }), re); From f69da29055807c1f3ff63511eb0dcdcc22dbc7c9 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 17:18:17 -0700 Subject: [PATCH 002/137] node:worker_threads: add 3 more upstream Node worker tests that already 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 --- .../test/parallel/test-worker-dispose.mjs | 8 +++ ...t-worker-fshandles-error-on-termination.js | 51 +++++++++++++++++++ ...ker-fshandles-open-close-on-termination.js | 46 +++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 test/js/node/test/parallel/test-worker-dispose.mjs create mode 100644 test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js create mode 100644 test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js diff --git a/test/js/node/test/parallel/test-worker-dispose.mjs b/test/js/node/test/parallel/test-worker-dispose.mjs new file mode 100644 index 000000000000..770c91d51ca7 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-dispose.mjs @@ -0,0 +1,8 @@ +import * as common from '../common/index.mjs'; +import { Worker } from 'node:worker_threads'; + +{ + // Verifies that the worker is async disposable + await using worker = new Worker('for(;;) {}', { eval: true }); + worker.on('exit', common.mustCall()); +} diff --git a/test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js b/test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js new file mode 100644 index 000000000000..e7438c0cc7b2 --- /dev/null +++ b/test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js @@ -0,0 +1,51 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs/promises'); +const { scheduler } = require('timers/promises'); +const { parentPort, Worker } = require('worker_threads'); + +const MAX_ITERATIONS = 5; +const MAX_THREADS = 6; + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + + function spinWorker(iter) { + const w = new Worker(__filename); + w.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'terminate'); + w.terminate(); + })); + + w.on('exit', common.mustCall(() => { + if (iter < MAX_ITERATIONS) + spinWorker(++iter); + })); + } + + for (let i = 0; i < MAX_THREADS; i++) { + spinWorker(0); + } +} else { + async function open_nok() { + await assert.rejects( + fs.open('this file does not exist'), + { + code: 'ENOENT', + syscall: 'open', + } + ); + await scheduler.yield(); + await open_nok(); + } + + // These async function calls never return as they are meant to continually + // open nonexistent files until the worker is terminated. + open_nok(); + open_nok(); + + parentPort.postMessage('terminate'); +} diff --git a/test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js b/test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js new file mode 100644 index 000000000000..db7c0221fcc1 --- /dev/null +++ b/test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js @@ -0,0 +1,46 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs/promises'); +const { scheduler } = require('timers/promises'); +const { parentPort, Worker } = require('worker_threads'); + +const MAX_ITERATIONS = 5; +const MAX_THREADS = 6; + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + + function spinWorker(iter) { + const w = new Worker(__filename); + w.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'terminate'); + w.terminate(); + })); + + w.on('exit', common.mustCall(() => { + if (iter < MAX_ITERATIONS) + spinWorker(++iter); + })); + } + + for (let i = 0; i < MAX_THREADS; i++) { + spinWorker(0); + } +} else { + async function open_close() { + const fh = await fs.open(__filename); + await fh.close(); + await scheduler.yield(); + await open_close(); + } + + // These async function calls never return as they are meant to continually + // open and close files until the worker is terminated. + open_close(); + open_close(); + + parentPort.postMessage('terminate'); +} From 356f93623be42a061623ac941584098963e09348 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 18:00:29 -0700 Subject: [PATCH 003/137] node:worker_threads: drop test-worker-dns-terminate, exempt fshandles 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). 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 --- .../test/parallel/test-worker-dns-terminate.js | 14 -------------- test/no-validate-leaksan.txt | 5 +++++ 2 files changed, 5 insertions(+), 14 deletions(-) delete mode 100644 test/js/node/test/parallel/test-worker-dns-terminate.js diff --git a/test/js/node/test/parallel/test-worker-dns-terminate.js b/test/js/node/test/parallel/test-worker-dns-terminate.js deleted file mode 100644 index da9d543c3b0b..000000000000 --- a/test/js/node/test/parallel/test-worker-dns-terminate.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; -const common = require('../common'); -const { Worker } = require('worker_threads'); - -const w = new Worker(` -const dns = require('dns'); -dns.lookup('nonexistent.org', () => {}); -require('worker_threads').parentPort.postMessage('0'); -`, { eval: true }); - -w.on('message', common.mustCall(() => { - // This should not crash the worker during a DNS request. - w.terminate().then(common.mustCall()); -})); diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 334b94e568ce..a5d5384dc9bd 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -459,3 +459,8 @@ test/js/node/tls/node-tls-connect.test.ts # ("TODO: think about the finalizer here"), so the meta is never freed. All 18 # tests pass; only the exit check fails. Remove once that TODO is resolved. test/bundler/native-plugin.test.ts + +# Terminating a worker with JSC deferred work pending leaks the ConcurrentTask +# allocated for it (JSFinalizationRegistry -> DeferredWorkTimer::scheduleWorkSoon +# -> Bun__queueJSCDeferredWorkTaskConcurrently); the test itself passes. +test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js From f57c53b175d2c76282c0a7ec5d1ccfa729f7cd46 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 18:27:51 -0700 Subject: [PATCH 004/137] node:worker_threads: exempt both fshandles tests from LeakSanitizer The asan shard flagged the sibling too, on a different allocation site: an in-flight AsyncFSTask 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. --- test/no-validate-leaksan.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index a5d5384dc9bd..806ca830b73a 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -460,7 +460,8 @@ test/js/node/tls/node-tls-connect.test.ts # tests pass; only the exit check fails. Remove once that TODO is resolved. test/bundler/native-plugin.test.ts -# Terminating a worker with JSC deferred work pending leaks the ConcurrentTask -# allocated for it (JSFinalizationRegistry -> DeferredWorkTimer::scheduleWorkSoon -# -> Bun__queueJSCDeferredWorkTaskConcurrently); the test itself passes. +# Terminating a worker with work still in flight leaks the ConcurrentTask boxed +# for it (ConcurrentTask::create for an AsyncFSTask, or for JSC deferred +# work via DeferredWorkTimer); the VM is torn down before it runs. Tests pass. +test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js From 2872f8f39b986ee2eed5ad0a30bf40e02a7f9749 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 18:53:27 -0700 Subject: [PATCH 005/137] node:worker_threads: drop the two fshandles tests instead of exempting 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, 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. --- ...t-worker-fshandles-error-on-termination.js | 51 ------------------- ...ker-fshandles-open-close-on-termination.js | 46 ----------------- test/no-validate-leaksan.txt | 6 --- 3 files changed, 103 deletions(-) delete mode 100644 test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js delete mode 100644 test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js diff --git a/test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js b/test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js deleted file mode 100644 index e7438c0cc7b2..000000000000 --- a/test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; - -const common = require('../common'); -const assert = require('assert'); -const fs = require('fs/promises'); -const { scheduler } = require('timers/promises'); -const { parentPort, Worker } = require('worker_threads'); - -const MAX_ITERATIONS = 5; -const MAX_THREADS = 6; - -// Do not use isMainThread so that this test itself can be run inside a Worker. -if (!process.env.HAS_STARTED_WORKER) { - process.env.HAS_STARTED_WORKER = 1; - - function spinWorker(iter) { - const w = new Worker(__filename); - w.on('message', common.mustCall((msg) => { - assert.strictEqual(msg, 'terminate'); - w.terminate(); - })); - - w.on('exit', common.mustCall(() => { - if (iter < MAX_ITERATIONS) - spinWorker(++iter); - })); - } - - for (let i = 0; i < MAX_THREADS; i++) { - spinWorker(0); - } -} else { - async function open_nok() { - await assert.rejects( - fs.open('this file does not exist'), - { - code: 'ENOENT', - syscall: 'open', - } - ); - await scheduler.yield(); - await open_nok(); - } - - // These async function calls never return as they are meant to continually - // open nonexistent files until the worker is terminated. - open_nok(); - open_nok(); - - parentPort.postMessage('terminate'); -} diff --git a/test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js b/test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js deleted file mode 100644 index db7c0221fcc1..000000000000 --- a/test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -const common = require('../common'); -const assert = require('assert'); -const fs = require('fs/promises'); -const { scheduler } = require('timers/promises'); -const { parentPort, Worker } = require('worker_threads'); - -const MAX_ITERATIONS = 5; -const MAX_THREADS = 6; - -// Do not use isMainThread so that this test itself can be run inside a Worker. -if (!process.env.HAS_STARTED_WORKER) { - process.env.HAS_STARTED_WORKER = 1; - - function spinWorker(iter) { - const w = new Worker(__filename); - w.on('message', common.mustCall((msg) => { - assert.strictEqual(msg, 'terminate'); - w.terminate(); - })); - - w.on('exit', common.mustCall(() => { - if (iter < MAX_ITERATIONS) - spinWorker(++iter); - })); - } - - for (let i = 0; i < MAX_THREADS; i++) { - spinWorker(0); - } -} else { - async function open_close() { - const fh = await fs.open(__filename); - await fh.close(); - await scheduler.yield(); - await open_close(); - } - - // These async function calls never return as they are meant to continually - // open and close files until the worker is terminated. - open_close(); - open_close(); - - parentPort.postMessage('terminate'); -} diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 806ca830b73a..334b94e568ce 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -459,9 +459,3 @@ test/js/node/tls/node-tls-connect.test.ts # ("TODO: think about the finalizer here"), so the meta is never freed. All 18 # tests pass; only the exit check fails. Remove once that TODO is resolved. test/bundler/native-plugin.test.ts - -# Terminating a worker with work still in flight leaks the ConcurrentTask boxed -# for it (ConcurrentTask::create for an AsyncFSTask, or for JSC deferred -# work via DeferredWorkTimer); the VM is torn down before it runs. Tests pass. -test/js/node/test/sequential/test-worker-fshandles-error-on-termination.js -test/js/node/test/sequential/test-worker-fshandles-open-close-on-termination.js From c8a6e904f2dee7f8e64b7b4a3a3bf3a483b1a447 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 19:28:31 -0700 Subject: [PATCH 006/137] worker_threads: publish node's 'worker_threads' diagnostics channel 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. --- src/js/node/worker_threads.ts | 9 +++++++++ .../test-diagnostics-channel-worker-threads.js | 11 +++++++++++ 2 files changed, 20 insertions(+) create mode 100644 test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 7262da0f30f7..e0bbf0958878 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -100,6 +100,10 @@ type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; // Used to ensure that Blobs created to hold the source code for `eval: true` Workers get cleaned up // after their Worker exits let urlRevokeRegistry: FinalizationRegistry | undefined = undefined; +// Resolved at module load, as node does (lib/internal/worker.js): resolving it +// lazily inside the constructor would build the channel out of whatever +// Map/Object user code had tampered with by then. +const workerThreadsChannel = require("node:diagnostics_channel").channel("worker_threads"); function injectFakeEmitter(Class) { // Per-instance registry mapping each event to (user listener -> wrapper), so @@ -1088,6 +1092,11 @@ class Worker extends EventEmitter { } urlRevokeRegistry.register(this.#worker, this.#urlToRevoke); } + // node publishes the newly-constructed Worker here, at the end of the + // constructor (lib/internal/worker.js). + if (workerThreadsChannel.hasSubscribers) { + workerThreadsChannel.publish({ worker: this }); + } } get threadId() { diff --git a/test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js b/test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js new file mode 100644 index 000000000000..786b77da1709 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js @@ -0,0 +1,11 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); +const dc = require('diagnostics_channel'); + +dc.subscribe('worker_threads', common.mustCall(({ worker }) => { + assert.strictEqual(worker instanceof Worker, true); +})); + +new Worker('const a = 1;', { eval: true }); From 10fe341683e361a8138b224238a0f6294a3fe680 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 21:22:15 -0700 Subject: [PATCH 007/137] worker_threads: async_hooks WORKER resource, error-clone stack, shared 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. --- src/js/node/async_hooks.ts | 12 +++-- src/js/node/worker_threads.ts | 46 +++++++++++++++++++ src/jsc/VirtualMachine.rs | 14 +++++- src/jsc/bindings/webcore/Worker.cpp | 19 ++++++++ .../test-perf-hooks-worker-timeorigin.js | 20 ++++++++ .../test-worker-error-stack-getter-throws.js | 22 +++++++++ .../node/test/parallel/test-worker-hasref.js | 33 +++++++++++++ 7 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 test/js/node/test/parallel/test-perf-hooks-worker-timeorigin.js create mode 100644 test/js/node/test/parallel/test-worker-error-stack-getter-throws.js create mode 100644 test/js/node/test/parallel/test-worker-hasref.js diff --git a/src/js/node/async_hooks.ts b/src/js/node/async_hooks.ts index 049a9c7e560a..2c4ebe6b4a3d 100644 --- a/src/js/node/async_hooks.ts +++ b/src/js/node/async_hooks.ts @@ -377,16 +377,19 @@ function createHook(hook) { throw $ERR_ASYNC_CALLBACK("hook.promiseResolve"); let enabledInit; - return { + const asyncHook = { enable() { if (init !== undefined && enabledInit === undefined) { - // init is delivered for TickObject resources (process.nextTick); - // other resource types are still unimplemented. + // init is delivered for TickObject (process.nextTick) and WORKER + // resources; other resource types are still unimplemented. // Per-instance wrapper: two hooks registered with the same init // function must stay independently removable (removal is by // identity, and removing the other instance's entry would reorder // its callback relative to unrelated hooks). - enabledInit = (asyncId, type, triggerAsyncId, resource) => init(asyncId, type, triggerAsyncId, resource); + // node invokes init as a method on the AsyncHook instance + // (lib/internal/async_hooks.js), so `this.disable()` inside init works. + enabledInit = (asyncId, type, triggerAsyncId, resource) => + init.$call(asyncHook, asyncId, type, triggerAsyncId, resource); require("internal/async_hooks_tick").tickInitHooks.push(enabledInit); } if (before !== undefined || after !== undefined || destroy !== undefined || promiseResolve !== undefined) { @@ -413,6 +416,7 @@ function createHook(hook) { return this; }, }; + return asyncHook; } const executionAsyncIdNotImpl = createWarning( diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index e0bbf0958878..4d9faba57a76 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -104,6 +104,7 @@ let urlRevokeRegistry: FinalizationRegistry | undefined = undefined; // lazily inside the constructor would build the channel out of whatever // Map/Object user code had tampered with by then. const workerThreadsChannel = require("node:diagnostics_channel").channel("worker_threads"); +const { tickInitHooks, newAsyncId } = require("internal/async_hooks_tick"); function injectFakeEmitter(Class) { // Per-instance registry mapping each event to (user listener -> wrapper), so @@ -932,6 +933,9 @@ class Worker extends EventEmitter { #stderr; #stdoutAutoPipe = false; #stderrAutoPipe = false; + // Mirrors ref()/unref() for the async_hooks WORKER resource's hasRef(); + // undefined once the thread has exited, as node's handle reads back. + #hasRef: boolean | undefined = true; // this is used by terminate(); // either is the exit code if exited, a promise resolving to the exit code, or undefined if we haven't sent .terminate() yet @@ -1097,6 +1101,40 @@ class Worker extends EventEmitter { if (workerThreadsChannel.hasSubscribers) { workerThreadsChannel.publish({ worker: this }); } + this.#emitAsyncHooksInit(); + } + + // node's WORKER resource is the C++ handle, so hasRef() follows ref()/unref() + // and reads back undefined once the handle is gone. Bun delivered init for + // TickObject only; this extends the same array to the type worker consumers + // look for. Only hasRef() is exposed, not the full handle. + #emitAsyncHooksInit() { + const count = tickInitHooks.length; + if (count === 0) return; + const worker = this; + const resource = { + hasRef() { + return worker.#hasRef; + }, + }; + const asyncId = newAsyncId(); + // Snapshot: enable()/disable() from inside a hook must not affect the + // in-flight dispatch (node stages such mutations in tmp_array). + const snapshot = $newArrayWithSize(count); + for (let i = 0; i < count; i++) snapshot[i] = tickInitHooks[i]; + for (let i = 0; i < count; i++) { + try { + snapshot[i](asyncId, "WORKER", 0, resource); + } catch (err) { + // node: a throwing init hook is fatal (fatalError: print + exit 1), + // never surfaced to the `new Worker` caller — which here has already + // spawned the thread. console is user-mutable, so shield the print. + try { + console.error(typeof err?.stack === "string" ? err.stack : err); + } catch {} + process.exit(1); + } + } } get threadId() { @@ -1114,6 +1152,9 @@ class Worker extends EventEmitter { if (!this.#stdoutAutoPipe) this.#stdoutPort?.ref(); if (!this.#stderrAutoPipe) this.#stderrPort?.ref(); this.#stdinPort?.ref(); + // node's ref()/unref() no-op once the handle is gone, leaving hasRef() + // undefined rather than resurrecting it. + if (!this.#exited) this.#hasRef = true; } unref() { @@ -1121,6 +1162,7 @@ class Worker extends EventEmitter { if (!this.#stdoutAutoPipe) this.#stdoutPort?.unref(); if (!this.#stderrAutoPipe) this.#stderrPort?.unref(); this.#stdinPort?.unref(); + if (!this.#exited) this.#hasRef = false; } get stdin() { @@ -1314,6 +1356,10 @@ class Worker extends EventEmitter { this.#stdinPort?.close(); this.#onExitPromise = e.code; this.emit("exit", e.code); + // node's WORKER handle is gone once the thread has exited, so its + // hasRef() reads back undefined. 'exit' listeners ran synchronously above + // and still saw the live value. + this.#hasRef = undefined; } #onError(event: ErrorEvent) { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2dc03fbd4246..14b9902c6e3a 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1978,6 +1978,15 @@ fn get_origin_timestamp() -> u64 { (now - ORIGIN_RELATIVE_EPOCH).max(0) as u64 } +/// `performance.timeOrigin` is the PROCESS start time and every thread reports +/// the same one — a worker's timeOrigin equals the main thread's in node, and +/// `performance.now()` inside a worker is relative to that same origin. Capture +/// it on the first VM so worker VMs inherit it instead of restarting the clock. +fn process_origin() -> (std::time::Instant, u64) { + static ORIGIN: std::sync::OnceLock<(std::time::Instant, u64)> = std::sync::OnceLock::new(); + *ORIGIN.get_or_init(|| (std::time::Instant::now(), get_origin_timestamp())) +} + impl VirtualMachine { /// `VirtualMachine.init(opts)` — allocate + wire the per-thread VM. /// @@ -2069,8 +2078,9 @@ impl VirtualMachine { addr_of_mut!((*vm).pending_internal_promise_reported_at).write(u32::MAX); addr_of_mut!((*vm).on_unhandled_rejection) .write(VirtualMachine::default_on_unhandled_rejection); - addr_of_mut!((*vm).origin_timer).write(std::time::Instant::now()); - addr_of_mut!((*vm).origin_timestamp).write(get_origin_timestamp()); + let (origin_timer, origin_timestamp) = process_origin(); + addr_of_mut!((*vm).origin_timer).write(origin_timer); + addr_of_mut!((*vm).origin_timestamp).write(origin_timestamp); addr_of_mut!((*vm).smol).write(opts.smol); // `Option<{CPU,Heap}ProfilerConfig>` are NOT zero-valid: each // payload contains a `bool`, and rustc picks that field's invalid diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 142bd56baf62..994fdd3cd4be 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -33,6 +33,7 @@ #include "Event.h" #include "EventNames.h" #include "StructuredSerializeOptions.h" +#include #include #include #include @@ -539,6 +540,24 @@ bool Worker::dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSVal auto serialized = SerializedScriptValue::create(*workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); CLEAR_IF_EXCEPTION(scope); + // Cloning an Error reads `stack`, so a throwing Error.prepareStackTrace takes + // the whole error down and the caller reports the pretty-printed text as the + // message instead. Node drops only the unreadable `stack` + // (lib/internal/error_serdes.js TryGetAllProperties); retry once with an own + // undefined `stack` so name/message/code still cross. Safe to mutate: the + // worker's own error event and the fallback message are both already + // materialized by the time we get here, and the thread is terminating. + if (!serialized && !scope.exception()) { + if (auto* errorInstance = dynamicDowncast(value)) { + errorInstance->putDirect(vm, vm.propertyNames->stack, JSC::jsUndefined(), JSC::PropertyAttribute::DontEnum | 0); + // putDirect bypasses the ErrorInstance property overrides, so tell it + // not to re-materialize `stack` (and re-run prepareStackTrace) below. + errorInstance->setStackPropertyAlreadyMaterialized(); + CLEAR_IF_EXCEPTION(scope); + serialized = SerializedScriptValue::create(*workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); + CLEAR_IF_EXCEPTION(scope); + } + } if (!serialized) return false; diff --git a/test/js/node/test/parallel/test-perf-hooks-worker-timeorigin.js b/test/js/node/test/parallel/test-perf-hooks-worker-timeorigin.js new file mode 100644 index 000000000000..a7cf35db904f --- /dev/null +++ b/test/js/node/test/parallel/test-perf-hooks-worker-timeorigin.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +const w = new Worker(` +require('worker_threads').parentPort.postMessage(performance.timeOrigin); +`, { eval: true }); + +w.on('message', common.mustCall((timeOrigin) => { + // PerformanceNodeTiming exposes process milestones so the + // `performance.timeOrigin` in the `worker_threads.Worker` must be the start + // time of the process. + assert.strictEqual(timeOrigin, performance.timeOrigin); +})); + +w.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); +})); diff --git a/test/js/node/test/parallel/test-worker-error-stack-getter-throws.js b/test/js/node/test/parallel/test-worker-error-stack-getter-throws.js new file mode 100644 index 000000000000..108fa3f5143d --- /dev/null +++ b/test/js/node/test/parallel/test-worker-error-stack-getter-throws.js @@ -0,0 +1,22 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +const w = new Worker( + `const fn = (err) => { + if (err.message === 'fhqwhgads') + throw new Error('come on'); + return 'This is my custom stack trace!'; + }; + Error.prepareStackTrace = fn; + throw new Error('fhqwhgads'); + `, + { eval: true } +); +w.on('message', common.mustNotCall()); +w.on('error', common.mustCall((err) => { + assert.strictEqual(err.stack, undefined); + assert.strictEqual(err.message, 'fhqwhgads'); + assert.strictEqual(err.name, 'Error'); +})); diff --git a/test/js/node/test/parallel/test-worker-hasref.js b/test/js/node/test/parallel/test-worker-hasref.js new file mode 100644 index 000000000000..936a144bebba --- /dev/null +++ b/test/js/node/test/parallel/test-worker-hasref.js @@ -0,0 +1,33 @@ +'use strict'; +const common = require('../common'); + +const { Worker } = require('worker_threads'); +const { createHook } = require('async_hooks'); +const assert = require('assert'); + +let handle; + +createHook({ + init(asyncId, type, triggerAsyncId, resource) { + if (type === 'WORKER') { + handle = resource; + this.disable(); + } + } +}).enable(); + +const w = new Worker('', { eval: true }); + +assert.strictEqual(handle.hasRef(), true); +w.unref(); +assert.strictEqual(handle.hasRef(), false); +w.ref(); +assert.strictEqual(handle.hasRef(), true); + +w.on('exit', common.mustCall((exitCode) => { + assert.strictEqual(exitCode, 0); + assert.strictEqual(handle.hasRef(), true); + setTimeout(common.mustCall(() => { + assert.strictEqual(handle.hasRef(), undefined); + }), 0); +})); From c308af4b6b42ca2714642cfc9d24b7a1863c598f Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 02:41:14 -0700 Subject: [PATCH 008/137] BroadcastChannel: ref() should return the channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/jsc/bindings/webcore/JSBroadcastChannel.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp index b4b16dac3fc7..eb6683aa4ec5 100644 --- a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp +++ b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp @@ -387,7 +387,9 @@ static inline JSC::EncodedJSValue jsBroadcastChannelPrototypeFunction_refBody(JS UNUSED_PARAM(throwScope); UNUSED_PARAM(callFrame); auto& impl = castedThis->wrapped(); - RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.jsRef(lexicalGlobalObject); }))); + impl.jsRef(lexicalGlobalObject); + // node's ref() returns the channel so it chains (lib/internal/worker/io.js). + RELEASE_AND_RETURN(throwScope, JSValue::encode(castedThis)); } JSC_DEFINE_HOST_FUNCTION(jsBroadcastChannelPrototypeFunction_ref, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) From f1384cda99c36e612e3ddee9d5762f7fe5c465bf Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 11:25:41 -0700 Subject: [PATCH 009/137] worker_threads: fire 'online' before the entry point runs, as node does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/jsc/bindings/webcore/Worker.cpp | 33 ++++++++++--------- src/jsc/bindings/webcore/Worker.h | 1 + src/jsc/web_worker.rs | 18 +++++----- .../worker_threads/worker_threads.test.ts | 24 ++++++++++++++ 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 994fdd3cd4be..655bf48257f0 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -447,31 +447,29 @@ void Worker::rejectAllCrossVMRequests(JSC::JSGlobalObject* globalObject) // ---- Worker-thread entry points --------------------------------------------- +// Posted before the entry point runs, matching node's 'online', so a worker whose +// top-level never returns still reports online. Deliberately leaves m_state alone: +// Pending queues in postTaskToWorkerGlobalScope, it does not reject. +void Worker::dispatchOnlineEvent() +{ + postTaskToParent([protectedThis = Ref { *this }](ScriptExecutionContext&) { + if (protectedThis->hasEventListeners(eventNames().openEvent)) { + auto event = Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No); + protectedThis->dispatchEvent(event); + } + }); +} + void Worker::dispatchOnline(Zig::GlobalObject* workerGlobalObject) { // Pending→Running under the same lock postTaskToWorkerGlobalScope uses, so // a message post racing this transition either queues (drained below by // fireEarlyMessages) or posts directly — never both, never neither. - // - // This MUST happen BEFORE the open event is posted to the parent: the - // parent's `online` handler may immediately call getHeapSnapshot() (or - // anything else gated on isOnline() / postTaskToWorkerGlobalScope()). If - // the state flip happens after the post, a fast parent thread can run the - // open task while m_state is still Pending and observe - // ERR_WORKER_NOT_RUNNING — flaky `await once(worker, "online"); - // worker.getHeapSnapshot()` in worker_threads.test.ts. { Locker lock(m_pendingTasksMutex); m_state.store(State::Running); } - postTaskToParent([protectedThis = Ref { *this }](ScriptExecutionContext&) { - if (protectedThis->hasEventListeners(eventNames().openEvent)) { - auto event = Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No); - protectedThis->dispatchEvent(event); - } - }); - auto* thisContext = workerGlobalObject->scriptExecutionContext(); if (!thisContext) { return; @@ -727,6 +725,11 @@ extern "C" void WebWorker__entrySettled(Zig::GlobalObject* globalObject) CLEAR_IF_EXCEPTION(scope); } +extern "C" void WebWorker__dispatchOnlineEvent(Worker* worker) +{ + worker->dispatchOnlineEvent(); +} + extern "C" void WebWorker__dispatchOnline(Worker* worker, Zig::GlobalObject* globalObject) { WebWorker__entrySettled(globalObject); diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h index 657597d84ec5..22b9e8ab84e8 100644 --- a/src/jsc/bindings/webcore/Worker.h +++ b/src/jsc/bindings/webcore/Worker.h @@ -130,6 +130,7 @@ class Worker final : public ThreadSafeRefCounted, public EventTargetWith WorkerOptions& options() { return m_options; } // -- Worker-thread entry points (each posts to m_parentContextId) -------- + void dispatchOnlineEvent(); void dispatchOnline(Zig::GlobalObject* workerGlobalObject); void fireEarlyMessages(Zig::GlobalObject* workerGlobalObject); void dispatchErrorWithMessage(WTF::String message); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index a77bacc58bac..141c68d1b4a5 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -30,7 +30,7 @@ //! Lifecycle of the worker thread (`threadMain`): //! 1. `startVM()` — build a mimalloc arena, clone env, initialise a //! `jsc.VirtualMachine`, publish `vm` under `vm_lock`. -//! 2. `spin()` — load the entry point, call `dispatchOnline` + +//! 2. `spin()` — post 'online', load the entry point, call `dispatchOnline` + //! `fireEarlyMessages`, run the event loop until it drains or //! `requested_terminate` is observed, run `beforeExit`. //! 3. `shutdown()` — call `vm.onExit()`, tear down the JSC VM, post @@ -218,6 +218,7 @@ unsafe extern "C" { // Re-declared here (also private in VM.rs) so `thread_main` can take the // API lock as a raw FFI call with NO RAII guard — see the note there. safe fn JSC__VM__getAPILock(vm: &jsc::VM); + safe fn WebWorker__dispatchOnlineEvent(cpp_worker: *mut c_void); safe fn WebWorker__dispatchOnline(cpp_worker: *mut c_void, global: &JSGlobalObject); safe fn WebWorker__fireEarlyMessages(cpp_worker: *mut c_void, global: &JSGlobalObject); safe fn WebWorker__entrySettled(global: &JSGlobalObject); @@ -1091,6 +1092,11 @@ impl WebWorker { return self.shutdown(); } + // node reports 'online' before user code runs, so a worker whose entry + // point never returns still goes online. Only the event goes out here: + // the Pending→Running flip stays put, so message routing is unchanged. + WebWorker__dispatchOnlineEvent(self.cpp_worker); + // `path` borrows the resolver's process-lifetime string store, the // standalone module graph, or `self.unresolved_specifier` — all of // which outlive the worker VM. `vm.main` stores it as a raw BACKREF @@ -1143,13 +1149,9 @@ impl WebWorker { self.flush_logs(vm); log!("[{}] event loop start", self.execution_context_id); - // dispatchOnline fires the parent-side 'open' event and flips the C++ - // state to Running (which routes postMessage directly instead of - // queuing). It is placed after the entry point has loaded so the parent - // observes 'online' only once the worker's top-level code has completed; - // moving it earlier would change that observable ordering. - // `cpp_worker` is the opaque C++-owned handle round-tripped via `safe fn`; - // `vm.global()` yields the live `&JSGlobalObject` published in start_vm. + // Flips the C++ state to Running, which routes postMessage directly + // instead of queuing; the 'online' event already went out before the + // entry point loaded. WebWorker__dispatchOnline(self.cpp_worker, vm.global()); WebWorker__fireEarlyMessages(self.cpp_worker, vm.global()); self.set_status(Status::Running); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index cedccbbc66b8..d71561cac1e9 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -33,6 +33,30 @@ test("support eval in worker", async () => { await worker.terminate(); }); +test("online fires before the entry point finishes", async () => { + // node reports 'online' once the thread has bootstrapped, BEFORE user code + // (lib/internal/worker.js), so a worker whose top-level never returns still + // goes online. Blocking in Atomics.wait keeps the entry point unsettled. + const sab = new SharedArrayBuffer(4); + const signal = new Int32Array(sab); + const worker = new Worker( + `const { workerData } = require("worker_threads"); + Atomics.wait(new Int32Array(workerData), 0, 0);`, + { eval: true, workerData: sab }, + ); + // Registered before the awaits: if 'online' never fires the worker is parked + // in Atomics.wait, and the thread would outlive the test. + try { + await once(worker, "online"); + Atomics.store(signal, 0, 1); + Atomics.notify(signal, 0); + const [code] = await once(worker, "exit"); + expect(code).toBe(0); + } finally { + await worker.terminate(); + } +}); + test("all worker_threads module properties are present", () => { expect(wt).toHaveProperty("getEnvironmentData"); expect(wt).toHaveProperty("isMainThread"); From d7e165939c7ee4a2dd89eef674f3afa99ebe7da8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:30:38 +0000 Subject: [PATCH 010/137] worker_threads: address review feedback and drop test-worker-memory - 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 --- src/js/internal/async_hooks_tick.ts | 6 +-- src/js/node/worker_threads.ts | 9 ++-- .../node/test/parallel/test-worker-memory.js | 51 ------------------- .../broadcast-channel.test.ts | 7 +++ 4 files changed, 16 insertions(+), 57 deletions(-) delete mode 100644 test/js/node/test/parallel/test-worker-memory.js diff --git a/src/js/internal/async_hooks_tick.ts b/src/js/internal/async_hooks_tick.ts index 8b000fceac8a..878be02cea42 100644 --- a/src/js/internal/async_hooks_tick.ts +++ b/src/js/internal/async_hooks_tick.ts @@ -6,9 +6,9 @@ // The array identity must stay stable (push/splice only, never reassign): // the nextTick closure captures it once at setup. // -// Currently only TickObject `init` events are delivered (enough for -// console.log/stream.write tick-coalescing tests); promise, timer and native -// resource events are still unimplemented. +// Currently only TickObject (process.nextTick) and WORKER `init` events are +// delivered; promise, timer and other native resource events are still +// unimplemented. const tickInitHooks = []; let nextAsyncId = 1; diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 23e4e2a111c7..acac42253063 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -958,6 +958,9 @@ class Worker extends EventEmitter { // option accesses below don't throw on `new Worker(file, null)`. options ??= {}; + // Bun's WebWorker honours { ref: false }; hasRef() should agree with it. + if ((options as any).ref === false) this.#hasRef = false; + this.#name = normalizeWorkerName(options.name); const builtinsGeneratorHatesEval = "ev" + "a" + "l"[0]; @@ -1100,12 +1103,12 @@ class Worker extends EventEmitter { } urlRevokeRegistry.register(this.#worker, this.#urlToRevoke); } - // node publishes the newly-constructed Worker here, at the end of the - // constructor (lib/internal/worker.js). + // node's AsyncWrap emits the WORKER init synchronously when the handle is + // constructed mid-constructor, before the dc publish at the end. + this.#emitAsyncHooksInit(); if (workerThreadsChannel.hasSubscribers) { workerThreadsChannel.publish({ worker: this }); } - this.#emitAsyncHooksInit(); } // node's WORKER resource is the C++ handle, so hasRef() follows ref()/unref() diff --git a/test/js/node/test/parallel/test-worker-memory.js b/test/js/node/test/parallel/test-worker-memory.js deleted file mode 100644 index 9b115053aab5..000000000000 --- a/test/js/node/test/parallel/test-worker-memory.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; -const common = require('../common'); -if (common.isIBMi) - common.skip('On IBMi, the rss memory always returns zero'); - -const assert = require('assert'); -const util = require('util'); -const { Worker } = require('worker_threads'); - -let numWorkers = +process.env.JOBS || require('os').availableParallelism(); -if (numWorkers > 20) { - // Cap the number of workers at 20 (as an even divisor of 60 used as - // the total number of workers started) otherwise the test fails on - // machines with high core counts. - numWorkers = 20; -} - -// Verify that a Worker's memory isn't kept in memory after the thread finishes. - -function run(n, done) { - console.log(`run() called with n=${n} (numWorkers=${numWorkers})`); - if (n <= 0) - return done(); - const worker = new Worker( - 'require(\'worker_threads\').parentPort.postMessage(2 + 2)', - { eval: true }); - worker.on('message', common.mustCall((value) => { - assert.strictEqual(value, 4); - })); - worker.on('exit', common.mustCall(() => { - run(n - 1, done); - })); -} - -const startStats = process.memoryUsage(); -let finished = 0; -for (let i = 0; i < numWorkers; ++i) { - run(60 / numWorkers, common.mustCall(() => { - console.log(`done() called (finished=${finished})`); - if (++finished === numWorkers) { - const finishStats = process.memoryUsage(); - // A typical value for this ratio would be ~1.15. - // 5 as a upper limit is generous, but the main point is that we - // don't have the memory of 50 Isolates/Node.js environments just lying - // around somewhere. - assert.ok(finishStats.rss / startStats.rss < 5, - 'Unexpected memory overhead: ' + - util.inspect([startStats, finishStats])); - } - })); -} diff --git a/test/js/web/broadcastchannel/broadcast-channel.test.ts b/test/js/web/broadcastchannel/broadcast-channel.test.ts index fd8114dc58fb..877809ffecfd 100644 --- a/test/js/web/broadcastchannel/broadcast-channel.test.ts +++ b/test/js/web/broadcastchannel/broadcast-channel.test.ts @@ -31,6 +31,13 @@ test("broadcast channel properties", () => { c1.close(); }); +test("ref() and unref() return the channel", () => { + const bc = new BroadcastChannel("ref-return"); + expect(bc.ref()).toBe(bc); + expect(bc.unref()).toBe(bc); + bc.close(); +}); + test("broadcast channel worker wait", done => { var worker = new Worker(new URL("broadcast-channel-worker-simple.ts", import.meta.url).href); worker.ref(); From c7d3e81cb52579f7413db29cdbb3730b4f171cbc Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 13:13:10 -0700 Subject: [PATCH 011/137] cli: support node's -pe alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun -pe "1+1"` printed a ReferenceError for `e`. `-p` is declared `-p, --print ` 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. --- src/clap/comptime.rs | 1 + src/clap/lib.rs | 6 ++++++ src/clap/streaming.rs | 13 ++++++++++++- src/install/PackageManager/CommandLineArguments.rs | 1 + src/runtime/cli/Arguments.rs | 10 ++++++++++ src/runtime/node/node_process.rs | 7 +++++++ test/js/node/test/parallel/test-preload-worker.js | 10 ++++++++++ 7 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 test/js/node/test/parallel/test-preload-worker.js diff --git a/src/clap/comptime.rs b/src/clap/comptime.rs index 15bef04b8691..c80a0641316e 100644 --- a/src/clap/comptime.rs +++ b/src/clap/comptime.rs @@ -571,6 +571,7 @@ impl ComptimeClap { diagnostic: opt.diagnostic, state: streaming::State::Normal, positional: None, + short_aliases: opt.short_aliases, }; while let Some(arg) = stream.next()? { diff --git a/src/clap/lib.rs b/src/clap/lib.rs index bd3ae1f24d4b..613c3ea0fc6b 100644 --- a/src/clap/lib.rs +++ b/src/clap/lib.rs @@ -414,6 +414,10 @@ impl Default for Help { pub struct ParseOptions<'a> { pub diagnostic: Option<&'a mut Diagnostic>, pub stop_after_positional_at: usize, + /// Whole-token rewrites applied only where a token is being classified as a + /// flag, never to an option's value or a `--` target. Node keeps its own + /// aliases on exactly that branch (node_options-inl.h). + pub short_aliases: &'static [(&'static [u8], &'static [u8])], } // Help/usage/error rendering — none of this is on the cold-start hot chain @@ -507,6 +511,7 @@ pub fn parse( ParseOptions { diagnostic: opt.diagnostic, stop_after_positional_at: opt.stop_after_positional_at, + short_aliases: opt.short_aliases, }, )?; Ok(Args { clap, exe_arg }) @@ -527,6 +532,7 @@ pub fn parse_with_table( ParseOptions { diagnostic: opt.diagnostic, stop_after_positional_at: opt.stop_after_positional_at, + short_aliases: opt.short_aliases, }, )?; Ok(Args { clap, exe_arg }) diff --git a/src/clap/streaming.rs b/src/clap/streaming.rs index b139a4f4bc01..b3dc193c269d 100644 --- a/src/clap/streaming.rs +++ b/src/clap/streaming.rs @@ -61,6 +61,7 @@ pub struct StreamingClap<'p, 'a, Id, ArgIterator> { pub state: State<'a>, pub positional: Option<&'p clap::Param>, pub diagnostic: Option<&'p mut clap::Diagnostic>, + pub short_aliases: &'static [(&'static [u8], &'static [u8])], } // ArgIterator is the @@ -300,9 +301,17 @@ where } fn parse_next_arg(&mut self) -> Result>, ArgError> { - let Some(full_arg) = self.iter.next() else { + let Some(mut full_arg) = self.iter.next() else { return Ok(None); }; + // Only tokens reaching here are being read as flags: option values and + // `--` targets are pulled straight off `iter`, so they stay verbatim. + for (from, to) in self.short_aliases { + if full_arg == *from { + full_arg = to; + break; + } + } if full_arg == b"--" || full_arg == b"-" { return Ok(Some(ArgInfo { arg: full_arg, @@ -354,6 +363,7 @@ mod tests { remain: args_strings, }; let mut c = StreamingClap:: { + short_aliases: &[], params, iter: &mut iter, state: State::Normal, @@ -386,6 +396,7 @@ mod tests { remain: args_strings, }; let mut c = StreamingClap:: { + short_aliases: &[], params, iter: &mut iter, state: State::Normal, diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index 498787b95e23..73fb09365bed 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -1010,6 +1010,7 @@ Full documentation is available at https://bun.com/docs/cli/pm#scan. clap::ParseOptions { diagnostic: Some(&mut diag), stop_after_positional_at: 0, + ..Default::default() }, ) { Ok(a) => { diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 14e20c0e7b5a..88b478708f52 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -717,6 +717,11 @@ pub(crate) static Bun__Node__UseSystemCA: core::sync::atomic::AtomicBool = // `crate::cli::arguments::load_config*` callers are unaffected. pub use bun_bunfig::arguments::{load_config, load_config_path, load_config_with_cmd_args}; +/// node aliases `-pe` to `--print --eval` as a whole token (node_options.cc): +/// it can't be a short in either runtime, being ambiguous with `-p` carrying +/// the attached value `e`. Bun's `-p` takes the code, so `-pe X` is `-p X`. +pub const NODE_SHORT_ALIASES: &[(&[u8], &[u8])] = &[(b"-pe", b"-p")]; + /// Parse `argv` into `api::TransformOptions` for the given subcommand. /// /// `command::tag_params(cmd)` does a runtime lookup of the per-subcommand @@ -735,6 +740,11 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result 1, _ => 0, }, + // Only the paths standing in for `node` get node's aliases. + short_aliases: match cmd { + CommandTag::AutoCommand | CommandTag::RunAsNodeCommand => NODE_SHORT_ALIASES, + _ => &[], + }, }, ) { Ok(a) => a, diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 7f9d124c6a75..7e647e6f2359 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -298,6 +298,13 @@ mod _impl { } } } + // Node's whole-token aliases are not params, so they never + // land above; an alias takes a value iff its target does. + for (from, to) in crate::cli::arguments::NODE_SHORT_ALIASES { + if set.contains(to) { + bun_core::handle_oom(set.insert(from)); + } + } set }); diff --git a/test/js/node/test/parallel/test-preload-worker.js b/test/js/node/test/parallel/test-preload-worker.js new file mode 100644 index 000000000000..552698c2fce4 --- /dev/null +++ b/test/js/node/test/parallel/test-preload-worker.js @@ -0,0 +1,10 @@ +'use strict'; + +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const worker = fixtures.path('worker-preload.js'); +const { exec } = require('child_process'); +const kNodeBinary = process.argv[0]; + + +exec(...common.escapePOSIXShell`"${kNodeBinary}" -r "${worker}" -pe "1+1"`, common.mustSucceed()); From 80aae50f2a6e52d4b0d8933df73ca829468b0249 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 14:23:40 -0700 Subject: [PATCH 012/137] worker_threads: profile worker threads under --cpu-prof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node writes one CPU profile per thread when the process is started with --cpu-prof, and honours a Worker's own execArgv. Bun set cpu_profiler_config only on the main-thread run path, so a process with a worker wrote 1 profile where node writes 2, and `execArgv: ['--cpu-prof']` did nothing at all. VirtualMachine::on_exit already writes whatever config its VM carries, so the worker side only needed the config and a profiler start. Inheritance follows node: execArgv absent means inherit the parent's, and an explicitly-provided list — even an empty one — replaces it, so a worker with `execArgv: []` is not profiled (node_worker.cc resets to fresh defaults whenever execArgv is given). Bun already carries that distinction as Option<&[...]> from WorkerOptions; the worker's own --cpu-prof simply wins. The sampling interval is a thread_local, so it is set on the worker thread — without that a worker silently sampled at the 1000us default instead of the requested rate (312 vs 1529 samples at --cpu-prof-interval 100). Default profile filenames gain the thread id for workers only, so concurrent writes can't land on the same name; the main thread keeps the name it has always had. A custom --cpu-prof-name still collides across threads, which is node's behaviour too (it only thread-stamps the default name). --cpu-prof-dir/-name are not read from a worker's execArgv: their values would have to outlive the parse, and no test needs them. set_sampling_interval now clamps instead of panicking — a Worker's execArgv can reach it, and '--cpu-prof-interval 3000000000' parses as u32 but not as c_int. Verified against node v26.3.0 — profiles written, per worker options: no execArgv node 2 bun 2 execArgv: [] node 1 bun 1 execArgv: ['--no-addons'] node 1 bun 1 test-cpu-prof-dir-worker and test-cpu-prof-worker-argv both go 0/3 -> 5/5; node itself is 10/10 on the latter. Bun's worker_threads suite is unchanged. --- src/jsc/BunCPUProfiler.rs | 36 +++++++++-- src/jsc/VirtualMachine.rs | 18 ++++-- src/jsc/web_worker.rs | 61 +++++++++++++------ src/runtime/cli/run_command.rs | 9 ++- src/runtime/jsc_hooks.rs | 35 +++++++---- .../sequential/test-cpu-prof-dir-worker.js | 54 ++++++++++++++++ .../sequential/test-cpu-prof-worker-argv.js | 38 ++++++++++++ 7 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 test/js/node/test/sequential/test-cpu-prof-dir-worker.js create mode 100644 test/js/node/test/sequential/test-cpu-prof-worker-argv.js diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index ff5ca57bc3f2..06993057c5d4 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -16,6 +16,7 @@ pub(crate) enum ProfilerError { FilenameTooLong, } +#[derive(Clone, Copy)] pub struct CPUProfilerConfig { // CLI-arg-backed and // process-lifetime, so `&'static` is sound (no struct lifetime params). @@ -24,6 +25,9 @@ pub struct CPUProfilerConfig { pub md_format: bool, pub json_format: bool, pub interval: u32, + /// 0 for the main thread; a worker's execution context id otherwise. Only + /// used to keep concurrently-written default filenames distinct. + pub thread_id: u32, } impl Default for CPUProfilerConfig { @@ -34,10 +38,25 @@ impl Default for CPUProfilerConfig { md_format: false, json_format: false, interval: 1000, + thread_id: 0, } } } +/// The main thread's `--cpu-prof` config, published at startup so worker +/// VMs can inherit it: node profiles every thread when `--cpu-prof` is passed to +/// the process, and writes one profile per thread. +static INHERITED_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn publish_inherited_config(config: CPUProfilerConfig) { + let _ = INHERITED_CONFIG.set(config); +} + +/// The config a newly-started worker VM should profile with, if any. +pub fn inherited_config_for_worker(thread_id: u32) -> Option { + INHERITED_CONFIG.get().map(|c| CPUProfilerConfig { thread_id, ..*c }) +} + // C++ function declarations unsafe extern "C" { /// `VM` is an opaque `UnsafeCell`-backed ZST handle; `&mut VM` is @@ -56,7 +75,9 @@ unsafe extern "C" { } pub fn set_sampling_interval(interval: u32) { - Bun__setSamplingInterval(c_int::try_from(interval).expect("int cast")); + // Reachable from a Worker's execArgv, so an out-of-range value must clamp + // rather than panic. + Bun__setSamplingInterval(c_int::try_from(interval).unwrap_or(c_int::MAX)); } pub fn start_cpu_profiler(vm: &mut VM) { @@ -174,7 +195,7 @@ fn build_output_path( } } } else { - generate_default_filename(&mut filename_buf, is_md_format)? + generate_default_filename(&mut filename_buf, is_md_format, config.thread_id)? }; // Append directory if specified @@ -193,6 +214,7 @@ fn build_output_path( fn generate_default_filename( buf: &mut PathBuffer, md_format: bool, + thread_id: u32, ) -> Result<&[u8], ProfilerError> { // Generate filename like: CPU.{timestamp}.{pid}.cpuprofile (or .md for markdown format) // Use microsecond timestamp for uniqueness @@ -214,8 +236,14 @@ fn generate_default_filename( let extension: &str = if md_format { ".md" } else { ".cpuprofile" }; let mut cursor = std::io::Cursor::new(&mut buf[..]); - write!(cursor, "CPU.{}.{}{}", epoch_microseconds, pid, extension) - .map_err(|_| ProfilerError::FilenameTooLong)?; + // Worker threads carry their id so concurrent writes can't land on the same + // name; the main thread keeps the name it has always had. + if thread_id == 0 { + write!(cursor, "CPU.{}.{}{}", epoch_microseconds, pid, extension) + } else { + write!(cursor, "CPU.{}.{}.{}{}", epoch_microseconds, pid, thread_id, extension) + } + .map_err(|_| ProfilerError::FilenameTooLong)?; let len = usize::try_from(cursor.position()).expect("int cast"); Ok(&buf[..len]) } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index ed1851bb07b9..11b4386c48d0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1645,6 +1645,15 @@ extern crate alloc; /// casts back on the other side of each hook. pub type RuntimeState = *mut c_void; +/// The subset of a Worker's `execArgv` that bun acts on. Flags whose value must +/// outlive the parse (--cpu-prof-dir/-name) are absent; nothing needs them yet. +#[derive(Default, Clone, Copy)] +pub struct WorkerExecArgv { + pub allow_addons: Option, + pub cpu_prof: bool, + pub cpu_prof_interval: Option, +} + pub struct RuntimeHooks { /// `bun.api.Timer.All.init()` + `Body.Value.HiveAllocator.init()` + /// `configureDebugger()` — everything `init()` does that names a @@ -1775,11 +1784,12 @@ pub struct RuntimeHooks { /// (`!args.flag("--no-addons")`), or `None` if parsing failed. /// The param table lives in /// `bun_runtime::cli` (forward-dep). Only `--no-addons` is honoured; - /// the caller writes the returned bool back into + /// the caller writes the returned `allow_addons` back into /// `transform_options.allow_addons` so the override semantics - /// ("override the existing even if it was set") match. - pub parse_worker_exec_argv_allow_addons: - unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> Option, + /// ("override the existing even if it was set") match, and applies + /// `cpu_prof` to the worker VM. + pub parse_worker_exec_argv: + unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> WorkerExecArgv, /// `CronJob.clearAllForVM(vm, .teardown)`. `CronJob` lives in /// `bun_runtime::api::cron`. pub cron_clear_all_teardown: fn(vm: &mut VirtualMachine), diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index f3bac82f6b74..e7c5f4df284a 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -859,23 +859,24 @@ impl WebWorker { // and passes the owned struct as `args` to the new VM. let mut transform_options = (*parent.transpiler.options.transform_options).clone(); - if let Some(exec_argv) = self.exec_argv() { - // Parse `execArgv` with the - // RunCommand param table. The param table lives in - // `bun_runtime::cli` (forward-dep), so dispatch through - // `RuntimeHooks::parse_worker_exec_argv_allow_addons`. Currently - // only honours `--no-addons`; the hook owns the temporary UTF-8 - // alloc + clap parse + `args.deinit()`. `None` on parse failure - // (the parent's setting is kept). - - // SAFETY: `exec_argv` borrows C++ `WorkerOptions` kept alive by the - // owning `WebCore::Worker` for `self`'s lifetime; the hook only - // reads the slice and owns its own temporary allocations. - let parsed = unsafe { (hooks.parse_worker_exec_argv_allow_addons)(exec_argv) }; - if let Some(allow_addons) = parsed { - // override the existing even if it was set - transform_options.allow_addons = Some(allow_addons); - } + // Honours `--no-addons` and `--cpu-prof`; the hook owns the temporary + // UTF-8 allocs. The param table lives in `bun_runtime::cli` (forward-dep), + // so dispatch through `RuntimeHooks::parse_worker_exec_argv`. + // + // SAFETY: `exec_argv` borrows C++ `WorkerOptions` kept alive by the + // owning `WebCore::Worker` for `self`'s lifetime; the hook only reads + // the slice and owns its own temporary allocations. + // `None` means "inherit from the parent" (WorkerOptions.h); an explicit + // list — even an empty one — replaces the parent's, as node resets to + // fresh defaults whenever execArgv is given (node_worker.cc). + let own_exec_argv = self.exec_argv(); + let exec_argv = match own_exec_argv { + Some(a) => unsafe { (hooks.parse_worker_exec_argv)(a) }, + None => Default::default(), + }; + if let Some(allow_addons) = exec_argv.allow_addons { + // override the existing even if it was set + transform_options.allow_addons = Some(allow_addons); } // worker-thread only field; no other thread reads `arena`. @@ -961,6 +962,32 @@ impl WebWorker { vm_ref.is_main_thread = false; VirtualMachine::set_is_main_thread_vm(false); vm_ref.on_unhandled_rejection = on_unhandled_rejection; + + // Node profiles every thread; own execArgv wins, and only a worker + // inheriting the parent's picks up the process-wide --cpu-prof. + let profile = if exec_argv.cpu_prof { + let mut config = crate::bun_cpu_profiler::CPUProfilerConfig { + json_format: true, + thread_id: self.execution_context_id, + ..Default::default() + }; + if let Some(interval) = exec_argv.cpu_prof_interval { + config.interval = interval; + } + Some(config) + } else if own_exec_argv.is_none() { + crate::bun_cpu_profiler::inherited_config_for_worker(self.execution_context_id) + } else { + None + }; + if let Some(config) = profile { + vm_ref.cpu_profiler_config = Some(config); + // thread_local, so it must be set from this thread or the + // worker samples at the default rather than the requested rate. + crate::bun_cpu_profiler::set_sampling_interval(config.interval); + // SAFETY: `jsc_vm` is set by `init_worker` above. + crate::bun_cpu_profiler::start_cpu_profiler(unsafe { &mut *vm_ref.jsc_vm }); + } } // Publish `vm` now (rather than at the end of startVM) so that: diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 28ed08bfbaeb..065850175fd1 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1370,13 +1370,18 @@ impl Run { let name: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.name.as_ref()) }; // SAFETY: same process-lifetime erasure as `name` above. let dir: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.dir.as_ref()) }; - vm.cpu_profiler_config = Some(bun_jsc::bun_cpu_profiler::CPUProfilerConfig { + let config = bun_jsc::bun_cpu_profiler::CPUProfilerConfig { name, dir, md_format: opts.md_format, json_format: opts.json_format, interval: opts.interval, - }); + thread_id: 0, + }; + vm.cpu_profiler_config = Some(config); + // Node profiles every thread when the process gets --cpu-prof, so + // publish for worker VMs to pick up as they start. + bun_jsc::bun_cpu_profiler::publish_inherited_config(config); bun_jsc::bun_cpu_profiler::set_sampling_interval(opts.interval); // SAFETY: `vm.jsc_vm` set in `init`. bun_jsc::bun_cpu_profiler::start_cpu_profiler(unsafe { &mut *vm.jsc_vm }); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index dba1935522fa..227a49520903 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1480,7 +1480,7 @@ pub(crate) static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { console_print_runtime_object, load_standalone_sourcemap, apply_standalone_runtime_flags, - parse_worker_exec_argv_allow_addons, + parse_worker_exec_argv, cron_clear_all_teardown, cron_clear_all_reload, terminate_all_workers_and_wait, @@ -1523,20 +1523,21 @@ unsafe fn apply_standalone_runtime_flags( /// /// Note: the Rust `bun_clap::parse_ex` port currently constrains /// `ArgIter<'static>` (parsed values are stored by reference), which would -/// force leaking the per-call UTF-8 copies of `exec_argv`. Spec only ever -/// reads the single `--no-addons` flag from the result (per the in-tree -/// `// TODO: currently this only checks for --no-addons`), so this body scans -/// the converted argv directly with the same `stop_after_positional_at = 1` -/// short-circuit. Full clap routing can return when `ComptimeClap` grows a -/// borrowed-lifetime variant. +/// force leaking the per-call UTF-8 copies of `exec_argv`. Only flags whose +/// values need not outlive the parse are read, so this body scans the converted +/// argv directly with the same `stop_after_positional_at = 1` short-circuit. +/// Full clap routing can return when `ComptimeClap` grows a borrowed-lifetime +/// variant. /// /// # Safety /// Each `WTFStringImpl` in `exec_argv` is a live WTF string (the C++ /// `Worker::create` array, kept alive for the worker's lifetime). -unsafe fn parse_worker_exec_argv_allow_addons( +unsafe fn parse_worker_exec_argv( exec_argv: &[bun_core::WTFStringImpl], -) -> Option { +) -> bun_jsc::virtual_machine::WorkerExecArgv { + let mut out = bun_jsc::virtual_machine::WorkerExecArgv::default(); let mut no_addons = false; + let mut want_interval = false; for &arg in exec_argv { if arg.is_null() { continue; @@ -1544,6 +1545,11 @@ unsafe fn parse_worker_exec_argv_allow_addons( // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. let owned = unsafe { &*arg }.to_owned_slice_z(); let bytes = owned.as_bytes(); + if want_interval { + want_interval = false; + out.cpu_prof_interval = std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()); + continue; + } // `stop_after_positional_at = 1` — first non-flag token ends parsing. if bytes.first() != Some(&b'-') { break; @@ -1553,10 +1559,17 @@ unsafe fn parse_worker_exec_argv_allow_addons( } if bytes == b"--no-addons" { no_addons = true; + } else if bytes == b"--cpu-prof" { + out.cpu_prof = true; + } else if bytes == b"--cpu-prof-interval" { + want_interval = true; + } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-interval=") { + out.cpu_prof_interval = std::str::from_utf8(v).ok().and_then(|s| s.parse().ok()); } } - // Override `allow_addons` unconditionally on successful parse. - Some(!no_addons) + // Override `allow_addons` unconditionally. + out.allow_addons = Some(!no_addons); + out } /// `jsc.API.cron.CronJob.clearAllForVM(vm, .teardown)` — diff --git a/test/js/node/test/sequential/test-cpu-prof-dir-worker.js b/test/js/node/test/sequential/test-cpu-prof-dir-worker.js new file mode 100644 index 000000000000..dd827414c5fe --- /dev/null +++ b/test/js/node/test/sequential/test-cpu-prof-dir-worker.js @@ -0,0 +1,54 @@ +'use strict'; + +// This tests that --cpu-prof-dir works for workers. + +const common = require('../common'); +const fixtures = require('../common/fixtures'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const fs = require('fs'); +const { spawnSync } = require('child_process'); + +const tmpdir = require('../common/tmpdir'); +const { + getCpuProfiles, + kCpuProfInterval, + env, + getFrames, +} = require('../common/cpu-prof'); + +// --cpu-prof-dir with worker +{ + tmpdir.refresh(); + const output = spawnSync(process.execPath, [ + '--cpu-prof-interval', + kCpuProfInterval, + '--cpu-prof-dir', + 'prof', + '--cpu-prof', + fixtures.path('workload', 'fibonacci-worker.js'), + ], { + cwd: tmpdir.path, + env, + }); + if (output.status !== 0) { + console.log(output.stderr.toString()); + } + assert.strictEqual(output.status, 0); + const dir = tmpdir.resolve('prof'); + assert(fs.existsSync(dir)); + const profiles = getCpuProfiles(dir); + assert.strictEqual(profiles.length, 2); + const profile1 = getFrames(profiles[0], 'fibonacci.js'); + const profile2 = getFrames(profiles[1], 'fibonacci.js'); + if (profile1.frames.length === 0 && profile2.frames.length === 0) { + // Show native debug output and the profile for debugging. + console.log(output.stderr.toString()); + console.log('CPU path: ', profiles[0]); + console.log(profile1.nodes); + console.log('CPU path: ', profiles[1]); + console.log(profile2.nodes); + } + assert(profile1.frames.length > 0 || profile2.frames.length > 0); +} diff --git a/test/js/node/test/sequential/test-cpu-prof-worker-argv.js b/test/js/node/test/sequential/test-cpu-prof-worker-argv.js new file mode 100644 index 000000000000..37af0b95b7ea --- /dev/null +++ b/test/js/node/test/sequential/test-cpu-prof-worker-argv.js @@ -0,0 +1,38 @@ +'use strict'; + +// This tests that --cpu-prof generates CPU profile from worker +// when execArgv is set. + +const common = require('../common'); +const fixtures = require('../common/fixtures'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +const tmpdir = require('../common/tmpdir'); +const { + getCpuProfiles, + kCpuProfInterval, + verifyFrames, +} = require('../common/cpu-prof'); + +{ + tmpdir.refresh(); + const output = spawnSync(process.execPath, [ + fixtures.path('workload', 'fibonacci-worker-argv.js'), + ], { + cwd: tmpdir.path, + env: { + ...process.env, + CPU_PROF_INTERVAL: kCpuProfInterval, + }, + }); + if (output.status !== 0) { + console.log(output.stderr.toString()); + } + assert.strictEqual(output.status, 0); + const profiles = getCpuProfiles(tmpdir.path); + assert.strictEqual(profiles.length, 1); + verifyFrames(output, profiles[0], 'fibonacci.js'); +} From e61bfa4e63f850cc4bf35e7cb71c7be4b645ba83 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:27:03 +0000 Subject: [PATCH 013/137] cli: scope the -pe alias to the bun/node entry points in execArgv create_exec_argv added -pe to the value-taking set unconditionally, so bun run -pe script reported the script path as part of execArgv even though Arguments::parse only applies the alias under AutoCommand and RunAsNodeCommand. Check the alias at the use site, gated on seen_run, instead of baking it into the static set. --- src/runtime/node/node_process.rs | 16 ++++++++-------- test/js/node/process/process.test.js | 12 ++++++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 7e647e6f2359..eb98af42018c 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -298,18 +298,18 @@ mod _impl { } } } - // Node's whole-token aliases are not params, so they never - // land above; an alias takes a value iff its target does. - for (from, to) in crate::cli::arguments::NODE_SHORT_ALIASES { - if set.contains(to) { - bun_core::handle_oom(set.insert(from)); - } - } set }); if let Some(p) = prev { - if MAP.contains(p) { + // Node's whole-token aliases only apply on the `bun`/`node` + // entry points (Arguments::parse scopes them the same way). + let takes_value = MAP.contains(p) + || (!seen_run + && crate::cli::arguments::NODE_SHORT_ALIASES + .iter() + .any(|(from, to)| *from == p && MAP.contains(to))); + if takes_value { args.push(BunString::clone_utf8(arg)); prev = Some(arg); continue; diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 6f8df13f6b3b..34ae88b61573 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1178,6 +1178,18 @@ it("process.execArgv", async () => { } }); +it("process.execArgv with node's -pe alias", async () => { + // `bun -pe X`: node's whole-token alias, X is -p's value. + const auto = await Bun.$`${bunExe()} -pe ${"JSON.stringify(process.execArgv)"}`.text(); + expect(JSON.parse(auto)).toEqual(["-pe", "JSON.stringify(process.execArgv)"]); + + // `bun run -pe script`: the alias is scoped to the bun/node entry points, so + // the script name must not be swallowed into execArgv. + const script = join(__dirname, "print-process-execArgv.js"); + const run = await Bun.$`${bunExe()} run -pe ${script}`.text(); + expect(JSON.parse(run.split("\n")[0])).toEqual({ execArgv: ["-pe"], argv: [] }); +}); + describe("process.exitCode", () => { it("normal", async () => { await runInlineFixture( From 4f599cf2b0c0f37dd8e80e24ca05c788cb8ed3d1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:28:04 +0000 Subject: [PATCH 014/137] [autofix.ci] apply automated fixes --- src/jsc/BunCPUProfiler.rs | 10 ++++++++-- src/jsc/VirtualMachine.rs | 3 +-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index 06993057c5d4..3329351cd59d 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -54,7 +54,9 @@ pub fn publish_inherited_config(config: CPUProfilerConfig) { /// The config a newly-started worker VM should profile with, if any. pub fn inherited_config_for_worker(thread_id: u32) -> Option { - INHERITED_CONFIG.get().map(|c| CPUProfilerConfig { thread_id, ..*c }) + INHERITED_CONFIG + .get() + .map(|c| CPUProfilerConfig { thread_id, ..*c }) } // C++ function declarations @@ -241,7 +243,11 @@ fn generate_default_filename( if thread_id == 0 { write!(cursor, "CPU.{}.{}{}", epoch_microseconds, pid, extension) } else { - write!(cursor, "CPU.{}.{}.{}{}", epoch_microseconds, pid, thread_id, extension) + write!( + cursor, + "CPU.{}.{}.{}{}", + epoch_microseconds, pid, thread_id, extension + ) } .map_err(|_| ProfilerError::FilenameTooLong)?; let len = usize::try_from(cursor.position()).expect("int cast"); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 11b4386c48d0..a305307eac34 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1788,8 +1788,7 @@ pub struct RuntimeHooks { /// `transform_options.allow_addons` so the override semantics /// ("override the existing even if it was set") match, and applies /// `cpu_prof` to the worker VM. - pub parse_worker_exec_argv: - unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> WorkerExecArgv, + pub parse_worker_exec_argv: unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> WorkerExecArgv, /// `CronJob.clearAllForVM(vm, .teardown)`. `CronJob` lives in /// `bun_runtime::api::cron`. pub cron_clear_all_teardown: fn(vm: &mut VirtualMachine), From fb8b3d376fac9e263d06f0bc508a55d255204da9 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 14:35:05 -0700 Subject: [PATCH 015/137] cpu profiler: clamp the sampling interval to a usable range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --cpu-prof-interval 0 hung the process: the sampler never fires and the profile is never written (30s timeout, 0 profiles). Pre-existing on the CLI, but worker execArgv now reaches the same setter from JS, where a hang is a DoS. Clamp to [1, c_int::MAX]. The upper bound also covers a value that fits u32 but not c_int, which panicked the cast. bun --cpu-prof --cpu-prof-interval 0 -e '...' 30s timeout -> 0s, 1 profile new Worker(f, { execArgv: [..., '0'] }) 30s timeout -> exit 0 --cpu-prof-interval 100 with a worker 2 profiles, unchanged Reported by coderabbitai on #34424. Its other two points on the same parse — non-UTF-8 and malformed values — need no change: they fall back to the default, which is what Bun's own CLI (unwrap_or(1000)) and node both do. --- src/jsc/BunCPUProfiler.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index 3329351cd59d..c1ea5fa000f1 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -77,9 +77,10 @@ unsafe extern "C" { } pub fn set_sampling_interval(interval: u32) { - // Reachable from a Worker's execArgv, so an out-of-range value must clamp - // rather than panic. - Bun__setSamplingInterval(c_int::try_from(interval).unwrap_or(c_int::MAX)); + // Reachable from a Worker's execArgv: 0 stalls the sampler and the process + // never exits, and a value past c_int would panic the cast. + let clamped = interval.clamp(1, c_int::MAX as u32); + Bun__setSamplingInterval(clamped as c_int); } pub fn start_cpu_profiler(vm: &mut VM) { From dbe27a00872825208fcbb573e695de97356df008 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 15:01:51 -0700 Subject: [PATCH 016/137] tls: make --use-system-ca a per-Environment option, add --no-use-system-ca MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node treats --use-system-ca as an Environment option: a Worker's execArgv can enable or disable it independently of the process. Bun kept the decision in two process-global atomics, so a worker could not differ and the two upstream tests covering exactly that had nowhere to land. Node's actual model, measured against v26.3.0 (env -u NODE_EXTRA_CA_CERTS): baseline 120 (= bundled) NODE_USE_SYSTEM_CA=1 122 env adds the system store --no-use-system-ca + env=1 120 the flag wins, env ignored --use-bundled-ca + env=1 122 env still wins So the intent is three-valued, not a bool: unset lets NODE_USE_SYSTEM_CA decide, and only the explicit negation overrides it. --use-bundled-ca deliberately does not, which is why it maps to None rather than Some(false). VirtualMachine carries Option; getUseSystemCA() returns true/false/ undefined and tls.ts only consults the env var when it is undefined. A worker takes its own execArgv when one was given — an explicit list, even empty, replaces the parent's rather than inheriting, as node resets to fresh defaults whenever execArgv is present — and otherwise inherits the parent's intent. --no-use-system-ca is new (node gets the negation from its generic --no- prefix) and it restricts trust for real, not just for reporting: root_certs.cpp checks it before both Bun__Node__UseSystemCA and getenv, and Arguments no longer lets NODE_USE_SYSTEM_CA select the System store underneath it. A flag whose whole purpose is to restrict trust must not leave connections trusting the system store while getCACertificates() claims otherwise. bun test parses the CA flags too, so its VM is seeded like run/repl; without that `bun test --use-system-ca` would have started under-reporting. test-tls-get-ca-certificates-worker-{,no-}use-system-ca: 0/2 -> 5/5. All 12 vendored get-ca tests pass; tls suite 178 pass/0 fail; cpu-prof unaffected. Known gaps, deliberately not in scope: bun reports one fewer system certificate than node (121 vs 122) from the same store, unrelated to this; TLS connections still resolve the store once per process, so a worker that only *enables* --use-system-ca reports certs its own connections won't use (node builds the store per-Environment); and `--use-system-ca --no-use-system-ca` together resolves by priority here, where node is last-one-wins. --- .../bun-usockets/src/crypto/root_certs.cpp | 8 ++- src/js/node/tls.ts | 7 ++- src/jsc/VirtualMachine.rs | 12 ++++ src/jsc/web_worker.rs | 8 +++ src/runtime/cli/Arguments.rs | 31 +++++++++- src/runtime/cli/repl_command.rs | 1 + src/runtime/cli/run_command.rs | 1 + src/runtime/cli/test_command.rs | 1 + src/runtime/dispatch_js2native.rs | 15 ++--- src/runtime/jsc_hooks.rs | 6 ++ ...ca-certificates-worker-no-use-system-ca.js | 60 +++++++++++++++++++ ...et-ca-certificates-worker-use-system-ca.js | 47 +++++++++++++++ 12 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 test/js/node/test/parallel/test-tls-get-ca-certificates-worker-no-use-system-ca.js create mode 100644 test/js/node/test/parallel/test-tls-get-ca-certificates-worker-use-system-ca.js diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 4257ddeee0dc..187d6977c185 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -23,12 +23,18 @@ extern "C" void BUN__warn__extra_ca_load_failed(const char* filename, const char // Forward declarations for platform-specific functions // (Actual implementations are in platform-specific files) -// External variable from Zig CLI arguments +// External variables from the CLI arguments extern "C" bool Bun__Node__UseSystemCA; +extern "C" bool Bun__Node__NoUseSystemCA; // Helper function to check if system CA should be used // Checks both CLI flag (--use-system-ca) and environment variable (NODE_USE_SYSTEM_CA=1) static bool us_should_use_system_ca() { + // --no-use-system-ca is the one thing that overrides NODE_USE_SYSTEM_CA, so a + // flag whose purpose is to restrict trust actually restricts it. + if (Bun__Node__NoUseSystemCA) { + return false; + } // Check CLI flag first if (Bun__Node__UseSystemCA) { return true; diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 5645d2e41794..59e0f4a8d071 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1527,8 +1527,11 @@ function cacheDefaultCACertificates() { ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); } - // Include system certificates when --use-system-ca is set or NODE_USE_SYSTEM_CA=1 - if (getUseSystemCA() || process.env.NODE_USE_SYSTEM_CA === "1") { + // --use-system-ca / --no-use-system-ca are per-thread and explicit; only when + // neither was given does NODE_USE_SYSTEM_CA decide. node lets the env var win + // under --use-bundled-ca but not under --no-use-system-ca. + const useSystemCA = getUseSystemCA(); + if (useSystemCA === true || (useSystemCA === undefined && process.env.NODE_USE_SYSTEM_CA === "1")) { const system = cacheSystemCACertificates(); for (let i = 0; i < system.length; ++i) { ArrayPrototypePush.$call(defaultCACertificates, system[i]); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index a305307eac34..7e11f2cdc079 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -84,6 +84,8 @@ pub struct InitOptions { /// The CLI's `api.TransformOptions`. Consumed by `RuntimeHooks::init_runtime_state` /// → `Transpiler::init(.., configureTransformOptionsForBunVM(args), ..)`. pub transform_options: bun_options_types::schema::api::TransformOptions, + /// Explicit CA intent for this VM; `None` lets NODE_USE_SYSTEM_CA decide. + pub use_system_ca: Option, /// Consumed by `RuntimeHooks::init_runtime_state` → `configureDebugger`. pub debugger: bun_options_types::context::Debugger, /// When `Some`, [`init`] adopts @@ -126,6 +128,7 @@ impl Default for InitOptions { store_fd: false, smol: false, eval_mode: false, + use_system_ca: None, is_main_thread: false, worker_ptr: core::ptr::null_mut(), context_id: None, @@ -216,6 +219,10 @@ pub struct VirtualMachine { /// only leak the hook's `ctx` allocation. pub has_run_cleanup_hooks: bool, pub plugin_runner: Option, + /// Explicit `--use-system-ca` / `--no-use-system-ca` for THIS thread, or + /// `None` when neither was given and NODE_USE_SYSTEM_CA decides. Node makes + /// this an Environment option, so a Worker's execArgv can differ. + pub use_system_ca: Option, pub is_main_thread: bool, pub exit_handler: ExitHandler, @@ -1650,6 +1657,7 @@ pub type RuntimeState = *mut c_void; #[derive(Default, Clone, Copy)] pub struct WorkerExecArgv { pub allow_addons: Option, + pub use_system_ca: Option, pub cpu_prof: bool, pub cpu_prof_interval: Option, } @@ -2088,6 +2096,7 @@ impl VirtualMachine { addr_of_mut!((*vm).main_resolved_path).write(bun_core::String::empty()); addr_of_mut!((*vm).hide_bun_stackframes).write(true); addr_of_mut!((*vm).is_main_thread).write(opts.is_main_thread); + addr_of_mut!((*vm).use_system_ca).write(opts.use_system_ca); // Left at the // zeroed default this aliases `hot_reload_counter`'s initial 0, so a // watcher event that races the very first entry-point load makes @@ -2832,6 +2841,8 @@ pub struct Options { // configuration is plumbed through `RuntimeHooks::ensure_debugger` (the // CLI option struct lives in `bun_cli`, a forward dep). See // `runtime/jsc_hooks.rs` for the `configureDebugger` call site. + /// Explicit CA intent; `None` lets NODE_USE_SYSTEM_CA decide. + pub use_system_ca: Option, pub is_main_thread: bool, pub destruct_main_thread_on_exit: bool, } @@ -3726,6 +3737,7 @@ impl VirtualMachine { smol: opts.smol, eval_mode: opts.eval, is_main_thread: false, + use_system_ca: opts.use_system_ca, // The global is created // with `worker.cpp_worker`, `worker.execution_context_id`, // and `worker.mini` so the C++ ZigGlobalObject is born with its diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index e7c5f4df284a..a0f83e3c5e90 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -936,6 +936,14 @@ impl WebWorker { env_loader: NonNull::new(loader_ptr), store_fd: self.store_fd, graph: parent.standalone_module_graph, + // Same rule as every other execArgv option: an explicit list, + // even an empty one, replaces the parent's rather than adding + // to it, so only an inheriting worker takes the parent's CA intent. + use_system_ca: if own_exec_argv.is_some() { + exec_argv.use_system_ca + } else { + parent.use_system_ca + }, ..Default::default() }, )?; diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 88b478708f52..cf6210237545 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -284,6 +284,9 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "--use-system-ca Use the system's trusted certificate authorities" ), + parse_param!( + "--no-use-system-ca Do not use the system's trusted certificate authorities, overriding $NODE_USE_SYSTEM_CA" + ), parse_param!("--use-openssl-ca Use OpenSSL's default CA store"), parse_param!("--use-bundled-ca Use bundled CA store"), parse_param!("--redis-preconnect Preconnect to $REDIS_URL at startup"), @@ -710,6 +713,25 @@ pub(crate) static Bun__Node__CAStore: core::sync::atomic::AtomicU8 = #[unsafe(no_mangle)] pub(crate) static Bun__Node__UseSystemCA: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); +/// `--no-use-system-ca`: the only thing that beats NODE_USE_SYSTEM_CA. Read by +/// C++ (root_certs.cpp) so connections restrict trust too, not just the +/// getCACertificates() reporting path. +#[unsafe(no_mangle)] +pub(crate) static Bun__Node__NoUseSystemCA: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + +/// The main thread's explicit CA intent; `None` leaves NODE_USE_SYSTEM_CA to +/// decide. `--use-bundled-ca`/`--use-openssl-ca` are deliberately not +/// `Some(false)` — node lets the env var win under those. +pub(crate) fn main_use_system_ca() -> Option { + if Bun__Node__NoUseSystemCA.load(core::sync::atomic::Ordering::Relaxed) { + return Some(false); + } + if Bun__Node__UseSystemCA.load(core::sync::atomic::Ordering::Relaxed) { + return Some(true); + } + None +} // ─── bunfig loading ────────────────────────────────────────────────────────── // their private helpers moved to `bun_bunfig::arguments` so `bun_install` can @@ -1303,6 +1325,9 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result) -> crate::Result = if use_bundled_ca { Some(BunCAStore::Bundled) } else if use_openssl_ca { Some(BunCAStore::Openssl) + } else if no_use_system_ca { + Some(BunCAStore::Bundled) } else if use_system_ca || env_var::NODE_USE_SYSTEM_CA.get().unwrap_or(false) { Some(BunCAStore::System) } else { diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index faf25548c8fd..3b3c084206b0 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -75,6 +75,7 @@ impl ReplCommand { // not take a caller-provided allocator in the Rust port; `vm.arena` is // set below). let vm: *mut VirtualMachine = VirtualMachine::init(jsc::VirtualMachineInitOptions { + use_system_ca: crate::cli::Arguments::main_use_system_ca(), transform_options: core::mem::take(&mut ctx.args), debugger: core::mem::take(&mut ctx.runtime_options.debugger), log: core::ptr::NonNull::new(ctx.log), diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 065850175fd1..4ec92174ba20 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -951,6 +951,7 @@ Full documentation is available at https://bun.com/docs/cli/run bun_ast::initialize_store(); let vm_ptr = VirtualMachine::init(VmInitOptions { + use_system_ca: crate::cli::Arguments::main_use_system_ca(), transform_options: ctx.args.clone(), log: ::core::ptr::NonNull::new(ctx.log), debugger: ::core::mem::take(&mut ctx.runtime_options.debugger), diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 4827edb650e7..b304fb4f8a86 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2167,6 +2167,7 @@ impl TestCommand { // SAFETY: `init` returns the heap-allocated process-lifetime VM; deref once. let vm: &mut VirtualMachine = unsafe { &mut *VirtualMachine::init(jsc::virtual_machine::InitOptions { + use_system_ca: crate::cli::Arguments::main_use_system_ca(), // Clone (not take): ParallelRunner::run_as_coordinator → build_worker_argv // reads ctx.args.{conditions,define,loaders,tsconfig_override,drop, // main_fields,extension_order,env_files,feature_flags,preserve_symlinks, diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 500fe8b5bbae..87a6033c6b5e 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -64,17 +64,18 @@ pub use bun_sys_jsc::error_jsc::TestingAPIs::translate_uv_error_to_e as sys_sys_ pub use bun_http_jsc::headers_jsc::h2_live_counts as http_h2_client_testing_ap_is_live_counts; pub use bun_http_jsc::headers_jsc::h3_quic_live_counts as http_h3_client_testing_ap_is_quic_live_counts; -/// Lives here (not in `src/bun.rs`) -/// because the flag it reads — `cli::Arguments::Bun__Node__UseSystemCA` — is -/// owned by `bun_runtime`; placing the body in a lower crate would invert the -/// dependency edge. +/// Per-VM, not a process-wide atomic: node treats `--use-system-ca` as an +/// Environment option, so a Worker's execArgv can differ from the process. +/// `undefined` means neither flag was given and NODE_USE_SYSTEM_CA decides — +/// only the explicit `--no-use-system-ca` beats that env var. pub(crate) fn bun_get_use_system_ca( _global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let v = - crate::cli::Arguments::Bun__Node__UseSystemCA.load(core::sync::atomic::Ordering::Relaxed); - Ok(JSValue::js_boolean(v)) + Ok(match bun_jsc::virtual_machine::VirtualMachine::get().use_system_ca { + Some(v) => JSValue::js_boolean(v), + None => JSValue::UNDEFINED, + }) } mod css { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 227a49520903..ac14638a9937 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1559,6 +1559,12 @@ unsafe fn parse_worker_exec_argv( } if bytes == b"--no-addons" { no_addons = true; + } else if bytes == b"--use-system-ca" { + out.use_system_ca = Some(true); + } else if bytes == b"--no-use-system-ca" { + // Only the explicit negation beats NODE_USE_SYSTEM_CA; node lets the + // env var still win under --use-bundled-ca. + out.use_system_ca = Some(false); } else if bytes == b"--cpu-prof" { out.cpu_prof = true; } else if bytes == b"--cpu-prof-interval" { diff --git a/test/js/node/test/parallel/test-tls-get-ca-certificates-worker-no-use-system-ca.js b/test/js/node/test/parallel/test-tls-get-ca-certificates-worker-no-use-system-ca.js new file mode 100644 index 000000000000..16ed20e9a8c8 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-get-ca-certificates-worker-no-use-system-ca.js @@ -0,0 +1,60 @@ +'use strict'; +// Flags: --no-use-system-ca + +// This tests that NODE_USE_SYSTEM_CA can be +// overridden by --no-use-system-ca. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const { once } = require('events'); +const { Worker } = require('worker_threads'); +const fixtures = require('../common/fixtures'); + +const systemCerts = tls.getCACertificates('system'); +if (systemCerts.length === 0) { + common.skip('No trusted system certificates installed. Skip.'); +} + +async function runWorker({ execArgv, env }) { + const worker = new Worker( + fixtures.path('tls-get-ca-certificates-worker.js'), + { execArgv, env }, + ); + worker.once('error', common.mustNotCall()); + const exitPromise = once(worker, 'exit'); + const messagePromise = once(worker, 'message'); + const [message] = await messagePromise; + const [exitCode] = await exitPromise; + assert.strictEqual(exitCode, 0); + return message; +} + +(async () => { + // with --no-use-system-ca. + assert.strictEqual( + tls.getCACertificates('default').length, + tls.getCACertificates('bundled').length, + ); + + const envEnabled = await runWorker({ + execArgv: [], + env: { ...process.env, NODE_USE_SYSTEM_CA: '1' }, + }); + + assert.strictEqual(envEnabled.systemLen, systemCerts.length); + assert.strictEqual( + envEnabled.defaultLen, + envEnabled.bundledLen + envEnabled.systemLen, + ); + + const flagDisabled = await runWorker({ + execArgv: ['--no-use-system-ca'], + env: { ...process.env, NODE_USE_SYSTEM_CA: '1' }, + }); + + assert.strictEqual(flagDisabled.systemLen, systemCerts.length); + assert.strictEqual(flagDisabled.defaultLen, flagDisabled.bundledLen); +})().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-tls-get-ca-certificates-worker-use-system-ca.js b/test/js/node/test/parallel/test-tls-get-ca-certificates-worker-use-system-ca.js new file mode 100644 index 000000000000..a07c4ffe93d4 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-get-ca-certificates-worker-use-system-ca.js @@ -0,0 +1,47 @@ +'use strict'; +// Flags: --no-use-system-ca + +// This tests that --use-system-ca is an Environment option (i.e. can be +// enabled/disabled in workers) and that it affects tls.getCACertificates('default'). + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const { once } = require('events'); +const { Worker } = require('worker_threads'); +const fixtures = require('../common/fixtures'); + +const systemCerts = tls.getCACertificates('system'); +if (systemCerts.length === 0) { + common.skip('No trusted system certificates installed. Skip.'); +} +async function runWorker(execArgv) { + const worker = new Worker( + fixtures.path('tls-get-ca-certificates-worker.js'), + { execArgv }, + ); + worker.once('error', common.mustNotCall()); + const exitPromise = once(worker, 'exit'); + const messagePromise = once(worker, 'message'); + const [message] = await messagePromise; + const [exitCode] = await exitPromise; + assert.strictEqual(exitCode, 0); + return message; +} + +(async () => { + const withSystemCA = await runWorker(['--use-system-ca']); + assert.strictEqual(withSystemCA.systemLen, systemCerts.length); + + assert.strictEqual( + withSystemCA.defaultLen, + withSystemCA.bundledLen + withSystemCA.systemLen, + ); + + assert.strictEqual( + tls.getCACertificates('default').length, + tls.getCACertificates('bundled').length, + ); +})().then(common.mustCall()); From 0fe453a794af39a05802e0ec1576f557f8123b13 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:03:44 +0000 Subject: [PATCH 017/137] [autofix.ci] apply automated fixes --- src/runtime/dispatch_js2native.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 87a6033c6b5e..369708a4363a 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -72,10 +72,12 @@ pub(crate) fn bun_get_use_system_ca( _global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - Ok(match bun_jsc::virtual_machine::VirtualMachine::get().use_system_ca { - Some(v) => JSValue::js_boolean(v), - None => JSValue::UNDEFINED, - }) + Ok( + match bun_jsc::virtual_machine::VirtualMachine::get().use_system_ca { + Some(v) => JSValue::js_boolean(v), + None => JSValue::UNDEFINED, + }, + ) } mod css { From e20129c7f38686ad1ab0cd3f5fb6f567d957b3ce Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:17:38 +0000 Subject: [PATCH 018/137] cpu profiler: do not inherit --cpu-prof-name into workers inherited_config_for_worker copied name verbatim, so with --cpu-prof --cpu-prof-name foo.cpuprofile every worker resolved to the same output path as the main thread and whichever on_exit ran last overwrote the other. Clearing name lets each worker fall through to the thread-id-suffixed default, matching node (--cpu-prof-name is per-Environment there). --- src/jsc/BunCPUProfiler.rs | 4 +++- test/cli/run/cpu-prof.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index c1ea5fa000f1..0522ad62c0f7 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -53,10 +53,12 @@ pub fn publish_inherited_config(config: CPUProfilerConfig) { } /// The config a newly-started worker VM should profile with, if any. +/// `name` is cleared so each worker writes a thread-id-suffixed default +/// filename instead of every thread truncating the one `--cpu-prof-name` path. pub fn inherited_config_for_worker(thread_id: u32) -> Option { INHERITED_CONFIG .get() - .map(|c| CPUProfilerConfig { thread_id, ..*c }) + .map(|c| CPUProfilerConfig { thread_id, name: b"", ..*c }) } // C++ function declarations diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 87f7ce6d06f9..ea57aa01a86d 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -125,6 +125,35 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); + test("--cpu-prof-name is not inherited by workers (they get distinct files)", async () => { + using dir = tempDir("cpu-prof-name-worker", { + "test.js": ` + const { Worker } = require("node:worker_threads"); + const w = new Worker(\`const end = Date.now() + 100; while (Date.now() < end) {}\`, { eval: true }); + const end = Date.now() + 100; + while (Date.now() < end) {} + await new Promise(r => w.on("exit", r)); + `, + }); + + const customName = "main.cpuprofile"; + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-name", customName, "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + + const profiles = readdirSync(String(dir)).filter(f => f.endsWith(".cpuprofile")); + // Main thread writes the named file; the worker writes a separate + // thread-id-suffixed default instead of clobbering it. + expect(profiles).toContain(customName); + expect(profiles.length).toBe(2); + expect(exitCode).toBe(0); + }); + test("--cpu-prof-dir sets custom directory", async () => { using dir = tempDir("cpu-prof-dir", { "test.js": ` From 3a09c2ee70ea910d1b1a008b162d68cf6c292d06 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:19:41 +0000 Subject: [PATCH 019/137] [autofix.ci] apply automated fixes --- src/jsc/BunCPUProfiler.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index 0522ad62c0f7..ecf8cb968d81 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -56,9 +56,11 @@ pub fn publish_inherited_config(config: CPUProfilerConfig) { /// `name` is cleared so each worker writes a thread-id-suffixed default /// filename instead of every thread truncating the one `--cpu-prof-name` path. pub fn inherited_config_for_worker(thread_id: u32) -> Option { - INHERITED_CONFIG - .get() - .map(|c| CPUProfilerConfig { thread_id, name: b"", ..*c }) + INHERITED_CONFIG.get().map(|c| CPUProfilerConfig { + thread_id, + name: b"", + ..*c + }) } // C++ function declarations From fc01f2f4e91dc6e238f8876ef051749066f6a0e4 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 15:20:58 -0700 Subject: [PATCH 020/137] worker_threads: keep error.code when the thrown value cannot be cloned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worker that throws something structured-clone can't serialize falls back to sending only the message text, and the parent rebuilds a bare Error from it — losing `code`. Bun's own ResolveMessage is exactly that case: it carries the right code and is not even an Error instance. new Worker(`require("node:internal/freelist")`, { eval: true }) node: code ERR_UNKNOWN_BUILTIN_MODULE bun : code undefined (the worker-side value has the right code) Carry the code alongside the message instead of replacing the value. Replacing it was the first attempt and it broke `support require in eval for a file that doesnt exist`: rebuilding from ResolveMessage's own .message drops Bun's "error: ..." prefix, which that test asserts on. The message text is now untouched — only `code` is added. The read is shared with the value path as Worker::errorCodeOf, under a top exception scope since a `code` getter can run JS. test-worker-internal-modules.mjs: 3 fail -> 3 pass, and 0/2 without the change. Vendored test-worker* 106 pass/2 fail (both pre-existing: arraybuffer-zerofill needs `bun test`, on-process-exit is debug-only). Thrown errors keep their code as before, and test-worker-error-stack-getter-throws still passes. --- src/js/node/worker_threads.ts | 4 ++ src/jsc/bindings/webcore/Worker.cpp | 48 ++++++++++++++----- src/jsc/bindings/webcore/Worker.h | 3 +- .../parallel/test-worker-internal-modules.mjs | 36 ++++++++++++++ 4 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 test/js/node/test/parallel/test-worker-internal-modules.mjs diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index acac42253063..6e63f675cd3d 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -1357,7 +1357,11 @@ class Worker extends EventEmitter { // if not the message is the actual error const message = event.message; if (message !== "") { + // The value didn't clone, so rebuild from the text — but keep the `code` + // the native side carried over, which is all that survived of it. + const code = error?.code; error = new Error(message, { cause: event }); + if (typeof code === "string") error.code = code; const stack = event?.stack; if (stack) { error.stack = stack; diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 75b93e0bb15b..6682985171ce 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -33,6 +33,7 @@ #include "Event.h" #include "EventNames.h" #include "StructuredSerializeOptions.h" +#include #include #include #include @@ -516,17 +517,45 @@ void Worker::fireEarlyMessages(Zig::GlobalObject* workerGlobalObject) } } -void Worker::dispatchErrorWithMessage(WTF::String message) +void Worker::dispatchErrorWithMessage(WTF::String message, WTF::String code) { - postTaskToParent([protectedThis = Ref { *this }, message = message.isolatedCopy()](ScriptExecutionContext&) { + postTaskToParent([protectedThis = Ref { *this }, message = message.isolatedCopy(), + code = code.isolatedCopy()](ScriptExecutionContext& context) { ErrorEvent::Init init; init.message = message; + // The worker's value would not clone (Bun's ResolveMessage is not even an + // Error), so the parent rebuilds one from the text; hand `code` over on a + // carrier object or it is lost, unlike every other coded worker error. + if (!code.isNull()) { + auto* globalObject = context.globalObject(); + auto& vm = JSC::getVM(globalObject); + if (auto* carrier = JSC::createError(globalObject, message)) { + carrier->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), JSC::jsString(vm, code)); + init.error = carrier; + } + } auto event = ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes); protectedThis->dispatchEvent(event); }); } +// A string `code` off an error-ish value, or null. Reading it can run JS (a +// getter/proxy), so it is done under a top scope and any throw drops the code. +String Worker::errorCodeOf(JSC::JSGlobalObject* globalObject, JSValue value) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (!value.isObject()) + return {}; + JSValue codeValue = value.getObject()->getIfPropertyExists(globalObject, WebCore::builtinNames(vm).codePublicName()); + String code; + if (!scope.exception() && codeValue && codeValue.isString()) + code = codeValue.toWTFString(globalObject); + CLEAR_IF_EXCEPTION(scope); + return code; +} + bool Worker::dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSValue value) { // This is the top of the stack for the worker's error dispatch: both the @@ -565,13 +594,7 @@ bool Worker::dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSVal // vendored node tests assert on it (e.g. ERR_TRACE_EVENTS_UNAVAILABLE). // Carry a string `code` across the thread boundary manually. If reading // `code` throws (a throwing getter/proxy), drop the code and proceed. - String errorCode; - if (value.isObject() && !scope.exception()) { - JSValue codeValue = value.getObject()->getIfPropertyExists(workerGlobalObject, WebCore::builtinNames(vm).codePublicName()); - if (!scope.exception() && codeValue && codeValue.isString()) - errorCode = codeValue.toWTFString(workerGlobalObject); - CLEAR_IF_EXCEPTION(scope); - } + String errorCode = errorCodeOf(workerGlobalObject, value); return postTaskToParent([protectedThis = Ref { *this }, serialized, errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { auto* globalObject = context.globalObject(); @@ -759,11 +782,12 @@ extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker globalObject->globalEventScope->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); switch (worker->options().kind) { case WorkerOptions::Kind::Web: - return worker->dispatchErrorWithMessage(WTF::move(messageStr)); + return worker->dispatchErrorWithMessage(WTF::move(messageStr), {}); case WorkerOptions::Kind::Node: if (!worker->dispatchErrorWithValue(globalObject, error)) { - // If serialization threw an error, use the string instead - worker->dispatchErrorWithMessage(WTF::move(messageStr)); + // If serialization threw an error, use the string instead — but keep + // `code`, which is all the parent can otherwise recover. + worker->dispatchErrorWithMessage(WTF::move(messageStr), Worker::errorCodeOf(globalObject, error)); } return; } diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h index 22b9e8ab84e8..38a1382b5aae 100644 --- a/src/jsc/bindings/webcore/Worker.h +++ b/src/jsc/bindings/webcore/Worker.h @@ -133,7 +133,8 @@ class Worker final : public ThreadSafeRefCounted, public EventTargetWith void dispatchOnlineEvent(); void dispatchOnline(Zig::GlobalObject* workerGlobalObject); void fireEarlyMessages(Zig::GlobalObject* workerGlobalObject); - void dispatchErrorWithMessage(WTF::String message); + void dispatchErrorWithMessage(WTF::String message, WTF::String code); + static WTF::String errorCodeOf(JSC::JSGlobalObject*, JSC::JSValue); bool dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSValue value); bool dispatchExit(int32_t exitCode); diff --git a/test/js/node/test/parallel/test-worker-internal-modules.mjs b/test/js/node/test/parallel/test-worker-internal-modules.mjs new file mode 100644 index 000000000000..607eade85c5d --- /dev/null +++ b/test/js/node/test/parallel/test-worker-internal-modules.mjs @@ -0,0 +1,36 @@ +import '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import fs from 'node:fs/promises'; +import { describe, test, before } from 'node:test'; +import { Worker } from 'node:worker_threads'; + +const accessInternalsSource = ` +import 'node:internal/freelist'; +`; + +function convertScriptSourceToDataUrl(script) { + return new URL(`data:text/javascript,${encodeURIComponent(script)}`); +} + +describe('Worker threads should not be able to access internal modules', () => { + before(() => tmpdir.refresh()); + + test('worker instantiated with module file path', async () => { + const moduleFilepath = tmpdir.resolve('test-worker-internal-modules.mjs'); + await fs.writeFile(moduleFilepath, accessInternalsSource); + const w = new Worker(moduleFilepath); + await assert.rejects(once(w, 'exit'), { code: 'ERR_UNKNOWN_BUILTIN_MODULE' }); + }); + + test('worker instantiated with module source', async () => { + const w = new Worker(accessInternalsSource, { eval: true }); + await assert.rejects(once(w, 'exit'), { code: 'ERR_UNKNOWN_BUILTIN_MODULE' }); + }); + + test('worker instantiated with data: URL', async () => { + const w = new Worker(convertScriptSourceToDataUrl(accessInternalsSource)); + await assert.rejects(once(w, 'exit'), { code: 'ERR_UNKNOWN_BUILTIN_MODULE' }); + }); +}); From 57cf856ad637cfec82e277343251628c0294b772 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 16:11:16 -0700 Subject: [PATCH 021/137] perf_hooks: implement eventLoopUtilization() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eventLoopUtilization() returned hardcoded zeros and worker.performance's was a notImplemented stub, so test-worker-eventlooputil did not fail — it HUNG FOREVER, spinning on `if (elu().idle <= 0) return setTimeout(r, 5)`. The loop already knew when it was about to park (`will_idle_inside_event_loop`), so the accounting is two clock reads on ticks that were going to sleep anyway; a busy tick pays nothing. libuv's own idle metrics cover the Windows path, enabled with uv_loop_configure(UV_METRICS_IDLE_TIME) as node does unconditionally. Two things this got wrong first, both worth recording: `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 shifted num_polls, so us_loop_run_bun_tick took its `num_polls == 0` early return and the loop stopped parking — no compile error, and it read as an architectural wall until a printf of num_polls showed 1 vs 0. A counter only folded in when a park ENDS reads stale mid-park, which over-reports active (49.8 vs the required 50). libuv has the same problem and solves it the same way: publish the park's entry time and let the reader add the in-progress interval (uv_metrics_idle_time, uv-common.c:1042). The read order — idle, then now — and the unguarded divisions both match node: eventLoopUtilization(u, u) yields NaN there, verified on v26.3.0, so collapsing it to 0 would diverge. The shared math lives in internal/perf/event_loop_utilization exactly as node shares it between perf_hooks and worker_threads. Also fixes MessagePort listeners being called with `this === undefined` where node passes the port; injectFakeEmitter's wrapper had the receiver and dropped it. Worker is unaffected (real EventEmitter, already correct). test-worker-eventlooputil: hung -> 10/10, byte-identical to node, clean under BUN_JSC_validateExceptionChecks. perf_hooks 8 pass/0 fail; worker_threads unchanged at its 2 known failures. Matches node on main-script ({0,0,0} before the loop turns), 2-arg identical (NaN), and no-arg (0 < utilization < 1). Known divergence: node reports {0,0,0} during synchronous main-script evaluation because its loopStart milestone is still unset; Bun's loop_start is fixed at VM init. Modelling that needs node's real milestone, not a proxy — iteration_nr looks like one but is wrong, since a worker's script runs after its loop starts and the main script runs before. --- .../bun-usockets/src/eventing/epoll_kqueue.c | 13 ++ packages/bun-usockets/src/eventing/libuv.c | 4 + packages/bun-usockets/src/internal/internal.h | 3 + .../bun-usockets/src/internal/loop_data.h | 9 ++ packages/bun-usockets/src/loop.c | 20 +++ .../internal/perf/event_loop_utilization.ts | 35 ++++++ src/js/node/perf_hooks.ts | 13 +- src/js/node/worker_threads.ts | 21 ++-- src/jsc/VirtualMachine.rs | 5 + src/jsc/bindings/webcore/JSWorker.cpp | 29 +++++ src/jsc/bindings/webcore/Worker.cpp | 11 ++ src/jsc/bindings/webcore/Worker.h | 3 + src/jsc/web_worker.rs | 43 +++++++ src/runtime/dispatch_js2native.rs | 24 ++++ src/uws_sys/InternalLoopData.rs | 9 ++ src/uws_sys/Loop.rs | 17 +++ .../sequential/test-worker-eventlooputil.js | 115 ++++++++++++++++++ 17 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 src/js/internal/perf/event_loop_utilization.ts create mode 100644 test/js/node/test/sequential/test-worker-eventlooputil.js diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index e8e36dc9d05b..7b4e7da36dcc 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -408,6 +408,13 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout } } + /* Only ticks that really park are timed, so a busy loop pays nothing and a + * parked one pays two vDSO reads against a syscall it was making anyway. + * Publish the entry so a cross-thread reader can add the in-progress park. */ + const uint64_t idle_start_ns = will_idle_inside_event_loop ? us_internal_monotonic_ns() : 0; + if (will_idle_inside_event_loop) + __atomic_store_n(&loop->data.idle_entry_ns, idle_start_ns, __ATOMIC_RELEASE); + /* Fetch ready polls */ #ifdef LIBUS_USE_EPOLL /* A zero timespec already has a fast path in ep_poll (fs/eventpoll.c): @@ -428,6 +435,12 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout } while (IS_EINTR(loop->num_ready_polls)); #endif + if (will_idle_inside_event_loop) { + __atomic_add_fetch(&loop->data.idle_ns, us_internal_monotonic_ns() - idle_start_ns, + __ATOMIC_RELAXED); + __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_RELEASE); + } + /* Before anything can allocate again. */ if (handed_off) mi_on_thread_idle_end(); diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 01c3a1932372..f06cd654b34f 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -269,6 +269,10 @@ struct us_loop_t *us_create_loop(void *hint, loop->uv_loop = hint ? hint : uv_loop_new(); loop->is_default = hint != 0; + /* Without this libuv never accumulates provider_idle_time, so + * uv_metrics_idle_time() — and performance.eventLoopUtilization() — read 0. + * node enables it unconditionally too (node.cc, node_worker.cc). */ + uv_loop_configure(loop->uv_loop, UV_METRICS_IDLE_TIME); loop->uv_pre = us_malloc(sizeof(uv_prepare_t)); uv_prepare_init(loop->uv_loop, loop->uv_pre); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 27148b318f55..f8d350495bc0 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -156,6 +156,9 @@ uint64_t us_internal_monotonic_ns(void); long long us_internal_sweep_timeout_ns(struct us_loop_t *loop); void us_internal_sweep_if_due(struct us_loop_t *loop); #endif +/* Nanoseconds this loop has spent parked, including a park in progress. Safe + * from another thread. Both platforms: Rust calls it uncgated. */ +uint64_t us_loop_idle_ns(struct us_loop_t *loop); void us_internal_free_closed_sockets(us_loop_r loop); void us_internal_loop_link_group(struct us_loop_t *loop, struct us_socket_group_t *group); void us_internal_loop_unlink_group(struct us_loop_t *loop, struct us_socket_group_t *group); diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 071ceaadcd56..4cf58e2f8cf4 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -70,6 +70,15 @@ struct us_internal_loop_data_t { * for lsquic's time-driven state. POSIX folds the deadline into the * epoll_pwait2 timeout via getTimeout() instead. */ struct us_timer_t *quic_timer; +#endif +#ifndef LIBUS_USE_LIBUV + /* Nanoseconds parked, for eventLoopUtilization(). Read cross-thread — + * __atomic_* only. MIRRORED in src/uws_sys/InternalLoopData.rs: this struct + * is us_loop_t's first member, so a field here shifts num_polls. */ + unsigned long long idle_ns; + /* Monotonic ns the current park began, 0 when not parked: a mid-park reader + * must add (now - entry) or it sees a stale total. So does libuv. */ + unsigned long long idle_entry_ns; #endif struct us_socket_group_t *iterator; char *recv_buf; diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 987120f6bda6..ac16c2a9814c 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -119,6 +119,26 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { #endif +/* Nanoseconds this loop has spent parked, for performance.eventLoopUtilization(). + * Safe to call from another thread. */ +uint64_t us_loop_idle_ns(struct us_loop_t *loop) { +#ifdef LIBUS_USE_LIBUV + return uv_metrics_idle_time(loop->uv_loop); +#else + uint64_t idle = __atomic_load_n(&loop->data.idle_ns, __ATOMIC_RELAXED); + /* Parked right now? The total is only folded in when the park ends, so add + * the in-progress interval — otherwise a mid-park reader sees a stale idle + * and over-reports active. Same as libuv's uv_metrics_idle_time. */ + uint64_t entry = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_ACQUIRE); + if (entry > 0) { + uint64_t now = us_internal_monotonic_ns(); + if (now > entry) + idle += now - entry; + } + return idle; +#endif +} + void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), void (*pre_cb)(struct us_loop_t *loop), void (*post_cb)(struct us_loop_t *loop)) { // We allocate with calloc, so we only need to initialize the specific fields in use. diff --git a/src/js/internal/perf/event_loop_utilization.ts b/src/js/internal/perf/event_loop_utilization.ts new file mode 100644 index 000000000000..07fb6eedbffb --- /dev/null +++ b/src/js/internal/perf/event_loop_utilization.ts @@ -0,0 +1,35 @@ +// Shared by perf_hooks and worker_threads, as node shares +// lib/internal/perf/event_loop_utilization.js between the two. +// +// `elu` is [elapsedSinceLoopStartMs, idleMs] from native, or null when the loop +// has not turned yet — node's equivalent of its `loopStart <= 0` branch, and it +// is checked first there too, so elu(u, u) before the loop turns is {0,0,0} +// rather than NaN. +// +// The divisions are deliberately unguarded: node returns NaN for a zero total +// (verified on v26.3.0 — eventLoopUtilization(u, u) after the loop has turned +// yields NaN), so collapsing that to 0 would diverge. +function internalEventLoopUtilization(elu, util1, util2) { + if (elu === null) { + return { idle: 0, active: 0, utilization: 0 }; + } + + if (util2) { + const idle = util1.idle - util2.idle; + const active = util1.active - util2.active; + return { idle, active, utilization: active / (idle + active) }; + } + + const idle = elu[1]; + const active = elu[0] - idle; + + if (!util1) { + return { idle, active, utilization: active / (idle + active) }; + } + + const idleDelta = idle - util1.idle; + const activeDelta = active - util1.active; + return { idle: idleDelta, active: activeDelta, utilization: activeDelta / (idleDelta + activeDelta) }; +} + +export default { internalEventLoopUtilization }; diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 16206fe4a3b2..2aabd36192a9 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -114,12 +114,13 @@ function createPerformanceNodeTiming() { return object; } -function eventLoopUtilization(_utilization1, _utilization2) { - return { - idle: 0, - active: 0, - utilization: 0, - }; +// [elapsedSinceLoopStartMs, idleMs] for this thread's loop, or null before it +// has turned. +const getLoopELU = $newRustFunction("bun.rs", "getLoopELU", 0); +const { internalEventLoopUtilization } = require("internal/perf/event_loop_utilization"); + +function eventLoopUtilization(utilization1, utilization2) { + return internalEventLoopUtilization(getLoopELU(), utilization1, utilization2); } // PerformanceEntry is not a valid constructor, so we have to fake it. diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 6e63f675cd3d..8deebd49f300 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -6,6 +6,7 @@ type WebWorker = InstanceType; const EventEmitter = require("node:events"); const { SafeMap } = require("internal/primordials"); const Readable = require("internal/streams/readable"); +const { internalEventLoopUtilization } = require("internal/perf/event_loop_utilization"); const Writable = require("internal/streams/writable"); const { throwNotImplemented, warnNotImplementedOnce } = require("internal/shared"); const { @@ -135,7 +136,9 @@ function injectFakeEmitter(Class) { function wrapped(run, listener) { return function (event) { - return listener(run(event)); + // node invokes emitter listeners with the emitter as `this`; an + // addEventListener handler's `this` is already the target, so forward it. + return listener.$call(this, run(event)); }; } @@ -1187,17 +1190,17 @@ class Worker extends EventEmitter { get performance() { return (this.#performance ??= { - eventLoopUtilization() { - warnNotImplementedOnce("worker_threads.Worker.performance"); - return { - idle: 0, - active: 0, - utilization: 0, - }; - }, + eventLoopUtilization: this.#eventLoopUtilization.bind(this), }); } + #eventLoopUtilization(utilization1, utilization2) { + // null covers both "thread gone" and "loop has not turned" — node reports + // all-zero for each. + return internalEventLoopUtilization(this.#worker.eventLoopUtilizationInternal(), utilization1, utilization2); + } + + terminate(callback: unknown) { if (typeof callback === "function") { process.emitWarning( diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 7e11f2cdc079..d1cab79c2214 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -279,6 +279,10 @@ pub struct VirtualMachine { pub argv: Vec>, pub origin_timer: std::time::Instant, + /// When THIS thread's loop started, for performance.eventLoopUtilization(). + /// Not `origin_timer`, which is the process origin shared by every thread — + /// a worker's active time is measured from its own start. + pub loop_start: std::time::Instant, pub origin_timestamp: u64, /// For fake timers: override performance.now() with a specific value (in nanoseconds). pub overridden_performance_now: Option, @@ -2106,6 +2110,7 @@ impl VirtualMachine { addr_of_mut!((*vm).pending_internal_promise_reported_at).write(u32::MAX); addr_of_mut!((*vm).on_unhandled_rejection) .write(VirtualMachine::default_on_unhandled_rejection); + addr_of_mut!((*vm).loop_start).write(std::time::Instant::now()); let (origin_timer, origin_timestamp) = process_origin(); addr_of_mut!((*vm).origin_timer).write(origin_timer); addr_of_mut!((*vm).origin_timestamp).write(origin_timestamp); diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 0e94c9adb507..3ef854b72537 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -86,6 +86,7 @@ static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_ref); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapSnapshot); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapStatistics); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_startCpuProfileInternal); +static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_eventLoopUtilizationInternal); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_stopCpuProfileInternal); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_cpuUsageInternal); @@ -447,6 +448,7 @@ static const HashTableValue JSWorkerPrototypeTableValues[] = { { "startCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_startCpuProfileInternal, 0 } }, { "stopCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_stopCpuProfileInternal, 0 } }, { "cpuUsageInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_cpuUsageInternal, 0 } }, + { "eventLoopUtilizationInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_eventLoopUtilizationInternal, 0 } }, }; const ClassInfo JSWorkerPrototype::s_info = { "Worker"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSWorkerPrototype) }; @@ -857,6 +859,33 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_stopCpuProfileIntern return JSValue::encode(promise); } +// Synchronous by contract: node's worker.performance.eventLoopUtilization() +// returns a value, it does not await the worker. Safe to read cross-thread — +// the counter is atomic and the loop start is immutable after publish. +static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_eventLoopUtilizationInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + UNUSED_PARAM(callFrame); + double elapsedMs = 0; + double idleMs = 0; + if (!castedThis->wrapped().eventLoopUtilization(elapsedMs, idleMs)) + RELEASE_AND_RETURN(throwScope, JSValue::encode(JSC::jsNull())); + JSC::JSArray* result = JSC::constructEmptyArray(globalObject, nullptr, 2); + RETURN_IF_EXCEPTION(throwScope, {}); + result->putDirectIndex(globalObject, 0, JSC::jsNumber(elapsedMs)); + RETURN_IF_EXCEPTION(throwScope, {}); + result->putDirectIndex(globalObject, 1, JSC::jsNumber(idleMs)); + RETURN_IF_EXCEPTION(throwScope, {}); + RELEASE_AND_RETURN(throwScope, JSValue::encode(result)); +} + +JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_eventLoopUtilizationInternal, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "eventLoopUtilizationInternal"); +} + static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_cpuUsageInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) { auto* globalObject = defaultGlobalObject(lexicalGlobalObject); diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 6682985171ce..e245c1e5718f 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -98,6 +98,10 @@ void WebWorker__releaseParentPollRef(void* worker); // Free the native WebWorker struct. Called from ~Worker. void WebWorker__destroy(void* worker); +// Read this worker's loop counters from the parent thread. False if the worker +// VM is gone. See src/jsc/web_worker.rs. +bool WebWorker__getELU(void* worker, double* outElapsedMs, double* outIdleMs); + } // extern "C" // ------------------------------------------------------------------------------------------------- @@ -375,6 +379,13 @@ void Worker::terminate() WebWorker__notifyNeedTermination(impl_); } +bool Worker::eventLoopUtilization(double& elapsedMs, double& idleMs) +{ + if (!impl_) + return false; + return WebWorker__getELU(impl_, &elapsedMs, &idleMs); +} + void Worker::setKeepAlive(bool keepAlive) { // Once terminate() has been called or the close task has started, the diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h index 38a1382b5aae..03221175e8b2 100644 --- a/src/jsc/bindings/webcore/Worker.h +++ b/src/jsc/bindings/webcore/Worker.h @@ -134,6 +134,9 @@ class Worker final : public ThreadSafeRefCounted, public EventTargetWith void dispatchOnline(Zig::GlobalObject* workerGlobalObject); void fireEarlyMessages(Zig::GlobalObject* workerGlobalObject); void dispatchErrorWithMessage(WTF::String message, WTF::String code); + /// `[elapsedSinceLoopStartMs, idleMs]` for this worker's loop, read live from + /// the parent. False once the thread is gone (node reports all-zero then). + bool eventLoopUtilization(double& elapsedMs, double& idleMs); static WTF::String errorCodeOf(JSC::JSGlobalObject*, JSC::JSValue); bool dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSValue value); bool dispatchExit(int32_t exitCode); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index a0f83e3c5e90..bbddeb358a5e 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -399,6 +399,49 @@ pub fn terminate_all_and_wait(timeout_ms: u64) { } } +/// The PARENT reading a live worker's loop counters. False once the worker VM is +/// gone, which node reports as all-zero. `vm_lock` only closes the TOCTOU on +/// `vm`: `idle_ns` is atomic and `loop_start` is fixed before publish. +/// +/// # Safety +/// `worker` is a live `WebWorker*` owned by the calling C++ `Worker`; the out +/// params are non-null and writable. +#[unsafe(no_mangle)] +pub(crate) unsafe extern "C" fn WebWorker__getELU( + worker: *mut WebWorker, + out_elapsed_ms: *mut f64, + out_idle_ms: *mut f64, +) -> bool { + // SAFETY: per fn contract — a live WebWorker for the duration of the call. + let w = unsafe { &*worker }; + w.vm_lock.lock(); + let vm_ptr = w.vm_ptr(); + let live = !vm_ptr.is_null(); + if live { + // No `&VirtualMachine` binding: the worker thread may hold a live mutable + // view. Raw-pointer access keeps any autoref scoped to the access, as the + // terminate path above does. + // SAFETY: event_loop() is the live self-pointer; the reads below are an + // atomic load and a Copy field written before the VM was published. + let loop_ = unsafe { (*(*vm_ptr).event_loop()).usockets_loop().as_ref() }; + let Some(loop_) = loop_ else { + w.vm_lock.unlock(); + return false; + }; + // Idle BEFORE elapsed, matching node's order — reversed, idle is dated + // after now and active = now - idle comes out short. + let idle_ms = loop_.idle_ns() as f64 / 1_000_000.0; + let elapsed_ms = unsafe { (*vm_ptr).loop_start }.elapsed().as_secs_f64() * 1000.0; + // SAFETY: per fn contract — out params are writable. + unsafe { + *out_elapsed_ms = elapsed_ms; + *out_idle_ms = idle_ms; + } + } + w.vm_lock.unlock(); + live +} + #[unsafe(no_mangle)] pub(crate) extern "C" fn WebWorker__getParentWorker(vm: &VirtualMachine) -> *mut c_void { vm.worker_ref() diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 369708a4363a..ae1c54335ab6 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -80,6 +80,30 @@ pub(crate) fn bun_get_use_system_ca( ) } +/// `[elapsedSinceLoopStartMs, idleMs]` for THIS thread's loop — the two numbers +/// performance.eventLoopUtilization() is defined in terms of (node derives +/// active as now - loopStart - idle). +pub(crate) fn bun_get_loop_elu( + global: &JSGlobalObject, + _frame: &CallFrame, +) -> JsResult { + let vm = bun_jsc::virtual_machine::VirtualMachine::get(); + // SAFETY: the VM owns this loop and this runs on its thread. + let loop_ = unsafe { (*vm.event_loop).usockets_loop().as_ref() }; + let Some(loop_) = loop_ else { + return Ok(JSValue::NULL); + }; + // Idle BEFORE elapsed, matching node's order (it passes loopIdleTime() in + // and reads process.hrtime() after). Reversed, idle is dated after now and + // active = now - idle comes out short. + let idle_ms = loop_.idle_ns() as f64 / 1_000_000.0; + let elapsed_ms = vm.loop_start.elapsed().as_secs_f64() * 1000.0; + let arr = JSValue::create_empty_array(global, 2)?; + arr.put_index(global, 0, JSValue::js_number(elapsed_ms))?; + arr.put_index(global, 1, JSValue::js_number(idle_ms))?; + Ok(arr) +} + mod css { pub use bun_css_jsc::css_internals::{ _test, attr_test, minify_error_test_with_options, minify_test, minify_test_with_options, diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index 5a38af507434..58d34d102ad5 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -35,6 +35,15 @@ pub struct InternalLoopData { pub quic_next_tick_us: i64, #[cfg(windows)] pub quic_timer: *mut Timer, + /// Nanoseconds this loop has spent parked, for eventLoopUtilization(). + /// Mirrors the `#ifndef LIBUS_USE_LIBUV` field in loop_data.h — libuv tracks + /// the same itself via uv_metrics_idle_time. + #[cfg(not(windows))] + pub idle_ns: u64, + /// Monotonic ns the current park began, 0 when not parked. Mirrors + /// loop_data.h — see the layout warning on `idle_ns`. + #[cfg(not(windows))] + pub idle_entry_ns: u64, pub iterator: *mut SocketGroup, pub recv_buf: *mut u8, pub send_buf: *mut u8, diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 144d86379b31..4d83ace8879e 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -243,6 +243,14 @@ impl PosixLoop { unsafe { c::us_wakeup_loop(self) }; } + /// Nanoseconds this loop has spent parked, for eventLoopUtilization(). + /// `&self`: a parent thread reads this while the worker holds its own + /// `&mut` — the body is one atomic load, so it must not alias mutably. + pub fn idle_ns(&self) -> u64 { + // SAFETY: self is a valid loop pointer; the counter is read atomically. + unsafe { c::us_loop_idle_ns(self as *const Loop as *mut Loop) } + } + #[inline] pub fn wake(&mut self) { self.wakeup(); @@ -472,6 +480,14 @@ impl WindowsLoop { unsafe { c::us_wakeup_loop(self) }; } + /// Nanoseconds this loop has spent parked, for eventLoopUtilization(). + /// `&self`: a parent thread reads this while the worker holds its own + /// `&mut` — the body is one atomic load, so it must not alias mutably. + pub fn idle_ns(&self) -> u64 { + // SAFETY: self is a valid loop pointer; the counter is read atomically. + unsafe { c::us_loop_idle_ns(self as *const Loop as *mut Loop) } + } + #[inline] pub fn wake(&mut self) { self.wakeup(); @@ -649,6 +665,7 @@ mod c { #[cfg(windows)] pub(super) fn us_loop_pump(loop_: *mut Loop); pub fn us_wakeup_loop(loop_: *mut Loop); + pub(super) fn us_loop_idle_ns(loop_: *mut Loop) -> u64; pub(super) fn uws_loop_addPostHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); pub(super) fn uws_loop_removePostHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); pub(super) fn uws_loop_addPreHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); diff --git a/test/js/node/test/sequential/test-worker-eventlooputil.js b/test/js/node/test/sequential/test-worker-eventlooputil.js new file mode 100644 index 000000000000..39c6d723ff79 --- /dev/null +++ b/test/js/node/test/sequential/test-worker-eventlooputil.js @@ -0,0 +1,115 @@ +'use strict'; + +const { mustCall, mustCallAtLeast } = require('../common'); + +const assert = require('assert'); +const { + Worker, + MessageChannel, + MessagePort, + parentPort, +} = require('worker_threads'); +const { performance } = require('perf_hooks'); +const { eventLoopUtilization } = require('perf_hooks'); + +// Use argv to detect whether we're running as a Worker called by this test vs. +// this test also being called as a Worker. +if (process.argv[2] === 'iamalive') { + const iaElu = idleActive(eventLoopUtilization()); + // Checks that the worker bootstrap is running after the event loop started. + assert.ok(iaElu > 0, `${iaElu} <= 0`); + parentPort.once('message', mustCall((msg) => { + assert.ok(msg.metricsCh instanceof MessagePort); + msg.metricsCh.on('message', mustCallAtLeast(workerOnMetricsMsg, 1)); + })); + return; +} + +function workerOnMetricsMsg(msg) { + if (msg.cmd === 'close') { + return this.close(); + } + + if (msg.cmd === 'elu') { + return this.postMessage(eventLoopUtilization()); + } + + if (msg.cmd === 'spin') { + const elu = eventLoopUtilization(); + const t = performance.now(); + while (performance.now() - t < msg.dur); + return this.postMessage(eventLoopUtilization(elu)); + } +} + +let worker; +let metricsCh; +let mainElu; +let workerELU; + +(function r() { + // Force some idle time to accumulate before proceeding with test. + if (eventLoopUtilization().idle <= 0) + return setTimeout(mustCall(r), 5); + + mainElu = eventLoopUtilization(); + + worker = new Worker(__filename, { argv: [ 'iamalive' ] }); + metricsCh = new MessageChannel(); + worker.postMessage({ metricsCh: metricsCh.port1 }, [ metricsCh.port1 ]); + + workerELU = worker.performance.eventLoopUtilization; + metricsCh.port2.once('message', mustCall(checkWorkerIdle)); + metricsCh.port2.postMessage({ cmd: 'elu' }); + // Make sure it's still safe to call eventLoopUtilization() after the worker + // has been closed. + worker.on('exit', mustCall(() => { + assert.deepStrictEqual(worker.performance.eventLoopUtilization(), + { idle: 0, active: 0, utilization: 0 }); + })); +})(); + +function checkWorkerIdle(wElu) { + const perfWorkerElu = workerELU(); + const tmpMainElu = eventLoopUtilization(mainElu); + + assert.ok(idleActive(wElu) > 0, `${idleActive(wElu)} <= 0`); + assert.ok(idleActive(workerELU(wElu)) > 0, + `${idleActive(workerELU(wElu))} <= 0`); + assert.ok(idleActive(perfWorkerElu) > idleActive(wElu), + `${idleActive(perfWorkerElu)} <= ${idleActive(wElu)}`); + assert.ok(idleActive(tmpMainElu) > idleActive(perfWorkerElu), + `${idleActive(tmpMainElu)} <= ${idleActive(perfWorkerElu)}`); + + wElu = workerELU(); + setTimeout(mustCall(() => { + wElu = workerELU(wElu); + // Some clocks fire early. Removing a few milliseconds to cover that. + assert.ok(idleActive(wElu) >= 45, `${idleActive(wElu)} < 45`); + // Cutting the idle time in half since it's possible that the call took a + // lot of resources to process? + assert.ok(wElu.idle >= 25, `${wElu.idle} < 25`); + + checkWorkerActive(); + }), 50); +} + +function checkWorkerActive() { + const w = workerELU(); + + metricsCh.port2.postMessage({ cmd: 'spin', dur: 50 }); + metricsCh.port2.once('message', mustCall((wElu) => { + const w2 = workerELU(w); + + assert.ok(w2.active >= 50, `${w2.active} < 50`); + assert.ok(wElu.active >= 50, `${wElu.active} < 50`); + assert.ok(idleActive(wElu) < idleActive(w2), + `${idleActive(wElu)} >= ${idleActive(w2)}`); + + metricsCh.port2.postMessage({ cmd: 'close' }); + })); +} + +function idleActive(elu) { + return elu.idle + elu.active; +} From dfe31cd2377131725e0dd9f623dcb4600d54f8d8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:21:13 +0000 Subject: [PATCH 022/137] [autofix.ci] apply automated fixes --- src/js/node/worker_threads.ts | 1 - src/runtime/dispatch_js2native.rs | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 8deebd49f300..d00c6db41449 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -1200,7 +1200,6 @@ class Worker extends EventEmitter { return internalEventLoopUtilization(this.#worker.eventLoopUtilizationInternal(), utilization1, utilization2); } - terminate(callback: unknown) { if (typeof callback === "function") { process.emitWarning( diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index ae1c54335ab6..4ce01ff610b7 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -83,10 +83,7 @@ pub(crate) fn bun_get_use_system_ca( /// `[elapsedSinceLoopStartMs, idleMs]` for THIS thread's loop — the two numbers /// performance.eventLoopUtilization() is defined in terms of (node derives /// active as now - loopStart - idle). -pub(crate) fn bun_get_loop_elu( - global: &JSGlobalObject, - _frame: &CallFrame, -) -> JsResult { +pub(crate) fn bun_get_loop_elu(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { let vm = bun_jsc::virtual_machine::VirtualMachine::get(); // SAFETY: the VM owns this loop and this runs on its thread. let loop_ = unsafe { (*vm.event_loop).usockets_loop().as_ref() }; From aba81ddd8e96c60c6bd4bb6555979e9eb2077aa4 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 16:27:26 -0700 Subject: [PATCH 023/137] cpu profiler: let workers inherit --cpu-prof-name, as node does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing `name` for inherited worker configs gave every thread its own thread-id-suffixed file, which reads like a fix — a custom name no longer gets truncated by whichever thread writes last. But it is not what node does, and this is a node-compat surface. Measured against v26.3.0, worker awaited so the profiles are actually flushed: --cpu-prof --cpu-prof-name main.cpuprofile node 1 file bun 2 -> 1 --cpu-prof (default name) node 2 files bun 2 2 node only thread-stamps the default name; an explicit --cpu-prof-name is inherited verbatim and the threads collide. Keeping the collision. The test is kept — it covers a gap nothing else did — with its expectation corrected to node's behaviour. cpu-prof suite 10 pass/0 fail; the vendored test-cpu-prof-dir-worker and test-cpu-prof-worker-argv stay 3/3. Reverses one line of e20129c7f3; the rest of that commit stands. --- src/jsc/BunCPUProfiler.rs | 12 ++++-------- test/cli/run/cpu-prof.test.ts | 10 +++++----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index ecf8cb968d81..9ef7ee29cf70 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -52,15 +52,11 @@ pub fn publish_inherited_config(config: CPUProfilerConfig) { let _ = INHERITED_CONFIG.set(config); } -/// The config a newly-started worker VM should profile with, if any. -/// `name` is cleared so each worker writes a thread-id-suffixed default -/// filename instead of every thread truncating the one `--cpu-prof-name` path. +/// The config a newly-started worker VM should profile with, if any. An explicit +/// `--cpu-prof-name` is inherited, so every thread writes that one path and the +/// last wins — node does the same (v26.3.0: named gives 1 file, default 2). pub fn inherited_config_for_worker(thread_id: u32) -> Option { - INHERITED_CONFIG.get().map(|c| CPUProfilerConfig { - thread_id, - name: b"", - ..*c - }) + INHERITED_CONFIG.get().map(|c| CPUProfilerConfig { thread_id, ..*c }) } // C++ function declarations diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index ea57aa01a86d..f9940619cd04 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -125,7 +125,7 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); - test("--cpu-prof-name is not inherited by workers (they get distinct files)", async () => { + test("--cpu-prof-name is inherited by workers, as node does", async () => { using dir = tempDir("cpu-prof-name-worker", { "test.js": ` const { Worker } = require("node:worker_threads"); @@ -147,10 +147,10 @@ describe.concurrent("--cpu-prof", () => { const exitCode = await proc.exited; const profiles = readdirSync(String(dir)).filter(f => f.endsWith(".cpuprofile")); - // Main thread writes the named file; the worker writes a separate - // thread-id-suffixed default instead of clobbering it. - expect(profiles).toContain(customName); - expect(profiles.length).toBe(2); + // Every thread writes the one named path, last wins, so there is a single + // file. node v26.3.0 does the same: with a worker, --cpu-prof-name yields 1 + // file where the default name yields 2 — it only thread-stamps the default. + expect(profiles).toEqual([customName]); expect(exitCode).toBe(0); }); From 74c1ebfa2df08b0b76c0430fbd63fc6f908636ac Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:29:27 +0000 Subject: [PATCH 024/137] [autofix.ci] apply automated fixes --- src/jsc/BunCPUProfiler.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index 9ef7ee29cf70..d1ba53819c1d 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -56,7 +56,9 @@ pub fn publish_inherited_config(config: CPUProfilerConfig) { /// `--cpu-prof-name` is inherited, so every thread writes that one path and the /// last wins — node does the same (v26.3.0: named gives 1 file, default 2). pub fn inherited_config_for_worker(thread_id: u32) -> Option { - INHERITED_CONFIG.get().map(|c| CPUProfilerConfig { thread_id, ..*c }) + INHERITED_CONFIG + .get() + .map(|c| CPUProfilerConfig { thread_id, ..*c }) } // C++ function declarations From f17365cc6eddec451b9acca9b7b7574fc88065b7 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 16:55:05 -0700 Subject: [PATCH 025/137] fix lint and clippy fallout from the eventLoopUtilization work - worker_threads: drop the now-unused warnNotImplementedOnce import; the stub that used it is gone. - uws_sys: `core::ptr::from_ref(self).cast_mut()` instead of an `as` chain, which tripped both ptr_as_ptr and ref_as_ptr. - web_worker: restore two SAFETY comments an earlier comment trim removed. The last two were not in CI's report: bun_uws_sys failed first and masked every downstream crate's lints, so fixing only what CI showed would have turned red again on the next run. Verified with `cargo clippy -p bun_jsc -p bun_runtime -p bun_uws_sys` (rc=0) and `bun lint` (0 errors), and test-worker-eventlooputil stays 3/3 since the cast is on its read path. --- src/js/node/worker_threads.ts | 2 +- src/jsc/web_worker.rs | 4 ++++ src/uws_sys/Loop.rs | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index d00c6db41449..f1335ef4b6d6 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -8,7 +8,7 @@ const { SafeMap } = require("internal/primordials"); const Readable = require("internal/streams/readable"); const { internalEventLoopUtilization } = require("internal/perf/event_loop_utilization"); const Writable = require("internal/streams/writable"); -const { throwNotImplemented, warnNotImplementedOnce } = require("internal/shared"); +const { throwNotImplemented } = require("internal/shared"); const { validateString, validateObject, diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index bbddeb358a5e..71b198b606c0 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -431,6 +431,8 @@ pub(crate) unsafe extern "C" fn WebWorker__getELU( // Idle BEFORE elapsed, matching node's order — reversed, idle is dated // after now and active = now - idle comes out short. let idle_ms = loop_.idle_ns() as f64 / 1_000_000.0; + // SAFETY: vm_ptr is published under vm_lock and non-null here; + // loop_start is Copy and fixed before the VM was published. let elapsed_ms = unsafe { (*vm_ptr).loop_start }.elapsed().as_secs_f64() * 1000.0; // SAFETY: per fn contract — out params are writable. unsafe { @@ -914,6 +916,8 @@ impl WebWorker { // fresh defaults whenever execArgv is given (node_worker.cc). let own_exec_argv = self.exec_argv(); let exec_argv = match own_exec_argv { + // SAFETY: `a` is this worker's execArgv, owned by the WebWorker and + // alive for the call; the hook only reads it. Some(a) => unsafe { (hooks.parse_worker_exec_argv)(a) }, None => Default::default(), }; diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 4d83ace8879e..eed1b42c96f0 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -248,7 +248,7 @@ impl PosixLoop { /// `&mut` — the body is one atomic load, so it must not alias mutably. pub fn idle_ns(&self) -> u64 { // SAFETY: self is a valid loop pointer; the counter is read atomically. - unsafe { c::us_loop_idle_ns(self as *const Loop as *mut Loop) } + unsafe { c::us_loop_idle_ns(core::ptr::from_ref(self).cast_mut()) } } #[inline] @@ -485,7 +485,7 @@ impl WindowsLoop { /// `&mut` — the body is one atomic load, so it must not alias mutably. pub fn idle_ns(&self) -> u64 { // SAFETY: self is a valid loop pointer; the counter is read atomically. - unsafe { c::us_loop_idle_ns(self as *const Loop as *mut Loop) } + unsafe { c::us_loop_idle_ns(core::ptr::from_ref(self).cast_mut()) } } #[inline] From 997ce14b7c2ebba8b5ce80d24bd542dca5f2177a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:56:54 +0000 Subject: [PATCH 026/137] address review: once() this-binding, init sites, nested cpu-prof, lint - MessagePort .once() now forwards the port as this to the listener (.on() already did); covered by a new worker_threads test - seed use_system_ca in init_with_module_graph, init_bake and boot_standalone so compiled/bake binaries keep the per-VM value - workers inherit cpu-prof from the immediate parent VM instead of a process-global OnceLock, so nested workers follow node's per-Environment model; the OnceLock and its publish call are dead and removed - parse_worker_exec_argv consumes the value of --cpu-prof-dir/-name so a later flag is not dropped by the positional short-circuit - drop unused warnNotImplementedOnce import (oxlint) - fix 'uncgated' typo in internal.h --- packages/bun-usockets/src/internal/internal.h | 2 +- src/js/node/worker_threads.ts | 2 +- src/jsc/BunCPUProfiler.rs | 18 ------------------ src/jsc/VirtualMachine.rs | 2 ++ src/jsc/web_worker.rs | 11 ++++++++--- src/runtime/cli/run_command.rs | 4 +--- src/runtime/jsc_hooks.rs | 9 +++++++++ .../node/worker_threads/worker_threads.test.ts | 18 ++++++++++++++++++ 8 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index f8d350495bc0..5badd03ee6fb 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -157,7 +157,7 @@ long long us_internal_sweep_timeout_ns(struct us_loop_t *loop); void us_internal_sweep_if_due(struct us_loop_t *loop); #endif /* Nanoseconds this loop has spent parked, including a park in progress. Safe - * from another thread. Both platforms: Rust calls it uncgated. */ + * from another thread. Both platforms: Rust calls it ungated. */ uint64_t us_loop_idle_ns(struct us_loop_t *loop); void us_internal_free_closed_sockets(us_loop_r loop); void us_internal_loop_link_group(struct us_loop_t *loop, struct us_socket_group_t *group); diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index f1335ef4b6d6..b6f772de3cb8 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -205,7 +205,7 @@ function injectFakeEmitter(Class) { // a listener that already fired. function onceWrapper(ev) { registryFor(target, false)?.get(event)?.delete(listener); - return wrapper(ev); + return wrapper.$call(target, ev); } register(this, event, listener, onceWrapper, { once: true }); return this; diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index d1ba53819c1d..c9af38d2ded4 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -43,24 +43,6 @@ impl Default for CPUProfilerConfig { } } -/// The main thread's `--cpu-prof` config, published at startup so worker -/// VMs can inherit it: node profiles every thread when `--cpu-prof` is passed to -/// the process, and writes one profile per thread. -static INHERITED_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); - -pub fn publish_inherited_config(config: CPUProfilerConfig) { - let _ = INHERITED_CONFIG.set(config); -} - -/// The config a newly-started worker VM should profile with, if any. An explicit -/// `--cpu-prof-name` is inherited, so every thread writes that one path and the -/// last wins — node does the same (v26.3.0: named gives 1 file, default 2). -pub fn inherited_config_for_worker(thread_id: u32) -> Option { - INHERITED_CONFIG - .get() - .map(|c| CPUProfilerConfig { thread_id, ..*c }) -} - // C++ function declarations unsafe extern "C" { /// `VM` is an opaque `UnsafeCell`-backed ZST handle; `&mut VM` is diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d1cab79c2214..d2266afdb1ea 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3712,6 +3712,7 @@ impl VirtualMachine { mini_mode: opts.smol, eval_mode: false, is_main_thread: opts.is_main_thread, + use_system_ca: opts.use_system_ca, ..Default::default() }; let vm = Self::init(init_opts)?; @@ -3789,6 +3790,7 @@ impl VirtualMachine { mini_mode: opts.smol, eval_mode: false, is_main_thread: opts.is_main_thread, + use_system_ca: opts.use_system_ca, ..Default::default() }; // Note: shares the console / log / event-loop wiring with `init`; diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 71b198b606c0..35b4a0fa05bd 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1018,8 +1018,8 @@ impl WebWorker { VirtualMachine::set_is_main_thread_vm(false); vm_ref.on_unhandled_rejection = on_unhandled_rejection; - // Node profiles every thread; own execArgv wins, and only a worker - // inheriting the parent's picks up the process-wide --cpu-prof. + // Node profiles every thread; own execArgv wins, and an inheriting + // worker takes the immediate parent's config (not the process's). let profile = if exec_argv.cpu_prof { let mut config = crate::bun_cpu_profiler::CPUProfilerConfig { json_format: true, @@ -1031,7 +1031,12 @@ impl WebWorker { } Some(config) } else if own_exec_argv.is_none() { - crate::bun_cpu_profiler::inherited_config_for_worker(self.execution_context_id) + parent + .cpu_profiler_config + .map(|c| crate::bun_cpu_profiler::CPUProfilerConfig { + thread_id: self.execution_context_id, + ..c + }) } else { None }; diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 4ec92174ba20..9ef8a7e14b17 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1174,6 +1174,7 @@ Full documentation is available at https://bun.com/docs/cli/run graph: Some(graph_dyn), is_main_thread: true, smol: ctx.runtime_options.smol, + use_system_ca: crate::cli::Arguments::main_use_system_ca(), // `Options::dns_result_order` is `u8` until the // b2-cycle widens it to `bun_dns::Order`; the enum is // `#[repr(u8)]` so `as u8` is exact. @@ -1380,9 +1381,6 @@ impl Run { thread_id: 0, }; vm.cpu_profiler_config = Some(config); - // Node profiles every thread when the process gets --cpu-prof, so - // publish for worker VMs to pick up as they start. - bun_jsc::bun_cpu_profiler::publish_inherited_config(config); bun_jsc::bun_cpu_profiler::set_sampling_interval(opts.interval); // SAFETY: `vm.jsc_vm` set in `init`. bun_jsc::bun_cpu_profiler::start_cpu_profiler(unsafe { &mut *vm.jsc_vm }); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index ac14638a9937..43e73bcc5929 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1538,6 +1538,7 @@ unsafe fn parse_worker_exec_argv( let mut out = bun_jsc::virtual_machine::WorkerExecArgv::default(); let mut no_addons = false; let mut want_interval = false; + let mut skip_next = false; for &arg in exec_argv { if arg.is_null() { continue; @@ -1545,6 +1546,10 @@ unsafe fn parse_worker_exec_argv( // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. let owned = unsafe { &*arg }.to_owned_slice_z(); let bytes = owned.as_bytes(); + if skip_next { + skip_next = false; + continue; + } if want_interval { want_interval = false; out.cpu_prof_interval = std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()); @@ -1571,6 +1576,10 @@ unsafe fn parse_worker_exec_argv( want_interval = true; } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-interval=") { out.cpu_prof_interval = std::str::from_utf8(v).ok().and_then(|s| s.parse().ok()); + } else if bytes == b"--cpu-prof-dir" || bytes == b"--cpu-prof-name" { + // Value is discarded here but must be consumed so it is not misread + // as the first positional (which would stop the scan early). + skip_next = true; } } // Override `allow_addons` unconditionally. diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index e5cebb819b7e..9247bb7962e6 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1136,6 +1136,24 @@ test("off() removes only the listener it names, per event and per port", () => { } }); +test("MessagePort listeners receive the port as `this` for on() and once()", async () => { + const { port1, port2 } = new MessageChannel(); + const seen: unknown[] = []; + const { promise, resolve } = Promise.withResolvers(); + port1.on("message", function () { + seen.push(this); + }); + port1.once("message", function () { + seen.push(this); + resolve(); + }); + port2.postMessage("x"); + await promise; + expect(seen).toEqual([port1, port1]); + port1.close(); + port2.close(); +}); + // bun collects entangled ports; node never does. A worker that drops its transferred // port must therefore still notify the peer, or the peer's loop ref is never released // and the parent hangs forever. Spawned: the symptom is "the process never exits". From 1fbc0d107626ffa85c3a0e65d0d563f518cf4d83 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:33:37 +0000 Subject: [PATCH 027/137] eventLoopUtilization: fix two cross-thread races - epoll_kqueue: zero idle_entry_ns before folding the delta into idle_ns, so a reader landing between the two under-counts (bounded, monotonic) instead of double-counting and driving active negative - WebWorker__getELU: read a loop pointer cached under vm_lock instead of following event_loop_handle, which spawnSync swaps on the worker thread without taking the lock (data race, and the temp loop's idle_ns is ~0) --- .../bun-usockets/src/eventing/epoll_kqueue.c | 4 +++- src/jsc/web_worker.rs | 23 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 7b4e7da36dcc..503a041dbb08 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -436,9 +436,11 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout #endif if (will_idle_inside_event_loop) { + /* Zero entry BEFORE folding into idle_ns: a cross-thread reader between + * the two under-counts (bounded, monotonic) instead of double-counting. */ + __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_RELEASE); __atomic_add_fetch(&loop->data.idle_ns, us_internal_monotonic_ns() - idle_start_ns, __ATOMIC_RELAXED); - __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_RELEASE); } /* Before anything can allocate again. */ diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 35b4a0fa05bd..923573a3561c 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -139,6 +139,10 @@ pub struct WebWorker { /// live. `*mut T` is `Copy`, so `Cell` gives safe `.get()`/`.set()`/ /// `.replace()` and no `unsafe` at the access sites. vm: Cell<*mut VirtualMachine>, + /// The worker's real uws loop, cached under `vm_lock` for cross-thread ELU + /// reads: `event_loop_handle` is swapped by `spawnSync` so following it from + /// the parent would race and read the wrong loop's counters. + elu_loop: Cell<*mut bun_uws::Loop>, vm_lock: Mutex, // ---- Parent-thread only ------------------------------------------------- @@ -416,18 +420,15 @@ pub(crate) unsafe extern "C" fn WebWorker__getELU( let w = unsafe { &*worker }; w.vm_lock.lock(); let vm_ptr = w.vm_ptr(); - let live = !vm_ptr.is_null(); + let loop_ptr = w.elu_loop.get(); + let live = !vm_ptr.is_null() && !loop_ptr.is_null(); if live { // No `&VirtualMachine` binding: the worker thread may hold a live mutable // view. Raw-pointer access keeps any autoref scoped to the access, as the // terminate path above does. - // SAFETY: event_loop() is the live self-pointer; the reads below are an - // atomic load and a Copy field written before the VM was published. - let loop_ = unsafe { (*(*vm_ptr).event_loop()).usockets_loop().as_ref() }; - let Some(loop_) = loop_ else { - w.vm_lock.unlock(); - return false; - }; + // SAFETY: elu_loop was cached under vm_lock alongside vm; the real loop + // outlives the vm publish window (freed only after vm is nulled). + let loop_ = unsafe { &*loop_ptr }; // Idle BEFORE elapsed, matching node's order — reversed, idle is dated // after now and active = now - idle comes out short. let idle_ms = loop_.idle_ns() as f64 / 1_000_000.0; @@ -613,6 +614,7 @@ impl WebWorker { live_prev: Cell::new(core::ptr::null_mut()), requested_terminate: AtomicBool::new(false), vm: Cell::new(core::ptr::null_mut()), + elu_loop: Cell::new(core::ptr::null_mut()), vm_lock: Mutex::new(), parent_poll_ref: JsCell::new(KeepAlive::init()), status: Cell::new(Status::Start), @@ -1063,6 +1065,11 @@ impl WebWorker { self.vm_lock.lock(); // vm_lock held; this is the publish point. self.vm.set(vm); + // SAFETY: `vm` is valid; ensure_waker() during init already set the uws + // loop. Cache it so cross-thread ELU reads bypass event_loop_handle, + // which spawnSync swaps on the worker thread without taking vm_lock. + self.elu_loop + .set(unsafe { (*(*vm).event_loop()).usockets_loop() }); self.vm_lock.unlock(); // Post-publish: do NOT re-form `&mut VirtualMachine`. Field/method From 19b36ffd0cb4fd4bbaa298e10b8adf02f1eb8309 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:49:43 +0000 Subject: [PATCH 028/137] eventLoopUtilization: use seq_cst for the idle_ns/idle_entry_ns pair A release store does not order a later relaxed RMW before it on ARM, so the zero-then-add could still reorder and let a cross-thread reader double-count. seq_cst on all five accesses (pre-park store, post-wake zero+add, reader loads) keeps the pair atomic to readers; the path only runs once per park so the extra barriers are in the noise. --- packages/bun-usockets/src/eventing/epoll_kqueue.c | 12 +++++++----- packages/bun-usockets/src/loop.c | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 503a041dbb08..910aa2954f53 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -413,7 +413,7 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout * Publish the entry so a cross-thread reader can add the in-progress park. */ const uint64_t idle_start_ns = will_idle_inside_event_loop ? us_internal_monotonic_ns() : 0; if (will_idle_inside_event_loop) - __atomic_store_n(&loop->data.idle_entry_ns, idle_start_ns, __ATOMIC_RELEASE); + __atomic_store_n(&loop->data.idle_entry_ns, idle_start_ns, __ATOMIC_SEQ_CST); /* Fetch ready polls */ #ifdef LIBUS_USE_EPOLL @@ -436,11 +436,13 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout #endif if (will_idle_inside_event_loop) { - /* Zero entry BEFORE folding into idle_ns: a cross-thread reader between - * the two under-counts (bounded, monotonic) instead of double-counting. */ - __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_RELEASE); + /* Zero entry BEFORE folding into idle_ns so a cross-thread reader between + * the two under-counts (bounded, monotonic) instead of double-counting. + * seq_cst on both: a release store alone would not order the later add + * before it on ARM, and this path only runs on ticks that park. */ + __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_SEQ_CST); __atomic_add_fetch(&loop->data.idle_ns, us_internal_monotonic_ns() - idle_start_ns, - __ATOMIC_RELAXED); + __ATOMIC_SEQ_CST); } /* Before anything can allocate again. */ diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index ac16c2a9814c..eaed6243f424 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -125,11 +125,11 @@ uint64_t us_loop_idle_ns(struct us_loop_t *loop) { #ifdef LIBUS_USE_LIBUV return uv_metrics_idle_time(loop->uv_loop); #else - uint64_t idle = __atomic_load_n(&loop->data.idle_ns, __ATOMIC_RELAXED); + uint64_t idle = __atomic_load_n(&loop->data.idle_ns, __ATOMIC_SEQ_CST); /* Parked right now? The total is only folded in when the park ends, so add * the in-progress interval — otherwise a mid-park reader sees a stale idle * and over-reports active. Same as libuv's uv_metrics_idle_time. */ - uint64_t entry = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_ACQUIRE); + uint64_t entry = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_SEQ_CST); if (entry > 0) { uint64_t now = us_internal_monotonic_ns(); if (now > entry) From c7cfb4e04b56eb641462d889b35dca3c09c25d2c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:26:12 +0000 Subject: [PATCH 029/137] web_worker: fix two aliasing/race hazards in the cross-thread ELU/cpu-prof paths - WebWorker__getELU: call us_loop_idle_ns on the raw *mut Loop instead of forming &PosixLoop while the worker holds &mut inside tick_with_timeout (matching the us_wakeup_loop re-export convention in Loop.rs) - snapshot parent.cpu_profiler_config on the parent thread in WebWorker__create and read that in start_vm, since on_exit() .take()s the field on the parent thread with no sync and a live read on the worker thread would race the ~40-byte write --- src/jsc/web_worker.rs | 18 +++++++++++------- src/uws/lib.rs | 2 +- src/uws_sys/Loop.rs | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 923573a3561c..59b6040b2a98 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -143,6 +143,10 @@ pub struct WebWorker { /// reads: `event_loop_handle` is swapped by `spawnSync` so following it from /// the parent would race and read the wrong loop's counters. elu_loop: Cell<*mut bun_uws::Loop>, + /// Parent's profiler config snapshotted in `create()` on the parent thread, + /// since `on_exit()` `.take()`s it on that thread and `start_vm` reading it + /// live would race. + parent_cpu_profiler_config: Option, vm_lock: Mutex, // ---- Parent-thread only ------------------------------------------------- @@ -423,15 +427,14 @@ pub(crate) unsafe extern "C" fn WebWorker__getELU( let loop_ptr = w.elu_loop.get(); let live = !vm_ptr.is_null() && !loop_ptr.is_null(); if live { - // No `&VirtualMachine` binding: the worker thread may hold a live mutable - // view. Raw-pointer access keeps any autoref scoped to the access, as the - // terminate path above does. + // No `&VirtualMachine` or `&Loop` binding: the worker thread holds + // `&mut` to both while parked. Raw-pointer access only, matching the + // us_wakeup_loop re-export convention in Loop.rs. // SAFETY: elu_loop was cached under vm_lock alongside vm; the real loop // outlives the vm publish window (freed only after vm is nulled). - let loop_ = unsafe { &*loop_ptr }; // Idle BEFORE elapsed, matching node's order — reversed, idle is dated // after now and active = now - idle comes out short. - let idle_ms = loop_.idle_ns() as f64 / 1_000_000.0; + let idle_ms = unsafe { bun_uws::us_loop_idle_ns(loop_ptr) } as f64 / 1_000_000.0; // SAFETY: vm_ptr is published under vm_lock and non-null here; // loop_start is Copy and fixed before the VM was published. let elapsed_ms = unsafe { (*vm_ptr).loop_start }.elapsed().as_secs_f64() * 1000.0; @@ -615,6 +618,8 @@ impl WebWorker { requested_terminate: AtomicBool::new(false), vm: Cell::new(core::ptr::null_mut()), elu_loop: Cell::new(core::ptr::null_mut()), + // SAFETY: `parent` is the calling thread's live VM (checked above). + parent_cpu_profiler_config: unsafe { (*parent).cpu_profiler_config }, vm_lock: Mutex::new(), parent_poll_ref: JsCell::new(KeepAlive::init()), status: Cell::new(Status::Start), @@ -1033,8 +1038,7 @@ impl WebWorker { } Some(config) } else if own_exec_argv.is_none() { - parent - .cpu_profiler_config + self.parent_cpu_profiler_config .map(|c| crate::bun_cpu_profiler::CPUProfilerConfig { thread_id: self.execution_context_id, ..c diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 2f0ce5dc0bd9..a6f15642996c 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -1272,7 +1272,7 @@ pub mod ssl_wrapper { // loop_data.h) and `struct us_loop_t` (epoll_kqueue.h / libuv.h). Re-exported // from bun_uws_sys so `bun_uws::Loop` and `bun_uws_sys::Loop` are the same // type (bun_io's EventLoopCtxVTable is typed against the uws_sys version). -pub use bun_uws_sys::loop_::{LoopHandler, us_wakeup_loop}; +pub use bun_uws_sys::loop_::{LoopHandler, us_loop_idle_ns, us_wakeup_loop}; pub use bun_uws_sys::{InternalLoopData, Loop, NOW_NS_UNKNOWN, PosixLoop, Timespec, WindowsLoop}; /// Carrier trait so `set_parent_event_loop` can accept the higher-tier diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index eed1b42c96f0..87c4545b87e8 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -665,7 +665,7 @@ mod c { #[cfg(windows)] pub(super) fn us_loop_pump(loop_: *mut Loop); pub fn us_wakeup_loop(loop_: *mut Loop); - pub(super) fn us_loop_idle_ns(loop_: *mut Loop) -> u64; + pub fn us_loop_idle_ns(loop_: *mut Loop) -> u64; pub(super) fn uws_loop_addPostHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); pub(super) fn uws_loop_removePostHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); pub(super) fn uws_loop_addPreHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); @@ -693,7 +693,7 @@ mod c { // event-loop thread parks inside it while worker threads call // `us_wakeup_loop` concurrently; routing either through a `&mut self` // receiver would create two live `&mut Loop` to the same singleton (UB). -pub use c::{us_loop_run, us_wakeup_loop}; +pub use c::{us_loop_idle_ns, us_loop_run, us_wakeup_loop}; unsafe extern "C" { // safe: no args; clears the C side's thread-local loop pointer — no preconditions. From f0f5e8e66dcd0957a0b8157be1f81cb153ad2b71 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:28:18 +0000 Subject: [PATCH 030/137] [autofix.ci] apply automated fixes --- src/jsc/web_worker.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 59b6040b2a98..c7dd66208bd6 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1038,11 +1038,12 @@ impl WebWorker { } Some(config) } else if own_exec_argv.is_none() { - self.parent_cpu_profiler_config - .map(|c| crate::bun_cpu_profiler::CPUProfilerConfig { + self.parent_cpu_profiler_config.map(|c| { + crate::bun_cpu_profiler::CPUProfilerConfig { thread_id: self.execution_context_id, ..c - }) + } + }) } else { None }; From de70866e56128f5afae95ac045a66852e698e95b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:06:53 +0000 Subject: [PATCH 031/137] ci: retrigger From 31fb9fcddaed5afcc973d8f54be3dbaaba791227 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:38:39 +0000 Subject: [PATCH 032/137] epoll_kqueue: hoist the post-park clock read before zeroing idle_entry_ns so the zero+add are adjacent seq_cst ops with no vDSO call between them, shrinking the window where a cross-thread reader can observe a dip. Drop the 'monotonic' claim from the comment (a reader between the two can still return less than a preceding mid-park read; bounded by one park and self-correcting on the next call). --- packages/bun-usockets/src/eventing/epoll_kqueue.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 910aa2954f53..2ae0c2858825 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -436,13 +436,13 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout #endif if (will_idle_inside_event_loop) { - /* Zero entry BEFORE folding into idle_ns so a cross-thread reader between - * the two under-counts (bounded, monotonic) instead of double-counting. - * seq_cst on both: a release store alone would not order the later add - * before it on ARM, and this path only runs on ticks that park. */ + /* Clock read first so the zero+add are adjacent seq_cst ops: a reader + * between them under-counts (bounded by one park) rather than + * double-counting. seq_cst on both — release alone would not order the + * later add before the store on ARM. Only runs on parking ticks. */ + uint64_t now = us_internal_monotonic_ns(); __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_SEQ_CST); - __atomic_add_fetch(&loop->data.idle_ns, us_internal_monotonic_ns() - idle_start_ns, - __ATOMIC_SEQ_CST); + __atomic_add_fetch(&loop->data.idle_ns, now - idle_start_ns, __ATOMIC_SEQ_CST); } /* Before anything can allocate again. */ From a757b03fe569db87013071506af537c237be7f86 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:18:38 +0000 Subject: [PATCH 033/137] Worker.cpp: guard errorCodeOf against a pending TerminationException The inline code this replaced checked !scope.exception() before the property read; CLEAR_IF_EXCEPTION at the call sites cannot clear a TerminationException, so restore the guard inside the helper. --- src/jsc/bindings/webcore/Worker.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index e245c1e5718f..251aba4175e8 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -557,7 +557,9 @@ String Worker::errorCodeOf(JSC::JSGlobalObject* globalObject, JSValue value) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - if (!value.isObject()) + // A TerminationException can be pending here (CLEAR_IF_EXCEPTION at the + // call sites cannot clear it); do not enter JS with one on the VM. + if (!value.isObject() || scope.exception()) return {}; JSValue codeValue = value.getObject()->getIfPropertyExists(globalObject, WebCore::builtinNames(vm).codePublicName()); String code; From 747cbbb8dd8465a708527e566aee3b116b393c9f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:28:15 +0000 Subject: [PATCH 034/137] trim comments to <=3 lines, cite spec/node source --- .../bun-usockets/src/eventing/epoll_kqueue.c | 7 ++-- src/js/internal/async_hooks_tick.ts | 14 ++------ .../internal/perf/event_loop_utilization.ts | 14 ++------ src/js/node/async_hooks.ts | 11 ++----- src/js/node/worker_threads.ts | 6 ++-- src/jsc/VirtualMachine.rs | 17 +++------- src/jsc/bindings/webcore/Worker.cpp | 10 ++---- src/jsc/web_worker.rs | 33 +++++-------------- src/runtime/dispatch_js2native.rs | 6 ++-- src/runtime/jsc_hooks.rs | 16 ++------- 10 files changed, 34 insertions(+), 100 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 0834c0b8f320..4c3487f4be6e 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -499,10 +499,9 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout #endif if (will_idle_inside_event_loop) { - /* Clock read first so the zero+add are adjacent seq_cst ops: a reader - * between them under-counts (bounded by one park) rather than - * double-counting. seq_cst on both — release alone would not order the - * later add before the store on ARM. Only runs on parking ticks. */ + /* Clock read first so zero+add are adjacent seq_cst ops: a reader between them under-counts + * (bounded by one park) rather than double-counts. seq_cst — release would not order the add + * before the store on ARM. */ uint64_t now = us_internal_monotonic_ns(); __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_SEQ_CST); __atomic_add_fetch(&loop->data.idle_ns, now - idle_start_ns, __ATOMIC_SEQ_CST); diff --git a/src/js/internal/async_hooks_tick.ts b/src/js/internal/async_hooks_tick.ts index 878be02cea42..07046006811b 100644 --- a/src/js/internal/async_hooks_tick.ts +++ b/src/js/internal/async_hooks_tick.ts @@ -1,14 +1,6 @@ -// Bridge between node:async_hooks createHook() and the process.nextTick -// queue (builtins/ProcessObjectInternals.ts). Enabled `init` hooks are pushed -// into `tickInitHooks` so the nextTick hot path pays only an array-length -// check when no hook is enabled. -// -// The array identity must stay stable (push/splice only, never reassign): -// the nextTick closure captures it once at setup. -// -// Currently only TickObject (process.nextTick) and WORKER `init` events are -// delivered; promise, timer and other native resource events are still -// unimplemented. +// Bridge between createHook() and process.nextTick: enabled `init` hooks live in `tickInitHooks` so the +// hot path pays only an array-length check. Array identity is stable (push/splice only) — nextTick captures it once. +// Only TickObject and WORKER `init` events are delivered; other resource types are unimplemented. const tickInitHooks = []; let nextAsyncId = 1; diff --git a/src/js/internal/perf/event_loop_utilization.ts b/src/js/internal/perf/event_loop_utilization.ts index 07fb6eedbffb..75df0d2f67d9 100644 --- a/src/js/internal/perf/event_loop_utilization.ts +++ b/src/js/internal/perf/event_loop_utilization.ts @@ -1,14 +1,6 @@ -// Shared by perf_hooks and worker_threads, as node shares -// lib/internal/perf/event_loop_utilization.js between the two. -// -// `elu` is [elapsedSinceLoopStartMs, idleMs] from native, or null when the loop -// has not turned yet — node's equivalent of its `loopStart <= 0` branch, and it -// is checked first there too, so elu(u, u) before the loop turns is {0,0,0} -// rather than NaN. -// -// The divisions are deliberately unguarded: node returns NaN for a zero total -// (verified on v26.3.0 — eventLoopUtilization(u, u) after the loop has turned -// yields NaN), so collapsing that to 0 would diverge. +// Shared by perf_hooks and worker_threads; see https://github.com/nodejs/node/blob/main/lib/internal/perf/event_loop_utilization.js +// `elu` is [elapsedSinceLoopStartMs, idleMs] or null before the loop turns (node's `loopStart <= 0` branch → {0,0,0}). +// Divisions are unguarded: node returns NaN for a zero total, collapsing to 0 would diverge. function internalEventLoopUtilization(elu, util1, util2) { if (elu === null) { return { idle: 0, active: 0, utilization: 0 }; diff --git a/src/js/node/async_hooks.ts b/src/js/node/async_hooks.ts index 104c4ae4a1dd..b385692f76a0 100644 --- a/src/js/node/async_hooks.ts +++ b/src/js/node/async_hooks.ts @@ -498,14 +498,9 @@ function createHook(hook) { const asyncHook = { enable() { if (init !== undefined && enabledInit === undefined) { - // init is delivered for TickObject (process.nextTick) and WORKER - // resources; other resource types are still unimplemented. - // Per-instance wrapper: two hooks registered with the same init - // function must stay independently removable (removal is by - // identity, and removing the other instance's entry would reorder - // its callback relative to unrelated hooks). - // node invokes init as a method on the AsyncHook instance - // (lib/internal/async_hooks.js), so `this.disable()` inside init works. + // Only TickObject and WORKER init are delivered. Per-instance wrapper so two hooks with the + // same init stay independently removable. node calls init as a method on the AsyncHook + // (https://github.com/nodejs/node/blob/main/lib/internal/async_hooks.js), so `this.disable()` works. enabledInit = (asyncId, type, triggerAsyncId, resource) => init.$call(asyncHook, asyncId, type, triggerAsyncId, resource); require("internal/async_hooks_tick").tickInitHooks.push(enabledInit); diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 13c0c225e6fd..2caadf9e534a 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -1112,10 +1112,8 @@ class Worker extends EventEmitter { } } - // node's WORKER resource is the C++ handle, so hasRef() follows ref()/unref() - // and reads back undefined once the handle is gone. Bun delivered init for - // TickObject only; this extends the same array to the type worker consumers - // look for. Only hasRef() is exposed, not the full handle. + // node's WORKER resource is the C++ handle: hasRef() follows ref()/unref() and reads undefined + // once the handle is gone. Extends the TickObject init array to WORKER; only hasRef() is exposed. #emitAsyncHooksInit() { const count = tickInitHooks.length; if (count === 0) return; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 7c2e6cb35220..a22ef9b61095 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1787,15 +1787,8 @@ pub struct RuntimeHooks { transpiler: *mut Transpiler<'static>, graph: &'static dyn bun_resolver::StandaloneModuleGraph, ), - /// Parse `execArgv` against the `RunCommand` - /// param table and return the resulting `allow_addons` value - /// (`!args.flag("--no-addons")`), or `None` if parsing failed. - /// The param table lives in - /// `bun_runtime::cli` (forward-dep). Only `--no-addons` is honoured; - /// the caller writes the returned `allow_addons` back into - /// `transform_options.allow_addons` so the override semantics - /// ("override the existing even if it was set") match, and applies - /// `cpu_prof` to the worker VM. + /// Parse `execArgv` against the `RunCommand` param table (lives in `bun_runtime::cli`, forward-dep). + /// Caller writes `allow_addons` back into `transform_options` and applies `cpu_prof` to the worker VM. pub parse_worker_exec_argv: unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> WorkerExecArgv, /// `CronJob.clearAllForVM(vm, .teardown)`. `CronJob` lives in /// `bun_runtime::api::cron`. @@ -2015,10 +2008,8 @@ fn get_origin_timestamp() -> u64 { (now - ORIGIN_RELATIVE_EPOCH).max(0) as u64 } -/// `performance.timeOrigin` is the PROCESS start time and every thread reports -/// the same one — a worker's timeOrigin equals the main thread's in node, and -/// `performance.now()` inside a worker is relative to that same origin. Capture -/// it on the first VM so worker VMs inherit it instead of restarting the clock. +/// `performance.timeOrigin` is the PROCESS start time, shared by every thread (a worker's equals the +/// main thread's in node). Captured once on the first VM so worker VMs inherit instead of restarting. fn process_origin() -> (std::time::Instant, u64) { static ORIGIN: std::sync::OnceLock<(std::time::Instant, u64)> = std::sync::OnceLock::new(); *ORIGIN.get_or_init(|| (std::time::Instant::now(), get_origin_timestamp())) diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 251aba4175e8..9ffa545259d9 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -580,13 +580,9 @@ bool Worker::dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSVal auto serialized = SerializedScriptValue::create(*workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); CLEAR_IF_EXCEPTION(scope); - // Cloning an Error reads `stack`, so a throwing Error.prepareStackTrace takes - // the whole error down and the caller reports the pretty-printed text as the - // message instead. Node drops only the unreadable `stack` - // (lib/internal/error_serdes.js TryGetAllProperties); retry once with an own - // undefined `stack` so name/message/code still cross. Safe to mutate: the - // worker's own error event and the fallback message are both already - // materialized by the time we get here, and the thread is terminating. + // Cloning an Error reads `stack`; a throwing prepareStackTrace sinks the whole error. Node drops only + // `stack` (https://github.com/nodejs/node/blob/main/lib/internal/error_serdes.js TryGetAllProperties), + // so retry once with own undefined `stack`. Safe to mutate: fallback message is already materialized. if (!serialized && !scope.exception()) { if (auto* errorInstance = dynamicDowncast(value)) { errorInstance->putDirect(vm, vm.propertyNames->stack, JSC::jsUndefined(), JSC::PropertyAttribute::DontEnum | 0); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 9894ac85336f..2366f3d74dea 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -405,13 +405,9 @@ pub fn terminate_all_and_wait(timeout_ms: u64) { } } -/// The PARENT reading a live worker's loop counters. False once the worker VM is -/// gone, which node reports as all-zero. `vm_lock` only closes the TOCTOU on -/// `vm`: `idle_ns` is atomic and `loop_start` is fixed before publish. -/// -/// # Safety -/// `worker` is a live `WebWorker*` owned by the calling C++ `Worker`; the out -/// params are non-null and writable. +/// Parent reading a live worker's loop counters; false once the VM is gone (node reports all-zero). +/// `vm_lock` only closes the TOCTOU on `vm` — `idle_ns` is atomic, `loop_start` fixed before publish. +/// SAFETY: `worker` is a live `WebWorker*` owned by the C++ `Worker`; out params are non-null/writable. #[unsafe(no_mangle)] pub(crate) unsafe extern "C" fn WebWorker__getELU( worker: *mut WebWorker, @@ -425,13 +421,9 @@ pub(crate) unsafe extern "C" fn WebWorker__getELU( let loop_ptr = w.elu_loop.get(); let live = !vm_ptr.is_null() && !loop_ptr.is_null(); if live { - // No `&VirtualMachine` or `&Loop` binding: the worker thread holds - // `&mut` to both while parked. Raw-pointer access only, matching the - // us_wakeup_loop re-export convention in Loop.rs. - // SAFETY: elu_loop was cached under vm_lock alongside vm; the real loop - // outlives the vm publish window (freed only after vm is nulled). - // Idle BEFORE elapsed, matching node's order — reversed, idle is dated - // after now and active = now - idle comes out short. + // Raw-pointer only (worker thread holds `&mut` to both while parked). Idle BEFORE elapsed per + // node's order — reversed, active = now - idle comes out short. + // SAFETY: elu_loop cached under vm_lock alongside vm; loop outlives the vm publish window. let idle_ms = unsafe { bun_uws::us_loop_idle_ns(loop_ptr) } as f64 / 1_000_000.0; // SAFETY: vm_ptr is published under vm_lock and non-null here; // loop_start is Copy and fixed before the VM was published. @@ -908,16 +900,9 @@ impl WebWorker { // and passes the owned struct as `args` to the new VM. let mut transform_options = (*parent.transpiler.options.transform_options).clone(); - // Honours `--no-addons` and `--cpu-prof`; the hook owns the temporary - // UTF-8 allocs. The param table lives in `bun_runtime::cli` (forward-dep), - // so dispatch through `RuntimeHooks::parse_worker_exec_argv`. - // - // SAFETY: `exec_argv` borrows C++ `WorkerOptions` kept alive by the - // owning `WebCore::Worker` for `self`'s lifetime; the hook only reads - // the slice and owns its own temporary allocations. - // `None` means "inherit from the parent" (WorkerOptions.h); an explicit - // list — even an empty one — replaces the parent's, as node resets to - // fresh defaults whenever execArgv is given (node_worker.cc). + // Param table lives in `bun_runtime::cli` (forward-dep). `None` ⇒ inherit from parent; an explicit + // list (even empty) replaces it, as node resets to fresh defaults per execArgv (src/node_worker.cc). + // SAFETY: `exec_argv` borrows C++ `WorkerOptions` kept alive by the owning `WebCore::Worker`. let own_exec_argv = self.exec_argv(); let exec_argv = match own_exec_argv { // SAFETY: `a` is this worker's execArgv, owned by the WebWorker and diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 73858af95c03..7b4af0d6d8ac 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -64,10 +64,8 @@ pub use bun_sys_jsc::error_jsc::TestingAPIs::translate_uv_error_to_e as sys_sys_ pub use bun_http_jsc::headers_jsc::h2_live_counts as http_h2_client_testing_ap_is_live_counts; pub use bun_http_jsc::headers_jsc::h3_quic_live_counts as http_h3_client_testing_ap_is_quic_live_counts; -/// Per-VM, not a process-wide atomic: node treats `--use-system-ca` as an -/// Environment option, so a Worker's execArgv can differ from the process. -/// `undefined` means neither flag was given and NODE_USE_SYSTEM_CA decides — -/// only the explicit `--no-use-system-ca` beats that env var. +/// Per-VM (node treats `--use-system-ca` as an Environment option, so a Worker's execArgv can differ). +/// `undefined` ⇒ neither flag given, NODE_USE_SYSTEM_CA decides; only `--no-use-system-ca` beats the env var. pub(crate) fn bun_get_use_system_ca( _global: &JSGlobalObject, _frame: &CallFrame, diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 2016aac18cbd..059681fcbfbc 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1511,21 +1511,9 @@ unsafe fn apply_standalone_runtime_flags( crate::run_main::apply_standalone_runtime_flags(unsafe { &mut *transpiler }, graph); } -/// Parse a Worker's `execArgv` against the -/// `RunCommand` param table and return `!args.flag("--no-addons")`, or `None` -/// on parse error. -/// -/// Note: the Rust `bun_clap::parse_ex` port currently constrains -/// `ArgIter<'static>` (parsed values are stored by reference), which would -/// force leaking the per-call UTF-8 copies of `exec_argv`. Only flags whose -/// values need not outlive the parse are read, so this body scans the converted -/// argv directly with the same `stop_after_positional_at = 1` short-circuit. -/// Full clap routing can return when `ComptimeClap` grows a borrowed-lifetime -/// variant. -/// +/// Parse a Worker's `execArgv`; scans argv directly since `ArgIter<'static>` would leak the UTF-8 copies. /// # Safety -/// Each `WTFStringImpl` in `exec_argv` is a live WTF string (the C++ -/// `Worker::create` array, kept alive for the worker's lifetime). +/// Each `WTFStringImpl` in `exec_argv` is a live WTF string kept alive for the worker's lifetime. unsafe fn parse_worker_exec_argv( exec_argv: &[bun_core::WTFStringImpl], ) -> bun_jsc::virtual_machine::WorkerExecArgv { From 79b3862c4bf0d28f5db6b7ab46edcfdd30968a49 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:03:55 +0000 Subject: [PATCH 035/137] Worker.cpp: guard WebWorker__dispatchError/dispatchErrorWithValue against a pending TerminationException The dispatchOnlineEvent split means terminate() can land mid-entry-evaluation; the rejected entry promise then routes here with the TerminationException still on the VM, and dispatchEvent / SerializedScriptValue::create enter JS with it pending (EXCEPTION_ASSERT on the asan lane). Bail to the message-only path. Also drop the per-test timeout on the cpu-prof worker test per test/CLAUDE.md. --- src/jsc/bindings/webcore/Worker.cpp | 10 ++++++++++ test/cli/run/cpu-prof.test.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 9ffa545259d9..c81c04f5a122 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -577,6 +577,9 @@ bool Worker::dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSVal // property read must not propagate exceptions out of this function. auto& vm = JSC::getVM(workerGlobalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + // A TerminationException survives CLEAR_IF_EXCEPTION; do not enter JS with one pending. + if (scope.exception()) + return false; auto serialized = SerializedScriptValue::create(*workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); CLEAR_IF_EXCEPTION(scope); @@ -782,6 +785,13 @@ extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker { JSValue error = JSC::JSValue::decode(errorValue); WTF::String messageStr = message->transferToWTFString(); + auto& vm = JSC::getVM(globalObject); + // terminate() mid-entry-evaluation routes the rejected entry promise here; + // do not run JS (dispatchEvent, structured clone) with it still pending. + if (vm.hasPendingTerminationException()) { + worker->dispatchErrorWithMessage(WTF::move(messageStr), {}); + return; + } ErrorEvent::Init init; init.message = messageStr.isolatedCopy(); init.error = error; diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 75ad48aad3cf..f9940619cd04 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -152,7 +152,7 @@ describe.concurrent("--cpu-prof", () => { // file where the default name yields 2 — it only thread-stamps the default. expect(profiles).toEqual([customName]); expect(exitCode).toBe(0); - }, 15_000); + }); test("--cpu-prof-dir sets custom directory", async () => { using dir = tempDir("cpu-prof-dir", { From 388af0e962dd69095293e8be14acafed5a55b581 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:30:00 +0000 Subject: [PATCH 036/137] bun_get_loop_elu: use raw us_loop_idle_ns, drop the &self idle_ns wrappers Same pattern as c7cfb4e for WebWorker__getELU: this runs inside tick_with_timeout(&mut self), so forming &PosixLoop here aliases the live &mut. The raw extern on *mut Loop is already re-exported for exactly this; the &self wrappers now have zero callers and are removed. --- src/runtime/dispatch_js2native.rs | 12 +++++++----- src/uws_sys/Loop.rs | 16 ---------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 7b4af0d6d8ac..a17beb23dd59 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -83,15 +83,17 @@ pub(crate) fn bun_get_use_system_ca( /// active as now - loopStart - idle). pub(crate) fn bun_get_loop_elu(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { let vm = bun_jsc::virtual_machine::VirtualMachine::get(); - // SAFETY: the VM owns this loop and this runs on its thread. - let loop_ = unsafe { (*vm.event_loop).usockets_loop().as_ref() }; - let Some(loop_) = loop_ else { + // SAFETY: the VM owns this loop and this runs on its thread. Raw *mut, no + // &Loop — a &mut PosixLoop is live above us via tick_with_timeout for the + // whole tick (see the re-export comment in Loop.rs). + let loop_ptr = unsafe { (*vm.event_loop).usockets_loop() }; + if loop_ptr.is_null() { return Ok(JSValue::NULL); - }; + } // Idle BEFORE elapsed, matching node's order (it passes loopIdleTime() in // and reads process.hrtime() after). Reversed, idle is dated after now and // active = now - idle comes out short. - let idle_ms = loop_.idle_ns() as f64 / 1_000_000.0; + let idle_ms = unsafe { bun_uws::us_loop_idle_ns(loop_ptr) } as f64 / 1_000_000.0; let elapsed_ms = vm.loop_start.elapsed().as_secs_f64() * 1000.0; let arr = JSValue::create_empty_array(global, 2)?; arr.put_index(global, 0, JSValue::js_number(elapsed_ms))?; diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index a3e6285cccd1..d3b16f553356 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -248,14 +248,6 @@ impl PosixLoop { unsafe { c::us_wakeup_loop(self) }; } - /// Nanoseconds this loop has spent parked, for eventLoopUtilization(). - /// `&self`: a parent thread reads this while the worker holds its own - /// `&mut` — the body is one atomic load, so it must not alias mutably. - pub fn idle_ns(&self) -> u64 { - // SAFETY: self is a valid loop pointer; the counter is read atomically. - unsafe { c::us_loop_idle_ns(core::ptr::from_ref(self).cast_mut()) } - } - #[inline] pub fn wake(&mut self) { self.wakeup(); @@ -457,14 +449,6 @@ impl WindowsLoop { unsafe { c::us_wakeup_loop(self) }; } - /// Nanoseconds this loop has spent parked, for eventLoopUtilization(). - /// `&self`: a parent thread reads this while the worker holds its own - /// `&mut` — the body is one atomic load, so it must not alias mutably. - pub fn idle_ns(&self) -> u64 { - // SAFETY: self is a valid loop pointer; the counter is read atomically. - unsafe { c::us_loop_idle_ns(core::ptr::from_ref(self).cast_mut()) } - } - #[inline] pub fn wake(&mut self) { self.wakeup(); From fb72372a97547e4f38c6e8c5cb1c8101109ebc73 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:38:49 +0000 Subject: [PATCH 037/137] web_worker: don't report a terminate()-rejected entry promise as uncaughtException terminate() during entry evaluation rejects the entry promise with the TerminationException; routing that through uncaught_exception re-enters JS (process 'uncaughtException' emit) with it still pending. node does not report a terminate() as uncaught; shut down quietly like the terminated-while-resolving path above. --- src/jsc/web_worker.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 5fb5d867d883..da500d139fdb 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1198,6 +1198,15 @@ impl WebWorker { // Atomics.waitAsync settles. dispatchOnline re-calls it as a no-op. WebWorker__entrySettled(vm.global()); + // Terminated during entry evaluation: the rejection IS the + // TerminationException, still pending on the VM. uncaught_exception + // would re-enter JS with it (process 'uncaughtException' emit) and trip + // assertNoException; node does not report a terminate() as uncaught. + if self.has_requested_terminate() { + self.flush_logs(vm); + return self.shutdown(); + } + // SAFETY: `promise` is a live JSC heap cell. unsafe { let status = (*promise).status(); From f99b7c0a44bab03664645008e7f474b32d7a17c7 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 5 Aug 2026 12:56:57 -0700 Subject: [PATCH 038/137] Add missing SAFETY comment on us_loop_idle_ns call No-Verification-Needed: comment-only change --- src/runtime/dispatch_js2native.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 7b89a9726734..37aa91c29414 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -90,6 +90,8 @@ pub(crate) fn bun_get_loop_elu(global: &JSGlobalObject, _frame: &CallFrame) -> J // Idle BEFORE elapsed, matching node's order (it passes loopIdleTime() in // and reads process.hrtime() after). Reversed, idle is dated after now and // active = now - idle comes out short. + // SAFETY: `loop_ptr` was just null-checked and stays valid for this tick + // under the same ownership argument as above. let idle_ms = unsafe { bun_uws::us_loop_idle_ns(loop_ptr) } as f64 / 1_000_000.0; let elapsed_ms = vm.loop_start.elapsed().as_secs_f64() * 1000.0; let arr = JSValue::create_empty_array(global, 2)?; From 20f5734475d630c9efc2c2dfae638c1454772042 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:35:44 +0000 Subject: [PATCH 039/137] web_worker: close the Bun-side termination-exception holes in the terminate path - drop flush_logs on the terminate-during-entry branch: Log::to_js enters JS and, with the TerminationException guaranteed pending there, its Err landed on the panic arm (panic=abort); nothing user-facing to report on a terminate anyway - shutdown() now clears the materialized exception too (clear_termination_exception), not just the request flag, so on_exit() deferred tasks and close_all_socket_groups' on_close JS run with a clean scope; the flag-only helper is dead and removed Stress (30x each, validateExceptionChecks): the residual ~1-in-15 assert has the identical JSModuleLoader.cpp:1037 preload-drain stack, which is the separately tracked vendored-WebKit fix. --- src/jsc/VM.rs | 4 ---- src/jsc/web_worker.rs | 10 ++++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 41f780c67f5b..77e7f8e0bc76 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -118,10 +118,6 @@ impl VM { JSC__VM__notifyNeedTermination(self) } - pub(crate) fn clear_has_termination_request(&self) { - crate::cpp::JSC__VM__clearHasTerminationRequest(self) - } - #[track_caller] pub fn throw_error(&self, global_object: &JSGlobalObject, value: JSValue) -> JsError { crate::validation_scope!(scope, global_object); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index da500d139fdb..39371ba9aad0 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1202,8 +1202,9 @@ impl WebWorker { // TerminationException, still pending on the VM. uncaught_exception // would re-enter JS with it (process 'uncaughtException' emit) and trip // assertNoException; node does not report a terminate() as uncaught. + // No flush_logs here: Log::to_js enters JS and would see the pending + // exception, and there is nothing user-facing to report on terminate. if self.has_requested_terminate() { - self.flush_logs(vm); return self.shutdown(); } @@ -1340,9 +1341,10 @@ impl WebWorker { // other thread can dereference it now — `&mut` is exclusive. let vm = unsafe { &mut *vm_ptr }; // terminate() set the JSC termination flag to interrupt running JS; - // clear it so process.on('exit') handlers can run. teardownJSCVM - // re-sets it for the JSC VM teardown. - vm.jsc_vm().clear_has_termination_request(); + // clear it AND the materialized exception (tryClearException refuses + // TerminationExceptions) so on_exit() / socket on_close JS below run + // with a clean scope. teardownJSCVM re-arms it for JSC VM teardown. + vm.global().clear_termination_exception(); vm.is_shutting_down = true; vm.on_exit(); if let Some(hooks) = runtime_hooks() { From 04dca3c1b8bf5bb72b3357e33b0022725b90515a Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 5 Aug 2026 13:36:08 -0700 Subject: [PATCH 040/137] worker: keep flush_logs and teardown termination-safe on the remaining paths Follow-up to 20f5734475, which covered the post-entry terminate branch and swapped shutdown() to clear_termination_exception before on_exit. Two gaps remained: flush_logs has four other call sites reachable with the termination trap armed or the exception pending (the load-failure Err branch is the common terminate-during-entry route via load_entry_point_for_web_worker returning WorkerTerminated); any buffered diagnostic then hit the panic arm and aborted. Make flush_logs itself return on Terminated - the worker is being torn down and node emits no 'error' for a terminate. Clearing the exception before on_exit made process.on('exit') handlers run on a terminate that interrupted running JS; node skips them (verified on v24.18.0, busy and idle workers). Restore the flag-only clear in shutdown() and clear the pending TerminationException inside on_exit right after the exit-dispatch gate consumes it, so deferred tasks, cleanup hooks, and socket on_close JS still run with a clean scope. --- src/jsc/VM.rs | 4 ++++ src/jsc/VirtualMachine.rs | 6 ++++++ src/jsc/web_worker.rs | 16 +++++++++++----- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 77e7f8e0bc76..41f780c67f5b 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -118,6 +118,10 @@ impl VM { JSC__VM__notifyNeedTermination(self) } + pub(crate) fn clear_has_termination_request(&self) { + crate::cpp::JSC__VM__clearHasTerminationRequest(self) + } + #[track_caller] pub fn throw_error(&self, global_object: &JSGlobalObject, value: JSValue) -> JsError { crate::validation_scope!(scope, global_object); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 9709099c48b3..c9f75075642f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1501,6 +1501,12 @@ impl VirtualMachine { ExitHandler::dispatch_on_exit(self); + // A TerminationException still pending after a worker terminate() has + // served its purpose (dispatch_on_exit skips 'exit' on it); clear it so + // the deferred tasks, cleanup hooks, and native->JS teardown callbacks + // below don't trip assertNoException. No-op for other exception kinds. + self.global().clear_termination_exception(); + // process.exit() never reaches drain_microtasks; flush AutoFlusher sinks here. if !self.is_inside_deferred_task_queue.get() { self.is_inside_deferred_task_queue.set(true); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 39371ba9aad0..a4741d53e037 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1341,10 +1341,12 @@ impl WebWorker { // other thread can dereference it now — `&mut` is exclusive. let vm = unsafe { &mut *vm_ptr }; // terminate() set the JSC termination flag to interrupt running JS; - // clear it AND the materialized exception (tryClearException refuses - // TerminationExceptions) so on_exit() / socket on_close JS below run - // with a clean scope. teardownJSCVM re-arms it for JSC VM teardown. - vm.global().clear_termination_exception(); + // clear it so process.on('exit') handlers can run. teardownJSCVM + // re-sets it for the JSC VM teardown. A TerminationException still + // pending on the VM is left for on_exit's dispatch gate (it skips + // 'exit' on a terminate that interrupted running JS, as node does) + // and cleared there right after. + vm.jsc_vm().clear_has_termination_request(); vm.is_shutting_down = true; vm.on_exit(); if let Some(hooks) = runtime_hooks() { @@ -1566,7 +1568,11 @@ impl WebWorker { let (err, str) = match result { Ok(pair) => pair, Err(JsError::OutOfMemory) => bun_core::out_of_memory(), - Err(JsError::Thrown | JsError::Terminated) => panic!("unhandled exception"), + // terminate() raced this flush: the trap/pending TerminationException + // fails to_js. The worker is being torn down and node emits no + // 'error' for a terminate(), so drop the diagnostics. + Err(JsError::Terminated) => return, + Err(JsError::Thrown) => panic!("unhandled exception"), }; let mut str = bun_core::OwnedString::new(str); let dispatch = jsc::host_fn::from_js_host_call_generic(global, || { From 3b2831108a708f8b2bd77a052909e6734118fdb1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:57:26 +0000 Subject: [PATCH 041/137] execArgv: drop the unconditional -pe insertion the merge reintroduced The main merge brought back the alias-into-MAP loop alongside the gated use-site check, and MAP.contains short-circuited the !seen_run gate, so bun run -pe script swallowed the script into execArgv again (the exact regression the process.test.js case guards). The use-site check alone scopes the alias to the bun/node entry points. --- src/runtime/node/node_process.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 03ae8163608e..b2823e13e850 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -343,13 +343,6 @@ mod _impl { } } } - // Node's whole-token aliases are not params, so they never - // land above; an alias takes a value iff its target does. - for (from, to) in crate::cli::arguments::NODE_SHORT_ALIASES { - if set.contains(to) { - bun_core::handle_oom(set.insert(from)); - } - } set }); From 49f1a4fb1d3be7a3a9fb63df1407a551bc65b35c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:33:37 +0000 Subject: [PATCH 042/137] Trim comments to node-source/spec references --- .../bun-usockets/src/crypto/root_certs.cpp | 2 -- .../bun-usockets/src/eventing/epoll_kqueue.c | 3 --- packages/bun-usockets/src/eventing/libuv.c | 3 --- .../bun-usockets/src/internal/loop_data.h | 2 -- packages/bun-usockets/src/loop.c | 5 ---- .../internal/perf/event_loop_utilization.ts | 2 -- src/js/node/perf_hooks.ts | 2 -- src/js/node/tls.ts | 3 --- src/js/node/worker_threads.ts | 24 ------------------- src/jsc/VirtualMachine.rs | 8 ------- .../bindings/webcore/JSBroadcastChannel.cpp | 1 - src/jsc/bindings/webcore/JSWorker.cpp | 3 --- src/jsc/bindings/webcore/Worker.cpp | 10 -------- src/jsc/web_worker.rs | 24 +------------------ src/runtime/cli/Arguments.rs | 2 -- src/runtime/dispatch_js2native.rs | 4 ---- src/runtime/jsc_hooks.rs | 2 -- src/runtime/node/node_process.rs | 2 -- test/cli/run/cpu-prof.test.ts | 3 --- test/js/node/process/process.test.js | 2 -- .../worker_threads/worker_threads.test.ts | 5 ---- 21 files changed, 1 insertion(+), 111 deletions(-) diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 187d6977c185..fdb45cd1b91f 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -30,8 +30,6 @@ extern "C" bool Bun__Node__NoUseSystemCA; // Helper function to check if system CA should be used // Checks both CLI flag (--use-system-ca) and environment variable (NODE_USE_SYSTEM_CA=1) static bool us_should_use_system_ca() { - // --no-use-system-ca is the one thing that overrides NODE_USE_SYSTEM_CA, so a - // flag whose purpose is to restrict trust actually restricts it. if (Bun__Node__NoUseSystemCA) { return false; } diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 4c3487f4be6e..d69a26aea1f8 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -473,9 +473,6 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout } } - /* Only ticks that really park are timed, so a busy loop pays nothing and a - * parked one pays two vDSO reads against a syscall it was making anyway. - * Publish the entry so a cross-thread reader can add the in-progress park. */ const uint64_t idle_start_ns = will_idle_inside_event_loop ? us_internal_monotonic_ns() : 0; if (will_idle_inside_event_loop) __atomic_store_n(&loop->data.idle_entry_ns, idle_start_ns, __ATOMIC_SEQ_CST); diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 1f95b775a937..4e66ee599ebb 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -332,9 +332,6 @@ struct us_loop_t *us_create_loop(void *hint, loop->uv_loop = hint ? hint : uv_loop_new(); loop->is_default = hint != 0; - /* Without this libuv never accumulates provider_idle_time, so - * uv_metrics_idle_time() — and performance.eventLoopUtilization() — read 0. - * node enables it unconditionally too (node.cc, node_worker.cc). */ uv_loop_configure(loop->uv_loop, UV_METRICS_IDLE_TIME); loop->uv_pre = us_malloc(sizeof(uv_prepare_t)); diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 4c570fddb5df..2d3863ac134d 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -82,8 +82,6 @@ struct us_internal_loop_data_t { * __atomic_* only. MIRRORED in src/uws_sys/InternalLoopData.rs: this struct * is us_loop_t's first member, so a field here shifts num_polls. */ unsigned long long idle_ns; - /* Monotonic ns the current park began, 0 when not parked: a mid-park reader - * must add (now - entry) or it sees a stale total. So does libuv. */ unsigned long long idle_entry_ns; #endif struct us_socket_group_t *iterator; diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 9112b4cec3b6..1a61c7be41ff 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -119,16 +119,11 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { #endif -/* Nanoseconds this loop has spent parked, for performance.eventLoopUtilization(). - * Safe to call from another thread. */ uint64_t us_loop_idle_ns(struct us_loop_t *loop) { #ifdef LIBUS_USE_LIBUV return uv_metrics_idle_time(loop->uv_loop); #else uint64_t idle = __atomic_load_n(&loop->data.idle_ns, __ATOMIC_SEQ_CST); - /* Parked right now? The total is only folded in when the park ends, so add - * the in-progress interval — otherwise a mid-park reader sees a stale idle - * and over-reports active. Same as libuv's uv_metrics_idle_time. */ uint64_t entry = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_SEQ_CST); if (entry > 0) { uint64_t now = us_internal_monotonic_ns(); diff --git a/src/js/internal/perf/event_loop_utilization.ts b/src/js/internal/perf/event_loop_utilization.ts index 75df0d2f67d9..5c5fdc3965d2 100644 --- a/src/js/internal/perf/event_loop_utilization.ts +++ b/src/js/internal/perf/event_loop_utilization.ts @@ -1,6 +1,4 @@ // Shared by perf_hooks and worker_threads; see https://github.com/nodejs/node/blob/main/lib/internal/perf/event_loop_utilization.js -// `elu` is [elapsedSinceLoopStartMs, idleMs] or null before the loop turns (node's `loopStart <= 0` branch → {0,0,0}). -// Divisions are unguarded: node returns NaN for a zero total, collapsing to 0 would diverge. function internalEventLoopUtilization(elu, util1, util2) { if (elu === null) { return { idle: 0, active: 0, utilization: 0 }; diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 5c735a864416..c530eb8ecf46 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -116,8 +116,6 @@ function createPerformanceNodeTiming() { return object; } -// [elapsedSinceLoopStartMs, idleMs] for this thread's loop, or null before it -// has turned. const getLoopELU = $newRustFunction("bun.rs", "getLoopELU", 0); const { internalEventLoopUtilization } = require("internal/perf/event_loop_utilization"); diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 8241d78a9514..244580ee4c8b 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1683,9 +1683,6 @@ function cacheDefaultCACertificates() { ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); } - // --use-system-ca / --no-use-system-ca are per-thread and explicit; only when - // neither was given does NODE_USE_SYSTEM_CA decide. node lets the env var win - // under --use-bundled-ca but not under --no-use-system-ca. const useSystemCA = getUseSystemCA(); if (useSystemCA === true || (useSystemCA === undefined && process.env.NODE_USE_SYSTEM_CA === "1")) { const system = cacheSystemCACertificates(); diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 2caadf9e534a..cf26fcd39e48 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -99,9 +99,6 @@ type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; // Used to ensure that Blobs created to hold the source code for `eval: true` Workers get cleaned up // after their Worker exits let urlRevokeRegistry: FinalizationRegistry | undefined = undefined; -// Resolved at module load, as node does (lib/internal/worker.js): resolving it -// lazily inside the constructor would build the channel out of whatever -// Map/Object user code had tampered with by then. const workerThreadsChannel = require("node:diagnostics_channel").channel("worker_threads"); const { tickInitHooks, newAsyncId } = require("internal/async_hooks_tick"); @@ -134,8 +131,6 @@ function injectFakeEmitter(Class) { function wrapped(run, listener) { return function (event) { - // node invokes emitter listeners with the emitter as `this`; an - // addEventListener handler's `this` is already the target, so forward it. return listener.$call(this, run(event)); }; } @@ -941,8 +936,6 @@ class Worker extends EventEmitter { #stdin; #stdout; #stderr; - // Mirrors ref()/unref() for the async_hooks WORKER resource's hasRef(); - // undefined once the thread has exited, as node's handle reads back. #hasRef: boolean | undefined = true; // this is used by terminate(); @@ -959,7 +952,6 @@ class Worker extends EventEmitter { // option accesses below don't throw on `new Worker(file, null)`. options ??= {}; - // Bun's WebWorker honours { ref: false }; hasRef() should agree with it. if ((options as any).ref === false) this.#hasRef = false; this.#name = normalizeWorkerName(options.name); @@ -1104,16 +1096,12 @@ class Worker extends EventEmitter { } urlRevokeRegistry.register(this.#worker, this.#urlToRevoke); } - // node's AsyncWrap emits the WORKER init synchronously when the handle is - // constructed mid-constructor, before the dc publish at the end. this.#emitAsyncHooksInit(); if (workerThreadsChannel.hasSubscribers) { workerThreadsChannel.publish({ worker: this }); } } - // node's WORKER resource is the C++ handle: hasRef() follows ref()/unref() and reads undefined - // once the handle is gone. Extends the TickObject init array to WORKER; only hasRef() is exposed. #emitAsyncHooksInit() { const count = tickInitHooks.length; if (count === 0) return; @@ -1132,9 +1120,6 @@ class Worker extends EventEmitter { try { snapshot[i](asyncId, "WORKER", 0, resource); } catch (err) { - // node: a throwing init hook is fatal (fatalError: print + exit 1), - // never surfaced to the `new Worker` caller — which here has already - // spawned the thread. console is user-mutable, so shield the print. try { console.error(typeof err?.stack === "string" ? err.stack : err); } catch {} @@ -1155,8 +1140,6 @@ class Worker extends EventEmitter { // stdio ports are not touched here (node's ref()/unref() only touch the // handle and the public port); their ref state tracks in-flight I/O. this.#worker.ref(); - // node's ref()/unref() no-op once the handle is gone, leaving hasRef() - // undefined rather than resurrecting it. if (!this.#exited) this.#hasRef = true; } @@ -1191,8 +1174,6 @@ class Worker extends EventEmitter { } #eventLoopUtilization(utilization1, utilization2) { - // null covers both "thread gone" and "loop has not turned" — node reports - // all-zero for each. return internalEventLoopUtilization(this.#worker.eventLoopUtilizationInternal(), utilization1, utilization2); } @@ -1343,9 +1324,6 @@ class Worker extends EventEmitter { this.#stdinPort?.close(); this.#onExitPromise = e.code; this.emit("exit", e.code); - // node's WORKER handle is gone once the thread has exited, so its - // hasRef() reads back undefined. 'exit' listeners ran synchronously above - // and still saw the live value. this.#hasRef = undefined; } @@ -1355,8 +1333,6 @@ class Worker extends EventEmitter { // if not the message is the actual error const message = event.message; if (message !== "") { - // The value didn't clone, so rebuild from the text — but keep the `code` - // the native side carried over, which is all that survived of it. const code = error?.code; error = new Error(message, { cause: event }); if (typeof code === "string") error.code = code; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2227ae2efcbb..9108be95d4ed 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -285,8 +285,6 @@ pub struct VirtualMachine { pub origin_timer: std::time::Instant, /// When THIS thread's loop started, for performance.eventLoopUtilization(). - /// Not `origin_timer`, which is the process origin shared by every thread — - /// a worker's active time is measured from its own start. pub loop_start: std::time::Instant, pub(crate) origin_timestamp: u64, /// For fake timers: override performance.now() with a specific value (in nanoseconds). @@ -1529,10 +1527,6 @@ impl VirtualMachine { ExitHandler::dispatch_on_exit(self); - // A TerminationException still pending after a worker terminate() has - // served its purpose (dispatch_on_exit skips 'exit' on it); clear it so - // the deferred tasks, cleanup hooks, and native->JS teardown callbacks - // below don't trip assertNoException. No-op for other exception kinds. self.global().clear_termination_exception(); // process.exit() never reaches drain_microtasks; flush AutoFlusher sinks here. @@ -2052,8 +2046,6 @@ fn get_origin_timestamp() -> u64 { (now - ORIGIN_RELATIVE_EPOCH).max(0) as u64 } -/// `performance.timeOrigin` is the PROCESS start time, shared by every thread (a worker's equals the -/// main thread's in node). Captured once on the first VM so worker VMs inherit instead of restarting. fn process_origin() -> (std::time::Instant, u64) { static ORIGIN: std::sync::OnceLock<(std::time::Instant, u64)> = std::sync::OnceLock::new(); *ORIGIN.get_or_init(|| (std::time::Instant::now(), get_origin_timestamp())) diff --git a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp index cbe1d2c4b2e7..d8062faaa22b 100644 --- a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp +++ b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp @@ -387,7 +387,6 @@ static inline JSC::EncodedJSValue jsBroadcastChannelPrototypeFunction_refBody(JS UNUSED_PARAM(callFrame); auto& impl = castedThis->wrapped(); impl.jsRef(lexicalGlobalObject); - // node's ref() returns the channel so it chains (lib/internal/worker/io.js). RELEASE_AND_RETURN(throwScope, JSValue::encode(castedThis)); } diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index a906a960f39a..f1f6e32d3d83 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -858,9 +858,6 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_stopCpuProfileIntern return JSValue::encode(promise); } -// Synchronous by contract: node's worker.performance.eventLoopUtilization() -// returns a value, it does not await the worker. Safe to read cross-thread — -// the counter is atomic and the loop start is immutable after publish. static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_eventLoopUtilizationInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) { auto* globalObject = defaultGlobalObject(lexicalGlobalObject); diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index c81c04f5a122..3da380d78a7b 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -459,9 +459,6 @@ void Worker::rejectAllCrossVMRequests(JSC::JSGlobalObject* globalObject) // ---- Worker-thread entry points --------------------------------------------- -// Posted before the entry point runs, matching node's 'online', so a worker whose -// top-level never returns still reports online. Deliberately leaves m_state alone: -// Pending queues in postTaskToWorkerGlobalScope, it does not reject. void Worker::dispatchOnlineEvent() { postTaskToParent([protectedThis = Ref { *this }](ScriptExecutionContext&) { @@ -534,9 +531,6 @@ void Worker::dispatchErrorWithMessage(WTF::String message, WTF::String code) code = code.isolatedCopy()](ScriptExecutionContext& context) { ErrorEvent::Init init; init.message = message; - // The worker's value would not clone (Bun's ResolveMessage is not even an - // Error), so the parent rebuilds one from the text; hand `code` over on a - // carrier object or it is lost, unlike every other coded worker error. if (!code.isNull()) { auto* globalObject = context.globalObject(); auto& vm = JSC::getVM(globalObject); @@ -551,8 +545,6 @@ void Worker::dispatchErrorWithMessage(WTF::String message, WTF::String code) }); } -// A string `code` off an error-ish value, or null. Reading it can run JS (a -// getter/proxy), so it is done under a top scope and any throw drops the code. String Worker::errorCodeOf(JSC::JSGlobalObject* globalObject, JSValue value) { auto& vm = JSC::getVM(globalObject); @@ -589,8 +581,6 @@ bool Worker::dispatchErrorWithValue(Zig::GlobalObject* workerGlobalObject, JSVal if (!serialized && !scope.exception()) { if (auto* errorInstance = dynamicDowncast(value)) { errorInstance->putDirect(vm, vm.propertyNames->stack, JSC::jsUndefined(), JSC::PropertyAttribute::DontEnum | 0); - // putDirect bypasses the ErrorInstance property overrides, so tell it - // not to re-materialize `stack` (and re-run prepareStackTrace) below. errorInstance->setStackPropertyAlreadyMaterialized(); CLEAR_IF_EXCEPTION(scope); serialized = SerializedScriptValue::create(*workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 29024202ba67..161653a40ac0 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -901,8 +901,6 @@ impl WebWorker { // and passes the owned struct as `args` to the new VM. let mut transform_options = (*parent.transpiler.options.transform_options).clone(); - // Param table lives in `bun_runtime::cli` (forward-dep). `None` ⇒ inherit from parent; an explicit - // list (even empty) replaces it, as node resets to fresh defaults per execArgv (src/node_worker.cc). // SAFETY: `exec_argv` borrows C++ `WorkerOptions` kept alive by the owning `WebCore::Worker`. let own_exec_argv = self.exec_argv(); let exec_argv = match own_exec_argv { @@ -967,9 +965,6 @@ impl WebWorker { env_loader: NonNull::new(loader_ptr), store_fd: self.store_fd, graph: parent.standalone_module_graph, - // Same rule as every other execArgv option: an explicit list, - // even an empty one, replaces the parent's rather than adding - // to it, so only an inheriting worker takes the parent's CA intent. use_system_ca: if own_exec_argv.is_some() { exec_argv.use_system_ca } else { @@ -1002,8 +997,6 @@ impl WebWorker { VirtualMachine::set_is_main_thread_vm(false); vm_ref.on_unhandled_rejection = on_unhandled_rejection; - // Node profiles every thread; own execArgv wins, and an inheriting - // worker takes the immediate parent's config (not the process's). let profile = if exec_argv.cpu_prof { let mut config = crate::bun_cpu_profiler::CPUProfilerConfig { json_format: true, @@ -1171,9 +1164,6 @@ impl WebWorker { return self.shutdown(); } - // node reports 'online' before user code runs, so a worker whose entry - // point never returns still goes online. Only the event goes out here: - // the Pending→Running flip stays put, so message routing is unchanged. WebWorker__dispatchOnlineEvent(self.cpp_worker); // `path` borrows the resolver's process-lifetime string store, the @@ -1198,12 +1188,6 @@ impl WebWorker { // Atomics.waitAsync settles. dispatchOnline re-calls it as a no-op. WebWorker__entrySettled(vm.global()); - // Terminated during entry evaluation: the rejection IS the - // TerminationException, still pending on the VM. uncaught_exception - // would re-enter JS with it (process 'uncaughtException' emit) and trip - // assertNoException; node does not report a terminate() as uncaught. - // No flush_logs here: Log::to_js enters JS and would see the pending - // exception, and there is nothing user-facing to report on terminate. if self.has_requested_terminate() { return self.shutdown(); } @@ -1346,10 +1330,7 @@ impl WebWorker { let vm = unsafe { &mut *vm_ptr }; // terminate() set the JSC termination flag to interrupt running JS; // clear it so process.on('exit') handlers can run. teardownJSCVM - // re-sets it for the JSC VM teardown. A TerminationException still - // pending on the VM is left for on_exit's dispatch gate (it skips - // 'exit' on a terminate that interrupted running JS, as node does) - // and cleared there right after. + // re-sets it for the JSC VM teardown. vm.jsc_vm().clear_has_termination_request(); vm.is_shutting_down = true; vm.on_exit(); @@ -1570,9 +1551,6 @@ impl WebWorker { let (err, str) = match result { Ok(pair) => pair, Err(JsError::OutOfMemory) => bun_core::out_of_memory(), - // terminate() raced this flush: the trap/pending TerminationException - // fails to_js. The worker is being torn down and node emits no - // 'error' for a terminate(), so drop the diagnostics. Err(JsError::Terminated) => return, Err(JsError::Thrown) => panic!("unhandled exception"), }; diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 47c11cc0f48e..047cbeb2e3b5 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -1495,8 +1495,6 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result = if use_bundled_ca { Some(BunCAStore::Bundled) diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 37aa91c29414..ce9d7eb13f62 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -61,7 +61,6 @@ pub use bun_sys_jsc::error_jsc::TestingAPIs::translate_uv_error_to_e as sys_sys_ pub use bun_http_jsc::headers_jsc::h2_live_counts as http_h2_client_testing_ap_is_live_counts; pub use bun_http_jsc::headers_jsc::h3_quic_live_counts as http_h3_client_testing_ap_is_quic_live_counts; -/// Per-VM (node treats `--use-system-ca` as an Environment option, so a Worker's execArgv can differ). /// `undefined` ⇒ neither flag given, NODE_USE_SYSTEM_CA decides; only `--no-use-system-ca` beats the env var. pub(crate) fn bun_get_use_system_ca( _global: &JSGlobalObject, @@ -87,9 +86,6 @@ pub(crate) fn bun_get_loop_elu(global: &JSGlobalObject, _frame: &CallFrame) -> J if loop_ptr.is_null() { return Ok(JSValue::NULL); } - // Idle BEFORE elapsed, matching node's order (it passes loopIdleTime() in - // and reads process.hrtime() after). Reversed, idle is dated after now and - // active = now - idle comes out short. // SAFETY: `loop_ptr` was just null-checked and stays valid for this tick // under the same ownership argument as above. let idle_ms = unsafe { bun_uws::us_loop_idle_ns(loop_ptr) } as f64 / 1_000_000.0; diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 67578c6b6e67..1f771d4837ea 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1545,8 +1545,6 @@ unsafe fn parse_worker_exec_argv( } else if bytes == b"--use-system-ca" { out.use_system_ca = Some(true); } else if bytes == b"--no-use-system-ca" { - // Only the explicit negation beats NODE_USE_SYSTEM_CA; node lets the - // env var still win under --use-bundled-ca. out.use_system_ca = Some(false); } else if bytes == b"--cpu-prof" { out.cpu_prof = true; diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index b2823e13e850..a7b33e35ef86 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -347,8 +347,6 @@ mod _impl { }); if let Some(p) = prev { - // Node's whole-token aliases only apply on the `bun`/`node` - // entry points (Arguments::parse scopes them the same way). let takes_value = MAP.contains(p) || (!seen_run && crate::cli::arguments::NODE_SHORT_ALIASES diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index dcda37a4e21f..0e22bc128c81 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -147,9 +147,6 @@ describe.concurrent("--cpu-prof", () => { const exitCode = await proc.exited; const profiles = readdirSync(String(dir)).filter(f => f.endsWith(".cpuprofile")); - // Every thread writes the one named path, last wins, so there is a single - // file. node v26.3.0 does the same: with a worker, --cpu-prof-name yields 1 - // file where the default name yields 2 — it only thread-stamps the default. expect(profiles).toEqual([customName]); expect(exitCode).toBe(0); }); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index ef6875d1caec..9c9c96b19506 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1560,8 +1560,6 @@ it("process.execArgv with node's -pe alias", async () => { const auto = await Bun.$`${bunExe()} -pe ${"JSON.stringify(process.execArgv)"}`.text(); expect(JSON.parse(auto)).toEqual(["-pe", "JSON.stringify(process.execArgv)"]); - // `bun run -pe script`: the alias is scoped to the bun/node entry points, so - // the script name must not be swallowed into execArgv. const script = join(__dirname, "print-process-execArgv.js"); const run = await Bun.$`${bunExe()} run -pe ${script}`.text(); expect(JSON.parse(run.split("\n")[0])).toEqual({ execArgv: ["-pe"], argv: [] }); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index cc8ec61875db..55254e3ff960 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -39,9 +39,6 @@ test("support eval in worker", async () => { }); test("online fires before the entry point finishes", async () => { - // node reports 'online' once the thread has bootstrapped, BEFORE user code - // (lib/internal/worker.js), so a worker whose top-level never returns still - // goes online. Blocking in Atomics.wait keeps the entry point unsettled. const sab = new SharedArrayBuffer(4); const signal = new Int32Array(sab); const worker = new Worker( @@ -49,8 +46,6 @@ test("online fires before the entry point finishes", async () => { Atomics.wait(new Int32Array(workerData), 0, 0);`, { eval: true, workerData: sab }, ); - // Registered before the awaits: if 'online' never fires the worker is parked - // in Atomics.wait, and the thread would outlive the test. try { await once(worker, "online"); Atomics.store(signal, 0, 1); From 7d7002724e9bb88b9369e98c52bfb39bd50be9ad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:57:47 +0000 Subject: [PATCH 043/137] Skip worker_destruction under ASAN while the terminate-during-load assert is open ASAN builds compile WebKit assertions in, and terminate() landing during the worker's module loads trips ExceptionScope::assertNoException, tracked in #34655. Non-ASAN lanes keep running all three methods. --- test/js/node/worker_threads/worker_destruction.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/node/worker_threads/worker_destruction.test.ts b/test/js/node/worker_threads/worker_destruction.test.ts index e0303327d7d6..e19aaf1a0c5d 100644 --- a/test/js/node/worker_threads/worker_destruction.test.ts +++ b/test/js/node/worker_threads/worker_destruction.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { bunRun, isBroken } from "harness"; +import { bunRun, isASAN, isBroken } from "harness"; import { join } from "path"; describe("Worker destruction", () => { const method = ["Bun.connect", "Bun.listen", "fetch"]; describe.each(method)("bun when %s is used in a Worker that is terminating", method => { // fetch: ASAN failure - test.concurrent.skipIf(isBroken && method == "fetch")("exits cleanly", async () => { + // ASAN builds have WebKit assertions enabled, and terminate() landing + // during the worker's module loads trips one (tracked in #34655). + test.concurrent.skipIf((isBroken && method == "fetch") || isASAN)("exits cleanly", async () => { expect(await bunRun([join(import.meta.dir, "worker_thread_check.ts"), method])).toSpawn(); }); }); From c6045a8d5fabbae4b171e54088e146001f38249d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:46:45 +0000 Subject: [PATCH 044/137] worker_destruction: terminate when the worker reports its action started The fixture terminated 1ms after online. With online now firing before entry evaluation, that raced module loading, and on ASAN builds terminate() landed mid-load and tripped the WebKit assert tracked in #34655 rather than the mid-I/O window the test is for. The worker now posts a message once its action is in flight and the parent terminates on it, making the timing deterministic, so the ASAN skip from 7d700272 comes back out. The pre-existing fetch skip stays: terminate landing mid-fetch still hits its ASAN failure (about 1 in 25 locally). --- .../worker_threads/worker_destruction.test.ts | 6 ++---- .../node/worker_threads/worker_thread_check.ts | 17 ++++++++--------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/test/js/node/worker_threads/worker_destruction.test.ts b/test/js/node/worker_threads/worker_destruction.test.ts index e19aaf1a0c5d..e0303327d7d6 100644 --- a/test/js/node/worker_threads/worker_destruction.test.ts +++ b/test/js/node/worker_threads/worker_destruction.test.ts @@ -1,14 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { bunRun, isASAN, isBroken } from "harness"; +import { bunRun, isBroken } from "harness"; import { join } from "path"; describe("Worker destruction", () => { const method = ["Bun.connect", "Bun.listen", "fetch"]; describe.each(method)("bun when %s is used in a Worker that is terminating", method => { // fetch: ASAN failure - // ASAN builds have WebKit assertions enabled, and terminate() landing - // during the worker's module loads trips one (tracked in #34655). - test.concurrent.skipIf((isBroken && method == "fetch") || isASAN)("exits cleanly", async () => { + test.concurrent.skipIf(isBroken && method == "fetch")("exits cleanly", async () => { expect(await bunRun([join(import.meta.dir, "worker_thread_check.ts"), method])).toSpawn(); }); }); diff --git a/test/js/node/worker_threads/worker_thread_check.ts b/test/js/node/worker_threads/worker_thread_check.ts index 531de8eb26fa..47785590973e 100644 --- a/test/js/node/worker_threads/worker_thread_check.ts +++ b/test/js/node/worker_threads/worker_thread_check.ts @@ -1,9 +1,8 @@ const CONCURRENCY = 10; const RUN_COUNT = 5; -import { Worker, isMainThread, workerData } from "worker_threads"; +import { Worker, isMainThread, workerData, parentPort } from "worker_threads"; -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const rss = process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint @@ -69,12 +68,10 @@ if (isMainThread) { const { promise, resolve, reject } = Promise.withResolvers(); promises.push(promise); - worker.on("online", () => { - sleep(1) - .then(() => { - return worker.terminate(); - }) - .finally(resolve); + // Terminate once the worker reports its action started, so terminate() + // lands with the I/O in flight rather than racing module loading. + worker.once("message", () => { + worker.terminate().finally(resolve); }); worker.on("error", e => reject(e)); } @@ -87,5 +84,7 @@ if (isMainThread) { } else { Bun.gc(true); const { action, port } = workerData; - await actions[action](port); + const pending = actions[action](port); + parentPort!.postMessage("started"); + await pending; } From bc4eab359d0976ddcfb093b3571de8e424ecdc4c Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 8 Aug 2026 13:05:14 -0700 Subject: [PATCH 045/137] worker_threads: read a Worker's hasRef / loop utilization through private bindings, not the web Worker prototype `hasRef` and `eventLoopUtilizationInternal` had been added as methods on globalThis.Worker.prototype (one of them enumerable), i.e. API surface on the web Worker that neither the Web spec nor Node has. node:worker_threads already receives its native helpers as a private function array from createNodeWorkerThreadsBinding; `workerHasRef(worker)` and `workerEventLoopUtilization(worker)` join it there and the prototype entries are gone. --- src/js/node/worker_threads.ts | 8 ++++-- src/jsc/bindings/webcore/JSWorker.cpp | 41 --------------------------- src/jsc/bindings/webcore/Worker.cpp | 33 ++++++++++++++++++++- 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 8ab1c1285d47..e0f76cc3b3d2 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -81,6 +81,8 @@ const { 9: _setEntryEvaluatedHook, 10: _isNodeWorker, 11: _setParentPort, + 12: _workerHasRef, + 13: _workerEventLoopUtilization, } = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [ unknown, number, @@ -94,6 +96,8 @@ const { (hook: () => void) => void, boolean, (port: MessagePort) => void, + (worker: WebWorker) => boolean | undefined, + (worker: WebWorker) => [number, number] | null, ]; type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; @@ -1159,7 +1163,7 @@ class Worker extends EventEmitter { // (through 'exit'), undefined once it has been released. const resource = { hasRef() { - return worker.#worker.hasRef(); + return _workerHasRef(worker.#worker); }, }; const asyncId = newAsyncId(); @@ -1225,7 +1229,7 @@ class Worker extends EventEmitter { } #eventLoopUtilization(utilization1, utilization2) { - return internalEventLoopUtilization(this.#worker.eventLoopUtilizationInternal(), utilization1, utilization2); + return internalEventLoopUtilization(_workerEventLoopUtilization(this.#worker), utilization1, utilization2); } terminate(callback: unknown) { diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index afad013a7cfa..f9ec2e787741 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -87,8 +87,6 @@ static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_ref); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapSnapshot); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapStatistics); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_startCpuProfileInternal); -static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_eventLoopUtilizationInternal); -static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_hasRef); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_stopCpuProfileInternal); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_cpuUsageInternal); @@ -450,8 +448,6 @@ static const HashTableValue JSWorkerPrototypeTableValues[] = { { "startCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_startCpuProfileInternal, 0 } }, { "stopCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_stopCpuProfileInternal, 0 } }, { "cpuUsageInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_cpuUsageInternal, 0 } }, - { "eventLoopUtilizationInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_eventLoopUtilizationInternal, 0 } }, - { "hasRef"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_hasRef, 0 } }, }; const ClassInfo JSWorkerPrototype::s_info = { "Worker"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSWorkerPrototype) }; @@ -861,43 +857,6 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_stopCpuProfileIntern return JSValue::encode(promise); } -static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_eventLoopUtilizationInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) -{ - auto* globalObject = defaultGlobalObject(lexicalGlobalObject); - auto& vm = JSC::getVM(globalObject); - auto throwScope = DECLARE_THROW_SCOPE(vm); - UNUSED_PARAM(callFrame); - double elapsedMs = 0; - double idleMs = 0; - if (!castedThis->wrapped().eventLoopUtilization(elapsedMs, idleMs)) - RELEASE_AND_RETURN(throwScope, JSValue::encode(JSC::jsNull())); - JSC::JSArray* result = JSC::constructEmptyArray(globalObject, nullptr, 2); - RETURN_IF_EXCEPTION(throwScope, {}); - result->putDirectIndex(globalObject, 0, JSC::jsNumber(elapsedMs)); - RETURN_IF_EXCEPTION(throwScope, {}); - result->putDirectIndex(globalObject, 1, JSC::jsNumber(idleMs)); - RETURN_IF_EXCEPTION(throwScope, {}); - RELEASE_AND_RETURN(throwScope, JSValue::encode(result)); -} - -JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_eventLoopUtilizationInternal, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) -{ - return IDLOperation::call(*lexicalGlobalObject, *callFrame, "eventLoopUtilizationInternal"); -} - -static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_hasRefBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) -{ - UNUSED_PARAM(lexicalGlobalObject); - UNUSED_PARAM(callFrame); - auto hasRef = castedThis->wrapped().hasRef(); - return JSValue::encode(hasRef ? jsBoolean(*hasRef) : jsUndefined()); -} - -JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_hasRef, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) -{ - return IDLOperation::call(*lexicalGlobalObject, *callFrame, "hasRef"); -} - static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_cpuUsageInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) { auto* globalObject = defaultGlobalObject(lexicalGlobalObject); diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index c2843f275c93..2b041687a2c0 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -26,6 +26,7 @@ #include "config.h" #include "Worker.h" +#include "JSWorker.h" #include "BunClientData.h" #include "ErrorCode.h" @@ -218,6 +219,34 @@ extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker JSC_DECLARE_HOST_FUNCTION(jsFunctionSetParentPort); +// node:worker_threads internals that read a Worker's native state; private (handed to the module +// through createNodeWorkerThreadsBinding), not properties of the web Worker. +JSC_DEFINE_HOST_FUNCTION(jsFunctionWorkerHasRef, (JSGlobalObject*, CallFrame* callFrame)) +{ + auto* worker = dynamicDowncast(callFrame->argument(0)); + if (!worker) + return JSValue::encode(jsUndefined()); + auto hasRef = worker->wrapped().hasRef(); + return JSValue::encode(hasRef ? jsBoolean(*hasRef) : jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionWorkerEventLoopUtilization, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* worker = dynamicDowncast(callFrame->argument(0)); + double elapsedMs = 0; + double idleMs = 0; + if (!worker || !worker->wrapped().eventLoopUtilization(elapsedMs, idleMs)) + return JSValue::encode(jsNull()); + auto* result = constructEmptyArray(lexicalGlobalObject, nullptr, 2); + RETURN_IF_EXCEPTION(scope, {}); + result->putDirectIndex(lexicalGlobalObject, 0, jsNumber(elapsedMs)); + RETURN_IF_EXCEPTION(scope, {}); + result->putDirectIndex(lexicalGlobalObject, 1, jsNumber(idleMs)); + RELEASE_AND_RETURN(scope, JSValue::encode(result)); +} + JSC_DEFINE_HOST_FUNCTION(jsReceiveMessageOnPort, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(lexicalGlobalObject); @@ -359,7 +388,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) bool isNodeWorker = proxy && proxy->options().kind == WorkerOptions::Kind::Node; - JSObject* array = constructEmptyArray(globalObject, nullptr, 12); + JSObject* array = constructEmptyArray(globalObject, nullptr, 14); RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, 0, workerData); array->putDirectIndex(globalObject, 1, threadId); @@ -373,6 +402,8 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) array->putDirectIndex(globalObject, 9, JSFunction::create(vm, globalObject, 1, "setEntryEvaluatedHook"_s, jsFunctionSetEntryEvaluatedHook, ImplementationVisibility::Public, NoIntrinsic)); array->putDirectIndex(globalObject, 10, jsBoolean(isNodeWorker)); array->putDirectIndex(globalObject, 11, JSFunction::create(vm, globalObject, 1, "setParentPort"_s, jsFunctionSetParentPort, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 12, JSFunction::create(vm, globalObject, 1, "workerHasRef"_s, jsFunctionWorkerHasRef, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 13, JSFunction::create(vm, globalObject, 1, "workerEventLoopUtilization"_s, jsFunctionWorkerEventLoopUtilization, ImplementationVisibility::Public, NoIntrinsic)); return array; } From f3c28b8da15618d3a7cf8be31a3f90d22b79a986 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:05:28 +0000 Subject: [PATCH 046/137] Reattach release_parent_poll_ref doc comment to its function Inserting get_elu between the comment and its target handed the release_parent_poll_ref rustdoc to get_elu. --- src/jsc/web_worker.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 0112b288e6bf..147e176119c1 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -514,8 +514,6 @@ impl WebWorker { this.vm_lock.unlock(); } - /// The parent is releasing this thread: drop the keep-alive on the parent's - /// loop and forget it as a child. Parent thread. /// The parent reading this worker's loop counters for `eventLoopUtilization()`: false once the /// VM is unpublished (node reports all-zero then). Idle is read before elapsed, in node's /// order, so `active = elapsed - idle` cannot come out negative. @@ -541,6 +539,8 @@ impl WebWorker { live } + /// The parent is releasing this thread: drop the keep-alive on the parent's + /// loop and forget it as a child. Parent thread. #[unsafe(export_name = "WebWorker__releaseParentPollRef")] pub(crate) extern "C" fn release_parent_poll_ref(this: *mut WebWorker) { let this_ref = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); From 8c4567d2189cfc48392172ef7f960cdb9c31a107 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:33:35 +0000 Subject: [PATCH 047/137] Drop unreachable keep-alive guard in WorkerMessagingProxy::hasRef releaseWorkerThread() nulls m_workerThread before setting m_keepAliveReleased and both are parent-thread-only, so the m_workerThread check above already covers that state. --- src/jsc/bindings/webcore/WorkerMessagingProxy.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index fff703524e78..3f3b7cdf4b7a 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -192,8 +192,6 @@ std::optional WorkerMessagingProxy::hasRef() const ASSERT(!m_scriptExecutionContext || m_scriptExecutionContext->isContextThread()); if (!m_workerThread) return std::nullopt; - if (m_keepAliveReleased) - return false; return WebWorker__hasRef(m_workerThread); } From 94db9ae2c70a4b2ebcf2b3bbcfff4a79a810abff Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 8 Aug 2026 14:04:46 -0700 Subject: [PATCH 048/137] worker_threads: honour --cpu-prof-dir, --cpu-prof-name and --cpu-prof-md in a Worker's execArgv A worker's own execArgv recognised --cpu-prof-dir/--cpu-prof-name only to skip their values (and not --cpu-prof-md at all), so its profile always went to the cwd under the default name; node treats all of these as per-Environment options. CPUProfilerConfig now owns its name/dir, so a worker's strings need no 'static erasure (and the main thread's config no longer erases the CLI boxes either); the worker execArgv parser fills name (with the ${pid} placeholder), dir, md and interval in both `--flag value` and `--flag=value` forms, and start_vm builds the worker's config from them. --- src/jsc/BunCPUProfiler.rs | 21 +++++++------- src/jsc/VirtualMachine.rs | 9 ++++-- src/jsc/web_worker.rs | 35 +++++++++++------------ src/runtime/cli/Arguments.rs | 2 +- src/runtime/cli/run_command.rs | 11 ++------ src/runtime/jsc_hooks.rs | 51 +++++++++++++++++++++++----------- test/cli/run/cpu-prof.test.ts | 38 +++++++++++++++++++++++++ 7 files changed, 111 insertions(+), 56 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index b2d39577935c..bb85e1786a22 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -16,12 +16,11 @@ pub(crate) enum ProfilerError { FilenameTooLong, } -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct CPUProfilerConfig { - // CLI-arg-backed and - // process-lifetime, so `&'static` is sound (no struct lifetime params). - pub name: &'static [u8], - pub dir: &'static [u8], + /// Empty: the default `CPU....` name / the cwd. + pub name: Box<[u8]>, + pub dir: Box<[u8]>, pub md_format: bool, pub json_format: bool, pub interval: u32, @@ -33,8 +32,8 @@ pub struct CPUProfilerConfig { impl Default for CPUProfilerConfig { fn default() -> Self { Self { - name: b"", - dir: b"", + name: Box::default(), + dir: Box::default(), md_format: false, json_format: false, interval: 1000, @@ -133,7 +132,7 @@ fn write_profile_to_file( let errno = err.get_errno(); if errno == Errno::ENOENT || errno == Errno::EPERM || errno == Errno::EACCES { if !config.dir.is_empty() { - let _ = Fd::cwd().make_path(config.dir); + let _ = Fd::cwd().make_path(&config.dir); // Retry write let retry_result = bun_sys::File::write_file_os_path( Fd::cwd(), @@ -172,13 +171,13 @@ fn build_output_path( let ext: &[u8] = if is_md_format { b".md" } else { b".cpuprofile" }; let mut cursor = std::io::Cursor::new(&mut filename_buf[..]); cursor - .write_all(config.name) + .write_all(&config.name) .and_then(|_| cursor.write_all(ext)) .map_err(|_| ProfilerError::FilenameTooLong)?; let len = usize::try_from(cursor.position()).expect("int cast"); break 'blk &filename_buf[..len]; } else { - break 'blk config.name; + break 'blk &config.name; } } } else { @@ -186,7 +185,7 @@ fn build_output_path( }; if !config.dir.is_empty() { - path.join(&[config.dir]) + path.join(&[&config.dir]) .map_err(|_| ProfilerError::FilenameTooLong)?; } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index f73ede4e676c..27068a9b7828 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1993,14 +1993,17 @@ extern crate alloc; /// casts back on the other side of each hook. pub type RuntimeState = *mut c_void; -/// The subset of a Worker's `execArgv` that bun acts on. Flags whose value must -/// outlive the parse (--cpu-prof-dir/-name) are absent; nothing needs them yet. -#[derive(Default, Clone, Copy)] +/// The subset of a Worker's `execArgv` that bun acts on (node's per-Environment options). +#[derive(Default)] pub struct WorkerExecArgv { pub allow_addons: Option, pub use_system_ca: Option, + /// `--cpu-prof` (JSON) / `--cpu-prof-md`; either enables profiling of the worker thread. pub cpu_prof: bool, + pub cpu_prof_md: bool, pub cpu_prof_interval: Option, + pub cpu_prof_name: Option>, + pub cpu_prof_dir: Option>, } pub struct RuntimeHooks { diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 147e176119c1..d352eb6c14f5 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -356,8 +356,8 @@ impl WebWorker { } else { name_str.to_owned_slice_z() }, - // SAFETY: `parent` is live (see above); `CPUProfilerConfig` is `Copy`. - parent_cpu_profiler_config: unsafe { (*parent).cpu_profiler_config }, + // SAFETY: `parent` is live (see above); read on the parent's own thread. + parent_cpu_profiler_config: unsafe { (*parent).cpu_profiler_config.clone() }, ref_count: bun_ptr::ThreadSafeRefCount::init(), requested_terminate: AtomicBool::new(false), vm: Cell::new(core::ptr::null_mut()), @@ -654,7 +654,7 @@ impl WebWorker { // A worker's own `execArgv` is parsed with the RunCommand param table (in `bun_runtime::cli`, // hence the hook); a worker without one inherits the parent's per-Environment settings. let own_exec_argv = self.exec_argv(); - let exec_argv: virtual_machine::WorkerExecArgv = match own_exec_argv { + let mut exec_argv: virtual_machine::WorkerExecArgv = match own_exec_argv { // SAFETY: borrows the proxy's `WorkerOptions`, alive as long as the proxy; read only. Some(a) => unsafe { (hooks.parse_worker_exec_argv)(a) }, None => Default::default(), @@ -744,32 +744,33 @@ impl WebWorker { VirtualMachine::set_is_main_thread_vm(false); vm_ref.on_unhandled_rejection = on_unhandled_rejection; - // `--cpu-prof` in this worker's execArgv, or inherited from a profiling parent when the - // worker has no execArgv of its own. The profile is written by the VM's exit path. - let profile = if exec_argv.cpu_prof { - let mut config = crate::bun_cpu_profiler::CPUProfilerConfig { - json_format: true, + // `--cpu-prof` / `--cpu-prof-md` (with -name/-dir/-interval) in this worker's execArgv, + // or the parent's profiling options when the worker has no execArgv of its own, as node's + // per-Environment options work. The profile is written by the VM's exit path. + let profile = if exec_argv.cpu_prof || exec_argv.cpu_prof_md { + let defaults = crate::bun_cpu_profiler::CPUProfilerConfig::default(); + Some(crate::bun_cpu_profiler::CPUProfilerConfig { + name: exec_argv.cpu_prof_name.take().unwrap_or_default(), + dir: exec_argv.cpu_prof_dir.take().unwrap_or_default(), + md_format: exec_argv.cpu_prof_md, + json_format: exec_argv.cpu_prof, + interval: exec_argv.cpu_prof_interval.unwrap_or(defaults.interval), thread_id: self.execution_context_id, - ..Default::default() - }; - if let Some(interval) = exec_argv.cpu_prof_interval { - config.interval = interval; - } - Some(config) + }) } else if own_exec_argv.is_none() { - self.parent_cpu_profiler_config.map(|c| { + self.parent_cpu_profiler_config.as_ref().map(|c| { crate::bun_cpu_profiler::CPUProfilerConfig { thread_id: self.execution_context_id, - ..c + ..c.clone() } }) } else { None }; if let Some(config) = profile { - vm_ref.cpu_profiler_config = Some(config); // The sampling interval is thread-local: set it from this thread. crate::bun_cpu_profiler::set_sampling_interval(config.interval); + vm_ref.cpu_profiler_config = Some(config); crate::bun_cpu_profiler::start_cpu_profiler(vm_ref.jsc_vm_mut()); } } diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 047cbeb2e3b5..cfcbac4a4597 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -729,7 +729,7 @@ pub(crate) static Bun__Node__ProcessPendingDeprecation: core::sync::atomic::Atom core::sync::atomic::AtomicBool::new(false); /// Node parity: `--cpu-prof-name` supports a `${pid}` placeholder. -fn replace_pid_placeholder(name: &[u8]) -> Box<[u8]> { +pub(crate) fn replace_pid_placeholder(name: &[u8]) -> Box<[u8]> { if !bun_core::strings::contains(name, b"${pid}") { return name.into(); } diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 09a74ae32b04..69fe788675cf 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1353,14 +1353,9 @@ impl Run { // ── CPU profiler ──────────────────────────────────────────────────── if ctx.runtime_options.cpu_prof.enabled { let opts = &ctx.runtime_options.cpu_prof; - // SAFETY: `ctx` is process-lifetime; erase `Box<[u8]>` borrows to - // `'static` for `CPUProfilerConfig`. - let name: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.name.as_ref()) }; - // SAFETY: same process-lifetime erasure as `name` above. - let dir: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.dir.as_ref()) }; let config = bun_jsc::bun_cpu_profiler::CPUProfilerConfig { - name, - dir, + name: opts.name.clone(), + dir: opts.dir.clone(), md_format: opts.md_format, json_format: opts.json_format, interval: opts.interval, @@ -1376,7 +1371,7 @@ impl Run { // ── Heap profiler ─────────────────────────────────────────────────── if ctx.runtime_options.heap_prof.enabled { let opts = &ctx.runtime_options.heap_prof; - // SAFETY: `ctx` is process-lifetime; see CPU-profiler note above. + // SAFETY: `ctx` is process-lifetime; erase the `Box<[u8]>` borrow to `'static`. let name: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.name.as_ref()) }; // SAFETY: same process-lifetime erasure as `name` above. let dir: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.dir.as_ref()) }; diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 1447ebf894c3..814cb230d910 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1587,10 +1587,17 @@ unsafe fn apply_standalone_runtime_flags( unsafe fn parse_worker_exec_argv( exec_argv: &[bun_core::WTFStringImpl], ) -> bun_jsc::virtual_machine::WorkerExecArgv { + use crate::cli::arguments::replace_pid_placeholder; + enum Pending { + None, + Interval, + Name, + Dir, + } let mut out = bun_jsc::virtual_machine::WorkerExecArgv::default(); let mut no_addons = false; - let mut want_interval = false; - let mut skip_next = false; + let mut pending = Pending::None; + let parse_interval = |v: &[u8]| std::str::from_utf8(v).ok().and_then(|s| s.parse().ok()); for &arg in exec_argv { if arg.is_null() { continue; @@ -1598,14 +1605,20 @@ unsafe fn parse_worker_exec_argv( // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. let owned = unsafe { &*arg }.to_owned_slice_z(); let bytes = owned.as_bytes(); - if skip_next { - skip_next = false; - continue; - } - if want_interval { - want_interval = false; - out.cpu_prof_interval = std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()); - continue; + match core::mem::replace(&mut pending, Pending::None) { + Pending::None => {} + Pending::Interval => { + out.cpu_prof_interval = parse_interval(bytes); + continue; + } + Pending::Name => { + out.cpu_prof_name = Some(replace_pid_placeholder(bytes)); + continue; + } + Pending::Dir => { + out.cpu_prof_dir = Some(bytes.into()); + continue; + } } // `stop_after_positional_at = 1` — first non-flag token ends parsing. if bytes.first() != Some(&b'-') { @@ -1622,14 +1635,20 @@ unsafe fn parse_worker_exec_argv( out.use_system_ca = Some(false); } else if bytes == b"--cpu-prof" { out.cpu_prof = true; + } else if bytes == b"--cpu-prof-md" { + out.cpu_prof_md = true; } else if bytes == b"--cpu-prof-interval" { - want_interval = true; + pending = Pending::Interval; } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-interval=") { - out.cpu_prof_interval = std::str::from_utf8(v).ok().and_then(|s| s.parse().ok()); - } else if bytes == b"--cpu-prof-dir" || bytes == b"--cpu-prof-name" { - // Value is discarded here but must be consumed so it is not misread - // as the first positional (which would stop the scan early). - skip_next = true; + out.cpu_prof_interval = parse_interval(v); + } else if bytes == b"--cpu-prof-name" { + pending = Pending::Name; + } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-name=") { + out.cpu_prof_name = Some(replace_pid_placeholder(v)); + } else if bytes == b"--cpu-prof-dir" { + pending = Pending::Dir; + } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-dir=") { + out.cpu_prof_dir = Some(v.into()); } } // Override `allow_addons` unconditionally. diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 0e22bc128c81..528ee6d4575a 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -151,6 +151,44 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); + // node's --cpu-prof options are per-Environment: a worker's own execArgv decides where and under + // which name its profile is written, independently of the (unprofiled) parent. + test("--cpu-prof-dir and --cpu-prof-name in a worker's execArgv are honoured", async () => { + using dir = tempDir("cpu-prof-worker-execargv", { + "test.js": ` + const { Worker } = require("node:worker_threads"); + const spin = \`const end = Date.now() + 100; while (Date.now() < end) {}\`; + const json = new Worker(spin, { + eval: true, + execArgv: ["--cpu-prof", "--cpu-prof-dir", "profiles", "--cpu-prof-name", "worker.cpuprofile"], + }); + await new Promise(r => json.on("exit", r)); + const md = new Worker(spin, { eval: true, execArgv: ["--cpu-prof-md", "--cpu-prof-dir=mdprofiles"] }); + await new Promise(r => md.on("exit", r)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + + // Nothing in the cwd (the parent is not profiling); the JSON profile is where the worker asked. + expect(readdirSync(String(dir)).filter(f => f.endsWith(".cpuprofile") || f.endsWith(".md"))).toEqual([]); + expect(readdirSync(join(String(dir), "profiles"))).toEqual(["worker.cpuprofile"]); + const profile = JSON.parse(readFileSync(join(String(dir), "profiles", "worker.cpuprofile"), "utf-8")); + expect(Array.isArray(profile.nodes)).toBe(true); + // --cpu-prof-md alone enables the markdown format, with the default (thread-distinct) name. + const mdFiles = readdirSync(join(String(dir), "mdprofiles")); + expect(mdFiles).toHaveLength(1); + expect(mdFiles[0]).toMatch(/^CPU\..*\.md$/); + expect(exitCode).toBe(0); + }); + // On Windows the path buffer is ~98 KB and the CreateProcess command-line // limit is ~32 KB, so an overflowing CLI argument cannot be delivered. On // POSIX 5000 bytes exceeds the fixed path buffer (Linux 4096, macOS 1024); From fec0b600264bdeca57530cb6ea610188c4200615 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 8 Aug 2026 14:36:43 -0700 Subject: [PATCH 049/137] perf_hooks: eventLoopUtilization needs no null-loop guard usockets_loop() panics rather than return null and the loop is installed by ensure_waker() before any JS runs, so the branch could not fire. --- src/runtime/dispatch_js2native.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index ce9d7eb13f62..b039acc23f75 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -79,16 +79,11 @@ pub(crate) fn bun_get_use_system_ca( /// active as now - loopStart - idle). pub(crate) fn bun_get_loop_elu(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { let vm = bun_jsc::virtual_machine::VirtualMachine::get(); - // SAFETY: the VM owns this loop and this runs on its thread. Raw *mut, no - // &Loop — a &mut PosixLoop is live above us via tick_with_timeout for the - // whole tick (see the re-export comment in Loop.rs). - let loop_ptr = unsafe { (*vm.event_loop).usockets_loop() }; - if loop_ptr.is_null() { - return Ok(JSValue::NULL); - } - // SAFETY: `loop_ptr` was just null-checked and stays valid for this tick - // under the same ownership argument as above. - let idle_ms = unsafe { bun_uws::us_loop_idle_ns(loop_ptr) } as f64 / 1_000_000.0; + // SAFETY: the VM owns this loop (installed by `ensure_waker` before any JS ran; `usockets_loop` + // panics rather than return null) and this runs on its thread. Raw *mut, no &Loop — a + // &mut PosixLoop is live above us via tick_with_timeout for the whole tick. + let idle_ms = + unsafe { bun_uws::us_loop_idle_ns((*vm.event_loop).usockets_loop()) } as f64 / 1_000_000.0; let elapsed_ms = vm.loop_start.elapsed().as_secs_f64() * 1000.0; let arr = JSValue::create_empty_array(global, 2)?; arr.put_index(global, 0, JSValue::js_number(elapsed_ms))?; From 47cceeffef862d48ab27863b977ad0d97721f4ec Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 8 Aug 2026 17:25:03 -0700 Subject: [PATCH 050/137] tls: a Worker's --use-system-ca / --no-use-system-ca governs the roots its own connections trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node makes use_system_ca an Environment option and applies it where the root store is built (NewRootCertStore(env)); here the per-thread value only fed tls.getCACertificates() while every SSL context kept using one process-wide default store, so a worker started with --use-system-ca reported system roots it did not actually trust (and vice versa). The default root store is now built per decision — with or without the system CAs — each variant memoised once per process as before, and the decision travels with the context options (use_system_ca: 0 = process default from the CLI flags / NODE_USE_SYSTEM_CA, 1 = include, -1 = exclude). us_ssl_ctx_build_raw records the resolved decision on the SSL_CTX so the per-socket verify store, the own-store copy and the client-side SSL wrapper pick the same variant. Every SSLConfig created on a JS thread is stamped with that thread's decision; when it differs from the process default the config asks for a context of its own, and a fetch() without `tls` options in such a thread gets a minimal interned config so the HTTP thread builds and caches a client context for that variant instead of using its shared default one. Threads that don't override the process default take exactly the paths they did before. The test (Linux, where SSL_CERT_FILE defines the system store hermetically) runs a --use-system-ca and a --no-use-system-ca worker in one process against a local server signed by the injected root: the first verifies over tls.connect and fetch and reports the extra root, the second does neither. --- packages/bun-usockets/src/crypto/openssl.c | 24 ++++-- .../bun-usockets/src/crypto/root_certs.cpp | 55 ++++++++----- .../src/crypto/root_certs_header.h | 9 ++- packages/bun-usockets/src/libusockets.h | 4 + packages/bun-uws/src/App.h | 2 + src/http/ssl_config.rs | 11 +++ src/jsc/VirtualMachine.rs | 30 +++++++ src/runtime/socket/SSLConfig.rs | 4 + src/runtime/socket/tls_socket_functions.rs | 6 +- src/runtime/webcore/fetch.rs | 9 +++ src/uws/lib.rs | 10 ++- src/uws_sys/SocketContext.rs | 5 ++ test/js/node/tls/test-use-system-ca.test.ts | 81 ++++++++++++++++++- 13 files changed, 215 insertions(+), 35 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 91e7d3f78727..59e09628c3c6 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -162,6 +162,8 @@ static int us_ctx_cache_ex_idx = -1; * ca/caFile options or a later addCACert): the per-socket client attach must * not replace such a store with the process-shared default roots. */ static int us_ctx_user_ca_ex_idx = -1; +/* The resolved system-CA decision the context was built with (stored as value+1 so 0 = unset). */ +static int us_ctx_use_system_ca_ex_idx = -1; static int us_ssl_reneg_state_idx = -1; /* Per-connection async-SNI suspension state (select_certificate_cb retry). */ static int us_ssl_sni_pending_idx = -1; @@ -443,6 +445,7 @@ static void us_ex_idx_init(void) { us_sni_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_cache_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, bun_ssl_ctx_cache_on_free); us_ctx_user_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ctx_use_system_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_sni_policy_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_reneg_state_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_reneg_state_free); us_ssl_sni_pending_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_sni_pending_free); @@ -903,13 +906,15 @@ static int us_ssl_ctx_use_privatekey_content(SSL_CTX *ctx, const char *content, * the still-empty SSL_CTX_new() store are first replaced by a private full * default-root copy, and the context is marked so the per-socket attach keeps * it. https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_context.cc#L1831 */ +int us_ssl_ctx_use_system_ca(SSL_CTX *ctx) { + us_ex_idx_ensure(); + intptr_t stored = (intptr_t)SSL_CTX_get_ex_data(ctx, us_ctx_use_system_ca_ex_idx); + return stored ? (int)(stored - 1) : us_default_use_system_ca(); +} + static X509_STORE *us_ssl_ctx_get_own_cert_store(SSL_CTX *ctx) { X509_STORE *store = SSL_CTX_get_cert_store(ctx); - /* us_get_shared_default_ca_store() up-refs before returning, so release - * the reference taken just for this comparison. */ - X509_STORE *shared = us_get_shared_default_ca_store(); - int store_is_shared = store != NULL && store == shared; - X509_STORE_free(shared); + int store_is_shared = us_is_shared_default_ca_store(store); us_ex_idx_ensure(); int store_is_empty = 0; if (store != NULL && !store_is_shared) { @@ -921,7 +926,7 @@ static X509_STORE *us_ssl_ctx_get_own_cert_store(SSL_CTX *ctx) { * no `ca` configured at all may be seeded with the default roots here. */ int user_ca = SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx) != NULL; if (store == NULL || store_is_shared || (store_is_empty && !user_ca)) { - X509_STORE *own = us_get_default_ca_store(); + X509_STORE *own = us_get_default_ca_store(us_ssl_ctx_use_system_ca(ctx)); if (own == NULL) { return NULL; } @@ -1106,6 +1111,9 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, /* Register the live-count free_func first thing so every exit (including * build_fail) balances. The packed reneg policy reuses the same slot. */ SSL_CTX_set_ex_data(ssl_context, us_ssl_ctx_ex_idx(), NULL); + const int use_system_ca = us_resolve_use_system_ca(options.use_system_ca); + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_use_system_ca_ex_idx, (void *)(intptr_t)(use_system_ca + 1)); /* Default options we rely on — changing these breaks the BIO logic. */ SSL_CTX_set_read_ahead(ssl_context, 1); @@ -1233,7 +1241,7 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, * addRootCerts() when `ca` is absent - the handshake-time auto-chain and * (for requestCert) client verification both read it. The getter up-refs, * so set_cert_store owns exactly one reference per context. */ - SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store()); + SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store(use_system_ca)); if (options.request_cert) { SSL_CTX_set_verify(ssl_context, options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) @@ -1574,7 +1582,7 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, * A context whose store holds user-provided CAs (ca/caFile options or * addCACert) keeps using its own store - overriding it here would * hide those CAs from chain verification. */ - X509_STORE *roots = us_get_shared_default_ca_store(); + X509_STORE *roots = us_get_shared_default_ca_store(us_ssl_ctx_use_system_ca(ctx)); if (roots) SSL_set0_verify_cert_store(ssl, roots); } } diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index fdb45cd1b91f..38d0406dc1ae 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -2,6 +2,7 @@ #include "./root_certs_header.h" #include "./internal/internal.h" #include +#include #include #include "./default_ciphers.h" @@ -27,22 +28,27 @@ extern "C" void BUN__warn__extra_ca_load_failed(const char* filename, const char extern "C" bool Bun__Node__UseSystemCA; extern "C" bool Bun__Node__NoUseSystemCA; -// Helper function to check if system CA should be used -// Checks both CLI flag (--use-system-ca) and environment variable (NODE_USE_SYSTEM_CA=1) -static bool us_should_use_system_ca() { +// The process-wide default: --no-use-system-ca beats everything, then --use-system-ca, then +// NODE_USE_SYSTEM_CA=1. A thread (node: Environment) started with its own flag overrides this for +// the contexts it creates — see us_bun_socket_context_options_t.use_system_ca. +extern "C" int us_default_use_system_ca() { if (Bun__Node__NoUseSystemCA) { - return false; + return 0; } - // Check CLI flag first if (Bun__Node__UseSystemCA) { - return true; + return 1; } - - // Check environment variable const char *use_system_ca = getenv("NODE_USE_SYSTEM_CA"); return use_system_ca && strcmp(use_system_ca, "1") == 0; } +// Resolve an options-struct tri-state (0: process default, >0: include system roots, <0: exclude). +extern "C" int us_resolve_use_system_ca(int requested) { + if (requested > 0) return 1; + if (requested < 0) return 0; + return us_default_use_system_ca(); +} + // Platform-specific system certificate loading implementations are separated: // - macOS: root_certs_darwin.cpp (Security framework with dynamic loading) // - Windows: root_certs_windows.cpp (Windows CryptoAPI) @@ -211,7 +217,7 @@ STACK_OF(X509) *us_get_root_system_cert_instances() { return system_certs; } -extern "C" X509_STORE *us_get_default_ca_store() { +extern "C" X509_STORE *us_get_default_ca_store(int use_system_ca) { X509_STORE *store = X509_STORE_new(); if (store == NULL) { return NULL; @@ -243,7 +249,7 @@ extern "C" X509_STORE *us_get_default_ca_store() { } } - if (us_should_use_system_ca()) { + if (use_system_ca) { STACK_OF(X509) *root_system_cert_instances = us_get_root_system_cert_instances(); if (root_system_cert_instances) { for (int i = 0; i < sk_X509_num(root_system_cert_instances); i++) { @@ -257,20 +263,29 @@ extern "C" X509_STORE *us_get_default_ca_store() { return store; } -// Process-wide immutable default store. Safe to share across SSL_CTXs that -// don't add per-config CAs (the user-`ca` path in build_raw populates the -// SSL_CTX's own private, initially-empty store instead). This makes the -// ~150-root build a once-per-process cost instead of once-per-SSL_CTX, which -// is what kept Bun.connect({tls:true}) under the node-tls-server.test.ts -// 100ms cold-path budget in debug+ASAN. -extern "C" X509_STORE *us_get_shared_default_ca_store() { - static X509_STORE *shared = nullptr; - static std::once_flag once; - std::call_once(once, []() { shared = us_get_default_ca_store(); }); +// Process-wide immutable default stores, one per system-CA decision. Safe to share across SSL_CTXs +// that don't add per-config CAs (the user-`ca` path in build_raw populates the SSL_CTX's own +// private, initially-empty store instead). This makes the ~150-root build a once-per-process cost +// (per variant actually used) instead of once-per-SSL_CTX, which is what kept +// Bun.connect({tls:true}) under the node-tls-server.test.ts 100ms cold-path budget in debug+ASAN. +static std::atomic shared_default_ca_store[2] = { nullptr, nullptr }; + +extern "C" X509_STORE *us_get_shared_default_ca_store(int use_system_ca) { + static std::once_flag once[2]; + int i = use_system_ca ? 1 : 0; + std::call_once(once[i], [i]() { shared_default_ca_store[i].store(us_get_default_ca_store(i)); }); + X509_STORE *shared = shared_default_ca_store[i].load(); if (shared) X509_STORE_up_ref(shared); return shared; } +// Whether `store` is one of the process-shared default stores (as opposed to a context's own). +// Compares against whatever has been built so far; builds nothing. +extern "C" int us_is_shared_default_ca_store(X509_STORE *store) { + return store != nullptr + && (store == shared_default_ca_store[0].load() || store == shared_default_ca_store[1].load()); +} + extern "C" const char *us_get_default_ciphers() { return DEFAULT_CIPHER_LIST; } diff --git a/packages/bun-usockets/src/crypto/root_certs_header.h b/packages/bun-usockets/src/crypto/root_certs_header.h index 31b59acc9848..cf6636dc3c33 100644 --- a/packages/bun-usockets/src/crypto/root_certs_header.h +++ b/packages/bun-usockets/src/crypto/root_certs_header.h @@ -11,5 +11,10 @@ STACK_OF(X509) *us_get_root_system_cert_instances(); #define CPPDECL extern #endif -CPPDECL X509_STORE *us_get_default_ca_store(); -CPPDECL X509_STORE *us_get_shared_default_ca_store(); +CPPDECL int us_default_use_system_ca(); +CPPDECL int us_resolve_use_system_ca(int requested); +CPPDECL X509_STORE *us_get_default_ca_store(int use_system_ca); +CPPDECL X509_STORE *us_get_shared_default_ca_store(int use_system_ca); +CPPDECL int us_is_shared_default_ca_store(X509_STORE *store); +/* The resolved system-CA decision an SSL_CTX built by us_ssl_ctx_build_raw was created with. */ +CPPDECL int us_ssl_ctx_use_system_ca(SSL_CTX *ctx); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index cf62e75eee44..fbd3f922df6a 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -517,6 +517,10 @@ struct us_bun_socket_context_options_t { const char *sigalgs; /* Colon-separated named-group list applied via SSL_CTX_set1_groups_list. */ const char *ecdh_curve; + /* Whether the default root store of this context includes the system's trusted CAs (node's + * per-Environment --use-system-ca): 0 = the process default (CLI flags / NODE_USE_SYSTEM_CA), + * 1 = include, -1 = exclude. Only matters when no `ca`/`ca_file_name` is given. */ + int use_system_ca; }; enum create_bun_socket_error_t { diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index eb5ee2ba132d..8830f8d014e5 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -86,6 +86,8 @@ namespace uWS { int allow_partial_trust_chain = 0; const char *sigalgs = nullptr; const char *ecdh_curve = nullptr; + /* 0 = process default, 1 = include system CAs, -1 = exclude (see libusockets.h) */ + int use_system_ca = 0; /* Conversion operator used internally */ operator struct us_bun_socket_context_options_t() const { diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index c6bea951364a..ab7d2b69eb78 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -51,6 +51,10 @@ pub struct SSLConfig { pub requires_custom_request_ctx: bool, pub is_using_default_ciphers: bool, pub low_memory_mode: bool, + /// Whether contexts built from this config trust the system CAs by default (node's + /// per-Environment --use-system-ca): 0 = process default, 1 = include, -1 = exclude. Stamped + /// from the creating VM; only matters when no `ca`/`ca_file_name` is given. + pub use_system_ca: i32, /// Memoized `content_hash()`. Interior-mutable because it's lazily filled /// through `Arc` (shared ref) by the intern registry's hash /// context. @@ -120,6 +124,7 @@ impl SSLConfig { requires_custom_request_ctx: false, is_using_default_ciphers: true, low_memory_mode: false, + use_system_ca: 0, cached_hash: AtomicU64::new(0), }; @@ -225,6 +230,7 @@ impl SSLConfig { ctx_opts.crl = crl.as_ptr(); ctx_opts.crl_count = crl.len() as u32; } + ctx_opts.use_system_ca = self.use_system_ca; ctx_opts } @@ -324,6 +330,9 @@ impl SSLConfig { if self.is_using_default_ciphers != other.is_using_default_ciphers { return false; } + if self.use_system_ca != other.use_system_ca { + return false; + } if self.low_memory_mode != other.low_memory_mode { return false; } @@ -384,6 +393,7 @@ impl SSLConfig { hasher.update(&[u8::from(self.requires_custom_request_ctx)]); hasher.update(&[u8::from(self.is_using_default_ciphers)]); hasher.update(&[u8::from(self.low_memory_mode)]); + hasher.update(&self.use_system_ca.to_ne_bytes()); let hash = hasher.final_(); // Avoid 0 since it's the sentinel for "not computed" let hash = if hash == 0 { 1 } else { hash }; @@ -483,6 +493,7 @@ impl Clone for SSLConfig { requires_custom_request_ctx: self.requires_custom_request_ctx, is_using_default_ciphers: self.is_using_default_ciphers, low_memory_mode: self.low_memory_mode, + use_system_ca: self.use_system_ca, cached_hash: AtomicU64::new(0), } } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 27068a9b7828..45a5fed1294c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1993,6 +1993,36 @@ extern crate alloc; /// casts back on the other side of each hook. pub type RuntimeState = *mut c_void; +unsafe extern "C" { + safe fn us_default_use_system_ca() -> i32; +} + +impl VirtualMachine { + /// Whether TLS contexts created by this thread trust the system CAs by default: this thread's + /// explicit `--use-system-ca` / `--no-use-system-ca`, else the process default. + pub fn tls_use_system_ca(&self) -> bool { + self.use_system_ca + .unwrap_or_else(|| us_default_use_system_ca() != 0) + } + + /// The same decision as the `use_system_ca` tri-state TLS options carry + /// (0 = process default, 1 = include, -1 = exclude). + pub fn tls_use_system_ca_option(&self) -> i32 { + match self.use_system_ca { + None => 0, + Some(true) => 1, + Some(false) => -1, + } + } + + /// This thread's decision differs from the process default, so anything keyed on "the default + /// TLS context" (fetch's shared client context) must use a variant of its own. + pub fn tls_use_system_ca_differs_from_process(&self) -> bool { + self.use_system_ca + .is_some_and(|v| v != (us_default_use_system_ca() != 0)) + } +} + /// The subset of a Worker's `execArgv` that bun acts on (node's per-Environment options). #[derive(Default)] pub struct WorkerExecArgv { diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index a164e34f6915..0c7016d83414 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -152,6 +152,8 @@ impl SSLConfigFromJs for SSLConfig { ) -> JsResult> { let mut result = SSLConfig::zero(); // `result` cleanup handled by Drop on error-path `?` + result.use_system_ca = vm.tls_use_system_ca_option(); + result.requires_custom_request_ctx = vm.tls_use_system_ca_differs_from_process(); let mut any = false; if let Some(passphrase) = generated.passphrase.get() { @@ -271,6 +273,8 @@ impl SSLConfigFromJs for SSLConfig { pub fn tls_true_defaults(vm: &VirtualMachine) -> SSLConfig { let mut cfg = SSLConfig::zero(); cfg.reject_unauthorized = vm.get_tls_reject_unauthorized() as i32; + cfg.use_system_ca = vm.tls_use_system_ca_option(); + cfg.requires_custom_request_ctx = vm.tls_use_system_ca_differs_from_process(); cfg } diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 7d7da9fa4bdc..a93d414c8745 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -252,7 +252,7 @@ pub(super) mod ffi { pub(crate) fn OPENSSL_sk_num(sk: *const c_void) -> usize; // The process-wide default root store; up-refs before returning, so // the caller owns a reference it must release with X509_STORE_free. - pub(crate) fn us_get_shared_default_ca_store() -> *mut X509_STORE; + pub(crate) fn us_get_shared_default_ca_store(use_system_ca: i32) -> *mut X509_STORE; pub(crate) fn X509_STORE_free(store: *mut X509_STORE); // X509_STORE_CTX lifecycle for issuer lookups; `new` allocates, // `init` borrows the store, `free` releases. Used to extend the peer @@ -557,7 +557,9 @@ pub(super) fn get_peer_certificate( // reference is released after the walk. let mut shared_store: *mut boringssl::X509_STORE = core::ptr::null_mut(); if store.is_null() || ffi::OPENSSL_sk_num(ffi::X509_STORE_get0_objects(store)) == 0 { - shared_store = ffi::us_get_shared_default_ca_store(); + shared_store = ffi::us_get_shared_default_ca_store(i32::from( + bun_jsc::virtual_machine::VirtualMachine::get().tls_use_system_ca(), + )); if !shared_store.is_null() { store = shared_store; } diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 2a41b66e2cc4..9c1985e32bb9 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -792,6 +792,15 @@ fn fetch_impl( return Ok(JSValue::ZERO); } + // No `tls` options, but this thread's --use-system-ca decision differs from the process + // default the HTTP thread's shared client context was built with: give the request a config of + // its own (interned, so all such requests share one cached context). + if ssl_config.is_none() && vm.tls_use_system_ca_differs_from_process() { + ssl_config = Some(ssl_config_intern_for_http( + crate::socket::tls_true_defaults(vm), + )); + } + // unix: string | undefined unix_socket_path = 'extract_unix_socket_path: { let objects_to_try = [ diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 2d1f6ba92b40..4421a2bd34f3 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -416,7 +416,11 @@ pub mod ssl_wrapper { boring_sys::SSL_VERIFY_PEER, Some(always_continue_verify), ); - if let Some(roots) = NonNull::new(us_get_shared_default_ca_store()) { + // Same roots variant the context was built with (its creator's + // --use-system-ca decision is recorded on the SSL_CTX). + if let Some(roots) = NonNull::new(us_get_shared_default_ca_store( + us_ssl_ctx_use_system_ca(ctx.as_ptr()), + )) { let _ = boring_sys::SSL_set0_verify_cert_store( ssl.as_ptr(), roots.as_ptr(), @@ -1225,7 +1229,9 @@ pub mod ssl_wrapper { /// up_ref'd per consumer so the ~150-cert load happens once total, not per /// CTX. Returns null if root loading fails (treated as "no roots"). // safe: no args; idempotent lazy init reading a process global — no preconditions. - safe fn us_get_shared_default_ca_store() -> *mut boring_sys::X509_STORE; + safe fn us_get_shared_default_ca_store(use_system_ca: i32) -> *mut boring_sys::X509_STORE; + /// The system-CA decision an SSL_CTX built by usockets was created with. + fn us_ssl_ctx_use_system_ca(ctx: *mut boring_sys::SSL_CTX) -> i32; /// Implemented in uSockets C; reads /// `SSL_get_verify_result` and maps it onto the C `us_bun_verify_error_t`. fn us_ssl_socket_verify_error_from_ssl(ssl: *mut boring_sys::SSL) -> us_bun_verify_error_t; diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index a61ef5541871..7fd531c7d743 100644 --- a/src/uws_sys/SocketContext.rs +++ b/src/uws_sys/SocketContext.rs @@ -124,6 +124,9 @@ pub struct BunSocketContextOptions { pub allow_partial_trust_chain: i32, pub sigalgs: *const c_char, pub ecdh_curve: *const c_char, + /// The context's default root store includes the system CAs: 0 = process default + /// (CLI flags / NODE_USE_SYSTEM_CA), 1 = include, -1 = exclude. See libusockets.h. + pub use_system_ca: i32, } impl Default for BunSocketContextOptions { @@ -155,6 +158,7 @@ impl Default for BunSocketContextOptions { allow_partial_trust_chain: 0, sigalgs: ptr::null(), ecdh_curve: ptr::null(), + use_system_ca: 0, } } } @@ -260,6 +264,7 @@ impl BunSocketContextOptions { h.update(bun_core::bytes_of(&self.allow_partial_trust_chain)); feed_z(&mut h, self.sigalgs); feed_z(&mut h, self.ecdh_curve); + h.update(bun_core::bytes_of(&self.use_system_ca)); let mut out = [0u8; 32]; h.final_(&mut out); out diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index 52fed35e215b..07f4c975b5b0 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -1,6 +1,10 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isLinux } from "harness"; +import { join } from "path"; + +// node's test CA and a leaf it signed (CN=agent1). +const keysDir = join(import.meta.dir, "../test/fixtures/keys"); describe("--use-system-ca", () => { test("flag loads system certificates", async () => { @@ -51,6 +55,81 @@ describe("--use-system-ca", () => { expect(stderr).toBe(""); }); + // node makes --use-system-ca a per-Environment option: a Worker's execArgv decides which roots the + // TLS contexts *it* creates trust, independently of the parent and of sibling workers. Hermetic + // on Linux, where SSL_CERT_FILE defines the system store; the parent trusts only the bundled roots. + test.skipIf(!isLinux)( + "a Worker's --use-system-ca / --no-use-system-ca governs the roots its own connections trust", + async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + const tls = require("tls"); + const fs = require("fs"); + const { Worker } = require("worker_threads"); + const keys = process.env.KEYS_DIR; + const server = tls.createServer({ + key: fs.readFileSync(keys + "/agent1-key.pem"), + cert: fs.readFileSync(keys + "/agent1-cert.pem"), + }, s => s.end("hi")); + const http = require("https").createServer({ + key: fs.readFileSync(keys + "/agent1-key.pem"), + cert: fs.readFileSync(keys + "/agent1-cert.pem"), + }, (req, res) => res.end("ok")); + server.listen(0, () => http.listen(0, async () => { + const workerSrc = \` + const tls = require("tls"); + const { parentPort, workerData } = require("worker_threads"); + const connect = () => new Promise(resolve => { + const s = tls.connect({ port: workerData.tlsPort, host: "127.0.0.1", servername: "agent1", + checkServerIdentity: () => undefined }, () => { resolve("authorized"); s.destroy(); }); + s.on("error", e => resolve(e.code || e.message)); + }); + const doFetch = () => fetch("https://127.0.0.1:" + workerData.httpPort, { + tls: { checkServerIdentity: () => undefined } }).then(r => r.text(), e => e.code || e.message); + (async () => parentPort.postMessage({ + connect: await connect(), + fetch: await doFetch(), + reportedDefault: tls.getCACertificates("default").length, + reportedBundled: tls.getCACertificates("bundled").length, + }))(); + \`; + const run = execArgv => new Promise((resolve, reject) => { + const w = new Worker(workerSrc, { eval: true, execArgv, + workerData: { tlsPort: server.address().port, httpPort: http.address().port } }); + w.once("message", resolve); + w.once("error", reject); + }); + const withSystem = await run(["--use-system-ca"]); + const withoutSystem = await run(["--no-use-system-ca"]); + console.log(JSON.stringify({ withSystem, withoutSystem })); + server.close(); + http.close(); + })); + `, + ], + env: { ...bunEnv, SSL_CERT_FILE: join(keysDir, "ca1-cert.pem"), KEYS_DIR: keysDir, NODE_USE_SYSTEM_CA: "0" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const result = JSON.parse(stdout.trim()); + // The --use-system-ca worker trusts ca1 (the "system" root here) for both its own tls.connect and + // fetch, and reports it; the --no-use-system-ca worker in the same process trusts only the + // bundled roots, so the same server fails verification. + expect(result.withSystem.connect).toBe("authorized"); + expect(result.withSystem.fetch).toBe("ok"); + expect(result.withSystem.reportedDefault).toBe(result.withSystem.reportedBundled + 1); + expect(result.withoutSystem.connect).not.toBe("authorized"); + expect(result.withoutSystem.fetch).not.toBe("ok"); + expect(result.withoutSystem.reportedDefault).toBe(result.withoutSystem.reportedBundled); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }, + ); + test("--use-system-ca overrides NODE_USE_SYSTEM_CA=0", async () => { // Test that CLI flag takes precedence over environment variable await using proc = spawn({ From 153e90005acc31797c65c73c1ee9466799e2a9e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:50:50 +0000 Subject: [PATCH 051/137] Fix two review findings in the per-worker use-system-ca plumbing quic.c still declared us_get_default_ca_store with the old argless signature and called it that way, so the H3 client context read use_system_ca from whatever register residue was there (UB) and ignored --no-use-system-ca. It now includes root_certs_header.h so signature drift fails to compile, and takes the shared default store for the process-wide decision, which set_cert_store adopts via the getter's up-ref. SSLConfig::from_generated seeded requires_custom_request_ctx with the VM's differs-from-process bit before the any aggregate, so in a worker whose --use-system-ca differs from the process every object parsed as Some(config): Bun.serve({ port, fetch }) with no tls key became an HTTPS server with no certificate through the v0.x top-level compat path. The bit is now ORed in after any is decided, so it still shapes a real TLS config's request context while TLS-less objects keep returning None (whose differing-CA case the fetch call site already covers). --- packages/bun-usockets/src/quic.c | 8 +++-- src/runtime/socket/SSLConfig.rs | 8 ++++- test/js/node/tls/test-use-system-ca.test.ts | 33 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 8926c7cfbd8a..0d6ec5687d61 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -24,10 +24,11 @@ #include #endif +#include "crypto/root_certs_header.h" + extern SSL_CTX *us_ssl_ctx_build_raw( struct us_bun_socket_context_options_t options, enum create_bun_socket_error_t *err); -extern X509_STORE *us_get_default_ca_store(void); #define US_QUIC_READ_BUF (16 * 1024) @@ -1156,8 +1157,9 @@ us_quic_socket_context_t *us_create_quic_client_context( SSL_CTX_set_max_proto_version(ssl, TLS1_3_VERSION); /* Same root store the H1/H2 client uses (bundled Mozilla roots + platform * CAs + NODE_EXTRA_CA_CERTS); set_default_verify_paths alone doesn't find - * the system store on macOS/Windows. */ - SSL_CTX_set_cert_store(ssl, us_get_default_ca_store()); + * the system store on macOS/Windows. The shared store is never mutated on + * this path, and set_cert_store takes the getter's up-ref. */ + SSL_CTX_set_cert_store(ssl, us_get_shared_default_ca_store(us_default_use_system_ca())); SSL_CTX_set_custom_verify(ssl, SSL_VERIFY_PEER, us_quic_client_verify); us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 0c7016d83414..4760fe9d02f4 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -153,7 +153,6 @@ impl SSLConfigFromJs for SSLConfig { let mut result = SSLConfig::zero(); // `result` cleanup handled by Drop on error-path `?` result.use_system_ca = vm.tls_use_system_ca_option(); - result.requires_custom_request_ctx = vm.tls_use_system_ca_differs_from_process(); let mut any = false; if let Some(passphrase) = generated.passphrase.get() { @@ -263,6 +262,13 @@ impl SSLConfigFromJs for SSLConfig { || result.client_renegotiation_limit != 0 || generated.client_renegotiation_window != 0; + // ORed in after `any` is decided: the VM-level CA choice must shape a + // real TLS config's request context without turning a TLS-less object + // into Some(config) (callers treat None as "no TLS here", and the + // no-tls differing-CA case is handled at the fetch call site). + result.requires_custom_request_ctx = + result.requires_custom_request_ctx || vm.tls_use_system_ca_differs_from_process(); + // We don't need to deinit `result` if `any` is false. if any { Ok(Some(result)) } else { Ok(None) } } diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index 07f4c975b5b0..fcb918b8cbde 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -130,6 +130,39 @@ describe("--use-system-ca", () => { }, ); + // A worker whose --use-system-ca differs from the process default must not make TLS-less + // options parse as a TLS config: Bun.serve({ port: 0, fetch }) inside such a worker has to + // stay a plain HTTP server instead of silently becoming an HTTPS server with no certificate. + test("a differing --use-system-ca worker still serves plain HTTP without tls options", async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("worker_threads"); + const w = new Worker(\` + const { parentPort } = require("worker_threads"); + (async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("plain") }); + const body = await (await fetch(server.url)).text(); + parentPort.postMessage({ protocol: server.url.protocol, body }); + server.stop(true); + })(); + \`, { eval: true, execArgv: ["--use-system-ca"] }); + w.once("message", m => console.log(JSON.stringify(m))); + w.once("error", e => { console.error(e); process.exit(1); }); + `, + ], + env: { ...bunEnv, NODE_USE_SYSTEM_CA: "0" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual({ protocol: "http:", body: "plain" }); + expect(exitCode).toBe(0); + }); + test("--use-system-ca overrides NODE_USE_SYSTEM_CA=0", async () => { // Test that CLI flag takes precedence over environment variable await using proc = spawn({ From e7f89fda7da999046a9220b23508c52906c81603 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:56:37 +0000 Subject: [PATCH 052/137] Only install OpenSSL default cert paths in the system-CA store variant us_get_default_ca_store called X509_STORE_set_default_paths unconditionally, which installs the SSL_CERT_FILE / SSL_CERT_DIR lookups, i.e. the OS trust store on Linux. That handed the use_system_ca=0 variant system trust anyway, which is why the --no-use-system-ca worker in test-use-system-ca.test.ts still verified against the SSL_CERT_FILE root on every Linux lane. The lookups now join only the system variant, whose eager system-cert load they previously duplicated. Also unsets NODE_EXTRA_CA_CERTS in that test's child env: an ambient value joins every store and skews the count assertions. --- packages/bun-usockets/src/crypto/root_certs.cpp | 5 ++++- test/js/node/tls/test-use-system-ca.test.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 38d0406dc1ae..156b932629e8 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -223,7 +223,10 @@ extern "C" X509_STORE *us_get_default_ca_store(int use_system_ca) { return NULL; } - if (!X509_STORE_set_default_paths(store)) { + // OpenSSL's default file/dir lookups (SSL_CERT_FILE / SSL_CERT_DIR) are the + // OS trust store on Linux, so they only belong here when the system CAs do; + // unconditional, they hand a --no-use-system-ca store system trust anyway. + if (use_system_ca && !X509_STORE_set_default_paths(store)) { X509_STORE_free(store); return NULL; } diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index fcb918b8cbde..13ffe56de70e 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -110,7 +110,14 @@ describe("--use-system-ca", () => { })); `, ], - env: { ...bunEnv, SSL_CERT_FILE: join(keysDir, "ca1-cert.pem"), KEYS_DIR: keysDir, NODE_USE_SYSTEM_CA: "0" }, + env: { + ...bunEnv, + SSL_CERT_FILE: join(keysDir, "ca1-cert.pem"), + KEYS_DIR: keysDir, + NODE_USE_SYSTEM_CA: "0", + // An ambient NODE_EXTRA_CA_CERTS would join every store and skew the counts. + NODE_EXTRA_CA_CERTS: undefined, + }, stdout: "pipe", stderr: "pipe", }); From dc6b8bb4fdeaaee3440a5b157d5cb3de621fef10 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:46:34 +0000 Subject: [PATCH 053/137] tls.getCACertificates('default') reports the decision connections use getUseSystemCA returned undefined when this thread had no flag of its own and tls.ts then re-derived a decision from NODE_USE_SYSTEM_CA alone, while connections resolve through us_default_use_system_ca, where the CLI flags come first. Under bun --no-use-system-ca with NODE_USE_SYSTEM_CA=1, a worker given its own execArgv reported the system roots its connections do not trust. The binding now returns VirtualMachine::tls_use_system_ca, the predicate contexts are built with, so the JS fallback goes away. The bake production VM was the one init_bake caller not passing main_use_system_ca like its run/test/repl siblings. --- src/js/node/tls.ts | 3 +- src/runtime/bake/production.rs | 1 + src/runtime/dispatch_js2native.rs | 12 +++---- test/js/node/tls/test-use-system-ca.test.ts | 39 +++++++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 244580ee4c8b..5613a7bc969d 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1683,8 +1683,7 @@ function cacheDefaultCACertificates() { ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); } - const useSystemCA = getUseSystemCA(); - if (useSystemCA === true || (useSystemCA === undefined && process.env.NODE_USE_SYSTEM_CA === "1")) { + if (getUseSystemCA()) { const system = cacheSystemCACertificates(); for (let i = 0; i < system.length; ++i) { ArrayPrototypePush.$call(defaultCACertificates, system[i]); diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index 95aa242fdfa6..629bb626c704 100644 --- a/src/runtime/bake/production.rs +++ b/src/runtime/bake/production.rs @@ -107,6 +107,7 @@ pub fn build_command(ctx: Context) -> crate::Result<()> { log: NonNull::new(ctx.log), args: ctx.args.clone(), smol: ctx.runtime_options.smol, + use_system_ca: crate::cli::Arguments::main_use_system_ca(), ..Default::default() })?; // SAFETY: `init_bake` returns a freshly-allocated VM owned by this thread; diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index b039acc23f75..5d1befc44b86 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -61,17 +61,15 @@ pub use bun_sys_jsc::error_jsc::TestingAPIs::translate_uv_error_to_e as sys_sys_ pub use bun_http_jsc::headers_jsc::h2_live_counts as http_h2_client_testing_ap_is_live_counts; pub use bun_http_jsc::headers_jsc::h3_quic_live_counts as http_h3_client_testing_ap_is_quic_live_counts; -/// `undefined` ⇒ neither flag given, NODE_USE_SYSTEM_CA decides; only `--no-use-system-ca` beats the env var. +/// The decision this thread's TLS contexts are built with, so +/// `tls.getCACertificates('default')` reports what connections trust. pub(crate) fn bun_get_use_system_ca( _global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - Ok( - match bun_jsc::virtual_machine::VirtualMachine::get().use_system_ca { - Some(v) => JSValue::js_boolean(v), - None => JSValue::UNDEFINED, - }, - ) + Ok(JSValue::js_boolean( + bun_jsc::virtual_machine::VirtualMachine::get().tls_use_system_ca(), + )) } /// `[elapsedSinceLoopStartMs, idleMs]` for THIS thread's loop — the two numbers diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index 13ffe56de70e..e8fbb40f7012 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -137,6 +137,45 @@ describe("--use-system-ca", () => { }, ); + // A worker with no CA flag of its own takes the process default, where + // --no-use-system-ca beats NODE_USE_SYSTEM_CA=1 for connections, and + // getCACertificates('default') has to report that same decision rather than + // re-deriving one from the env var. + test.skipIf(!isLinux)("getCACertificates('default') reports the decision connections use", async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "--no-use-system-ca", + "-e", + ` + const tls = require("tls"); + const { Worker } = require("worker_threads"); + const count = () => tls.getCACertificates("default").length - tls.getCACertificates("bundled").length; + const w = new Worker(\` + const tls = require("tls"); + require("worker_threads").parentPort.postMessage( + tls.getCACertificates("default").length - tls.getCACertificates("bundled").length); + \`, { eval: true, execArgv: [] }); + w.once("message", worker => console.log(JSON.stringify({ main: count(), worker }))); + w.once("error", e => { console.error(e); process.exit(1); }); + `, + ], + env: { + ...bunEnv, + SSL_CERT_FILE: join(keysDir, "ca1-cert.pem"), + NODE_USE_SYSTEM_CA: "1", + NODE_EXTRA_CA_CERTS: undefined, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // ca1 is the single "system" root here; neither thread trusts it, so neither may report it. + expect(JSON.parse(stdout.trim())).toEqual({ main: 0, worker: 0 }); + expect(exitCode).toBe(0); + }); + // A worker whose --use-system-ca differs from the process default must not make TLS-less // options parse as a TLS config: Bun.serve({ port: 0, fetch }) inside such a worker has to // stay a plain HTTP server instead of silently becoming an HTTPS server with no certificate. From 6222f4da62335023fa6442ddcc6dd5d503ef9d9f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:50:48 +0000 Subject: [PATCH 054/137] root_certs: cite node's NewRootCertStore for the default-paths gating --- packages/bun-usockets/src/crypto/root_certs.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 156b932629e8..bca10c38532e 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -226,6 +226,10 @@ extern "C" X509_STORE *us_get_default_ca_store(int use_system_ca) { // OpenSSL's default file/dir lookups (SSL_CERT_FILE / SSL_CERT_DIR) are the // OS trust store on Linux, so they only belong here when the system CAs do; // unconditional, they hand a --no-use-system-ca store system trust anyway. + // Node's default store never installs them either: NewRootCertStore only + // calls set_default_paths under --use-openssl-ca and otherwise adds the + // system roots only when use_system_ca is set. + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_context.cc#L1099-L1109 if (use_system_ca && !X509_STORE_set_default_paths(store)) { X509_STORE_free(store); return NULL; From 6ab78d837ad5f0d5e200a8f82c9ac00da58c946e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:05:46 +0000 Subject: [PATCH 055/137] Restore per-thread NODE_USE_SYSTEM_CA fallback in getCACertificates dc6b8bb made a flagless thread report the process-wide decision, which broke the vendored test-tls-get-ca-certificates-worker-no-use-system-ca: node resolves a flagless Environment's use_system_ca from that Environment's own NODE_USE_SYSTEM_CA (node_options.cc HandleEnvOptions), so a worker given env NODE_USE_SYSTEM_CA=1 under a --no-use-system-ca parent reports the system roots. The binding returns undefined again for a flagless thread and tls.ts reads the thread's env, as before; the test that encoded the other semantics is removed, the vendored node tests cover this. The production.rs sibling fix from dc6b8bb stands. --- src/js/node/tls.ts | 3 +- src/runtime/dispatch_js2native.rs | 15 +++++--- test/js/node/tls/test-use-system-ca.test.ts | 39 --------------------- 3 files changed, 12 insertions(+), 45 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 5613a7bc969d..244580ee4c8b 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1683,7 +1683,8 @@ function cacheDefaultCACertificates() { ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); } - if (getUseSystemCA()) { + const useSystemCA = getUseSystemCA(); + if (useSystemCA === true || (useSystemCA === undefined && process.env.NODE_USE_SYSTEM_CA === "1")) { const system = cacheSystemCACertificates(); for (let i = 0; i < system.length; ++i) { ArrayPrototypePush.$call(defaultCACertificates, system[i]); diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 5d1befc44b86..8562c8f8b7a1 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -61,15 +61,20 @@ pub use bun_sys_jsc::error_jsc::TestingAPIs::translate_uv_error_to_e as sys_sys_ pub use bun_http_jsc::headers_jsc::h2_live_counts as http_h2_client_testing_ap_is_live_counts; pub use bun_http_jsc::headers_jsc::h3_quic_live_counts as http_h3_client_testing_ap_is_quic_live_counts; -/// The decision this thread's TLS contexts are built with, so -/// `tls.getCACertificates('default')` reports what connections trust. +/// This thread's own `--use-system-ca` / `--no-use-system-ca`; `undefined` when it has neither, in +/// which case node resolves the Environment option from that thread's own NODE_USE_SYSTEM_CA +/// (a Worker's `env` option included), which tls.ts reads: +/// https://github.com/nodejs/node/blob/v26.3.0/src/node_options.cc#L2207 pub(crate) fn bun_get_use_system_ca( _global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - Ok(JSValue::js_boolean( - bun_jsc::virtual_machine::VirtualMachine::get().tls_use_system_ca(), - )) + Ok( + match bun_jsc::virtual_machine::VirtualMachine::get().use_system_ca { + Some(v) => JSValue::js_boolean(v), + None => JSValue::UNDEFINED, + }, + ) } /// `[elapsedSinceLoopStartMs, idleMs]` for THIS thread's loop — the two numbers diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index e8fbb40f7012..13ffe56de70e 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -137,45 +137,6 @@ describe("--use-system-ca", () => { }, ); - // A worker with no CA flag of its own takes the process default, where - // --no-use-system-ca beats NODE_USE_SYSTEM_CA=1 for connections, and - // getCACertificates('default') has to report that same decision rather than - // re-deriving one from the env var. - test.skipIf(!isLinux)("getCACertificates('default') reports the decision connections use", async () => { - await using proc = spawn({ - cmd: [ - bunExe(), - "--no-use-system-ca", - "-e", - ` - const tls = require("tls"); - const { Worker } = require("worker_threads"); - const count = () => tls.getCACertificates("default").length - tls.getCACertificates("bundled").length; - const w = new Worker(\` - const tls = require("tls"); - require("worker_threads").parentPort.postMessage( - tls.getCACertificates("default").length - tls.getCACertificates("bundled").length); - \`, { eval: true, execArgv: [] }); - w.once("message", worker => console.log(JSON.stringify({ main: count(), worker }))); - w.once("error", e => { console.error(e); process.exit(1); }); - `, - ], - env: { - ...bunEnv, - SSL_CERT_FILE: join(keysDir, "ca1-cert.pem"), - NODE_USE_SYSTEM_CA: "1", - NODE_EXTRA_CA_CERTS: undefined, - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - // ca1 is the single "system" root here; neither thread trusts it, so neither may report it. - expect(JSON.parse(stdout.trim())).toEqual({ main: 0, worker: 0 }); - expect(exitCode).toBe(0); - }); - // A worker whose --use-system-ca differs from the process default must not make TLS-less // options parse as a TLS config: Bun.serve({ port: 0, fetch }) inside such a worker has to // stay a plain HTTP server instead of silently becoming an HTTPS server with no certificate. From 28ea0a87a7448ea0035a218720f5a68610519bb5 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 10 Aug 2026 13:20:24 -0700 Subject: [PATCH 056/137] worker_threads: apply the thread's --use-system-ca to the default client SSL context The per-Environment --use-system-ca decision was stamped onto every SSLConfig built from tls options and onto fetch's fallback config, but the VM's lazily-built default client context still used the process default. Clients that have no per-connection CA option and fall back to that context, WebSocket in particular, therefore trusted a different set of roots than tls.connect and fetch inside the same worker: with `execArgv: ["--use-system-ca"]` a wss:// connection to a system-trusted server failed the handshake while tls.connect and fetch to it succeeded. Seed the context from the same VM option; since tls_true_defaults stamps the identical value, tls.connect() with default options and WebSocket now also resolve to a single cached context again. --- src/runtime/jsc_hooks.rs | 12 +++- test/js/node/tls/test-use-system-ca.test.ts | 71 ++++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 814cb230d910..ac1a7ea248f8 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -272,6 +272,8 @@ pub(crate) unsafe fn runtime_state_of(vm: *mut VirtualMachine) -> *mut RuntimeSt /// # Safety /// `vm` must be the live per-thread VM; called only from the JS thread. pub(crate) unsafe fn default_client_ssl_ctx(vm: *mut VirtualMachine) -> *mut bun_uws::SslCtx { + // SAFETY: per fn contract. + let use_system_ca = unsafe { (*vm).tls_use_system_ca_option() }; // SAFETY: per fn contract; `rare_data()` lazy-inits the box. let rare = unsafe { (*vm).rare_data() }; if rare.default_client_ssl_ctx.is_none() { @@ -291,8 +293,14 @@ pub(crate) unsafe fn default_client_ssl_ctx(vm: *mut VirtualMachine) -> *mut bun // weak cache so a `tls.connect()` with default options later resolves // to the same CTX rather than building a second one with the same // digest. The +1 ref returned here is held for the VM's lifetime, so - // the entry never tombstones. - match cache.get_or_create_opts(&Default::default(), &mut err) { + // the entry never tombstones. `use_system_ca` is this thread's + // --use-system-ca decision (per-Environment in node), the same value + // `tls_true_defaults` stamps, so the two resolve to one CTX. + let opts = bun_uws::us_bun_socket_context_options_t { + use_system_ca, + ..Default::default() + }; + match cache.get_or_create_opts(&opts, &mut err) { Some(ctx) => rare.default_client_ssl_ctx = Some(ctx), None => bun_core::Output::panic(format_args!( "default client SSL_CTX init failed: {}", diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index 13ffe56de70e..136711e5b989 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux } from "harness"; +import { bunEnv, bunExe, isLinux, tempDir, tls } from "harness"; import { join } from "path"; // node's test CA and a leaf it signed (CN=agent1). @@ -137,6 +137,75 @@ describe("--use-system-ca", () => { }, ); + // Clients that fall back to the thread's default client context (WebSocket has no per-connection + // CA option) must follow the worker's --use-system-ca exactly like tls.connect and fetch do. + // The harness cert is self-signed with a 127.0.0.1 SAN, so it can stand in as the "system" root + // via SSL_CERT_FILE on every platform. + test("a Worker's --use-system-ca governs its WebSocket connections too", async () => { + using dir = tempDir("use-system-ca-ws", { "cert.pem": tls.cert, "key.pem": tls.key }); + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("worker_threads"); + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: { cert: Bun.file(process.env.CERT), key: Bun.file(process.env.KEY) }, + fetch(req, s) { return s.upgrade(req) ? undefined : new Response("http"); }, + websocket: { open(ws) { ws.send("hello"); ws.close(); }, message() {} }, + }); + const workerSrc = \` + const tls = require("tls"); + const { parentPort, workerData: { port } } = require("worker_threads"); + const ws = new WebSocket("wss://127.0.0.1:" + port); + const outcome = new Promise(resolve => { + ws.onmessage = m => resolve("message:" + m.data); + ws.onerror = () => {}; + ws.onclose = e => resolve("closed:" + e.code); + }); + const socket = new Promise(resolve => { + const s = tls.connect({ port, host: "127.0.0.1" }, () => { resolve("authorized"); s.destroy(); }); + s.on("error", e => resolve(e.code)); + }); + Promise.all([outcome, socket]).then(([webSocket, tlsConnect]) => parentPort.postMessage({ webSocket, tlsConnect })); + \`; + const run = execArgv => new Promise((resolve, reject) => { + const w = new Worker(workerSrc, { eval: true, execArgv, workerData: { port: server.port } }); + w.once("message", resolve); + w.once("error", reject); + }); + (async () => { + const withSystem = await run(["--use-system-ca"]); + const withoutSystem = await run(["--no-use-system-ca"]); + console.log(JSON.stringify({ withSystem, withoutSystem })); + server.stop(true); + })(); + `, + ], + env: { + ...bunEnv, + SSL_CERT_FILE: join(String(dir), "cert.pem"), + CERT: join(String(dir), "cert.pem"), + KEY: join(String(dir), "key.pem"), + NODE_USE_SYSTEM_CA: "0", + NODE_EXTRA_CA_CERTS: undefined, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ result: JSON.parse(stdout.trim()), stderr }).toEqual({ + result: { + withSystem: { webSocket: "message:hello", tlsConnect: "authorized" }, + withoutSystem: { webSocket: "closed:1015", tlsConnect: "DEPTH_ZERO_SELF_SIGNED_CERT" }, + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }); + // A worker whose --use-system-ca differs from the process default must not make TLS-less // options parse as a TLS config: Bun.serve({ port: 0, fetch }) inside such a worker has to // stay a plain HTTP server instead of silently becoming an HTTPS server with no certificate. From bb882507e57959e89294068c29de213c479e5e6f Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 10 Aug 2026 15:07:18 -0700 Subject: [PATCH 057/137] worker_threads: resolve a flagless thread's CA option from its env, restore --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. --- .../bun-usockets/src/crypto/root_certs.cpp | 5 +- .../bun-usockets/src/eventing/epoll_kqueue.c | 9 ++- .../bun-usockets/src/internal/loop_data.h | 2 + packages/bun-usockets/src/loop.c | 16 +++-- src/js/builtins/ProcessObjectInternals.ts | 1 + src/jsc/BunCPUProfiler.rs | 3 +- src/jsc/VirtualMachine.rs | 42 +++++++++++++- .../bindings/webcore/WorkerMessagingProxy.cpp | 9 +++ src/jsc/web_worker.rs | 58 +++++++++++++++---- src/runtime/dispatch_js2native.rs | 7 ++- src/uws_sys/InternalLoopData.rs | 3 + test/cli/run/cpu-prof.test.ts | 28 +++++++++ test/js/node/perf_hooks/perf_hooks.test.ts | 35 +++++++++++ test/js/node/process/process.test.js | 4 ++ test/js/node/tls/test-use-system-ca.test.ts | 52 +++++++++++++++-- 15 files changed, 241 insertions(+), 33 deletions(-) diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index bca10c38532e..b3857df70346 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -27,6 +27,9 @@ extern "C" void BUN__warn__extra_ca_load_failed(const char* filename, const char // External variables from the CLI arguments extern "C" bool Bun__Node__UseSystemCA; extern "C" bool Bun__Node__NoUseSystemCA; +// BunCAStore discriminant (Arguments.rs): 1 == --use-openssl-ca. +extern "C" uint8_t Bun__Node__CAStore; +static const uint8_t BUN_CA_STORE_OPENSSL = 1; // The process-wide default: --no-use-system-ca beats everything, then --use-system-ca, then // NODE_USE_SYSTEM_CA=1. A thread (node: Environment) started with its own flag overrides this for @@ -230,7 +233,7 @@ extern "C" X509_STORE *us_get_default_ca_store(int use_system_ca) { // calls set_default_paths under --use-openssl-ca and otherwise adds the // system roots only when use_system_ca is set. // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_context.cc#L1099-L1109 - if (use_system_ca && !X509_STORE_set_default_paths(store)) { + if ((use_system_ca || Bun__Node__CAStore == BUN_CA_STORE_OPENSSL) && !X509_STORE_set_default_paths(store)) { X509_STORE_free(store); return NULL; } diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 441f8af9b89d..5a903303adb0 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -538,12 +538,15 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout #endif if (will_idle_inside_event_loop) { - /* Clock read first so zero+add are adjacent seq_cst ops: a reader between them under-counts - * (bounded by one park) rather than double-counts. seq_cst — release would not order the add - * before the store on ARM. */ + /* us_loop_idle_ns (another thread) retries while idle_seq is odd or changed underneath it, so + * it never observes the entry cleared without the park added (a non-monotonic sample). */ + __atomic_add_fetch(&loop->data.idle_seq, 1, __ATOMIC_SEQ_CST); + /* Clock read inside the odd window: a reader's own clock read (taken before it validated an + * even seq) is then never later than the park length we record, so samples stay monotonic. */ uint64_t now = us_internal_monotonic_ns(); __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_SEQ_CST); __atomic_add_fetch(&loop->data.idle_ns, now - idle_start_ns, __ATOMIC_SEQ_CST); + __atomic_add_fetch(&loop->data.idle_seq, 1, __ATOMIC_SEQ_CST); } /* Before anything can allocate again. */ diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 2d3863ac134d..b79a6e0f8daa 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -83,6 +83,8 @@ struct us_internal_loop_data_t { * is us_loop_t's first member, so a field here shifts num_polls. */ unsigned long long idle_ns; unsigned long long idle_entry_ns; + /* Seqlock over the park-exit update of the two fields above (odd while in progress). */ + unsigned long long idle_seq; #endif struct us_socket_group_t *iterator; char *recv_buf; diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index ab09647849c3..03f07263456c 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -123,13 +123,17 @@ uint64_t us_loop_idle_ns(struct us_loop_t *loop) { #ifdef LIBUS_USE_LIBUV return uv_metrics_idle_time(loop->uv_loop); #else - uint64_t idle = __atomic_load_n(&loop->data.idle_ns, __ATOMIC_SEQ_CST); - uint64_t entry = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_SEQ_CST); - if (entry > 0) { - uint64_t now = us_internal_monotonic_ns(); - if (now > entry) - idle += now - entry; + uint64_t idle, entry, now; + for (;;) { + uint64_t seq = __atomic_load_n(&loop->data.idle_seq, __ATOMIC_SEQ_CST); + if (seq & 1) continue; + idle = __atomic_load_n(&loop->data.idle_ns, __ATOMIC_SEQ_CST); + entry = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_SEQ_CST); + now = entry > 0 ? us_internal_monotonic_ns() : 0; + if (__atomic_load_n(&loop->data.idle_seq, __ATOMIC_SEQ_CST) == seq) break; } + if (entry > 0 && now > entry) + idle += now - entry; return idle; #endif } diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 84f559291595..4e42c41ccaa4 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -801,6 +801,7 @@ export function buildAllowedNodeEnvironmentFlags() { "--max-http-header-size", "--no-addons", "--no-deprecation", + "--no-use-system-ca", "--no-warnings", "--pending-deprecation", "--perf-basic-prof", diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index bb85e1786a22..0d47e1d2b000 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -24,8 +24,7 @@ pub struct CPUProfilerConfig { pub md_format: bool, pub json_format: bool, pub interval: u32, - /// 0 for the main thread; a worker's execution context id otherwise. Only - /// used to keep concurrently-written default filenames distinct. + /// `worker.threadId` (0 on the main thread): the tid segment of node's default profile names. pub thread_id: u32, } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 45a5fed1294c..ebb36781b3c9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -293,8 +293,9 @@ pub struct VirtualMachine { pub argv: Vec>, pub origin_timer: std::time::Instant, - /// When THIS thread's loop started, for performance.eventLoopUtilization(). - pub loop_start: std::time::Instant, + /// Nanoseconds after `origin_timer` at which THIS thread's loop first polled; 0 until then, which + /// eventLoopUtilization() reports as node's "loop has not begun" zeros. Read cross-thread. + pub loop_start_ns: core::sync::atomic::AtomicU64, pub(crate) origin_timestamp: u64, /// For fake timers: override performance.now() with a specific value (in nanoseconds). pub overridden_performance_now: Option, @@ -2477,7 +2478,7 @@ impl VirtualMachine { addr_of_mut!((*vm).pending_internal_promise_reported_at).write(u32::MAX); addr_of_mut!((*vm).on_unhandled_rejection) .write(VirtualMachine::default_on_unhandled_rejection); - addr_of_mut!((*vm).loop_start).write(std::time::Instant::now()); + addr_of_mut!((*vm).loop_start_ns).write(core::sync::atomic::AtomicU64::new(0)); let (origin_timer, origin_timestamp) = process_origin(); addr_of_mut!((*vm).origin_timer).write(origin_timer); addr_of_mut!((*vm).origin_timestamp).write(origin_timestamp); @@ -2650,10 +2651,44 @@ impl VirtualMachine { self.event_loop_mut().wait_for_promise(promise) } + /// This thread's loop has begun: the main thread stamps it on its first poll (node's uv_run, after + /// the entry point's synchronous evaluation); a worker stamps it before its script, whose + /// bootstrap already runs inside the loop. + #[inline] + pub fn mark_loop_started(&self) { + use core::sync::atomic::Ordering; + if self.loop_start_ns.load(Ordering::Relaxed) == 0 { + let ns = (self.origin_timer.elapsed().as_nanos() as u64).max(1); + let _ = + self.loop_start_ns + .compare_exchange(0, ns, Ordering::Release, Ordering::Relaxed); + } + } + + /// Milliseconds since this thread's loop began polling; `None` before that. + pub fn loop_elapsed_ms(&self) -> Option { + Self::loop_elapsed_ms_from(&self.loop_start_ns, self.origin_timer) + } + + /// The same computation from the two fields alone, for a reader on another thread that must not + /// form a `&VirtualMachine` (see `WebWorker__getELU`). + pub fn loop_elapsed_ms_from( + loop_start_ns: &core::sync::atomic::AtomicU64, + origin_timer: std::time::Instant, + ) -> Option { + let start = loop_start_ns.load(core::sync::atomic::Ordering::Acquire); + if start == 0 { + return None; + } + let now = origin_timer.elapsed().as_nanos() as u64; + Some(now.saturating_sub(start) as f64 / 1_000_000.0) + } + /// `eventLoop().autoTick()` — dispatched through the runtime hook /// (needs `Timer::All` for the poll timeout). #[inline] pub fn auto_tick(&mut self) { + self.mark_loop_started(); if let Some(hooks) = runtime_hooks() { // SAFETY: hook contract — `self` is the live per-thread VM. unsafe { (hooks.auto_tick)(self) }; @@ -2671,6 +2706,7 @@ impl VirtualMachine { /// `on_before_exit` / `bun_main` still make forward progress. #[inline] pub fn auto_tick_active(&mut self) { + self.mark_loop_started(); if let Some(hooks) = runtime_hooks() { // SAFETY: `self` is the live per-thread VM (hook contract). unsafe { (hooks.auto_tick_active)(self) }; diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index 3f3b7cdf4b7a..a431075100c9 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -66,6 +66,8 @@ void* WebWorker__create( bool defaultExecArgv, StringImpl** execArgvPtr, size_t execArgvLen, + // NODE_USE_SYSTEM_CA as seen by the worker's own `env` option: 1 / 0, or -1 when it inherits the env. + int8_t envUseSystemCa, BunString* preloadModulesPtr, size_t preloadModulesLen); // Raise a TerminationException in the worker VM at its next safepoint and wake its loop. Any thread. @@ -140,6 +142,12 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr }) .value_or(std::span {}); + int8_t envUseSystemCa = -1; + if (m_options.env) { + auto it = m_options.env->find("NODE_USE_SYSTEM_CA"_s); + envUseSystemCa = it != m_options.env->end() && it->value == "1"_s ? 1 : 0; + } + // The thread holds a ref on the proxy until releaseWorkerThread(). ref(); BunString errorMessage = BunStringEmpty; @@ -159,6 +167,7 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr !m_options.execArgv.has_value(), execArgv.data(), execArgv.size(), + envUseSystemCa, preloadModules.begin(), preloadModules.size()); m_options.preloadModules.clear(); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index d352eb6c14f5..e8b102a7a468 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -70,6 +70,8 @@ pub struct WebWorker { exec_argv_ptr: *const WTFStringImpl, exec_argv_len: usize, inherit_exec_argv: bool, + /// `NODE_USE_SYSTEM_CA` from the worker's own `env` option (1 / 0), or -1 when it inherits the env. + env_use_system_ca: i8, unresolved_specifier: Box<[u8]>, preloads: Vec>, name: bun_core::ZBox, @@ -282,6 +284,7 @@ impl WebWorker { inherit_exec_argv: bool, exec_argv_ptr: *const WTFStringImpl, exec_argv_len: usize, + env_use_system_ca: i8, preload_modules_ptr: *const BunString, preload_modules_len: usize, ) -> *mut WebWorker { @@ -349,6 +352,7 @@ impl WebWorker { exec_argv_ptr, exec_argv_len, inherit_exec_argv, + env_use_system_ca, unresolved_specifier: spec_slice.slice().to_vec().into_boxed_slice(), preloads, name: if name_str.is_empty() { @@ -526,13 +530,25 @@ impl WebWorker { this.vm_lock.lock(); let vm_ptr = this.vm_ptr(); let loop_ptr = this.elu_loop.get(); - let live = !vm_ptr.is_null() && !loop_ptr.is_null(); + let mut live = !vm_ptr.is_null() && !loop_ptr.is_null(); if live { - // SAFETY: both published under `vm_lock`, held here; the idle counter is atomic and - // `loop_start` was fixed before the publish. Raw reads only: the worker thread owns `&mut`. + // SAFETY: both published under `vm_lock`, held here; the idle counter and loop start are + // atomic and `origin_timer` was fixed before the publish. Raw reads only: the worker + // thread owns `&mut`. unsafe { - *out_idle_ms = bun_uws::us_loop_idle_ns(loop_ptr) as f64 / 1_000_000.0; - *out_elapsed_ms = (*vm_ptr).loop_start.elapsed().as_secs_f64() * 1000.0; + // Idle before elapsed, so the derived active (elapsed - idle) never dips negative. + let idle_ms = bun_uws::us_loop_idle_ns(loop_ptr) as f64 / 1_000_000.0; + let elapsed = VirtualMachine::loop_elapsed_ms_from( + &*(&raw const (*vm_ptr).loop_start_ns), + *(&raw const (*vm_ptr).origin_timer), + ); + match elapsed { + Some(elapsed_ms) => { + *out_idle_ms = idle_ms; + *out_elapsed_ms = elapsed_ms; + } + None => live = false, + } } } this.vm_lock.unlock(); @@ -562,6 +578,11 @@ impl WebWorker { self.execution_context_id } + /// The `worker.threadId` node exposes (context ids start at 1 on the main thread). + pub(crate) fn thread_id(&self) -> u32 { + self.execution_context_id.saturating_sub(1) + } + /// The C++ `WorkerMessagingProxy`, handed to `Zig__GlobalObject__create` so /// the worker's global is born knowing its options (env, argv, workerData). #[inline] @@ -688,6 +709,13 @@ impl WebWorker { // Ensure map entries point at the exact bytes we hold refs on. temp_proxy_slots.sync_into(&mut map); + // node resolves a thread's CA option from its execArgv, else from that thread's own env; the + // same source `tls.getCACertificates("default")` reports from, so both halves agree. + let env_use_system_ca = match self.env_use_system_ca { + -1 => map.get(b"NODE_USE_SYSTEM_CA") == Some(b"1".as_slice()), + v => v == 1, + }; + // `heap::alloc`'d and stashed on `self` so `shutdown()` step 5 reclaims // it on every path — including the early-terminate checkpoint below, // which calls `shutdown()` before the VM exists. @@ -712,11 +740,14 @@ impl WebWorker { env_loader: NonNull::new(loader_ptr), store_fd: self.store_fd, graph: parent.standalone_module_graph, - use_system_ca: if own_exec_argv.is_some() { - exec_argv.use_system_ca - } else { - parent.use_system_ca - }, + use_system_ca: Some( + if own_exec_argv.is_some() { + exec_argv.use_system_ca + } else { + parent.use_system_ca + } + .unwrap_or(env_use_system_ca), + ), ..Default::default() }, )?; @@ -755,12 +786,12 @@ impl WebWorker { md_format: exec_argv.cpu_prof_md, json_format: exec_argv.cpu_prof, interval: exec_argv.cpu_prof_interval.unwrap_or(defaults.interval), - thread_id: self.execution_context_id, + thread_id: self.thread_id(), }) } else if own_exec_argv.is_none() { self.parent_cpu_profiler_config.as_ref().map(|c| { crate::bun_cpu_profiler::CPUProfilerConfig { - thread_id: self.execution_context_id, + thread_id: self.thread_id(), ..c.clone() } }) @@ -918,6 +949,9 @@ impl WebWorker { // standalone module graph, or `self.unresolved_specifier` — all of // which outlive the worker VM. `vm.main` stores it as a raw BACKREF // (see `VirtualMachine::set_main`); no lifetime extension needed. + // A worker's script runs inside its already-running loop (node's worker bootstrap is a loop + // iteration), so its ELU counts from here; the main thread's counts from its first poll. + vm.mark_loop_started(); let promise = match vm.as_mut().load_entry_point_for_web_worker(path) { Ok(p) => p, Err(_) => { diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 8562c8f8b7a1..8a046d314510 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -79,15 +79,18 @@ pub(crate) fn bun_get_use_system_ca( /// `[elapsedSinceLoopStartMs, idleMs]` for THIS thread's loop — the two numbers /// performance.eventLoopUtilization() is defined in terms of (node derives -/// active as now - loopStart - idle). +/// active as now - loopStart - idle) — or `null` before the loop has begun. pub(crate) fn bun_get_loop_elu(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { let vm = bun_jsc::virtual_machine::VirtualMachine::get(); // SAFETY: the VM owns this loop (installed by `ensure_waker` before any JS ran; `usockets_loop` // panics rather than return null) and this runs on its thread. Raw *mut, no &Loop — a // &mut PosixLoop is live above us via tick_with_timeout for the whole tick. + // Idle before elapsed, so the derived active (elapsed - idle) never dips negative. let idle_ms = unsafe { bun_uws::us_loop_idle_ns((*vm.event_loop).usockets_loop()) } as f64 / 1_000_000.0; - let elapsed_ms = vm.loop_start.elapsed().as_secs_f64() * 1000.0; + let Some(elapsed_ms) = vm.loop_elapsed_ms() else { + return Ok(JSValue::NULL); + }; let arr = JSValue::create_empty_array(global, 2)?; arr.put_index(global, 0, JSValue::js_number(elapsed_ms))?; arr.put_index(global, 1, JSValue::js_number(idle_ms))?; diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index ccd28bb8a445..c58d9a380ab4 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -46,6 +46,9 @@ pub struct InternalLoopData { /// loop_data.h — see the layout warning on `idle_ns`. #[cfg(not(windows))] pub idle_entry_ns: u64, + /// Seqlock guarding the park-exit update of the two fields above; mirrors loop_data.h. + #[cfg(not(windows))] + pub idle_seq: u64, pub iterator: *mut SocketGroup, pub recv_buf: *mut u8, pub send_buf: *mut u8, diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 528ee6d4575a..16c39627c32d 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -151,6 +151,34 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); + // With default names each thread gets its own file, and the tid segment is worker.threadId + // (CPU..