node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector

Merged
CI
23d ago
+6801-491

Compatibility — node worker_threads test suites (debug build vs bun 1.3.13, which passes 51/138 parallel + 2/5 sequential):

parallelsequentialcombined
worker_threads102/1382/5104/143 (72.7%)

Improves Node.js worker_threads compatibility across MessagePort, captured stdio, environment sharing, structured-clone transfer semantics, Worker construction and error handling, and ports the upstream Node worker tests these fix (plus a batch that already passed).

MessagePort

  • Move the EventEmitter helpers (on/off/once/emit/addListener/removeListener/…) off MessagePort.prototype onto an intermediate prototype so Object.getOwnPropertyNames(MessagePort.prototype) matches Node.
  • close([callback]): the callback runs asynchronously after the synchronous detach.
  • Dispatch the 'close' event natively, once (guarded), after delivering queued messages, to both the closed port and its entangled peer (including nested in-transit transferred ports); release the peer's event-loop ref.
  • Deliver messages already queued in a port's inbox when close() is called mid-dispatch.
  • moveMessagePortToContext throws ERR_CLOSED_MESSAGE_PORT for a closed port.
  • postMessage transfer-list errors match Node: source/peer port → DataCloneError "Transfer list contains source port"; already-detached port → "...already detached" (validated before any ArrayBuffer detach); a MessagePort in the message but not in the transfer list → DataCloneError "Object that needs transfer was found in message but not listed in transferList".
  • Node-compatible util.inspect output.
  • postMessage to a port's own entangled peer warns (not throws) and loses the channel, matching Node; every transfer-list port is still detached (transfer is atomic), and the source-port check is order-independent within the transfer list.

markAsUntransferable / markAsUncloneable / isMarkedAsUntransferable

  • Mark objects with non-enumerable registry-symbol markers, enforced in the structured-clone serializer: a marked-untransferable object in a transfer list throws DataCloneError before any detach; a marked-uncloneable object throws when cloned/posted (ArrayBuffers/views excepted, matching Node).

postMessageToThread

  • worker_threads.postMessageToThread(threadId, value[, transferList][, timeout]) and the process 'workerMessage' event (Node 22+). The main thread is a hub holding a control MessagePort to every thread; each worker keeps one port to the hub, so any thread can reach any other. Errors map to ERR_WORKER_MESSAGING_* (same-thread / failed / errored / timeout).
  • Fixed MessagePort.unref() to release the event-loop ref taken by a message listener (previously only the onmessage/ref() keepalive was released), so an always-listening control port no longer pins a worker's loop.

env: SHARE_ENV

  • A Worker created with env: SHARE_ENV shares a live, process-wide environment with the parent (write-through process.env over a lock-guarded store; reads/writes/deletes/enumeration). The normal snapshot path is unchanged; env: 42 still throws ERR_INVALID_ARG_TYPE.

Captured stdio

  • new Worker(f, { stdin/stdout/stderr: true }): worker.stdout/stderr are Readables fed by the worker's process.stdout/stderr (including console.log); worker.stdin is a Writable. Streams are ended when the worker exits; the stdin listener attaches lazily so an unread {stdin:true} worker doesn't stay alive.

Worker construction & lifecycle

  • Worker#threadName + threadName export; validate the filename like Node (ERR_WORKER_PATH / ERR_INVALID_URL_SCHEME); terminate() keeps the loop alive until it resolves; normalize options for new Worker(f, null); eval: false validates the path.

Worker introspection (Node 24)

  • worker.getHeapStatistics(), worker.cpuUsage([prevValue]), worker.startCpuProfile([options]) (returns a handle with an idempotent stop()), and worker.startHeapProfile([options]) — sampled from the worker thread across threads (mirroring getHeapSnapshot), with Node-matching synchronous option validation and ERR_WORKER_NOT_RUNNING once the worker has exited.

Top-level await

  • A worker whose entry module has an unsettled top-level await now exits with code 13 instead of hanging; a top-level await still waiting on real work is unaffected.

Errors

  • A worker entry/import parse failure surfaces a real SyntaxError to the parent's error event.

Worker process surface

  • On a worker thread process.abort/chdir (and setuid/seteuid/setgid/setegid/setgroups/initgroups off Windows) are disabled stubs that throw ERR_WORKER_UNSUPPORTED_OPERATION (with .disabled === true), process.umask(setMask) throws (the getter still works), debugPort defaults to 9229, and the main-only _startProfilerIdleNotifier/_stopProfilerIdleNotifier/_debugProcess/_debugEnd internals are removed — fixing process.abort() taking down the whole process from a worker. The IPC surface (send/disconnect/channel/connected) is disabled only when an IPC channel was inherited (NODE_CHANNEL_FD set), so if (process.send) still works in a normal worker.

Crash & teardown safety

  • Serializing an error whose stack getter throws (a throwing Error.prepareStackTrace) no longer crashes postMessage.
  • A worker that throws during a beforeExit handler exits cleanly with code 1 instead of SIGTRAP.
  • Terminating a worker mid-HTTP/2 no longer crashes the process: the HTTP/2 frame parser stops dispatching to JS once the worker is tearing down (no empty-value or pending-exception JS calls).

Worker entry resolution

  • A Worker pointed at a missing entry file reports Cannot find module '<path>' (code MODULE_NOT_FOUND), matching Node, instead of the native loader's ModuleNotFound message.

Tests

Ports the upstream Node worker tests for all of the above, plus a set that already passed. Existing Bun worker tests were updated where behavior is now Node-aligned (workers constructed from URL objects, not file:// strings; close delivers queued messages; parse errors are SyntaxError; eval:false validates the path).


no test proof · iteration 131 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts