Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
0ce409f
node:worker_threads: improve Node.js compatibility
cirospaciari Jun 4, 2026
bfbb9be
Fix per-worker preload leak, stale hub ports, and source-port check o…
cirospaciari Jun 4, 2026
1bfddb4
worker_threads: real stdio backpressure via parent acks and writev ba…
cirospaciari Jun 5, 2026
a76406f
MessagePort/BunString: fix teardown dispatch and cross-thread races
cirospaciari Jun 5, 2026
5744fed
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jun 5, 2026
0196284
worker_threads: discharge in-flight stdio write on stream destroy
cirospaciari Jun 5, 2026
aca44f2
test: deadline-bound the heap-snapshot GC stress so slow lanes don't …
cirospaciari Jun 8, 2026
867d1a0
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jun 10, 2026
ede3abd
Merge branch 'main' into ciro/worker-threads-compat
robobun Jun 10, 2026
a95d1bc
Merge branch 'main' into ciro/worker-threads-compat
cirospaciari Jun 17, 2026
6dd852d
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jun 18, 2026
3c00335
test: drop test-worker-http2-stream-terminate (linux ~LocalAllocator …
cirospaciari Jun 18, 2026
e6d37ea
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jun 22, 2026
1fc1252
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jun 22, 2026
58c93a3
Address review comments: lint fixes and native SHARE_ENV detection
cirospaciari Jun 22, 2026
5db8d58
event_loop: free queued CppTasks in deinit() instead of requeuing them
cirospaciari Jun 22, 2026
87550e6
napi: tolerate a null current handle scope in NapiHandleScope__append
cirospaciari Jun 22, 2026
225b24d
napi: always defer non-EXPERIMENTAL finalizers during GC, including t…
cirospaciari Jun 22, 2026
dba6796
Address review: drain CppTasks before JSC teardown; pack FileHandles …
cirospaciari Jun 22, 2026
9864280
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jun 22, 2026
4b2ee98
Address review nits: drop stale comment clause; use module-scope wt.S…
cirospaciari Jun 22, 2026
bd7632b
Worker: flip m_state to Running before the entryEvaluated hook
cirospaciari Jun 23, 2026
f172ce2
Address review: drop racy isOnline() gates; cover packJSTransferables…
cirospaciari Jun 24, 2026
aa1d414
JSWorker: actually drop the isOnline() gate from getHeapStatisticsBody
cirospaciari Jun 24, 2026
ee2d16a
Worker: reject Pending-state introspection calls when the worker neve…
cirospaciari Jun 24, 2026
5ae7d32
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 24, 2026
ca43fda
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jun 30, 2026
c5c6e2c
worker_threads: use JSC private names for the markAsUncloneable/markA…
robobun Jul 1, 2026
8b5ddd2
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
robobun Jul 1, 2026
e9fe0be
worker_threads: make SHARE_ENV a per-tree env store, not a process-wi…
cirospaciari Jul 8, 2026
f45d840
worker_threads: preserve env key case when enumerating a SHARE_ENV store
cirospaciari Jul 8, 2026
7ef3014
worker_threads: unsettled top-level await must not clobber process.ex…
cirospaciari Jul 8, 2026
8cc6410
worker_threads: address review findings (Tier 1-3)
alii Jul 8, 2026
70c0f84
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari Jul 8, 2026
3bcd327
structuredClone: ToString a non-string Error .message instead of drop…
cirospaciari Jul 8, 2026
728b715
worker_threads: fix debugPort default, options.name gating, close(cb)…
alii Jul 8, 2026
ed08592
ErrorCode: realign the Rust mirror after ERR_WORKER_PATH was inserted
cirospaciari Jul 8, 2026
8623af5
MessagePort: make the deferred close task safe against teardown and GC
cirospaciari Jul 8, 2026
d261f97
worker_threads: drop a fired once() listener from the MessagePort reg…
cirospaciari Jul 8, 2026
611a6a6
structuredClone: cover the throwing .stack getter and a custom prepar…
cirospaciari Jul 8, 2026
f9e8ebc
worker_threads: route integer-like SHARE_ENV keys through the shared …
cirospaciari Jul 9, 2026
168eba7
MessagePort: make m_closeEventPending atomic
cirospaciari Jul 9, 2026
6fe4275
MessagePort: ref()/onmessage after the peer closes must not pin the e…
cirospaciari Jul 9, 2026
97022bb
worker_threads: don't let the SHARE_ENV store shadow a defined accessor
cirospaciari Jul 9, 2026
62065e0
MessagePort: don't treat a collected peer as a closed peer, and keep …
cirospaciari Jul 9, 2026
078388f
worker_threads: drop a useless spread that fails the src/js lint gate
cirospaciari Jul 9, 2026
6dc2539
structuredClone: markAsUncloneable must not block transferring a Mess…
cirospaciari Jul 9, 2026
be9f1ae
worker_threads: make the listener registry tamper-proof
cirospaciari Jul 9, 2026
19b3f64
test: don't found a SHARE_ENV tree inside the test runner
cirospaciari Jul 9, 2026
f632752
worker_threads: key listener wrappers per (port, event, listener)
cirospaciari Jul 9, 2026
248ebe1
MessagePort: a collected peer is not a closed peer, and close() only …
cirospaciari Jul 9, 2026
3eccc73
MessagePort: report every invalid transfer-list entry the way node does
cirospaciari Jul 9, 2026
347bac1
worker_threads: back the listener registry with SafeMap
cirospaciari Jul 9, 2026
6cec18a
MessagePort: always notify the peer when a side closes
cirospaciari Jul 9, 2026
0edbc1e
worker_threads: harden the cross-thread routing table against a tampe…
cirospaciari Jul 9, 2026
bf974cf
test: drop the stale mock.fn shim from test-worker-arraybuffer-zerofill
cirospaciari Jul 9, 2026
e82fcf7
worker_threads: apply remaining second-pass review feedback
alii Jul 9, 2026
e890002
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 9, 2026
ec2f0d3
Merge remote-tracking branch 'origin/main' into pr31216-work
cirospaciari Jul 9, 2026
1278faf
worker_threads: apply the Windows env write-through on every mutation…
cirospaciari Jul 10, 2026
a1f2dc4
worker_threads: gate the Windows env write-through on the store, not …
cirospaciari Jul 10, 2026
2d04192
test: stop claiming a spawned child reads the OS environment
cirospaciari Jul 10, 2026
b6f8ae4
MessagePort: deliver queued messages before the peer's close event
cirospaciari Jul 10, 2026
27ad045
verify skill: use `bun bd -e`, not the debug binary directly
cirospaciari Jul 10, 2026
14c63f4
MessagePort: note why the close-listener keep-alive is intentional
cirospaciari Jul 10, 2026
8fb1a07
MessagePort: preserve remaining inbox when a handler transfers the po…
alii Jul 10, 2026
13a7f81
Merge branch 'main' into ciro/worker-threads-compat
cirospaciari Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: verify
description: Verify a Bun runtime change by driving the debug binary end-to-end.
---

# Verify a Bun runtime change

Build and drive the debug binary directly — never `bun test`, never import-and-call.

## Build

```sh
bun bd --version # builds ./build/debug/bun-debug and prints its version
```

## Drive

For any JS-visible change, run the debug binary with `-e` and observe stdout:

```sh
bun bd -e '<repro>' # builds, then runs; sets BUN_DEBUG_QUIET_LOGS for you
```

For worker/subprocess-shaped changes, spawn a subprocess (still `-e`) so worker teardown / event-loop-idle paths are exercised. Cross-check against `node -e '<same repro>'` for Node-compat changes.

## Gotchas

- `BUN_DEBUG_QUIET_LOGS=1` suppresses debug-build log spam.
- MessagePort's `.on/.off` are added by requiring `worker_threads` — plain `new MessageChannel()` ports only have `addEventListener` until then.
- The debug+asan build is 10-100× slower than release; large-allocation stress tests can time out locally while passing in CI.
16 changes: 14 additions & 2 deletions scripts/build/rust-lto-fix-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function isBitcode(path: string): boolean {
* list), so install it on demand.
*/
function ensureLlvmTools(llvmBin: string): void {
const needed = ["llvm-link", "opt", "llvm-as"];
const needed = ["llvm-link", "opt", "llvm-as", "llvm-dis"];
const missing = () => needed.filter(t => !existsSync(join(llvmBin, t)));
if (missing().length === 0) return;

Expand Down Expand Up @@ -134,9 +134,21 @@ function main(): void {
// The `ThinLTO=0` module flag is the bitcode writer's "this is a regular
// LTO module" marker — without it `--module-summary` writes a ThinLTO
// summary block and lld would send the module to a ThinLTO backend.
// Carry the module's target data layout on the stub too: without it the
// stub's empty layout mismatches the real module and llvm-link prints a
// "Linking two modules of different data layouts" warning on every link.
// llvm-dis streams the .ll header first, so a bounded read suffices.
const dis = spawnSync(join(llvmBin, "llvm-dis"), ["-o", "-", bitcode[0]], {
encoding: "utf8",
maxBuffer: 256 * 1024,
});
const dataLayout = /^target datalayout = "[^"]*"/m.exec(dis.stdout || "")?.[0];
const stubLl = join(tmp, "regular-lto-flag-stub.ll");
const stubBc = join(tmp, "regular-lto-flag-stub.bc");
writeFileSync(stubLl, '!llvm.module.flags = !{!0}\n!0 = !{i32 1, !"ThinLTO", i32 0}\n');
writeFileSync(
stubLl,
`${dataLayout ? `${dataLayout}\n` : ""}!llvm.module.flags = !{!0}\n!0 = !{i32 1, !"ThinLTO", i32 0}\n`,
);
run(join(llvmBin, "llvm-as"), [stubLl, "-o", stubBc]);

const merged = join(tmp, "merged.bc");
Expand Down
2 changes: 2 additions & 0 deletions src/js/builtins/BunBuiltinNames.h
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ using namespace JSC;
macro(internalRequire) \
macro(isAbortSignal) \
macro(isAbsolute) \
macro(isUncloneable) \
macro(isUntransferable) \
macro(join) \
macro(json) \
macro(key) \
Expand Down
297 changes: 297 additions & 0 deletions src/js/internal/worker/messaging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
// worker_threads.postMessageToThread (Node 22+), ported from node's
// lib/internal/worker/messaging.js. The main thread is the hub: every thread keeps a
// control MessagePort to it, and it routes each message to the destination's port.
// Delivery results are reported back through a SharedArrayBuffer + Atomics.
//
// Differences from node: thread info comes from initThreadInfo (Bun assigns threadId
// differently); createMainThreadPort is split into createMessagingChannel (before
// `new Worker`) + registerMainThreadPort (after the threadId exists); and the
// `workerMessage` listeners are invoked directly because Bun's process.emit cannot
// report no-listeners or a throwing listener (see receiveMessageFromWorker).

const { validateNumber } = require("internal/validators");
const { SafeMap } = require("internal/primordials");

const messageTypes = {
REGISTER_MAIN_THREAD_PORT: "registerMainThreadPort",
UNREGISTER_MAIN_THREAD_PORT: "unregisterMainThreadPort",
SEND_MESSAGE_TO_WORKER: "sendMessageToWorker",
RECEIVE_MESSAGE_FROM_WORKER: "receiveMessageFromWorker",
};

// Set once via initThreadInfo() when worker_threads.ts loads.
let currentThreadId = 0;
let isMainThread = true;

// Only populated on the main thread (the hub); always empty elsewhere.
// SafeMap: its prototype is a frozen null-proto snapshot taken at bootstrap, so the
// cross-thread routing table can't be broken by user code replacing Map.prototype.
const threadsPorts = new SafeMap<number, any>();

// Only populated on child threads; always undefined on the main thread.
let mainThreadPort: any;

// SharedArrayBuffer must always be Int32, so it's * 4.
// One slot for the operation status (performing / performed) and one for the result.
const WORKER_MESSAGING_SHARED_DATA = 2 * 4;
const WORKER_MESSAGING_STATUS_INDEX = 0;
const WORKER_MESSAGING_RESULT_INDEX = 1;

// Response codes
const WORKER_MESSAGING_RESULT_DELIVERED = 0;
const WORKER_MESSAGING_RESULT_NO_LISTENERS = 1;
const WORKER_MESSAGING_RESULT_LISTENER_ERROR = 2;

function initThreadInfo(threadId: number, mainThread: boolean) {
currentThreadId = threadId;
isMainThread = mainThread;
}

// This event handler is always executed on the main thread only.
function handleMessageFromThread(message) {
switch (message.type) {
case messageTypes.REGISTER_MAIN_THREAD_PORT: {
const { threadId, port } = message;

// Register the port.
threadsPorts.set(threadId, port);

// Handle messages on this port. When another thread wants to register a
// child, this takes care of relaying it, so any thread links to the main one.
port.on("message", handleMessageFromThread);

// Self-clean when the peer dies without an UNREGISTER (e.g. a grandchild
// whose intermediate parent was terminated, so that parent's Worker#onClose
// never ran); otherwise the stale entry lingers in the hub forever.
port.on("close", () => {
if (threadsPorts.get(threadId) === port) threadsPorts.delete(threadId);
});

// Never block the thread on this port.
port.unref();
break;
Comment thread
claude[bot] marked this conversation as resolved.
}
case messageTypes.UNREGISTER_MAIN_THREAD_PORT: {
const port = threadsPorts.get(message.threadId);
if (port) {
port.close();
threadsPorts.delete(message.threadId);
}
break;
}
case messageTypes.SEND_MESSAGE_TO_WORKER: {
const { source, destination, value, transferList, memory } = message;
sendMessageToWorker(source, destination, value, transferList, memory);
break;
}
}
}

function handleMessageFromMainThread(message) {
switch (message.type) {
case messageTypes.RECEIVE_MESSAGE_FROM_WORKER:
receiveMessageFromWorker(message.source, message.value, message.memory);
break;
}
}

function sendMessageToWorker(source, destination, value, transferList, memory) {
// We are on the main thread, we can directly process the message.
if (destination === 0) {
receiveMessageFromWorker(source, value, memory);
return;
}

// Find the port to the target thread.
const port = threadsPorts.get(destination);

if (!port) {
const status = new Int32Array(memory);
Atomics.store(status, WORKER_MESSAGING_RESULT_INDEX, WORKER_MESSAGING_RESULT_NO_LISTENERS);
Atomics.store(status, WORKER_MESSAGING_STATUS_INDEX, 1);
Atomics.notify(status, WORKER_MESSAGING_STATUS_INDEX, 1);
return;
}

port.postMessage(
{
type: messageTypes.RECEIVE_MESSAGE_FROM_WORKER,
source,
// destination omitted: the receiver routes by port and never reads it.
value,
memory,
},
transferList,
);
}

function receiveMessageFromWorker(source, value, memory) {
let response = WORKER_MESSAGING_RESULT_NO_LISTENERS;

// Don't use process.emit("workerMessage", ...): Bun's native emit routes a
// throwing listener to reportUnhandledError instead of rethrowing, so
// LISTENER_ERROR can't be detected. Invoke listeners directly.
//
// Known limitation: process.once('workerMessage', fn) listeners are not
// removed here — the native process EventEmitter tracks isOnce internally
// (fireEventListeners handles removal) with no JS-side onceWrapper to detect.
// Fixing this needs the native emit to rethrow (a broader change).
const listeners = process.listeners("workerMessage");
const listenerCount = listeners.length;
if (listenerCount > 0) {
try {
for (let i = 0; i < listenerCount; i++) {
listeners[i].$call(process, value, source);
}
Comment thread
cirospaciari marked this conversation as resolved.
response = WORKER_MESSAGING_RESULT_DELIVERED;
} catch {
response = WORKER_MESSAGING_RESULT_LISTENER_ERROR;
}
}

// Populate the result.
const status = new Int32Array(memory);
Atomics.store(status, WORKER_MESSAGING_RESULT_INDEX, response);
Atomics.store(status, WORKER_MESSAGING_STATUS_INDEX, 1);
Atomics.notify(status, WORKER_MESSAGING_STATUS_INDEX, 1);
}

// Bun half of Node's createMainThreadPort: create the channel linking a (future)
// thread to the main thread. Called before `new Worker`.
function createMessagingChannel() {
const { port1, port2 } = new globalThis.MessageChannel();
// port1 (portToMain) stays with the hub; port2 (portToWorker) is transferred to
// the new thread where it becomes that thread's mainThreadPort.
return { portToMain: port1, portToWorker: port2 };
}

// Bun half of Node's createMainThreadPort: register the hub-side port now that the
// child's threadId is known. Called after `new Worker`.
function registerMainThreadPort(threadId: number, portToMain: any) {
const registrationMessage = {
type: messageTypes.REGISTER_MAIN_THREAD_PORT,
threadId,
port: portToMain,
};

if (isMainThread) {
handleMessageFromThread(registrationMessage);
} else if (mainThreadPort) {
mainThreadPort.postMessage(registrationMessage, [portToMain]);
}
// Not connected to the main-thread hub (e.g. a raw Web Worker): the child still works,
// it's just unreachable via postMessageToThread.
}

function destroyMainThreadPort(threadId: number) {
const unregistrationMessage = {
type: messageTypes.UNREGISTER_MAIN_THREAD_PORT,
threadId,
};

if (isMainThread) {
handleMessageFromThread(unregistrationMessage);
} else if (mainThreadPort) {
mainThreadPort.postMessage(unregistrationMessage);
}
}

// Deliveries from the main-thread hub are deferred until the entry module has
// finished evaluating (the native side invokes the entryEvaluated hook right
// before dispatching 'online'), matching node's bootstrap -> synchronous CJS
// main ordering: a routed message must not observe "no listeners" while the
// entry that registers them is still loading.
let entryEvaluated = false;
let pendingMainPortMessages: any[] | null = null;

function handleMessageFromMainThreadGated(message) {
if (!entryEvaluated) {
(pendingMainPortMessages ??= []).push(message);
return;
}
handleMessageFromMainThread(message);
}

function setupMainThreadPort(port: any, setEntryEvaluatedHook: (hook: () => void) => void) {
mainThreadPort = port;
mainThreadPort.on("message", handleMessageFromMainThreadGated);

// Stored on ZigGlobalObject (WriteBarrier), not on globalThis, so user code
// can't observe or clobber it. WebWorker__dispatchOnline calls it once.
setEntryEvaluatedHook(() => {
entryEvaluated = true;
const pending = pendingMainPortMessages;
pendingMainPortMessages = null;
// Indexed, not for-of: Array.prototype[Symbol.iterator] is user-overridable.
if (pending) for (let i = 0; i < pending.length; i++) handleMessageFromMainThread(pending[i]);
});

// Never block the process on this port.
mainThreadPort.unref();
}

async function postMessageToThread(threadId, value, transferList, timeout) {
if (typeof transferList === "number" && typeof timeout === "undefined") {
timeout = transferList;
transferList = [];
}

if (typeof transferList === "undefined") {
transferList = [];
}

if (typeof timeout !== "undefined") {
validateNumber(timeout, "timeout", 0);
}

if (threadId === currentThreadId) {
throw $ERR_WORKER_MESSAGING_SAME_THREAD("Cannot send a message to the same thread.");
}

const memory = new SharedArrayBuffer(WORKER_MESSAGING_SHARED_DATA);
const status = new Int32Array(memory);
const promise = Atomics.waitAsync(status, WORKER_MESSAGING_STATUS_INDEX, 0, timeout).value;
Comment thread
claude[bot] marked this conversation as resolved.

const message = {
type: messageTypes.SEND_MESSAGE_TO_WORKER,
source: currentThreadId,
destination: threadId,
value,
memory,
transferList,
};

if (isMainThread) {
handleMessageFromThread(message);
} else if (mainThreadPort) {
mainThreadPort.postMessage(message, transferList);
} else {
// This thread is not connected to the main-thread hub (e.g. created via the raw Web
// Worker API), so there is no route to the destination.
Atomics.store(status, WORKER_MESSAGING_RESULT_INDEX, WORKER_MESSAGING_RESULT_NO_LISTENERS);
Atomics.store(status, WORKER_MESSAGING_STATUS_INDEX, 1);
Atomics.notify(status, WORKER_MESSAGING_STATUS_INDEX, 1);
}

// Wait for the response.
const response = await promise;

if (response === "timed-out") {
throw $ERR_WORKER_MESSAGING_TIMEOUT("The operation timed out.");
} else if (status[WORKER_MESSAGING_RESULT_INDEX] === WORKER_MESSAGING_RESULT_NO_LISTENERS) {
throw $ERR_WORKER_MESSAGING_FAILED(
"The destination thread no longer exists or is not listening for `workerMessage` events.",
);
} else if (status[WORKER_MESSAGING_RESULT_INDEX] === WORKER_MESSAGING_RESULT_LISTENER_ERROR) {
throw $ERR_WORKER_MESSAGING_ERRORED("The destination thread threw an error while processing the message.");
}
}

export default {
initThreadInfo,
createMessagingChannel,
registerMainThreadPort,
destroyMainThreadPort,
setupMainThreadPort,
postMessageToThread,
};
23 changes: 23 additions & 0 deletions src/js/node/inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,29 @@ class Session extends EventEmitter {
case "Profiler.takePreciseCoverage":
return new Error("Coverage APIs are not supported");

case "NodeWorker.enable": {
// Minimal NodeWorker domain stub for test-worker-name only: a session
// connected from inside a worker reports itself. Main-thread child
// enumeration is NOT implemented — return an error there instead of
// silent success so callers know.
const wt = require("node:worker_threads");
if (wt.isMainThread) {
return new Error("Inspector method NodeWorker.enable is not supported on the main thread yet");
}
const title = `[worker ${wt.threadId}] ${wt.threadName}`;
const workerInfo = { workerId: String(wt.threadId), type: "worker", title };
queueMicrotask(() => {
this.emit("NodeWorker.attachedToWorker", {
params: { sessionId: `worker:${wt.threadId}`, workerInfo },
});
});
return {};
}

case "NodeWorker.disable":
case "NodeWorker.detach":
return {};

case "NodeTracing.start": {
if (!Bun.isMainThread) {
return {
Expand Down
Loading
Loading