-
Notifications
You must be signed in to change notification settings - Fork 5k
node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 bfbb9be
Fix per-worker preload leak, stale hub ports, and source-port check o…
cirospaciari 1bfddb4
worker_threads: real stdio backpressure via parent acks and writev ba…
cirospaciari a76406f
MessagePort/BunString: fix teardown dispatch and cross-thread races
cirospaciari 5744fed
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari 0196284
worker_threads: discharge in-flight stdio write on stream destroy
cirospaciari aca44f2
test: deadline-bound the heap-snapshot GC stress so slow lanes don't …
cirospaciari 867d1a0
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari ede3abd
Merge branch 'main' into ciro/worker-threads-compat
robobun a95d1bc
Merge branch 'main' into ciro/worker-threads-compat
cirospaciari 6dd852d
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari 3c00335
test: drop test-worker-http2-stream-terminate (linux ~LocalAllocator …
cirospaciari e6d37ea
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari 1fc1252
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari 58c93a3
Address review comments: lint fixes and native SHARE_ENV detection
cirospaciari 5db8d58
event_loop: free queued CppTasks in deinit() instead of requeuing them
cirospaciari 87550e6
napi: tolerate a null current handle scope in NapiHandleScope__append
cirospaciari 225b24d
napi: always defer non-EXPERIMENTAL finalizers during GC, including t…
cirospaciari dba6796
Address review: drain CppTasks before JSC teardown; pack FileHandles …
cirospaciari 9864280
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari 4b2ee98
Address review nits: drop stale comment clause; use module-scope wt.S…
cirospaciari bd7632b
Worker: flip m_state to Running before the entryEvaluated hook
cirospaciari f172ce2
Address review: drop racy isOnline() gates; cover packJSTransferables…
cirospaciari aa1d414
JSWorker: actually drop the isOnline() gate from getHeapStatisticsBody
cirospaciari ee2d16a
Worker: reject Pending-state introspection calls when the worker neve…
cirospaciari 5ae7d32
[autofix.ci] apply automated fixes
autofix-ci[bot] ca43fda
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari c5c6e2c
worker_threads: use JSC private names for the markAsUncloneable/markA…
robobun 8b5ddd2
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
robobun e9fe0be
worker_threads: make SHARE_ENV a per-tree env store, not a process-wi…
cirospaciari f45d840
worker_threads: preserve env key case when enumerating a SHARE_ENV store
cirospaciari 7ef3014
worker_threads: unsettled top-level await must not clobber process.ex…
cirospaciari 8cc6410
worker_threads: address review findings (Tier 1-3)
alii 70c0f84
Merge remote-tracking branch 'origin/main' into ciro/worker-threads-c…
cirospaciari 3bcd327
structuredClone: ToString a non-string Error .message instead of drop…
cirospaciari 728b715
worker_threads: fix debugPort default, options.name gating, close(cb)…
alii ed08592
ErrorCode: realign the Rust mirror after ERR_WORKER_PATH was inserted
cirospaciari 8623af5
MessagePort: make the deferred close task safe against teardown and GC
cirospaciari d261f97
worker_threads: drop a fired once() listener from the MessagePort reg…
cirospaciari 611a6a6
structuredClone: cover the throwing .stack getter and a custom prepar…
cirospaciari f9e8ebc
worker_threads: route integer-like SHARE_ENV keys through the shared …
cirospaciari 168eba7
MessagePort: make m_closeEventPending atomic
cirospaciari 6fe4275
MessagePort: ref()/onmessage after the peer closes must not pin the e…
cirospaciari 97022bb
worker_threads: don't let the SHARE_ENV store shadow a defined accessor
cirospaciari 62065e0
MessagePort: don't treat a collected peer as a closed peer, and keep …
cirospaciari 078388f
worker_threads: drop a useless spread that fails the src/js lint gate
cirospaciari 6dc2539
structuredClone: markAsUncloneable must not block transferring a Mess…
cirospaciari be9f1ae
worker_threads: make the listener registry tamper-proof
cirospaciari 19b3f64
test: don't found a SHARE_ENV tree inside the test runner
cirospaciari f632752
worker_threads: key listener wrappers per (port, event, listener)
cirospaciari 248ebe1
MessagePort: a collected peer is not a closed peer, and close() only …
cirospaciari 3eccc73
MessagePort: report every invalid transfer-list entry the way node does
cirospaciari 347bac1
worker_threads: back the listener registry with SafeMap
cirospaciari 6cec18a
MessagePort: always notify the peer when a side closes
cirospaciari 0edbc1e
worker_threads: harden the cross-thread routing table against a tampe…
cirospaciari bf974cf
test: drop the stale mock.fn shim from test-worker-arraybuffer-zerofill
cirospaciari e82fcf7
worker_threads: apply remaining second-pass review feedback
alii e890002
[autofix.ci] apply automated fixes
autofix-ci[bot] ec2f0d3
Merge remote-tracking branch 'origin/main' into pr31216-work
cirospaciari 1278faf
worker_threads: apply the Windows env write-through on every mutation…
cirospaciari a1f2dc4
worker_threads: gate the Windows env write-through on the store, not …
cirospaciari 2d04192
test: stop claiming a spawned child reads the OS environment
cirospaciari b6f8ae4
MessagePort: deliver queued messages before the peer's close event
cirospaciari 27ad045
verify skill: use `bun bd -e`, not the debug binary directly
cirospaciari 14c63f4
MessagePort: note why the close-listener keep-alive is intentional
cirospaciari 8fb1a07
MessagePort: preserve remaining inbox when a handler transfers the po…
alii 13a7f81
Merge branch 'main' into ciro/worker-threads-compat
cirospaciari File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| 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); | ||
| } | ||
|
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; | ||
|
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, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.