From ea88a97718a18398a5dbe48e3f71dd0cf70bc1be Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 02:38:34 +0200 Subject: [PATCH 01/24] test: expand worker_threads parity coverage --- .../node-suite/worker_threads/README.md | 98 +++++++++++++++++++ .../broadcast-channel/close-and-clone.ts | 45 +++++++++ .../broadcast-channel/fanout-fifo.ts | 27 +++++ .../message-channel/broadcast-delivery.ts | 35 +++---- .../message-port/close-ordering.ts | 23 +++++ .../message-port/receive-fifo-validation.ts | 32 ++++++ .../worker_threads/message-port/ref-state.ts | 15 +++ .../message-port/start-and-listeners.ts | 20 ++++ .../structured-clone/arraybuffer-clone.ts | 23 +++++ .../structured-clone/arraybuffer-transfer.ts | 23 +++++ .../structured-clone/message-port-transfer.ts | 37 +++++++ .../structured-clone/transfer-validation.ts | 37 +++++++ .../transfer-markers/marker-semantics.ts | 35 +++++++ .../transfer-markers/uncloneable-post.ts | 41 ++++++++ .../untransferable-transfer.ts | 44 +++++++++ .../constructor-validation-worker.cjs | 1 + .../constructor-validation.ts | 53 ++++++++++ .../worker-lifecycle/data-and-environment.ts | 44 +++++++++ .../worker-lifecycle/data-worker.cjs | 25 +++++ .../worker-lifecycle/error-ordering.ts | 16 +++ .../worker-lifecycle/error-worker.cjs | 1 + .../worker-lifecycle/termination-ordering.ts | 29 ++++++ .../worker-lifecycle/termination-worker.cjs | 4 + test-parity/node_suite_baseline.json | 14 +-- 24 files changed, 693 insertions(+), 29 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/close-and-clone.ts create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/fanout-fifo.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/close-ordering.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/receive-fifo-validation.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/ref-state.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/start-and-listeners.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/arraybuffer-clone.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/arraybuffer-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/message-port-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/transfer-validation.ts create mode 100644 test-parity/node-suite/worker_threads/transfer-markers/marker-semantics.ts create mode 100644 test-parity/node-suite/worker_threads/transfer-markers/uncloneable-post.ts create mode 100644 test-parity/node-suite/worker_threads/transfer-markers/untransferable-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/data-and-environment.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/data-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/error-ordering.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/error-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/termination-ordering.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/termination-worker.cjs diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 402e8573a9..b1e7c13c38 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -2,3 +2,101 @@ Focused compatibility cases for `node:worker_threads` APIs that Perry wires through its Node-compat stdlib shim. + +## Upstream selection + +The expansion was compared against these primary-source snapshots: + +- Node.js [`34c28d5`](https://github.com/nodejs/node/tree/34c28d5a69f4f00cd599adcbe57834435d3a683b/test/parallel), especially the `test-worker-message-port-*`, `test-worker-message-channel*`, `test-worker-message-mark-as-uncloneable`, `test-worker-invalid-workerdata`, `test-worker-environmentdata`, `test-worker-event`, and `test-worker-broadcastchannel` cases. +- Deno [`f8a17c8`](https://github.com/denoland/deno/tree/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/specs/node/worker_threads) and its [`worker_threads_test.ts`](https://github.com/denoland/deno/blob/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/unit_node/worker_threads_test.ts) selection, including port transfer, listener removal/deduplication, `unref`, auto-exit, and broadcast coverage. +- Bun [`0bffb47`](https://github.com/oven-sh/bun/tree/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads), especially [`worker_threads.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker_threads.test.ts) and [`worker-transfer-list.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker-transfer-list.test.ts). + +Node `26.5.0`, pinned by this repository, remains the executable differential +oracle. The upstream snapshots above guide case selection rather than changing +that oracle. + +## Coverage added + +- `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, + transfer detachment, MessagePort ownership transfer, and transfer-list + validation/rollback. +- `message-port/`: synchronous FIFO receives, invalid-port validation, + explicit `start()`, listener deduplication, close ordering, queued-message + delivery, and `ref`/`unref`/`hasRef` state. +- `worker-lifecycle/`: constructor transfer validation, structured `workerData`, + environment-data snapshots, `online`/`message`/`error`/`exit` ordering, and + deterministic termination settlement. +- `transfer-markers/`: marker return/value semantics, inheritance boundaries, + clone rejection, transfer rejection, and retained ownership after rejection. +- `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, + typed-array cloning, untransferable-value rejection, and closed-channel + validation. + +Every asynchronous fixture uses a message, close, exit, or promise-settlement +barrier. No added fixture uses a sleep as a completion condition. + +## Stopping judgment + +The remaining upstream cases were not copied because they are redundant with +the cases above or belong to a separate slow/risky runtime feature: + +- Resource-limit enforcement, stdio timing/backpressure, heap snapshots and + CPU/heap profiling are resource- and platform-sensitive. The pre-existing + `worker-file/options-diagnostics.ts` keeps surface coverage only. +- Eval/data-URL/ESM loaders, preload/`execArgv`, signals, inspector integration, + process exit variants, and source maps primarily exercise loader, CLI, or + process subsystems rather than the worker messaging contract. +- Nested-worker stress, GC/finalization of unreachable ports, shared native + handles, large message loops, and termination races are scheduler-sensitive + and need dedicated stress infrastructure. +- SharedArrayBuffer/Atomics cross-agent behavior is a separate runtime feature. + Single-thread Atomics invariants are not worker-specific, so adding them here + would duplicate existing language/runtime coverage. +- The four existing `web-locks/` fixtures already cover deterministic surface, + validation, shared/exclusive ordering, `ifAvailable`, and query snapshots. + Abort/steal races and cross-agent lock ownership remain scheduler-sensitive. +- The existing `direct-message/` fixtures already cover deterministic + `postMessageToThread` delivery, rejection, and timeout behavior. + +The measured focused result is `20/34`: all 17 pre-existing cases remain green, +three new cases pass, and 14 new cases expose stable diagnostic differences. + +The passing additions are `broadcast-channel/fanout-fifo.ts`, +`message-port/start-and-listeners.ts`, and +`worker-lifecycle/termination-ordering.ts`. The diagnostic differences are: + +- All four `structured-clone/` fixtures: Perry preserves indexed values but + loses ArrayBuffer/typed-array brands, does not detach transferred buffers or + move MessagePort ownership, and accepts invalid transfer lists. +- `message-port/close-ordering.ts`, `receive-fifo-validation.ts`, and + `ref-state.ts`: closing drops a queued message, invalid receivers return + `undefined` instead of throwing, and `hasRef()` does not track listener or + explicit ref state. +- `worker-lifecycle/constructor-validation.ts`, `data-and-environment.ts`, and + `error-ordering.ts`: invalid constructor payloads are accepted, transferred + workerData is not detached or type-preserving and environment data is not + snapshotted, while a worker throw escapes instead of producing ordered + `error` then `exit` events. +- All three `transfer-markers/` fixtures: primitives are reported as marked, + nested clone rejection and ArrayBuffer exceptions differ, and marked + transferables are still accepted. +- `broadcast-channel/close-and-clone.ts`: typed-array branding is lost and + MessagePort/closed-channel posts are accepted. + +The clean measurement used: + +```sh +NODE_BIN="$(command -v node)" \ +PERRY_RUNTIME_DIR="$PWD/target/perry-dev" \ +python3 scripts/node_suite_run.py \ + "$PWD/target/perry-dev/perry" "$PWD" worker_threads +``` + +It reported `20/34 (58.8%), diff=14`, with no compile failures or timeouts. +The SharedArrayBuffer/Atomics stopping decision is backed by the existing +granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and +`util/types/arraybuffer-sharedarraybuffer.ts` cases; cross-agent fixtures such +as Node's `test-worker-message-channel-sharedarraybuffer.js` and Deno's +`broadcast_channel_sab.mjs` remain outside this messaging-focused batch. A +broader differential regression check of all six `globals/atomics-*.ts` cases +also remained clean at `6/6`. diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/close-and-clone.ts b/test-parity/node-suite/worker_threads/broadcast-channel/close-and-clone.ts new file mode 100644 index 0000000000..399b865f99 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/close-and-clone.ts @@ -0,0 +1,45 @@ +import { + BroadcastChannel, + MessageChannel, + receiveMessageOnPort, +} from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const sender = new BroadcastChannel("clone"); +const receiver = new BroadcastChannel("clone"); +const view = new Uint8Array([3, 1, 4]); +sender.postMessage(view); +view[0] = 9; + +const packet = receiveMessageOnPort(receiver); +const cloned = packet ? packet.message : undefined; +const clonedValues = typeof cloned?.join === "function" + ? cloned.join(",") + : "not-typed"; +console.log( + "typed clone:", + cloned instanceof Uint8Array, + clonedValues, + view.join(","), +); + +const channel = new MessageChannel(); +console.log( + "port rejected:", + outcome(() => sender.postMessage({ port: channel.port1 })), +); + +sender.close(); +console.log("post closed:", outcome(() => sender.postMessage("late"))); + +receiver.close(); +channel.port1.close(); +channel.port2.close(); diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/fanout-fifo.ts b/test-parity/node-suite/worker_threads/broadcast-channel/fanout-fifo.ts new file mode 100644 index 0000000000..f4172e4abb --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/fanout-fifo.ts @@ -0,0 +1,27 @@ +import { + BroadcastChannel, + receiveMessageOnPort, +} from "node:worker_threads"; + +function receive(channel: BroadcastChannel): string { + const packet = receiveMessageOnPort(channel); + return packet ? JSON.stringify(packet.message) : "empty"; +} + +const sender = new BroadcastChannel("fanout"); +const listenerA = new BroadcastChannel("fanout"); +const listenerB = new BroadcastChannel("fanout"); +const unrelated = new BroadcastChannel("other"); + +sender.postMessage({ index: 1 }); +sender.postMessage({ index: 2 }); + +console.log("listener a:", receive(listenerA), receive(listenerA)); +console.log("listener b:", receive(listenerB), receive(listenerB)); +console.log("sender excluded:", receive(sender)); +console.log("name isolated:", receive(unrelated)); + +sender.close(); +listenerA.close(); +listenerB.close(); +unrelated.close(); diff --git a/test-parity/node-suite/worker_threads/message-channel/broadcast-delivery.ts b/test-parity/node-suite/worker_threads/message-channel/broadcast-delivery.ts index 97a0875182..1a843d5fa2 100644 --- a/test-parity/node-suite/worker_threads/message-channel/broadcast-delivery.ts +++ b/test-parity/node-suite/worker_threads/message-channel/broadcast-delivery.ts @@ -9,38 +9,16 @@ const syncReceiver = new BroadcastChannel("perry-broadcast"); const globalSender = new globalThis.BroadcastChannel("perry-global-broadcast"); const globalListener = new globalThis.BroadcastChannel("perry-global-broadcast"); -let delivered = 0; const observed: Record = {}; -const finish = () => { - delivered += 1; - if (delivered !== 3) { - return; - } - console.log(observed.handler); - console.log(observed.event); - console.log(observed.global); - const afterEvent = receiveMessageOnPort(syncReceiver); - console.log("broadcast after event:", afterEvent ? afterEvent.message : afterEvent); - setTimeout(() => { - sender.close(); - listener.close(); - syncReceiver.close(); - globalSender.close(); - globalListener.close(); - }, 25); -}; listener.onmessage = (event: any) => { observed.handler = `broadcast handler: ${event.type} ${event.data} ${event.target === listener}`; - finish(); }; listener.addEventListener("message", (event: any) => { observed.event = `broadcast event: ${event.type} ${event.data} ${event.target === listener}`; - finish(); }); globalListener.onmessage = (event: any) => { observed.global = `global broadcast handler: ${event.type} ${event.data} ${event.target === globalListener}`; - finish(); }; console.log( @@ -55,3 +33,16 @@ globalSender.postMessage("global-bc-1"); const received = receiveMessageOnPort(syncReceiver); console.log("broadcast receive:", received ? received.message : received); + +setImmediate(() => { + console.log(observed.handler ?? "broadcast handler: missing"); + console.log(observed.event ?? "broadcast event: missing"); + console.log(observed.global ?? "global broadcast handler: missing"); + const afterEvent = receiveMessageOnPort(syncReceiver); + console.log("broadcast after event:", afterEvent ? afterEvent.message : afterEvent); + sender.close(); + listener.close(); + syncReceiver.close(); + globalSender.close(); + globalListener.close(); +}); diff --git a/test-parity/node-suite/worker_threads/message-port/close-ordering.ts b/test-parity/node-suite/worker_threads/message-port/close-ordering.ts new file mode 100644 index 0000000000..e807c27691 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/close-ordering.ts @@ -0,0 +1,23 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; +let closes = 0; + +function closed(side: string) { + events.push(`${side}-close`); + closes += 1; + if (closes === 2) { + console.log("events:", events.join(",")); + port2.postMessage("after-close"); + const packet = receiveMessageOnPort(port1); + console.log("after close:", packet?.message); + } +} + +port1.on("close", () => closed("port1")); +port2.on("message", (message) => events.push(`message:${message}`)); +port2.on("close", () => closed("port2")); + +port1.postMessage("before-close"); +port1.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/receive-fifo-validation.ts b/test-parity/node-suite/worker_threads/message-port/receive-fifo-validation.ts new file mode 100644 index 0000000000..46281d117b --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/receive-fifo-validation.ts @@ -0,0 +1,32 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +function receive(value: any): string { + try { + const packet = receiveMessageOnPort(value); + return packet ? `message:${JSON.stringify(packet.message)}` : "empty"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const { port1, port2 } = new MessageChannel(); +console.log("initial:", receive(port2)); +port1.postMessage({ index: 1 }); +port1.postMessage({ index: 2 }); +console.log("fifo one:", receive(port2)); +console.log("fifo two:", receive(port2)); +console.log("drained:", receive(port2)); + +let listenerCalls = 0; +port2.on("message", () => { + listenerCalls += 1; +}); +port1.postMessage("sync-wins"); +console.log("sync with listener:", receive(port2), listenerCalls); + +for (const value of [null, 0, {}, []]) { + console.log("invalid:", receive(value)); +} + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/ref-state.ts b/test-parity/node-suite/worker_threads/message-port/ref-state.ts new file mode 100644 index 0000000000..38edec3e7c --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/ref-state.ts @@ -0,0 +1,15 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +console.log("initial:", port1.hasRef()); + +const listener = () => {}; +port1.on("message", listener); +console.log("with listener:", port1.hasRef()); +console.log("unref return:", port1.unref() === port1, port1.hasRef()); +console.log("ref return:", port1.ref() === port1, port1.hasRef()); + +port1.off("message", listener); +console.log("without listener:", port1.hasRef()); +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/start-and-listeners.ts b/test-parity/node-suite/worker_threads/message-port/start-and-listeners.ts new file mode 100644 index 0000000000..b8c3845707 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/start-and-listeners.ts @@ -0,0 +1,20 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const messages: string[] = []; + +function listener(event: MessageEvent) { + messages.push(String(event.data)); + if (messages.length === 2) { + console.log("delivered:", messages.join(",")); + port1.close(); + port2.close(); + } +} + +port1.addEventListener("message", listener); +port1.addEventListener("message", listener); +port2.postMessage("first"); +port2.postMessage("second"); +console.log("before start:", messages.length); +console.log("start return:", port1.start()); diff --git a/test-parity/node-suite/worker_threads/structured-clone/arraybuffer-clone.ts b/test-parity/node-suite/worker_threads/structured-clone/arraybuffer-clone.ts new file mode 100644 index 0000000000..fb561bd4b4 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/arraybuffer-clone.ts @@ -0,0 +1,23 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const buffer = new ArrayBuffer(8); +const view = new Uint16Array(buffer); +view.set([0x1234, 0x5678]); + +port1.postMessage({ buffer, view }); +view[0] = 0xffff; + +const packet = receiveMessageOnPort(port2); +const received = packet?.message; +console.log( + "clone shape:", + received?.buffer instanceof ArrayBuffer, + received?.view instanceof Uint16Array, + received?.view?.buffer === received?.buffer, +); +console.log("clone values:", received?.view?.[0], received?.view?.[1]); +console.log("source retained:", buffer.byteLength, view[0]); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/arraybuffer-transfer.ts b/test-parity/node-suite/worker_threads/structured-clone/arraybuffer-transfer.ts new file mode 100644 index 0000000000..4b213abda2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/arraybuffer-transfer.ts @@ -0,0 +1,23 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const buffer = new ArrayBuffer(8); +const view = new Uint32Array(buffer); +view.set([0x12345678, 0x90abcdef]); + +port1.postMessage(view, [buffer]); +console.log("detached:", buffer.byteLength, view.byteLength, view.length); + +const packet = receiveMessageOnPort(port2); +const received = packet ? packet.message : undefined; +console.log( + "received:", + received instanceof Uint32Array, + received?.byteLength, + received?.length, + received?.[0]?.toString(16), + received?.[1]?.toString(16), +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/message-port-transfer.ts b/test-parity/node-suite/worker_threads/structured-clone/message-port-transfer.ts new file mode 100644 index 0000000000..d7796d5e11 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/message-port-transfer.ts @@ -0,0 +1,37 @@ +import { + MessageChannel, + MessagePort, + receiveMessageOnPort, +} from "node:worker_threads"; + +const carrier = new MessageChannel(); +const movable = new MessageChannel(); + +carrier.port1.postMessage({ port: movable.port1 }, [movable.port1]); +const packet = receiveMessageOnPort(carrier.port2); +const transferred = packet ? packet.message?.port : undefined; +console.log("transferred type:", transferred instanceof MessagePort); + +if (typeof transferred?.postMessage === "function") { + transferred.postMessage("new-owner"); +} +const newOwnerPacket = receiveMessageOnPort(movable.port2); +console.log( + "new owner delivery:", + newOwnerPacket ? newOwnerPacket.message : undefined, +); + +movable.port1.postMessage("old-owner"); +const oldOwnerPacket = receiveMessageOnPort(movable.port2); +console.log( + "old owner detached:", + oldOwnerPacket ? oldOwnerPacket.message : undefined, +); + +if (typeof transferred?.close === "function") { + transferred.close(); +} +movable.port1.close(); +movable.port2.close(); +carrier.port1.close(); +carrier.port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/transfer-validation.ts b/test-parity/node-suite/worker_threads/structured-clone/transfer-validation.ts new file mode 100644 index 0000000000..79eb2a5012 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/transfer-validation.ts @@ -0,0 +1,37 @@ +import { MessageChannel } from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const carrier = new MessageChannel(); +const extra = new MessageChannel(); + +const duplicate = new ArrayBuffer(8); +console.log( + "duplicate buffer:", + outcome(() => carrier.port1.postMessage(duplicate, [duplicate, duplicate])), + duplicate.byteLength, +); +console.log( + "source port:", + outcome(() => carrier.port1.postMessage(null, [carrier.port1])), +); +console.log( + "missing port transfer:", + outcome(() => carrier.port1.postMessage({ port: extra.port1 })), +); +console.log( + "invalid transfer entry:", + outcome(() => carrier.port1.postMessage("value", [1 as any])), +); + +carrier.port1.close(); +carrier.port2.close(); +extra.port1.close(); +extra.port2.close(); diff --git a/test-parity/node-suite/worker_threads/transfer-markers/marker-semantics.ts b/test-parity/node-suite/worker_threads/transfer-markers/marker-semantics.ts new file mode 100644 index 0000000000..40a7e57525 --- /dev/null +++ b/test-parity/node-suite/worker_threads/transfer-markers/marker-semantics.ts @@ -0,0 +1,35 @@ +import { + isMarkedAsUntransferable, + markAsUncloneable, + markAsUntransferable, +} from "node:worker_threads"; + +for (const value of [undefined, null, false, 0, "value"]) { + console.log( + "primitive:", + typeof value, + markAsUntransferable(value), + isMarkedAsUntransferable(value), + ); +} + +const object = {}; +const array: any[] = []; +console.log("object before:", isMarkedAsUntransferable(object)); +console.log("object mark return:", markAsUntransferable(object)); +markAsUntransferable(array); +console.log( + "objects after:", + isMarkedAsUntransferable(object), + isMarkedAsUntransferable(array), +); + +const prototype = {}; +markAsUntransferable(prototype); +const child = Object.create(prototype); +console.log( + "not inherited:", + isMarkedAsUntransferable(prototype), + isMarkedAsUntransferable(child), +); +console.log("uncloneable return:", markAsUncloneable({})); diff --git a/test-parity/node-suite/worker_threads/transfer-markers/uncloneable-post.ts b/test-parity/node-suite/worker_threads/transfer-markers/uncloneable-post.ts new file mode 100644 index 0000000000..80c5dc3b2e --- /dev/null +++ b/test-parity/node-suite/worker_threads/transfer-markers/uncloneable-post.ts @@ -0,0 +1,41 @@ +import { + markAsUncloneable, + MessageChannel, + receiveMessageOnPort, +} from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const { port1, port2 } = new MessageChannel(); +const root = { value: 1 }; +markAsUncloneable(root); +console.log("root:", outcome(() => port1.postMessage(root))); + +const nested = { value: 2 }; +markAsUncloneable(nested); +console.log( + "nested:", + outcome(() => port1.postMessage({ nested })), +); + +const buffer = new ArrayBuffer(4); +markAsUncloneable(buffer); +console.log("arraybuffer:", outcome(() => port1.postMessage(buffer))); +const packet = receiveMessageOnPort(port2); +const received = packet ? packet.message : undefined; +console.log( + "arraybuffer cloned:", + received instanceof ArrayBuffer, + received?.byteLength, + buffer.byteLength, +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/transfer-markers/untransferable-transfer.ts b/test-parity/node-suite/worker_threads/transfer-markers/untransferable-transfer.ts new file mode 100644 index 0000000000..a23b19867f --- /dev/null +++ b/test-parity/node-suite/worker_threads/transfer-markers/untransferable-transfer.ts @@ -0,0 +1,44 @@ +import { + isMarkedAsUntransferable, + markAsUntransferable, + MessageChannel, + receiveMessageOnPort, +} from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const carrier = new MessageChannel(); +const buffer = new ArrayBuffer(8); +markAsUntransferable(buffer); +console.log( + "buffer transfer:", + isMarkedAsUntransferable(buffer), + outcome(() => carrier.port1.postMessage(buffer, [buffer])), + buffer.byteLength, +); + +const movable = new MessageChannel(); +markAsUntransferable(movable.port1); +console.log( + "port transfer:", + isMarkedAsUntransferable(movable.port1), + outcome(() => carrier.port1.postMessage(movable.port1, [movable.port1])), +); +movable.port1.postMessage("still-owned"); +const retainedPacket = receiveMessageOnPort(movable.port2); +console.log( + "port retained:", + retainedPacket ? retainedPacket.message : undefined, +); + +carrier.port1.close(); +carrier.port2.close(); +movable.port1.close(); +movable.port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation-worker.cjs new file mode 100644 index 0000000000..cb123cde62 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation-worker.cjs @@ -0,0 +1 @@ +// Intentionally empty: invalid constructor inputs must fail before this runs. diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation.ts new file mode 100644 index 0000000000..17b0e66abf --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-validation.ts @@ -0,0 +1,53 @@ +import { MessageChannel, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function outcome(create: () => Worker): Promise { + try { + const worker = create(); + await worker.terminate(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +async function main() { + console.log( + "uncloneable workerData:", + await outcome(() => new Worker("./constructor-validation-worker.cjs", { + workerData: () => {}, + })), + ); + + const duplicate = new ArrayBuffer(8); + console.log( + "duplicate transfer:", + await outcome(() => new Worker("./constructor-validation-worker.cjs", { + workerData: duplicate, + transferList: [duplicate, duplicate], + })), + duplicate.byteLength, + ); + + console.log( + "invalid transfer:", + await outcome(() => new Worker("./constructor-validation-worker.cjs", { + transferList: [null as any], + })), + ); + + const channel = new MessageChannel(); + console.log( + "missing port transfer:", + await outcome(() => new Worker("./constructor-validation-worker.cjs", { + workerData: channel.port1, + })), + ); + channel.port1.close(); + channel.port2.close(); +} + +main().catch((error) => { + console.log("unexpected:", error?.name, error?.message); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/data-and-environment.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/data-and-environment.ts new file mode 100644 index 0000000000..04b8b26466 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/data-and-environment.ts @@ -0,0 +1,44 @@ +import { + setEnvironmentData, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const environment = { label: "snapshot", version: 1 }; +setEnvironmentData("suite-environment", environment); + +const buffer = new ArrayBuffer(4); +const view = new Uint8Array(buffer); +view.set([1, 2, 3, 4]); +const events: string[] = []; + +const worker = new Worker("./data-worker.cjs", { + name: "data-worker", + workerData: { buffer, view, nested: { value: "worker-value" } }, + transferList: [buffer], +}); + +environment.version = 2; +console.log("parent detached:", buffer.byteLength, view.byteLength); + +worker.on("online", () => events.push("online")); +worker.on("message", (message: any) => { + events.push("message"); + console.log( + "worker data:", + message?.isMainThread, + message?.threadName, + message?.buffer, + message?.view, + message?.sharedBacking, + message?.values, + message?.nested, + ); + console.log("environment:", message?.environment); +}); +worker.on("exit", (code) => { + events.push("exit"); + console.log("lifecycle:", events.join(","), code); + setEnvironmentData("suite-environment", undefined); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/data-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/data-worker.cjs new file mode 100644 index 0000000000..b4a131811f --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/data-worker.cjs @@ -0,0 +1,25 @@ +const { + getEnvironmentData, + isMainThread, + parentPort, + threadName, + workerData, +} = require("node:worker_threads"); + +const environment = getEnvironmentData("suite-environment"); +const data = workerData || {}; +parentPort.postMessage({ + isMainThread, + threadName, + buffer: data.buffer instanceof ArrayBuffer, + view: data.view instanceof Uint8Array, + sharedBacking: + data.view instanceof Uint8Array && + data.buffer instanceof ArrayBuffer && + data.view.buffer === data.buffer, + values: typeof data.view?.join === "function" + ? data.view.join(",") + : "not-typed", + nested: data.nested?.value, + environment: `${environment?.label}:${environment?.version}`, +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/error-ordering.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/error-ordering.ts new file mode 100644 index 0000000000..4b65563a4b --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/error-ordering.ts @@ -0,0 +1,16 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const events: string[] = []; +const worker = new Worker("./error-worker.cjs", { name: "error-worker" }); + +worker.on("online", () => events.push("online")); +worker.on("error", (error: any) => { + events.push("error"); + console.log("error:", error?.name, error?.message); +}); +worker.on("exit", (code) => { + events.push("exit"); + console.log("lifecycle:", events.join(","), code); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/error-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/error-worker.cjs new file mode 100644 index 0000000000..7e52d993af --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/error-worker.cjs @@ -0,0 +1 @@ +throw new TypeError("controlled worker failure"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/termination-ordering.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/termination-ordering.ts new file mode 100644 index 0000000000..c505e66fcb --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/termination-ordering.ts @@ -0,0 +1,29 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const events: string[] = []; +let exitCode: number | undefined; +let terminateCode: number | undefined; + +function finish() { + if (exitCode === undefined || terminateCode === undefined) return; + console.log("lifecycle:", events.join(",")); + console.log("codes:", exitCode, terminateCode); +} + +const worker = new Worker("./termination-worker.cjs"); +worker.on("online", () => events.push("online")); +worker.on("message", (message) => { + events.push(`message:${message}`); + worker.terminate().then((code) => { + events.push("terminate-resolved"); + terminateCode = code; + finish(); + }); +}); +worker.on("exit", (code) => { + events.push("exit"); + exitCode = code; + finish(); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/termination-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/termination-worker.cjs new file mode 100644 index 0000000000..86dee53f2c --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/termination-worker.cjs @@ -0,0 +1,4 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.on("message", () => {}); +parentPort.postMessage("ready"); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 15e974f4c1..e5104fb87c 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -1,13 +1,13 @@ { "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", - "oracle": "node v26.3.0 on Linux (the box)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. Floors refreshed from a clean node-26 run at 2810/2863 (98.1%)." + "oracle": "node v26.5.0 (pinned by .node-version)", + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; overall inventory metadata applies its measured +3 pass / +17 total delta to the prior clean snapshot, yielding 2813/2880 (97.7%)." }, "overall": { - "pass": 2810, - "total": 2863, - "pct": 98.1 + "pass": 2813, + "total": 2880, + "pct": 97.7 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 17, - "total": 17 + "pass": 20, + "total": 34 }, "zlib": { "pass": 58, From 1497eb57f0cd09c2e16d385c9e4f398e6cd021a8 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 07:58:12 +0200 Subject: [PATCH 02/24] test: deepen worker_threads parity coverage --- .../node-suite/worker_threads/README.md | 75 ++++++++++--------- .../broadcast-channel/listener-management.ts | 22 ++++++ .../broadcast-channel/shared-array-buffer.ts | 24 ++++++ .../message-port/close-callback-idempotent.ts | 21 ++++++ .../message-port/event-ports.ts | 28 +++++++ .../message-port/onmessage-event.ts | 17 +++++ .../message-port/post-message-validation.ts | 24 ++++++ .../message-port/transfer-options.ts | 42 +++++++++++ .../message-port/transfer-state-validation.ts | 30 ++++++++ .../structured-clone/builtin-types.ts | 43 +++++++++++ .../structured-clone/cycles-and-aliases.ts | 27 +++++++ .../multiple-views-transfer.ts | 34 +++++++++ .../structured-clone/shared-array-buffer.ts | 25 +++++++ .../structured-clone/transfer-rollback.ts | 31 ++++++++ .../web-locks/web-locks-abort.ts | 22 ++++++ .../web-locks/web-locks-steal.ts | 32 ++++++++ .../worker-lifecycle/builtin-data-worker.cjs | 13 ++++ .../worker-lifecycle/default-data-worker.cjs | 8 ++ .../natural-exit-terminate.ts | 15 ++++ .../worker-lifecycle/natural-exit-worker.cjs | 3 + .../worker-lifecycle/post-message-transfer.ts | 18 +++++ .../worker-lifecycle/post-transfer-worker.cjs | 10 +++ .../worker-lifecycle/worker-ref-state.ts | 12 +++ .../worker-lifecycle/workerdata-builtins.ts | 30 ++++++++ .../worker-lifecycle/workerdata-defaults.ts | 31 ++++++++ .../workerdata-port-transfer.ts | 28 +++++++ .../workerdata-port-worker.cjs | 14 ++++ test-parity/node_suite_baseline.json | 12 +-- 28 files changed, 650 insertions(+), 41 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/listener-management.ts create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/shared-array-buffer.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/close-callback-idempotent.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/event-ports.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/onmessage-event.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/post-message-validation.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-options.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-state-validation.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/builtin-types.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/cycles-and-aliases.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/multiple-views-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/shared-array-buffer.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/transfer-rollback.ts create mode 100644 test-parity/node-suite/worker_threads/web-locks/web-locks-abort.ts create mode 100644 test-parity/node-suite/worker_threads/web-locks/web-locks-steal.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/default-data-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-terminate.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/post-message-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/post-transfer-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/worker-ref-state.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-defaults.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-worker.cjs diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index b1e7c13c38..11322c4874 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -18,19 +18,23 @@ that oracle. ## Coverage added - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, - transfer detachment, MessagePort ownership transfer, and transfer-list - validation/rollback. + built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, + transfer detachment, MessagePort ownership, overloads, and atomic rollback. - `message-port/`: synchronous FIFO receives, invalid-port validation, - explicit `start()`, listener deduplication, close ordering, queued-message - delivery, and `ref`/`unref`/`hasRef` state. + explicit `start()`, event fields/ports, listener deduplication, close callback + ordering, transfer-state validation, queued delivery, overload validation, + and `ref`/`unref`/`hasRef` state. - `worker-lifecycle/`: constructor transfer validation, structured `workerData`, - environment-data snapshots, `online`/`message`/`error`/`exit` ordering, and - deterministic termination settlement. + default and built-in workerData, MessagePort/ArrayBuffer transfer, environment + snapshots, `online`/`message`/`error`/`exit` ordering, ref return values, + natural exit, and deterministic termination settlement. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, and retained ownership after rejection. - `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, - typed-array cloning, untransferable-value rejection, and closed-channel - validation. + listener management, typed-array and SharedArrayBuffer cloning, + untransferable-value rejection, and closed-channel validation. +- `web-locks/`: deterministic pre-aborted requests and lock stealing, extending + the existing surface, option, query, and shared/exclusive ordering cases. Every asynchronous fixture uses a message, close, exit, or promise-settlement barrier. No added fixture uses a sleep as a completion condition. @@ -49,39 +53,39 @@ the cases above or belong to a separate slow/risky runtime feature: - Nested-worker stress, GC/finalization of unreachable ports, shared native handles, large message loops, and termination races are scheduler-sensitive and need dedicated stress infrastructure. -- SharedArrayBuffer/Atomics cross-agent behavior is a separate runtime feature. - Single-thread Atomics invariants are not worker-specific, so adding them here - would duplicate existing language/runtime coverage. -- The four existing `web-locks/` fixtures already cover deterministic surface, - validation, shared/exclusive ordering, `ifAvailable`, and query snapshots. - Abort/steal races and cross-agent lock ownership remain scheduler-sensitive. +- SharedArrayBuffer aliasing is now covered deterministically through both a + MessagePort and BroadcastChannel. Cross-agent wait/notify coordination stays + separate because it requires scheduler-aware runtime infrastructure. +- Web Locks now cover deterministic pre-abort and steal behavior in addition to + surface, validation, shared/exclusive ordering, `ifAvailable`, and query + snapshots. In-flight abort races and cross-agent ownership remain excluded. - The existing `direct-message/` fixtures already cover deterministic `postMessageToThread` delivery, rejection, and timeout behavior. -The measured focused result is `20/34`: all 17 pre-existing cases remain green, -three new cases pass, and 14 new cases expose stable diagnostic differences. +The measured focused result is `24/55`: all 17 pre-existing cases remain green, +seven new cases pass, and 31 new cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, -`message-port/start-and-listeners.ts`, and -`worker-lifecycle/termination-ordering.ts`. The diagnostic differences are: - -- All four `structured-clone/` fixtures: Perry preserves indexed values but - loses ArrayBuffer/typed-array brands, does not detach transferred buffers or - move MessagePort ownership, and accepts invalid transfer lists. -- `message-port/close-ordering.ts`, `receive-fifo-validation.ts`, and - `ref-state.ts`: closing drops a queued message, invalid receivers return - `undefined` instead of throwing, and `hasRef()` does not track listener or - explicit ref state. -- `worker-lifecycle/constructor-validation.ts`, `data-and-environment.ts`, and - `error-ordering.ts`: invalid constructor payloads are accepted, transferred - workerData is not detached or type-preserving and environment data is not - snapshotted, while a worker throw escapes instead of producing ordered - `error` then `exit` events. +`broadcast-channel/listener-management.ts`, +`message-port/start-and-listeners.ts`, +`worker-lifecycle/termination-ordering.ts`, +`worker-lifecycle/worker-ref-state.ts`, `web-locks/web-locks-abort.ts`, and +`web-locks/web-locks-steal.ts`. The diagnostic differences are: + +- All nine `structured-clone/` fixtures: Perry preserves some indexed values but + loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects + cycles/BigInt, does not detach or move ownership, and accepts invalid lists. +- Nine `message-port/` diagnostics: closing drops queued data and callbacks, + invalid receivers/options/transfers do not throw, event `ports` are missing, + transferred ownership is not enforced, and `hasRef()` does not track state. +- Eight `worker-lifecycle/` diagnostics: invalid constructor payloads are + accepted; transfers, omitted workerData, Map cloning, environment snapshots, + post-exit terminate values, and worker error routing differ. - All three `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection and ArrayBuffer exceptions differ, and marked transferables are still accepted. -- `broadcast-channel/close-and-clone.ts`: typed-array branding is lost and - MessagePort/closed-channel posts are accepted. +- Two `broadcast-channel/` diagnostics: typed-array/SharedArrayBuffer branding + and sharing are lost, while MessagePort and closed-channel posts are accepted. The clean measurement used: @@ -92,8 +96,9 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `20/34 (58.8%), diff=14`, with no compile failures or timeouts. -The SharedArrayBuffer/Atomics stopping decision is backed by the existing +It reported `24/55 (43.6%), diff=31`, with no compile failures or timeouts. +The SharedArrayBuffer/Atomics boundary is backed by the added same-process +channel cases plus the existing granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and `util/types/arraybuffer-sharedarraybuffer.ts` cases; cross-agent fixtures such as Node's `test-worker-message-channel-sharedarraybuffer.js` and Deno's diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/listener-management.ts b/test-parity/node-suite/worker_threads/broadcast-channel/listener-management.ts new file mode 100644 index 0000000000..1b8a957e97 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/listener-management.ts @@ -0,0 +1,22 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const sender = new BroadcastChannel("listener-management"); +const receiver = new BroadcastChannel("listener-management"); +let calls = 0; + +function listener(event: MessageEvent) { + calls += 1; + console.log("listener:", calls, event.data); + sender.close(); + receiver.close(); +} + +function removed() { + console.log("removed listener fired"); +} + +receiver.addEventListener("message", listener); +receiver.addEventListener("message", listener); +receiver.addEventListener("message", removed); +receiver.removeEventListener("message", removed); +sender.postMessage("once"); diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/shared-array-buffer.ts b/test-parity/node-suite/worker_threads/broadcast-channel/shared-array-buffer.ts new file mode 100644 index 0000000000..4328e19951 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/shared-array-buffer.ts @@ -0,0 +1,24 @@ +import { + BroadcastChannel, + receiveMessageOnPort, +} from "node:worker_threads"; + +const sender = new BroadcastChannel("shared-buffer"); +const receiver = new BroadcastChannel("shared-buffer"); +const source = new SharedArrayBuffer(4); +const sourceView = new Int32Array(source); +Atomics.store(sourceView, 0, 5); + +sender.postMessage(source); +const packet = receiveMessageOnPort(receiver); +const received = packet ? packet.message : undefined; +const receivedView = received instanceof SharedArrayBuffer + ? new Int32Array(received) + : undefined; + +console.log("brand/identity:", received instanceof SharedArrayBuffer, received === source); +Atomics.store(sourceView, 0, 27); +console.log("shared update:", receivedView ? Atomics.load(receivedView, 0) : "missing"); + +sender.close(); +receiver.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/close-callback-idempotent.ts b/test-parity/node-suite/worker_threads/message-port/close-callback-idempotent.ts new file mode 100644 index 0000000000..a62eb33485 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/close-callback-idempotent.ts @@ -0,0 +1,21 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; +let closeEvents = 0; + +function closed(label: string) { + events.push(label); + closeEvents += 1; + if (closeEvents !== 2) return; + setImmediate(() => { + console.log("close events:", events.sort().join(",")); + console.log("closed methods:", port1.start(), port1.ref(), port1.unref()); + }); +} + +port1.on("close", () => closed("port1-event")); +port2.on("close", () => closed("port2-event")); +port1.close(() => events.push("port1-callback")); +port1.close(() => events.push("second-callback")); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/event-ports.ts b/test-parity/node-suite/worker_threads/message-port/event-ports.ts new file mode 100644 index 0000000000..da6b67ebef --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/event-ports.ts @@ -0,0 +1,28 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const carrier = new MessageChannel(); +const movable = new MessageChannel(); + +carrier.port2.addEventListener("message", (event: MessageEvent) => { + const transferred = event.data?.port; + const ports = Array.isArray(event.ports) ? event.ports : []; + console.log( + "event ports:", + ports.length, + ports[0] === transferred, + typeof transferred?.postMessage, + ); + if (typeof transferred?.postMessage === "function") { + transferred.postMessage("via-event-port"); + } + const packet = receiveMessageOnPort(movable.port2); + console.log("delivery:", packet ? packet.message : undefined); + + if (typeof transferred?.close === "function") transferred.close(); + carrier.port1.close(); + carrier.port2.close(); + movable.port1.close(); + movable.port2.close(); +}); +carrier.port2.start(); +carrier.port1.postMessage({ port: movable.port1 }, [movable.port1]); diff --git a/test-parity/node-suite/worker_threads/message-port/onmessage-event.ts b/test-parity/node-suite/worker_threads/message-port/onmessage-event.ts new file mode 100644 index 0000000000..385ba97b67 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/onmessage-event.ts @@ -0,0 +1,17 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +port1.onmessage = (event) => { + console.log( + "event:", + event.type, + event.data.value, + event.target === port1, + event.currentTarget === port1, + Array.isArray(event.ports) ? event.ports.length : "missing", + ); + port1.close(); + port2.close(); +}; + +port2.postMessage({ value: 7 }); diff --git a/test-parity/node-suite/worker_threads/message-port/post-message-validation.ts b/test-parity/node-suite/worker_threads/message-port/post-message-validation.ts new file mode 100644 index 0000000000..667a782f78 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/post-message-validation.ts @@ -0,0 +1,24 @@ +import { MessageChannel } from "node:worker_threads"; + +function outcome(value: any, transfer?: any): string { + try { + if (arguments.length === 1) { + port1.postMessage(value); + } else { + port1.postMessage(value, transfer); + } + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const { port1, port2 } = new MessageChannel(); +console.log("function:", outcome(() => {})); +console.log("symbol:", outcome(Symbol("value"))); +console.log("number transfer:", outcome("value", 1)); +console.log("string transfer:", outcome("value", "bad")); +console.log("options transfer:", outcome("value", { transfer: null })); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-options.ts b/test-parity/node-suite/worker_threads/message-port/transfer-options.ts new file mode 100644 index 0000000000..6766e4739a --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-options.ts @@ -0,0 +1,42 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +function make(value: number): ArrayBuffer { + const buffer = new ArrayBuffer(1); + new Uint8Array(buffer)[0] = value; + return buffer; +} + +const arrayBuffer = make(1); +const optionsBuffer = make(2); +const iterableBuffer = make(3); + +port1.postMessage({ buffer: arrayBuffer }, [arrayBuffer]); +port1.postMessage({ buffer: optionsBuffer }, { transfer: [optionsBuffer] }); +port1.postMessage( + { buffer: iterableBuffer }, + (function* () { yield iterableBuffer; })(), +); + +console.log( + "detached:", + arrayBuffer.byteLength, + optionsBuffer.byteLength, + iterableBuffer.byteLength, +); + +const values: string[] = []; +for (let index = 0; index < 3; index += 1) { + const packet = receiveMessageOnPort(port2); + const buffer = packet?.message?.buffer; + values.push( + buffer instanceof ArrayBuffer + ? `${buffer.byteLength}:${new Uint8Array(buffer)[0]}` + : "not-buffer", + ); +} +console.log("received:", values.join(",")); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-state-validation.ts b/test-parity/node-suite/worker_threads/message-port/transfer-state-validation.ts new file mode 100644 index 0000000000..8de4d761a6 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-state-validation.ts @@ -0,0 +1,30 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const carrier = new MessageChannel(); +console.log( + "source transfer:", + outcome(() => carrier.port1.postMessage(null, [carrier.port1])), +); +carrier.port1.postMessage("still-open"); +const retained = receiveMessageOnPort(carrier.port2); +console.log("source retained:", retained ? retained.message : undefined); + +const closed = new MessageChannel(); +closed.port1.close(); +console.log( + "closed transfer:", + outcome(() => carrier.port1.postMessage(null, [closed.port1])), +); + +carrier.port1.close(); +carrier.port2.close(); +closed.port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/builtin-types.ts b/test-parity/node-suite/worker_threads/structured-clone/builtin-types.ts new file mode 100644 index 0000000000..aba981cf7b --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/builtin-types.ts @@ -0,0 +1,43 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +let postStatus = "ok"; + +try { + port1.postMessage({ + date: new Date("2020-01-02T03:04:05.000Z"), + regexp: /worker/gi, + map: new Map([["answer", 42]]), + set: new Set([3, 1, 4]), + bigint: 9007199254740993n, + error: new TypeError("clone-me"), + }); +} catch (error: any) { + postStatus = `${error?.name}:${error?.code ?? ""}`; +} + +const packet = receiveMessageOnPort(port2); +const value = packet ? packet.message : undefined; +console.log("post:", postStatus); +console.log( + "brands:", + value?.date instanceof Date, + value?.regexp instanceof RegExp, + value?.map instanceof Map, + value?.set instanceof Set, + typeof value?.bigint, + value?.error instanceof TypeError, +); +console.log( + "values:", + value?.date?.toISOString?.(), + value?.regexp?.source, + value?.regexp?.flags, + value?.map?.get?.("answer"), + value?.set?.has?.(4), + String(value?.bigint), + value?.error?.message, +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/cycles-and-aliases.ts b/test-parity/node-suite/worker_threads/structured-clone/cycles-and-aliases.ts new file mode 100644 index 0000000000..ba4c8d9f79 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/cycles-and-aliases.ts @@ -0,0 +1,27 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const shared = { label: "shared" }; +const source: any = { left: shared, right: shared }; +source.self = source; + +let postStatus = "ok"; +try { + port1.postMessage(source); +} catch (error: any) { + postStatus = `${error?.name}:${error?.code ?? ""}`; +} + +const packet = receiveMessageOnPort(port2); +const value = packet ? packet.message : undefined; +console.log("post:", postStatus); +console.log( + "identity:", + value?.self === value, + value?.left === value?.right, + value?.left === shared, + value?.left?.label, +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/multiple-views-transfer.ts b/test-parity/node-suite/worker_threads/structured-clone/multiple-views-transfer.ts new file mode 100644 index 0000000000..503f479a13 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/multiple-views-transfer.ts @@ -0,0 +1,34 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const buffer = new ArrayBuffer(16); +const bytes = new Uint8Array(buffer); +const dataView = new DataView(buffer); +dataView.setFloat64(0, Math.PI); +bytes[8] = 99; + +port1.postMessage({ dataView, bytes }, [buffer]); +let dataViewLength: number | string; +try { + dataViewLength = dataView.byteLength; +} catch (error: any) { + dataViewLength = error?.name; +} +console.log("source detached:", buffer.byteLength, bytes.byteLength, dataViewLength); + +const packet = receiveMessageOnPort(port2); +const value = packet ? packet.message : undefined; +console.log( + "brands/backing:", + value?.dataView instanceof DataView, + value?.bytes instanceof Uint8Array, + value?.dataView?.buffer === value?.bytes?.buffer, +); +console.log( + "values:", + value?.dataView?.getFloat64?.(0), + value?.bytes?.[8], +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/shared-array-buffer.ts b/test-parity/node-suite/worker_threads/structured-clone/shared-array-buffer.ts new file mode 100644 index 0000000000..950958b2a7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/shared-array-buffer.ts @@ -0,0 +1,25 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const source = new SharedArrayBuffer(8); +const sourceView = new Int32Array(source); +Atomics.store(sourceView, 0, 10); + +port1.postMessage(source); +const packet = receiveMessageOnPort(port2); +const received = packet ? packet.message : undefined; +const receivedView = received instanceof SharedArrayBuffer + ? new Int32Array(received) + : undefined; + +console.log( + "brand/identity:", + received instanceof SharedArrayBuffer, + received === source, + received?.byteLength, +); +Atomics.store(sourceView, 0, 42); +console.log("shared update:", receivedView ? Atomics.load(receivedView, 0) : "missing"); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/transfer-rollback.ts b/test-parity/node-suite/worker_threads/structured-clone/transfer-rollback.ts new file mode 100644 index 0000000000..6e1e435738 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/transfer-rollback.ts @@ -0,0 +1,31 @@ +import { MessageChannel } from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const channel = new MessageChannel(); +const buffer = new ArrayBuffer(8); +console.log( + "invalid after buffer:", + outcome(() => channel.port1.postMessage(buffer, [buffer, null as any])), + buffer.byteLength, +); + +const closed = new MessageChannel(); +closed.port1.close(); +const rollback = new ArrayBuffer(16); +console.log( + "closed port rollback:", + outcome(() => channel.port1.postMessage(null, [rollback, closed.port1])), + rollback.byteLength, +); + +channel.port1.close(); +channel.port2.close(); +closed.port2.close(); diff --git a/test-parity/node-suite/worker_threads/web-locks/web-locks-abort.ts b/test-parity/node-suite/worker_threads/web-locks/web-locks-abort.ts new file mode 100644 index 0000000000..c0a08029ae --- /dev/null +++ b/test-parity/node-suite/worker_threads/web-locks/web-locks-abort.ts @@ -0,0 +1,22 @@ +import { locks } from "node:worker_threads"; + +async function main() { + const controller = new AbortController(); + controller.abort(new Error("pre-aborted")); + let callbackCalled = false; + + try { + await locks.request("aborted-lock", { signal: controller.signal }, () => { + callbackCalled = true; + return "unexpected"; + }); + console.log("pre-aborted: resolved", callbackCalled); + } catch (error: any) { + console.log("pre-aborted:", error?.name, error?.message, callbackCalled); + } + + const snapshot = await locks.query(); + console.log("snapshot:", snapshot.held.length, snapshot.pending.length); +} + +main().catch((error) => console.log("unexpected:", error?.name, error?.message)); diff --git a/test-parity/node-suite/worker_threads/web-locks/web-locks-steal.ts b/test-parity/node-suite/worker_threads/web-locks/web-locks-steal.ts new file mode 100644 index 0000000000..89a0ed13ed --- /dev/null +++ b/test-parity/node-suite/worker_threads/web-locks/web-locks-steal.ts @@ -0,0 +1,32 @@ +import { locks } from "node:worker_threads"; + +async function main() { + let release: () => void = () => {}; + const events: string[] = []; + const held = locks.request("steal-lock", () => { + events.push("held"); + return new Promise((resolve) => { + release = resolve; + }); + }); + + const stolen = locks.request("steal-lock", { steal: true }, () => { + events.push("stolen"); + return "stolen-result"; + }); + + try { + await held; + events.push("held-resolved"); + } catch (error: any) { + events.push(`held-${error?.name}`); + } + + console.log("stolen result:", await stolen); + release(); + console.log("events:", events.join(",")); + const snapshot = await locks.query(); + console.log("final:", snapshot.held.length, snapshot.pending.length); +} + +main().catch((error) => console.log("unexpected:", error?.name, error?.message)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs new file mode 100644 index 0000000000..0b964d5422 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs @@ -0,0 +1,13 @@ +const { parentPort, workerData } = require("node:worker_threads"); +const data = workerData || {}; + +parentPort.postMessage({ + date: data.date instanceof Date, + dateValue: data.date instanceof Date ? data.date.toISOString() : undefined, + map: data.map instanceof Map, + mapValue: data.map instanceof Map ? data.map.get("key") : undefined, + regexp: data.regexp instanceof RegExp, + regexpValue: data.regexp instanceof RegExp ? data.regexp.source : undefined, + bigintType: typeof data.bigint, + bigintValue: String(data.bigint), +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/default-data-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/default-data-worker.cjs new file mode 100644 index 0000000000..3ba38f5833 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/default-data-worker.cjs @@ -0,0 +1,8 @@ +const { isMainThread, parentPort, workerData } = require("node:worker_threads"); + +parentPort.postMessage({ + isMainThread, + type: typeof workerData, + isNull: workerData === null, + value: workerData?.value, +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-terminate.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-terminate.ts new file mode 100644 index 0000000000..885a84feb7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-terminate.ts @@ -0,0 +1,15 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const events: string[] = []; +const worker = new Worker("./natural-exit-worker.cjs"); +worker.on("online", () => events.push("online")); +worker.on("message", (message) => events.push(`message:${message}`)); +worker.on("exit", (code) => { + events.push(`exit:${code}`); + worker.terminate().then((terminateCode) => { + events.push(`terminate:${String(terminateCode)}`); + console.log("events:", events.join(",")); + }); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-worker.cjs new file mode 100644 index 0000000000..e968f3d093 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/natural-exit-worker.cjs @@ -0,0 +1,3 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.postMessage("natural"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/post-message-transfer.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/post-message-transfer.ts new file mode 100644 index 0000000000..155de50daf --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/post-message-transfer.ts @@ -0,0 +1,18 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./post-transfer-worker.cjs"); +const buffer = new ArrayBuffer(4); +const view = new Uint8Array(buffer); +view.set([2, 4, 6, 8]); + +worker.on("online", () => { + worker.postMessage({ view }, [buffer]); + console.log("source detached:", buffer.byteLength, view.byteLength); +}); +worker.on("message", (message: any) => { + console.log("worker received:", message?.brand, message?.length, message?.values); + worker.terminate(); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/post-transfer-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/post-transfer-worker.cjs new file mode 100644 index 0000000000..c857536255 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/post-transfer-worker.cjs @@ -0,0 +1,10 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.on("message", (message) => { + const view = message?.view; + parentPort.postMessage({ + brand: view instanceof Uint8Array, + length: view?.length, + values: typeof view?.join === "function" ? view.join(",") : "not-typed", + }); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/worker-ref-state.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-ref-state.ts new file mode 100644 index 0000000000..85f8ea4483 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-ref-state.ts @@ -0,0 +1,12 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./termination-worker.cjs"); +worker.on("message", (message) => { + console.log("ready:", message); + console.log("unref return:", worker.unref() === worker); + console.log("ref return:", worker.ref() === worker); + worker.terminate().then((code) => console.log("terminate:", code)); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts new file mode 100644 index 0000000000..e22ef28d71 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts @@ -0,0 +1,30 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./builtin-data-worker.cjs", { + workerData: { + date: new Date("2021-02-03T04:05:06.000Z"), + map: new Map([["key", 17]]), + regexp: /data/i, + bigint: 12345678901234567890n, + }, +}); + +worker.on("message", (message: any) => { + console.log( + "brands:", + message?.date, + message?.map, + message?.regexp, + message?.bigintType, + ); + console.log( + "values:", + message?.dateValue, + message?.mapValue, + message?.regexpValue, + message?.bigintValue, + ); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-defaults.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-defaults.ts new file mode 100644 index 0000000000..5729141de8 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-defaults.ts @@ -0,0 +1,31 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function run(label: string, options?: { workerData?: any }): Promise { + return new Promise((resolve) => { + const worker = new Worker("./default-data-worker.cjs", options); + worker.on("message", (message: any) => { + console.log( + `${label}:`, + message?.isMainThread, + message?.type, + message?.isNull, + message?.value, + ); + }); + worker.on("exit", (code) => { + console.log(`${label} exit:`, code); + resolve(); + }); + }); +} + +async function main() { + await run("omitted"); + await run("undefined", { workerData: undefined }); + await run("null", { workerData: null }); + await run("object", { workerData: { value: 5 } }); +} + +main().catch((error) => console.log("unexpected:", error?.name, error?.message)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-transfer.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-transfer.ts new file mode 100644 index 0000000000..fd3fadc09e --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-transfer.ts @@ -0,0 +1,28 @@ +import { + MessageChannel, + receiveMessageOnPort, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const channel = new MessageChannel(); +const worker = new Worker("./workerdata-port-worker.cjs", { + workerData: { port: channel.port1 }, + transferList: [channel.port1], +}); + +worker.on("message", (message: any) => { + const packet = receiveMessageOnPort(channel.port2); + console.log("worker port:", message?.brand, message?.postMessage); + console.log("peer delivery:", packet ? packet.message : undefined); + channel.port1.postMessage("old-owner"); + const oldOwner = receiveMessageOnPort(channel.port2); + console.log("old owner detached:", oldOwner ? oldOwner.message : undefined); + worker.terminate(); +}); +worker.on("exit", (code) => { + console.log("exit:", code); + channel.port1.close(); + channel.port2.close(); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-worker.cjs new file mode 100644 index 0000000000..6cd7fd048a --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-worker.cjs @@ -0,0 +1,14 @@ +const { + MessagePort, + parentPort, + workerData, +} = require("node:worker_threads"); + +const port = workerData?.port; +if (typeof port?.postMessage === "function") { + port.postMessage("from-worker"); +} +parentPort.postMessage({ + brand: port instanceof MessagePort, + postMessage: typeof port?.postMessage, +}); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index e5104fb87c..a409eca6ae 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; overall inventory metadata applies its measured +3 pass / +17 total delta to the prior clean snapshot, yielding 2813/2880 (97.7%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; overall inventory metadata applies its cumulative measured +7 pass / +38 total delta to the prior clean snapshot, yielding 2817/2901 (97.1%)." }, "overall": { - "pass": 2813, - "total": 2880, - "pct": 97.7 + "pass": 2817, + "total": 2901, + "pct": 97.1 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 20, - "total": 34 + "pass": 24, + "total": 55 }, "zlib": { "pass": 58, From c8c7d608455ff1a64f78c6930f37d3e571b5dc6c Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 08:36:41 +0200 Subject: [PATCH 03/24] test: broaden worker_threads compatibility matrix --- .../node-suite/worker_threads/README.md | 77 +++++++++++++------ .../close-and-ref-idempotence.ts | 15 ++++ .../broadcast-channel/constructor-and-name.ts | 20 +++++ .../broadcast-channel/listener-once.ts | 28 +++++++ .../direct-message/validation.ts | 26 +++++++ .../environment-data/value-identity.ts | 14 ++++ .../worker-snapshot-worker.cjs | 19 +++++ .../environment-data/worker-snapshot.ts | 19 +++++ .../main-thread/export-surface.ts | 36 +++++++++ .../main-thread/prototype-surface.ts | 43 +++++++++++ .../message-channel/constructor-options.ts | 22 ++++++ .../message-port/clone-error-rollback.ts | 16 ++++ .../message-port/duplicate-transfer.ts | 33 ++++++++ .../message-port/listener-removal-once.ts | 31 ++++++++ .../message-port/messageevent-constructor.ts | 42 ++++++++++ .../message-port/method-receivers.ts | 23 ++++++ .../message-port/onmessage-replacement.ts | 19 +++++ .../message-port/self-and-closed-transfer.ts | 31 ++++++++ .../uncloneable-port-transfer.ts | 28 +++++++ .../web-locks-callback-settlement.ts | 35 +++++++++ .../web-locks/web-locks-independent-names.ts | 28 +++++++ .../worker-lifecycle/argv-options.ts | 22 ++++++ .../worker-lifecycle/argv-worker.cjs | 3 + .../worker-lifecycle/env-options.ts | 29 +++++++ .../worker-lifecycle/env-worker.cjs | 9 +++ .../eventemitter-listeners.ts | 27 +++++++ .../worker-lifecycle/eventemitter-worker.cjs | 4 + .../worker-lifecycle/method-receivers.ts | 17 ++++ .../postmessage-errors-worker.cjs | 7 ++ .../worker-lifecycle/postmessage-errors.ts | 30 ++++++++ .../worker-lifecycle/share-env-worker.cjs | 13 ++++ .../worker-lifecycle/share-env.ts | 21 +++++ .../worker-lifecycle/terminate-idempotent.ts | 12 +++ .../thread-metadata-worker.cjs | 3 + .../worker-lifecycle/thread-metadata.ts | 22 ++++++ .../worker-lifecycle/unsupported-paths.ts | 20 +++++ .../workerdata-shared-array-buffer-worker.cjs | 10 +++ .../workerdata-shared-array-buffer.ts | 16 ++++ test-parity/node_suite_baseline.json | 12 +-- 39 files changed, 854 insertions(+), 28 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/close-and-ref-idempotence.ts create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/constructor-and-name.ts create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/listener-once.ts create mode 100644 test-parity/node-suite/worker_threads/direct-message/validation.ts create mode 100644 test-parity/node-suite/worker_threads/environment-data/value-identity.ts create mode 100644 test-parity/node-suite/worker_threads/environment-data/worker-snapshot-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts create mode 100644 test-parity/node-suite/worker_threads/main-thread/export-surface.ts create mode 100644 test-parity/node-suite/worker_threads/main-thread/prototype-surface.ts create mode 100644 test-parity/node-suite/worker_threads/message-channel/constructor-options.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/clone-error-rollback.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/duplicate-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/listener-removal-once.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/messageevent-constructor.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/method-receivers.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/onmessage-replacement.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/self-and-closed-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/transfer-markers/uncloneable-port-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/web-locks/web-locks-callback-settlement.ts create mode 100644 test-parity/node-suite/worker_threads/web-locks/web-locks-independent-names.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/argv-options.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/argv-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/env-options.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/env-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-listeners.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/method-receivers.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/terminate-idempotent.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 11322c4874..711012261a 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -11,30 +11,53 @@ The expansion was compared against these primary-source snapshots: - Deno [`f8a17c8`](https://github.com/denoland/deno/tree/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/specs/node/worker_threads) and its [`worker_threads_test.ts`](https://github.com/denoland/deno/blob/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/unit_node/worker_threads_test.ts) selection, including port transfer, listener removal/deduplication, `unref`, auto-exit, and broadcast coverage. - Bun [`0bffb47`](https://github.com/oven-sh/bun/tree/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads), especially [`worker_threads.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker_threads.test.ts) and [`worker-transfer-list.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker-transfer-list.test.ts). +The inventory review covered all 141 `test-worker-*` files in that Node +snapshot, all 48 cases in Deno's unit selection plus its directory fixtures, +and the 72 declarations in Bun's two central worker test files. Cases are +represented here by observable contract rather than copied one-for-one when +several upstream files exercise the same behavior. + Node `26.5.0`, pinned by this repository, remains the executable differential oracle. The upstream snapshots above guide case selection rather than changing that oracle. ## Coverage added +- `main-thread/`: complete namespace export availability and types, main-thread + values, Worker and MessagePort prototype descriptors, and constructor brands. +- `environment-data/`: key identity, live main-thread values, deletion, worker + snapshot cloning, and parent/worker mutation isolation. - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, transfer detachment, MessagePort ownership, overloads, and atomic rollback. - `message-port/`: synchronous FIFO receives, invalid-port validation, explicit `start()`, event fields/ports, listener deduplication, close callback - ordering, transfer-state validation, queued delivery, overload validation, - and `ref`/`unref`/`hasRef` state. + ordering, listener removal/`once`, `onmessage` replacement, method receiver + validation, MessageEvent construction, duplicate/self/closed transfers, + atomic clone rollback, transfer-state validation, queued delivery, overload + validation, and `ref`/`unref`/`hasRef` state. +- `message-channel/`: module/global identity, construction rules, port brands, + asynchronous and synchronous delivery, BroadcastChannel synchronous receive, + and VM-context port movement. - `worker-lifecycle/`: constructor transfer validation, structured `workerData`, default and built-in workerData, MessagePort/ArrayBuffer transfer, environment - snapshots, `online`/`message`/`error`/`exit` ordering, ref return values, - natural exit, and deterministic termination settlement. + snapshots, argv coercion/validation, inherited/explicit/shared environments, + SharedArrayBuffer workerData, thread id/name lifecycle, unsupported paths, + EventEmitter listener behavior, method receivers, postMessage clone rollback, + `online`/`message`/`error`/`exit` ordering, ref return values, natural exit, + and deterministic repeated termination settlement. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, - clone rejection, transfer rejection, and retained ownership after rejection. + clone rejection, transfer rejection, retained ownership after rejection, and + the distinction between marking a port uncloneable and transferring it. - `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, - listener management, typed-array and SharedArrayBuffer cloning, - untransferable-value rejection, and closed-channel validation. + listener management/`once`, name coercion, close/ref idempotence, typed-array + and SharedArrayBuffer cloning, untransferable-value rejection, and + closed-channel validation. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending - the existing surface, option, query, and shared/exclusive ordering cases. + the existing surface, option, query, callback settlement/cleanup, + independent-name concurrency, and shared/exclusive ordering cases. +- `direct-message/`: delivery, no-listener and timeout rejection, plus thread id + and timeout argument validation. Every asynchronous fixture uses a message, close, exit, or promise-settlement barrier. No added fixture uses a sleep as a completion condition. @@ -62,30 +85,40 @@ the cases above or belong to a separate slow/risky runtime feature: - The existing `direct-message/` fixtures already cover deterministic `postMessageToThread` delivery, rejection, and timeout behavior. -The measured focused result is `24/55`: all 17 pre-existing cases remain green, -seven new cases pass, and 31 new cases expose stable diagnostic differences. +The measured focused result is `28/84`: all 17 pre-existing cases remain green, +11 added cases pass, and 56 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, `message-port/start-and-listeners.ts`, `worker-lifecycle/termination-ordering.ts`, `worker-lifecycle/worker-ref-state.ts`, `web-locks/web-locks-abort.ts`, and -`web-locks/web-locks-steal.ts`. The diagnostic differences are: +`web-locks/web-locks-steal.ts`. The latest broad pass also adds green coverage +for `environment-data/value-identity.ts`, +`message-port/onmessage-replacement.ts`, +`worker-lifecycle/method-receivers.ts`, and +`web-locks/web-locks-independent-names.ts`. The diagnostic differences are: - All nine `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Nine `message-port/` diagnostics: closing drops queued data and callbacks, - invalid receivers/options/transfers do not throw, event `ports` are missing, - transferred ownership is not enforced, and `hasRef()` does not track state. -- Eight `worker-lifecycle/` diagnostics: invalid constructor payloads are - accepted; transfers, omitted workerData, Map cloning, environment snapshots, - post-exit terminate values, and worker error routing differ. -- All three `transfer-markers/` fixtures: primitives are reported as marked, +- Fifteen `message-port/` diagnostics: closing drops queued data and callbacks, + listener counts, receiver/option/transfer validation, event construction and + `ports`, duplicate/self/closed transfer rollback, moved ownership, and + `hasRef()` state differ. +- Seventeen `worker-lifecycle/` diagnostics: invalid constructor payloads and + paths, argv/env handling, shared environments, worker metadata, listener + behavior, repeated termination, workerData cloning/SAB sharing, postMessage + rollback, post-exit values, and worker error routing differ. +- All four `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection and ArrayBuffer exceptions differ, and marked - transferables are still accepted. -- Two `broadcast-channel/` diagnostics: typed-array/SharedArrayBuffer branding - and sharing are lost, while MessagePort and closed-channel posts are accepted. + transferables and uncloneable ports differ. +- Five `broadcast-channel/` diagnostics: name coercion, once listeners, + close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, + MessagePort validation, and closed-channel posts differ. +- Six additional diagnostics cover namespace/prototype completeness, + environment worker snapshots, MessageChannel construction, direct-message + argument validation, and Web Locks callback rejection cleanup. The clean measurement used: @@ -96,7 +129,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `24/55 (43.6%), diff=31`, with no compile failures or timeouts. +It reported `28/84 (33.3%), diff=56`, with no compile failures or timeouts. The SharedArrayBuffer/Atomics boundary is backed by the added same-process channel cases plus the existing granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/close-and-ref-idempotence.ts b/test-parity/node-suite/worker_threads/broadcast-channel/close-and-ref-idempotence.ts new file mode 100644 index 0000000000..0a5f05c418 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/close-and-ref-idempotence.ts @@ -0,0 +1,15 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const channel = new BroadcastChannel("idempotent"); +console.log("ref return:", channel.ref() === channel); +console.log("unref return:", channel.unref() === channel); +console.log("ref again:", channel.ref() === channel); +console.log("close return:", channel.close()); +console.log("close twice:", channel.close()); + +try { + channel.postMessage("closed"); + console.log("post closed: ok"); +} catch (error: any) { + console.log("post closed:", error?.name, error?.code ?? ""); +} diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/constructor-and-name.ts b/test-parity/node-suite/worker_threads/broadcast-channel/constructor-and-name.ts new file mode 100644 index 0000000000..53619fdf91 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/constructor-and-name.ts @@ -0,0 +1,20 @@ +import { BroadcastChannel } from "node:worker_threads"; + +for ( + const [label, value] of [ + ["missing", undefined], + ["empty", ""], + ["number", 42], + ["object", { toString: () => "object-name" }], + ] as const +) { + try { + const channel = value === undefined + ? new (BroadcastChannel as any)() + : new BroadcastChannel(value as any); + console.log(label, "name", channel.name, typeof channel.name); + channel.close(); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/listener-once.ts b/test-parity/node-suite/worker_threads/broadcast-channel/listener-once.ts new file mode 100644 index 0000000000..104e8c2e1e --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/listener-once.ts @@ -0,0 +1,28 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const sender = new BroadcastChannel("listener-once"); +const receiver = new BroadcastChannel("listener-once"); +const events: string[] = []; + +function removed(event: MessageEvent) { + events.push(`removed:${event.data}`); +} + +receiver.addEventListener("message", removed); +receiver.removeEventListener("message", removed); +receiver.addEventListener( + "message", + (event) => events.push(`once:${event.data}`), + { once: true }, +); +receiver.addEventListener("message", (event) => { + events.push(`regular:${event.data}`); + if (event.data === "second") { + console.log("events:", events.join(",")); + sender.close(); + receiver.close(); + } +}); + +sender.postMessage("first"); +sender.postMessage("second"); diff --git a/test-parity/node-suite/worker_threads/direct-message/validation.ts b/test-parity/node-suite/worker_threads/direct-message/validation.ts new file mode 100644 index 0000000000..f33e634acb --- /dev/null +++ b/test-parity/node-suite/worker_threads/direct-message/validation.ts @@ -0,0 +1,26 @@ +import { postMessageToThread } from "node:worker_threads"; + +async function check(label: string, fn: () => any) { + try { + await fn(); + console.log(label, "resolved"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +async function main() { + await check("missing id:", () => (postMessageToThread as any)()); + await check("string id:", () => postMessageToThread("1" as any, "value")); + await check("nan id:", () => postMessageToThread(Number.NaN, "value")); + await check("fraction id:", () => postMessageToThread(1.5, "value")); + await check("unknown id:", () => postMessageToThread(2147483647, "value")); + await check( + "bad timeout:", + () => postMessageToThread(2147483647, "value", undefined, -1), + ); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/environment-data/value-identity.ts b/test-parity/node-suite/worker_threads/environment-data/value-identity.ts new file mode 100644 index 0000000000..087aedfbbe --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/value-identity.ts @@ -0,0 +1,14 @@ +import { getEnvironmentData, setEnvironmentData } from "node:worker_threads"; + +const key = { key: true }; +const value = { nested: { count: 1 } }; + +setEnvironmentData(key, value); +console.log("same key:", getEnvironmentData(key) === value); +console.log("different key:", getEnvironmentData({ key: true })); + +value.nested.count = 2; +console.log("live value:", getEnvironmentData(key)?.nested.count); + +setEnvironmentData(key, undefined); +console.log("deleted:", getEnvironmentData(key)); diff --git a/test-parity/node-suite/worker_threads/environment-data/worker-snapshot-worker.cjs b/test-parity/node-suite/worker_threads/environment-data/worker-snapshot-worker.cjs new file mode 100644 index 0000000000..6f7c3203c7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/worker-snapshot-worker.cjs @@ -0,0 +1,19 @@ +const { getEnvironmentData, parentPort } = require("node:worker_threads"); + +const value = getEnvironmentData("snapshot"); +if (!value || !value.nested || !value.values) { + parentPort.postMessage({ + initialCount: "missing", + values: "missing", + localCount: "missing", + }); +} else { + const initialCount = value.nested.count; + value.nested.count = 5; + + parentPort.postMessage({ + initialCount, + values: value.values.join(","), + localCount: getEnvironmentData("snapshot").nested.count, + }); +} diff --git a/test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts b/test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts new file mode 100644 index 0000000000..403a7e8919 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts @@ -0,0 +1,19 @@ +import { Worker } from "node:worker_threads"; +import { getEnvironmentData, setEnvironmentData } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/environment-data"); + +const original = { nested: { count: 1 }, values: [1, 2] }; +setEnvironmentData("snapshot", original); + +const worker = new Worker("./worker-snapshot-worker.cjs"); +setEnvironmentData("snapshot", { nested: { count: 9 }, values: [9] }); +original.nested.count = 7; + +worker.on("message", (message) => { + console.log("worker initial:", message.initialCount, message.values); + console.log("worker local:", message.localCount); + const current = getEnvironmentData("snapshot"); + console.log("parent current:", current?.nested?.count ?? "missing"); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/main-thread/export-surface.ts b/test-parity/node-suite/worker_threads/main-thread/export-surface.ts new file mode 100644 index 0000000000..e624f37b2a --- /dev/null +++ b/test-parity/node-suite/worker_threads/main-thread/export-surface.ts @@ -0,0 +1,36 @@ +import * as workerThreads from "node:worker_threads"; + +const expected = [ + "BroadcastChannel", + "MessageChannel", + "MessagePort", + "SHARE_ENV", + "Worker", + "getEnvironmentData", + "isInternalThread", + "isMainThread", + "isMarkedAsUntransferable", + "locks", + "markAsUncloneable", + "markAsUntransferable", + "moveMessagePortToContext", + "parentPort", + "postMessageToThread", + "receiveMessageOnPort", + "resourceLimits", + "setEnvironmentData", + "threadId", + "threadName", + "workerData", +]; + +for (const name of expected) { + const value = (workerThreads as Record)[name]; + console.log( + name, + Object.prototype.hasOwnProperty.call(workerThreads, name), + typeof value, + ); +} + +console.log("default:", typeof (workerThreads as Record).default); diff --git a/test-parity/node-suite/worker_threads/main-thread/prototype-surface.ts b/test-parity/node-suite/worker_threads/main-thread/prototype-surface.ts new file mode 100644 index 0000000000..c740a59b99 --- /dev/null +++ b/test-parity/node-suite/worker_threads/main-thread/prototype-surface.ts @@ -0,0 +1,43 @@ +import { MessageChannel, Worker } from "node:worker_threads"; + +function summarize(label: string, prototype: object, names: string[]) { + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, name); + console.log( + label, + name, + Boolean(descriptor), + typeof descriptor?.value, + typeof descriptor?.get, + descriptor?.enumerable, + ); + } +} + +summarize("Worker", Worker.prototype, [ + "postMessage", + "terminate", + "ref", + "unref", + "threadId", + "threadName", + "resourceLimits", + "stdin", + "stdout", + "stderr", + "performance", +]); + +const { port1, port2 } = new MessageChannel(); +summarize("MessagePort", Object.getPrototypeOf(port1), [ + "postMessage", + "start", + "close", + "ref", + "unref", + "hasRef", + "onmessage", + "onmessageerror", +]); +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-channel/constructor-options.ts b/test-parity/node-suite/worker_threads/message-channel/constructor-options.ts new file mode 100644 index 0000000000..b742a5c791 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-channel/constructor-options.ts @@ -0,0 +1,22 @@ +import { MessageChannel } from "node:worker_threads"; + +for ( + const [label, args] of [ + ["none", []], + ["one", [1]], + ["many", [1, 2, 3]], + ] as const +) { + try { + const channel = Reflect.construct(MessageChannel, args as readonly any[]); + console.log( + label, + channel.port1 instanceof MessagePort, + channel.port2 instanceof MessagePort, + ); + channel.port1.close(); + channel.port2.close(); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} diff --git a/test-parity/node-suite/worker_threads/message-port/clone-error-rollback.ts b/test-parity/node-suite/worker_threads/message-port/clone-error-rollback.ts new file mode 100644 index 0000000000..e982646a2d --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/clone-error-rollback.ts @@ -0,0 +1,16 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const buffer = new ArrayBuffer(8); + +try { + port1.postMessage({ buffer, invalid() {} }, [buffer]); + console.log("post: ok"); +} catch (error: any) { + console.log("post:", error?.name, error?.code ?? ""); +} + +console.log("buffer retained:", buffer.byteLength); +console.log("nothing queued:", receiveMessageOnPort(port2)); +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/duplicate-transfer.ts b/test-parity/node-suite/worker_threads/message-port/duplicate-transfer.ts new file mode 100644 index 0000000000..1f91c1220c --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/duplicate-transfer.ts @@ -0,0 +1,33 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +function outcome(label: string, fn: () => void) { + try { + fn(); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +const carrier = new MessageChannel(); +const buffer = new ArrayBuffer(8); +outcome( + "duplicate buffer:", + () => carrier.port1.postMessage(buffer, [buffer, buffer]), +); +console.log("buffer retained:", buffer.byteLength); + +const movable = new MessageChannel(); +outcome( + "duplicate port:", + () => + carrier.port1.postMessage(movable.port1, [movable.port1, movable.port1]), +); +movable.port1.postMessage("retained"); +const retained = receiveMessageOnPort(movable.port2); +console.log("port retained:", retained ? retained.message : "missing"); + +carrier.port1.close(); +carrier.port2.close(); +movable.port1.close(); +movable.port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/listener-removal-once.ts b/test-parity/node-suite/worker_threads/message-port/listener-removal-once.ts new file mode 100644 index 0000000000..9e22292624 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/listener-removal-once.ts @@ -0,0 +1,31 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; + +function removed(value: any) { + events.push(`removed:${value}`); +} + +port1.on("message", removed); +port1.off("message", removed); +port1.once("message", (value) => events.push(`once:${value}`)); +port1.on("message", (value) => { + events.push(`on:${value}`); + if (value === "second") { + port1.close(); + } +}); +port1.on("close", () => { + console.log("events:", events.join(",")); + const count = typeof (port1 as any).listenerCount === "function" + ? `${(port1 as any).listenerCount("message")},${ + (port1 as any).listenerCount("close") + }` + : "unsupported"; + console.log("counts:", count); + port2.close(); +}); + +port2.postMessage("first"); +port2.postMessage("second"); diff --git a/test-parity/node-suite/worker_threads/message-port/messageevent-constructor.ts b/test-parity/node-suite/worker_threads/message-port/messageevent-constructor.ts new file mode 100644 index 0000000000..faeba4c2c1 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/messageevent-constructor.ts @@ -0,0 +1,42 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const event = new MessageEvent("message", { + data: { value: 1 }, + origin: "worker-origin", + lastEventId: "event-1", + source: port1, + ports: [port2], +}); + +console.log( + "event:", + event.type, + event.data?.value ?? "missing", + event.origin, + event.lastEventId, +); +console.log("source:", event.source === port1); +console.log( + "ports:", + event.ports?.length ?? "missing", + event.ports?.[0] === port2, +); +console.log("brands:", event instanceof Event, event instanceof MessageEvent); + +for ( + const [label, init] of [ + ["bad source", { source: {} }], + ["bad ports", { ports: [{}] }], + ] as const +) { + try { + new MessageEvent("message", init as any); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/method-receivers.ts b/test-parity/node-suite/worker_threads/message-port/method-receivers.ts new file mode 100644 index 0000000000..ce2a1a8e87 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/method-receivers.ts @@ -0,0 +1,23 @@ +import { MessageChannel, MessagePort } from "node:worker_threads"; + +function check(name: string, method: Function, args: any[] = []) { + for (const receiver of [undefined, null, {}, new EventTarget()]) { + try { + Reflect.apply(method, receiver, args); + console.log(name, typeof receiver, "ok"); + } catch (error: any) { + console.log(name, typeof receiver, error?.name, error?.code ?? ""); + } + } +} + +check("postMessage", MessagePort.prototype.postMessage, ["value"]); +check("start", MessagePort.prototype.start); +check("close", MessagePort.prototype.close); +check("ref", MessagePort.prototype.ref); +check("unref", MessagePort.prototype.unref); +check("hasRef", MessagePort.prototype.hasRef); + +const { port1, port2 } = new MessageChannel(); +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/onmessage-replacement.ts b/test-parity/node-suite/worker_threads/message-port/onmessage-replacement.ts new file mode 100644 index 0000000000..8426cfa1ef --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/onmessage-replacement.ts @@ -0,0 +1,19 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; + +port1.onmessage = (event) => events.push(`first:${event.data}`); +port1.onmessage = (event) => { + events.push(`second:${event.data}`); + port1.onmessage = null; + port2.postMessage("after-null"); + setImmediate(() => { + console.log("events:", events.join(",")); + console.log("handler:", port1.onmessage); + port1.close(); + port2.close(); + }); +}; + +port2.postMessage("value"); diff --git a/test-parity/node-suite/worker_threads/message-port/self-and-closed-transfer.ts b/test-parity/node-suite/worker_threads/message-port/self-and-closed-transfer.ts new file mode 100644 index 0000000000..f1650f5eb5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/self-and-closed-transfer.ts @@ -0,0 +1,31 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +function outcome(label: string, fn: () => void) { + try { + fn(); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +const self = new MessageChannel(); +outcome("self transfer:", () => self.port1.postMessage(null, [self.port1])); +self.port1.postMessage("usable"); +console.log("self retained:", receiveMessageOnPort(self.port2)?.message); + +const carrier = new MessageChannel(); +const closed = new MessageChannel(); +closed.port1.close(); +const buffer = new ArrayBuffer(4); +outcome( + "closed transfer:", + () => carrier.port1.postMessage({ buffer }, [buffer, closed.port1]), +); +console.log("rollback:", buffer.byteLength); + +self.port1.close(); +self.port2.close(); +carrier.port1.close(); +carrier.port2.close(); +closed.port2.close(); diff --git a/test-parity/node-suite/worker_threads/transfer-markers/uncloneable-port-transfer.ts b/test-parity/node-suite/worker_threads/transfer-markers/uncloneable-port-transfer.ts new file mode 100644 index 0000000000..7532008808 --- /dev/null +++ b/test-parity/node-suite/worker_threads/transfer-markers/uncloneable-port-transfer.ts @@ -0,0 +1,28 @@ +import { + markAsUncloneable, + MessageChannel, + receiveMessageOnPort, +} from "node:worker_threads"; + +const carrier = new MessageChannel(); +const movable = new MessageChannel(); +markAsUncloneable(movable.port1); + +try { + carrier.port1.postMessage({ port: movable.port1 }, [movable.port1]); + console.log("transfer: ok"); +} catch (error: any) { + console.log("transfer:", error?.name, error?.code ?? ""); +} + +const packet = receiveMessageOnPort(carrier.port2); +console.log("received port:", packet?.message?.port instanceof MessagePort); +if (typeof packet?.message?.port?.postMessage === "function") { + packet.message.port.postMessage("through-transfer"); +} +console.log("peer:", receiveMessageOnPort(movable.port2)?.message); + +packet?.message?.port?.close?.(); +carrier.port1.close(); +carrier.port2.close(); +movable.port2.close(); diff --git a/test-parity/node-suite/worker_threads/web-locks/web-locks-callback-settlement.ts b/test-parity/node-suite/worker_threads/web-locks/web-locks-callback-settlement.ts new file mode 100644 index 0000000000..a2d5d5a2cc --- /dev/null +++ b/test-parity/node-suite/worker_threads/web-locks/web-locks-callback-settlement.ts @@ -0,0 +1,35 @@ +import { locks } from "node:worker_threads"; + +async function main() { + console.log("sync:", await locks.request("sync", () => 42)); + console.log( + "promise:", + await locks.request("promise", async () => "resolved"), + ); + + try { + await locks.request("throw", () => { + throw new TypeError("callback-failure"); + }); + console.log("throw: resolved"); + } catch (error: any) { + console.log("throw:", error?.name, error?.message); + } + + try { + await locks.request( + "reject", + () => Promise.reject(new RangeError("rejected")), + ); + console.log("reject: resolved"); + } catch (error: any) { + console.log("reject:", error?.name, error?.message); + } + + const snapshot = await locks.query(); + console.log("cleanup:", snapshot.held.length, snapshot.pending.length); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/web-locks/web-locks-independent-names.ts b/test-parity/node-suite/worker_threads/web-locks/web-locks-independent-names.ts new file mode 100644 index 0000000000..1cc529aae7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/web-locks/web-locks-independent-names.ts @@ -0,0 +1,28 @@ +import { locks } from "node:worker_threads"; + +async function main() { + const events: string[] = []; + let releaseA: () => void = () => {}; + + const a = locks.request("lock-a", () => { + events.push("a-held"); + return new Promise((resolve) => { + releaseA = resolve; + }); + }); + + const b = locks.request("lock-b", () => { + events.push("b-held"); + return "b-result"; + }); + + console.log("b result:", await b); + console.log("before release:", events.join(",")); + releaseA(); + await a; + console.log("after release:", events.join(",")); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/argv-options.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-options.ts new file mode 100644 index 0000000000..44c14ded41 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-options.ts @@ -0,0 +1,22 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function start(label: string, argv: any) { + try { + const worker = new Worker("./argv-worker.cjs", { argv }); + worker.on("message", (message) => console.log(label, message)); + worker.on( + "error", + (error: any) => + console.log(label, "error", error?.name, error?.code ?? ""), + ); + worker.on("exit", (code) => console.log(label, "exit", code)); + } catch (error: any) { + console.log(label, "throw", error?.name, error?.code ?? ""); + } +} + +start("coerced:", [null, "text", 123, true]); +start("invalid option:", "text"); +start("invalid entry:", [Symbol("value")]); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/argv-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-worker.cjs new file mode 100644 index 0000000000..4233a6ed87 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-worker.cjs @@ -0,0 +1,3 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.postMessage(JSON.stringify(process.argv.slice(2))); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/env-options.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/env-options.ts new file mode 100644 index 0000000000..c4dc624936 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/env-options.ts @@ -0,0 +1,29 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +process.env.PERRY_PARENT_BEFORE = "before"; +delete process.env.PERRY_PARENT_AFTER; + +const inherited = new Worker("./env-worker.cjs", { workerData: "inherited" }); +process.env.PERRY_PARENT_AFTER = "after"; + +const explicit = new Worker("./env-worker.cjs", { + workerData: "explicit", + env: { PERRY_MANUAL: 42 as any, PERRY_BOOLEAN: true as any }, +}); + +for (const worker of [inherited, explicit]) { + worker.on( + "message", + (message) => console.log("env:", JSON.stringify(message)), + ); + worker.on("exit", (code) => console.log("exit:", code)); +} + +try { + new Worker("./env-worker.cjs", { env: 42 as any }); + console.log("invalid env: ok"); +} catch (error: any) { + console.log("invalid env:", error?.name, error?.code ?? ""); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/env-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/env-worker.cjs new file mode 100644 index 0000000000..0d7d006b77 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/env-worker.cjs @@ -0,0 +1,9 @@ +const { parentPort, workerData } = require("node:worker_threads"); + +parentPort.postMessage({ + label: workerData, + before: process.env.PERRY_PARENT_BEFORE ?? null, + after: process.env.PERRY_PARENT_AFTER ?? null, + manual: process.env.PERRY_MANUAL ?? null, + boolean: process.env.PERRY_BOOLEAN ?? null, +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-listeners.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-listeners.ts new file mode 100644 index 0000000000..069d694bc7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-listeners.ts @@ -0,0 +1,27 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./eventemitter-worker.cjs"); +const events: string[] = []; + +function removed() { + events.push("removed"); +} + +worker.on("message", removed); +worker.off("message", removed); +worker.once("online", () => events.push("online-once")); +worker.on("online", () => events.push("online-on")); +worker.once("message", (value) => events.push(`once:${value}`)); +worker.on("message", (value) => events.push(`on:${value}`)); +worker.on("exit", (code) => { + events.push(`exit:${code}`); + console.log("events:", events.join(",")); + const count = typeof (worker as any).listenerCount === "function" + ? `${(worker as any).listenerCount("message")},${ + (worker as any).listenerCount("online") + }` + : "unsupported"; + console.log("counts:", count); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-worker.cjs new file mode 100644 index 0000000000..97c5e91903 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/eventemitter-worker.cjs @@ -0,0 +1,4 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.postMessage("first"); +parentPort.postMessage("second"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/method-receivers.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/method-receivers.ts new file mode 100644 index 0000000000..c76340b94a --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/method-receivers.ts @@ -0,0 +1,17 @@ +import { Worker } from "node:worker_threads"; + +function check(name: string, method: Function, args: any[] = []) { + for (const receiver of [undefined, null, {}]) { + try { + Reflect.apply(method, receiver, args); + console.log(name, typeof receiver, "ok"); + } catch (error: any) { + console.log(name, typeof receiver, error?.name, error?.code ?? ""); + } + } +} + +check("postMessage", Worker.prototype.postMessage, ["value"]); +check("terminate", Worker.prototype.terminate); +check("ref", Worker.prototype.ref); +check("unref", Worker.prototype.unref); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors-worker.cjs new file mode 100644 index 0000000000..2eddaa1e7a --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors-worker.cjs @@ -0,0 +1,7 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.once("message", (message) => { + if (message === "finish") { + parentPort.postMessage("finished"); + } +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors.ts new file mode 100644 index 0000000000..108e23df10 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-errors.ts @@ -0,0 +1,30 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./postmessage-errors-worker.cjs"); + +function outcome(label: string, value: any, transfer?: readonly any[]) { + try { + worker.postMessage(value, transfer as any); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +worker.on("online", () => { + outcome("function:", () => 1); + outcome("symbol:", Symbol("value")); + + const buffer = new ArrayBuffer(8); + outcome("rollback:", { buffer, invalid() {} }, [buffer]); + console.log("buffer retained:", buffer.byteLength); + + worker.postMessage("finish"); +}); +worker.on("message", (message) => { + console.log("message:", message); + worker.terminate().then((code) => console.log("terminate:", code)); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-worker.cjs new file mode 100644 index 0000000000..80e22c5aa0 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-worker.cjs @@ -0,0 +1,13 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.postMessage({ + phase: "ready", + parent: process.env.PERRY_SHARED_PARENT, +}); +parentPort.once("message", () => { + process.env.PERRY_SHARED_WORKER = "worker-value"; + parentPort.postMessage({ + phase: "checked", + parent: process.env.PERRY_SHARED_PARENT, + }); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env.ts new file mode 100644 index 0000000000..29f211aafe --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env.ts @@ -0,0 +1,21 @@ +import { SHARE_ENV, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +process.env.PERRY_SHARED_PARENT = "initial"; +delete process.env.PERRY_SHARED_WORKER; + +const worker = new Worker("./share-env-worker.cjs", { env: SHARE_ENV }); +worker.on("message", (message: any) => { + if (message.phase === "ready") { + console.log("worker initial:", message.parent); + process.env.PERRY_SHARED_PARENT = "updated"; + worker.postMessage("check"); + return; + } + + console.log("worker updated:", message.parent); + console.log("parent sees worker:", process.env.PERRY_SHARED_WORKER); + worker.terminate().then((code) => console.log("terminate:", code)); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/terminate-idempotent.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/terminate-idempotent.ts new file mode 100644 index 0000000000..7d19637a16 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/terminate-idempotent.ts @@ -0,0 +1,12 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./termination-worker.cjs"); +worker.on("online", async () => { + const first = worker.terminate(); + const second = worker.terminate(); + console.log("promise identity:", first === second); + console.log("results:", await first, await second); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata-worker.cjs new file mode 100644 index 0000000000..fcb6cc2d37 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata-worker.cjs @@ -0,0 +1,3 @@ +const { parentPort, threadId, threadName } = require("node:worker_threads"); + +parentPort.postMessage({ threadId, threadName }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata.ts new file mode 100644 index 0000000000..ea8e55fabb --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/thread-metadata.ts @@ -0,0 +1,22 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./thread-metadata-worker.cjs", { + name: "parity-worker", +}); + +console.log("parent initial:", worker.threadId > 0, worker.threadName); +worker.on("online", () => { + console.log("online:", worker.threadId > 0, worker.threadName); +}); +worker.on("message", (message) => { + console.log( + "worker values:", + message.threadId === worker.threadId, + message.threadName, + ); +}); +worker.on("exit", (code) => { + console.log("exit:", code, worker.threadId, worker.threadName); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts new file mode 100644 index 0000000000..78e681f0fd --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts @@ -0,0 +1,20 @@ +import { Worker } from "node:worker_threads"; + +const values: Array<[string, any]> = [ + ["undefined", undefined], + ["null", null], + ["number", 1], + ["object", {}], + ["bare relative", "worker.cjs"], + ["http string", "https://example.com/worker.js"], + ["http URL", new URL("https://example.com/worker.js")], +]; + +for (const [label, value] of values) { + try { + new Worker(value); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer-worker.cjs new file mode 100644 index 0000000000..2e1e5b9b4e --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer-worker.cjs @@ -0,0 +1,10 @@ +const { parentPort, workerData } = require("node:worker_threads"); + +const view = new Uint8Array(workerData.shared); +const before = Array.from(view).join(","); +view[0] = 9; +parentPort.postMessage({ + brand: workerData.shared instanceof SharedArrayBuffer, + before, + after: Array.from(view).join(","), +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer.ts new file mode 100644 index 0000000000..9f92f030de --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-shared-array-buffer.ts @@ -0,0 +1,16 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const shared = new SharedArrayBuffer(4); +const view = new Uint8Array(shared); +view.set([1, 2, 3, 4]); + +const worker = new Worker("./workerdata-shared-array-buffer-worker.cjs", { + workerData: { shared }, +}); +worker.on("message", (message) => { + console.log("worker:", message.brand, message.before, message.after); + console.log("parent shared:", Array.from(view).join(",")); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index a409eca6ae..f707fa51df 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; overall inventory metadata applies its cumulative measured +7 pass / +38 total delta to the prior clean snapshot, yielding 2817/2901 (97.1%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; overall inventory metadata applies its cumulative measured +11 pass / +67 total delta to the prior clean snapshot, yielding 2821/2930 (96.3%)." }, "overall": { - "pass": 2817, - "total": 2901, - "pct": 97.1 + "pass": 2821, + "total": 2930, + "pct": 96.3 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 24, - "total": 55 + "pass": 28, + "total": 84 }, "zlib": { "pass": 58, From b8f320947fedeb705334b5e0283361e4dd36a460 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 09:26:14 +0200 Subject: [PATCH 04/24] test: extend worker_threads deterministic contracts --- .../node-suite/worker_threads/README.md | 104 +++++++++++------- .../builtin-snapshot-worker.cjs | 25 +++++ .../environment-data/builtin-snapshot.ts | 32 ++++++ .../environment-clean-worker.cjs | 3 + .../nested-inheritance-worker.cjs | 15 +++ .../environment-data/nested-inheritance.ts | 30 +++++ .../environment-data/nested-leaf-worker.cjs | 3 + .../uncloneable-construction.ts | 16 +++ .../environment-data/worker-snapshot.ts | 7 +- .../message-port/close-dispatch-flush.ts | 47 ++++++++ .../message-port/listener-validation.ts | 24 ++++ .../message-port/messageevent-iterables.ts | 36 ++++++ .../message-port/node-event-target-surface.ts | 37 +++++++ .../message-port/transfer-iterables.ts | 48 ++++++++ .../transfer-markers/marker-privacy.ts | 46 ++++++++ .../captured-output-worker.cjs | 5 + .../worker-lifecycle/captured-output.ts | 37 +++++++ .../worker-lifecycle/eval-basic.ts | 30 +++++ .../worker-lifecycle/eval-error.ts | 18 +++ .../worker-lifecycle/execargv-options.ts | 40 +++++++ .../worker-lifecycle/execargv-worker.cjs | 3 + .../worker-lifecycle/exit-code-explicit.ts | 14 +++ .../worker-lifecycle/exit-code-property.ts | 14 +++ .../worker-lifecycle/exit-codes-worker.cjs | 8 ++ .../worker-lifecycle/file-url-unicode.ts | 21 ++++ .../worker-lifecycle/listener-validation.ts | 25 +++++ .../worker-lifecycle/multiple-worker-ids.ts | 38 +++++++ .../parent-port-onmessage-worker.cjs | 13 +++ .../worker-lifecycle/parent-port-onmessage.ts | 23 ++++ .../parent-port-ref-worker.cjs | 14 +++ .../worker-lifecycle/parent-port-ref.ts | 17 +++ ...postmessage-shared-array-buffer-worker.cjs | 12 ++ .../postmessage-shared-array-buffer.ts | 16 +++ .../process-unsupported-worker.cjs | 18 +++ .../worker-lifecycle/process-unsupported.ts | 15 +++ .../queued-port-receive-worker.cjs | 18 +++ .../worker-lifecycle/queued-port-receive.ts | 27 +++++ .../unreferenced-arraybuffer-transfer.ts | 16 +++ .../worker-lifecycle/url-\317\200-worker.cjs" | 4 + .../worker-eventemitter-surface.ts | 48 ++++++++ test-parity/node_suite_baseline.json | 12 +- 41 files changed, 929 insertions(+), 50 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/environment-data/builtin-snapshot-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/environment-data/builtin-snapshot.ts create mode 100644 test-parity/node-suite/worker_threads/environment-data/environment-clean-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/environment-data/nested-inheritance-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/environment-data/nested-inheritance.ts create mode 100644 test-parity/node-suite/worker_threads/environment-data/nested-leaf-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/close-dispatch-flush.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/listener-validation.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/messageevent-iterables.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/node-event-target-surface.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-iterables.ts create mode 100644 test-parity/node-suite/worker_threads/transfer-markers/marker-privacy.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/captured-output-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/captured-output.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/eval-basic.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/eval-error.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/execargv-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-explicit.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-property.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/exit-codes-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/file-url-unicode.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/listener-validation.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/multiple-worker-ids.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/unreferenced-arraybuffer-transfer.ts create mode 100644 "test-parity/node-suite/worker_threads/worker-lifecycle/url-\317\200-worker.cjs" create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/worker-eventemitter-surface.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 711012261a..fefab28d56 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -26,29 +26,37 @@ that oracle. - `main-thread/`: complete namespace export availability and types, main-thread values, Worker and MessagePort prototype descriptors, and constructor brands. - `environment-data/`: key identity, live main-thread values, deletion, worker - snapshot cloning, and parent/worker mutation isolation. + snapshot cloning, built-in Map/Set/Date values, nested-worker inheritance, + non-cloneable construction rejection, and parent/worker mutation isolation. - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, transfer detachment, MessagePort ownership, overloads, and atomic rollback. - `message-port/`: synchronous FIFO receives, invalid-port validation, explicit `start()`, event fields/ports, listener deduplication, close callback ordering, listener removal/`once`, `onmessage` replacement, method receiver - validation, MessageEvent construction, duplicate/self/closed transfers, - atomic clone rollback, transfer-state validation, queued delivery, overload - validation, and `ref`/`unref`/`hasRef` state. + validation, NodeEventTarget surface/listener validation, iterable MessageEvent + ports and transfer options, dispatch-time close flushing, + duplicate/self/closed transfers, atomic clone rollback, transfer-state + validation, queued delivery, overload validation, and + `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement. - `worker-lifecycle/`: constructor transfer validation, structured `workerData`, default and built-in workerData, MessagePort/ArrayBuffer transfer, environment - snapshots, argv coercion/validation, inherited/explicit/shared environments, - SharedArrayBuffer workerData, thread id/name lifecycle, unsupported paths, - EventEmitter listener behavior, method receivers, postMessage clone rollback, - `online`/`message`/`error`/`exit` ordering, ref return values, natural exit, - and deterministic repeated termination settlement. + snapshots, eval/CJS require, file URLs with Unicode names, argv and execArgv + coercion/validation, inherited/explicit/shared environments, SharedArrayBuffer + through workerData and postMessage, queued transferred-port receive, thread + id/name uniqueness and lifecycle, parentPort `onmessage`/ref state, Worker + EventEmitter surface and listener validation, process restrictions, captured + stdout/stderr, explicit and `process.exitCode` exits, unsupported paths, + method receivers, postMessage clone rollback, `online`/`message`/`error`/`exit` + ordering, ref return values, natural exit, and deterministic repeated + termination settlement. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and - the distinction between marking a port uncloneable and transferring it. + the distinction between marking a port uncloneable and transferring it, plus + private, unforgeable, and permanent marker behavior. - `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, listener management/`once`, name coercion, close/ref idempotence, typed-array and SharedArrayBuffer cloning, untransferable-value rejection, and @@ -59,34 +67,42 @@ that oracle. - `direct-message/`: delivery, no-listener and timeout rejection, plus thread id and timeout argument validation. -Every asynchronous fixture uses a message, close, exit, or promise-settlement -barrier. No added fixture uses a sleep as a completion condition. +Every asynchronous fixture uses a message, close, exit, stream-EOF, or +promise-settlement barrier. No added fixture uses a sleep as a completion +condition. ## Stopping judgment The remaining upstream cases were not copied because they are redundant with the cases above or belong to a separate slow/risky runtime feature: -- Resource-limit enforcement, stdio timing/backpressure, heap snapshots and - CPU/heap profiling are resource- and platform-sensitive. The pre-existing - `worker-file/options-diagnostics.ts` keeps surface coverage only. -- Eval/data-URL/ESM loaders, preload/`execArgv`, signals, inspector integration, - process exit variants, and source maps primarily exercise loader, CLI, or - process subsystems rather than the worker messaging contract. -- Nested-worker stress, GC/finalization of unreachable ports, shared native - handles, large message loops, and termination races are scheduler-sensitive - and need dedicated stress infrastructure. -- SharedArrayBuffer aliasing is now covered deterministically through both a - MessagePort and BroadcastChannel. Cross-agent wait/notify coordination stays - separate because it requires scheduler-aware runtime infrastructure. +- Resource-limit enforcement, stdio backpressure/large-write timing, heap + snapshots, and CPU/heap profiling are resource- and platform-sensitive. Basic + captured stdout/stderr and the diagnostic method surface are covered without + introducing pressure or timing assertions. +- Basic eval/CJS require, execArgv, file-URL construction, process restrictions, + and exit codes are now covered. Data-URL/ESM/custom loaders, preload chains, + signals, inspector integration, beforeExit/uncaught-exception variants, and + source maps remain separate loader, CLI, or process-subsystem work. +- One controlled nested worker now covers environment-data inheritance. Deeper + nesting stress, GC/finalization of unreachable ports, shared native handles, + large message loops, and termination races remain scheduler-sensitive. +- SharedArrayBuffer aliasing is covered through same-thread MessagePort and + BroadcastChannel paths plus cross-agent workerData and postMessage. Atomics + wait/notify coordination remains separate scheduler-aware infrastructure. +- A cross-worker BroadcastChannel delivery candidate was rejected after Node + required cross-agent scheduling turns while Perry's absent delivery left no + deterministic completion event. It belongs with scheduler-aware cross-agent + infrastructure rather than a sleep/timeout-based fixture here. - Web Locks now cover deterministic pre-abort and steal behavior in addition to surface, validation, shared/exclusive ordering, `ifAvailable`, and query snapshots. In-flight abort races and cross-agent ownership remain excluded. - The existing `direct-message/` fixtures already cover deterministic `postMessageToThread` delivery, rejection, and timeout behavior. -The measured focused result is `28/84`: all 17 pre-existing cases remain green, -11 added cases pass, and 56 added cases expose stable diagnostic differences. +The measured focused result is `29/109`: all 17 pre-existing cases remain +green, 12 added cases pass, and 80 added cases expose stable diagnostic +differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -96,29 +112,33 @@ The passing additions are `broadcast-channel/fanout-fifo.ts`, `web-locks/web-locks-steal.ts`. The latest broad pass also adds green coverage for `environment-data/value-identity.ts`, `message-port/onmessage-replacement.ts`, +`worker-lifecycle/eval-basic.ts`, `worker-lifecycle/method-receivers.ts`, and `web-locks/web-locks-independent-names.ts`. The diagnostic differences are: - All nine `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Fifteen `message-port/` diagnostics: closing drops queued data and callbacks, - listener counts, receiver/option/transfer validation, event construction and - `ports`, duplicate/self/closed transfer rollback, moved ownership, and - `hasRef()` state differ. -- Seventeen `worker-lifecycle/` diagnostics: invalid constructor payloads and - paths, argv/env handling, shared environments, worker metadata, listener - behavior, repeated termination, workerData cloning/SAB sharing, postMessage - rollback, post-exit values, and worker error routing differ. -- All four `transfer-markers/` fixtures: primitives are reported as marked, - nested clone rejection and ArrayBuffer exceptions differ, and marked - transferables and uncloneable ports differ. +- Twenty `message-port/` diagnostics: closing and dispatch flushing, queued + data/callbacks, NodeEventTarget surface, listener counts and validation, + receiver/options/iterables/transfers, MessageEvent construction and `ports`, + duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. +- Thirty-two `worker-lifecycle/` diagnostics: invalid constructor payloads and + paths, execArgv, captured stdio, process restrictions/exit codes, shared + environments and nested messages, worker/parentPort EventEmitter behavior, + metadata/unique ids, repeated termination, workerData and postMessage SAB + sharing, queued port receive, rollback, post-exit values, and error routing + differ. Basic eval with CJS require passes. +- All five `transfer-markers/` fixtures: primitives are reported as marked, + nested clone rejection, private/permanent marker enforcement, ArrayBuffer + exceptions, and marked transferables/uncloneable ports differ. - Five `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, and closed-channel posts differ. -- Six additional diagnostics cover namespace/prototype completeness, - environment worker snapshots, MessageChannel construction, direct-message - argument validation, and Web Locks callback rejection cleanup. +- Nine additional diagnostics cover namespace/prototype completeness, + environment-data built-ins/inheritance/construction, MessageChannel + construction, direct-message argument validation, and Web Locks callback + rejection cleanup. The clean measurement used: @@ -129,9 +149,9 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `28/84 (33.3%), diff=56`, with no compile failures or timeouts. +It reported `29/109 (26.6%), diff=80`, with no compile failures or timeouts. The SharedArrayBuffer/Atomics boundary is backed by the added same-process -channel cases plus the existing +channel and cross-worker clone/alias cases plus the existing granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and `util/types/arraybuffer-sharedarraybuffer.ts` cases; cross-agent fixtures such as Node's `test-worker-message-channel-sharedarraybuffer.js` and Deno's diff --git a/test-parity/node-suite/worker_threads/environment-data/builtin-snapshot-worker.cjs b/test-parity/node-suite/worker_threads/environment-data/builtin-snapshot-worker.cjs new file mode 100644 index 0000000000..43cfa9ada2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/builtin-snapshot-worker.cjs @@ -0,0 +1,25 @@ +const { getEnvironmentData, parentPort } = require("node:worker_threads"); + +const value = getEnvironmentData("builtins"); +if (!(value instanceof Map)) { + parentPort.postMessage({ + map: false, + date: false, + set: false, + dateValue: "missing", + setValue: "missing", + localMutation: false, + }); +} else { + const date = value.get("date"); + const set = value.get("set"); + value.set("worker-only", true); + parentPort.postMessage({ + map: true, + date: date instanceof Date, + set: set instanceof Set, + dateValue: date instanceof Date ? date.toISOString() : "missing", + setValue: set instanceof Set ? Array.from(set).join(",") : "missing", + localMutation: value.has("worker-only"), + }); +} diff --git a/test-parity/node-suite/worker_threads/environment-data/builtin-snapshot.ts b/test-parity/node-suite/worker_threads/environment-data/builtin-snapshot.ts new file mode 100644 index 0000000000..b16a7d1492 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/builtin-snapshot.ts @@ -0,0 +1,32 @@ +import { + getEnvironmentData, + setEnvironmentData, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/environment-data"); + +const map = new Map([ + ["date", new Date("2022-03-04T05:06:07.000Z")], + ["set", new Set([2, 4, 6])], +]); +setEnvironmentData("builtins", map); + +const worker = new Worker("./builtin-snapshot-worker.cjs"); +worker.on("message", (message) => { + console.log( + "worker brands:", + message.map, + message.date, + message.set, + ); + console.log( + "worker values:", + message.dateValue, + message.setValue, + message.localMutation, + ); + const parent = getEnvironmentData("builtins"); + console.log("parent unchanged:", parent.has("worker-only"), parent === map); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/environment-data/environment-clean-worker.cjs b/test-parity/node-suite/worker_threads/environment-data/environment-clean-worker.cjs new file mode 100644 index 0000000000..056e53fe93 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/environment-clean-worker.cjs @@ -0,0 +1,3 @@ +const { getEnvironmentData, parentPort } = require("node:worker_threads"); + +parentPort.postMessage(getEnvironmentData("uncloneable") === undefined); diff --git a/test-parity/node-suite/worker_threads/environment-data/nested-inheritance-worker.cjs b/test-parity/node-suite/worker_threads/environment-data/nested-inheritance-worker.cjs new file mode 100644 index 0000000000..ced59efd61 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/nested-inheritance-worker.cjs @@ -0,0 +1,15 @@ +const path = require("node:path"); +const { + getEnvironmentData, + parentPort, + setEnvironmentData, + Worker, + workerData, +} = require("node:worker_threads"); + +const inherited = getEnvironmentData("nested-key"); +const first = inherited?.level ?? "missing"; +setEnvironmentData("nested-key", { level: workerData.level }); + +const nested = new Worker(path.join(__dirname, "nested-leaf-worker.cjs")); +nested.on("message", (second) => parentPort.postMessage({ first, second })); diff --git a/test-parity/node-suite/worker_threads/environment-data/nested-inheritance.ts b/test-parity/node-suite/worker_threads/environment-data/nested-inheritance.ts new file mode 100644 index 0000000000..8e46d4b321 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/nested-inheritance.ts @@ -0,0 +1,30 @@ +import { + getEnvironmentData, + setEnvironmentData, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/environment-data"); + +setEnvironmentData("nested-key", { level: 0 }); +const worker = new Worker("./nested-inheritance-worker.cjs", { + workerData: { level: 1 }, +}); + +let result: any; +worker.on("message", (message) => { + result = message; +}); +worker.on( + "error", + (error: any) => console.log("error:", error?.name, error?.code ?? ""), +); +worker.on("exit", (code) => { + console.log( + "levels:", + result?.first ?? "missing", + result?.second ?? "missing", + ); + console.log("parent:", getEnvironmentData("nested-key").level); + console.log("exit:", code); +}); diff --git a/test-parity/node-suite/worker_threads/environment-data/nested-leaf-worker.cjs b/test-parity/node-suite/worker_threads/environment-data/nested-leaf-worker.cjs new file mode 100644 index 0000000000..2c48f98f0b --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/nested-leaf-worker.cjs @@ -0,0 +1,3 @@ +const { getEnvironmentData, parentPort } = require("node:worker_threads"); + +parentPort.postMessage(getEnvironmentData("nested-key")?.level ?? "missing"); diff --git a/test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts b/test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts new file mode 100644 index 0000000000..345168fcb6 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts @@ -0,0 +1,16 @@ +import { setEnvironmentData, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/environment-data"); + +setEnvironmentData("uncloneable", () => "value"); +try { + new Worker("./worker-snapshot-worker.cjs"); + console.log("construct: ok"); +} catch (error: any) { + console.log("construct:", error?.name, error?.code ?? ""); +} + +setEnvironmentData("uncloneable", undefined); +const worker = new Worker("./environment-clean-worker.cjs"); +worker.on("message", (message) => console.log("after delete:", message)); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts b/test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts index 403a7e8919..5ccb378ec1 100644 --- a/test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts +++ b/test-parity/node-suite/worker_threads/environment-data/worker-snapshot.ts @@ -1,5 +1,8 @@ -import { Worker } from "node:worker_threads"; -import { getEnvironmentData, setEnvironmentData } from "node:worker_threads"; +import { + getEnvironmentData, + setEnvironmentData, + Worker, +} from "node:worker_threads"; process.chdir("test-parity/node-suite/worker_threads/environment-data"); diff --git a/test-parity/node-suite/worker_threads/message-port/close-dispatch-flush.ts b/test-parity/node-suite/worker_threads/message-port/close-dispatch-flush.ts new file mode 100644 index 0000000000..c9ff61e728 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/close-dispatch-flush.ts @@ -0,0 +1,47 @@ +import { MessageChannel } from "node:worker_threads"; + +function closeBeforeDispatch(): Promise { + return new Promise((resolve) => { + const { port1, port2 } = new MessageChannel(); + const seen: number[] = []; + port1.on("message", (value) => seen.push(value)); + port1.on("close", () => { + console.log("outside dispatch:", seen.join(",") || "empty"); + port2.close(); + resolve(); + }); + port2.postMessage(1); + port2.postMessage(2); + port1.close(); + }); +} + +function closeInsideDispatch(): Promise { + return new Promise((resolve) => { + const { port1, port2 } = new MessageChannel(); + const seen: number[] = []; + port1.on("message", (value) => { + seen.push(value); + if (value === 1) { + port1.close(); + } + }); + port1.on("close", () => { + console.log("inside dispatch:", seen.join(",") || "empty"); + port2.close(); + resolve(); + }); + port2.postMessage(1); + port2.postMessage(2); + port2.postMessage(3); + }); +} + +async function main() { + await closeBeforeDispatch(); + await closeInsideDispatch(); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/message-port/listener-validation.ts b/test-parity/node-suite/worker_threads/message-port/listener-validation.ts new file mode 100644 index 0000000000..e2a12fe060 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/listener-validation.ts @@ -0,0 +1,24 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +function outcome(label: string, fn: () => void) { + try { + fn(); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +outcome("on null:", () => (port1 as any).on("message", null)); +outcome("once number:", () => (port1 as any).once("message", 1)); +outcome("addListener object:", () => (port1 as any).addListener("message", {})); +outcome("off undefined:", () => (port1 as any).off("message", undefined)); +outcome( + "addEventListener null:", + () => port1.addEventListener("message", null as any), +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/messageevent-iterables.ts b/test-parity/node-suite/worker_threads/message-port/messageevent-iterables.ts new file mode 100644 index 0000000000..bc341ba735 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/messageevent-iterables.ts @@ -0,0 +1,36 @@ +import { MessageChannel } from "node:worker_threads"; + +const first = new MessageChannel(); +const second = new MessageChannel(); +let iterations = 0; + +const ports = { + *[Symbol.iterator]() { + iterations += 1; + yield first.port1; + yield second.port1; + }, +}; + +const event = new MessageEvent("message", { ports: ports as any }); +console.log( + "iterable:", + iterations, + event.ports?.length ?? "missing", + event.ports?.[0] === first.port1, + event.ports?.[1] === second.port1, +); + +try { + new MessageEvent("message", { + ports: new Set([first.port2, {} as MessagePort]) as any, + }); + console.log("invalid iterable: ok"); +} catch (error: any) { + console.log("invalid iterable:", error?.name, error?.code ?? ""); +} + +first.port1.close(); +first.port2.close(); +second.port1.close(); +second.port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/node-event-target-surface.ts b/test-parity/node-suite/worker_threads/message-port/node-event-target-surface.ts new file mode 100644 index 0000000000..933f15ce92 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/node-event-target-surface.ts @@ -0,0 +1,37 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const methods = [ + "addListener", + "emit", + "eventNames", + "listenerCount", + "listeners", + "off", + "on", + "once", + "prependListener", + "prependOnceListener", + "rawListeners", + "removeAllListeners", + "removeListener", +]; + +console.log( + "methods:", + methods.map((name) => `${name}:${typeof (port1 as any)[name]}`).join(","), +); +console.log( + "event target:", + typeof port1.addEventListener, + typeof port1.removeEventListener, + typeof port1.dispatchEvent, +); + +const eventNames = typeof (port1 as any).eventNames === "function" + ? JSON.stringify((port1 as any).eventNames()) + : "unsupported"; +console.log("event names:", eventNames); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-iterables.ts b/test-parity/node-suite/worker_threads/message-port/transfer-iterables.ts new file mode 100644 index 0000000000..0493ea3701 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-iterables.ts @@ -0,0 +1,48 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +function outcome(label: string, fn: () => void) { + try { + fn(); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? "", error?.message); + } +} + +const { port1, port2 } = new MessageChannel(); +const valid = new ArrayBuffer(4); +new Uint8Array(valid)[0] = 7; +port1.postMessage( + { valid }, + { transfer: new Set([valid]) } as any, +); +const packet = receiveMessageOnPort(port2); +const received = packet ? packet.message?.valid : undefined; +console.log( + "valid set:", + valid.byteLength, + received instanceof ArrayBuffer, + received instanceof ArrayBuffer ? new Uint8Array(received)[0] : "missing", +); + +let iterations = 0; +const throwing = { + *[Symbol.iterator]() { + iterations += 1; + throw new Error("iterator-boom"); + }, +}; +outcome( + "throwing iterable:", + () => port1.postMessage({}, { transfer: throwing } as any), +); +console.log("iterations:", iterations); + +outcome( + "invalid set:", + () => port1.postMessage({}, { transfer: new Set([5]) } as any), +); +outcome("noniterable:", () => port1.postMessage({}, { transfer: 5 } as any)); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/transfer-markers/marker-privacy.ts b/test-parity/node-suite/worker_threads/transfer-markers/marker-privacy.ts new file mode 100644 index 0000000000..20266e6aa5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/transfer-markers/marker-privacy.ts @@ -0,0 +1,46 @@ +import { + isMarkedAsUntransferable, + markAsUncloneable, + markAsUntransferable, +} from "node:worker_threads"; + +function cloneOutcome(value: any): string { + try { + structuredClone(value); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const marked: any = { value: 1 }; +markAsUncloneable(marked); +console.log( + "private clone mark:", + Object.getOwnPropertySymbols(marked).length, + Reflect.ownKeys(marked).join(","), + cloneOutcome(marked), +); + +delete marked[Symbol.for("nodejs.worker_threads.uncloneable")]; +for (const symbol of Object.getOwnPropertySymbols(marked)) { + delete marked[symbol]; +} +console.log("permanent:", cloneOutcome(marked)); + +const forged: any = { value: 2 }; +forged[Symbol.for("nodejs.worker_threads.uncloneable")] = true; +forged.isUncloneable = true; +console.log("forged clone:", cloneOutcome(forged)); + +const forgedBuffer: any = new ArrayBuffer(4); +forgedBuffer[Symbol.for("nodejs.worker_threads.untransferable")] = true; +console.log("forged transfer mark:", isMarkedAsUntransferable(forgedBuffer)); + +const realBuffer = new ArrayBuffer(4); +markAsUntransferable(realBuffer); +console.log( + "private transfer mark:", + Object.getOwnPropertySymbols(realBuffer).length, + isMarkedAsUntransferable(realBuffer), +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/captured-output-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/captured-output-worker.cjs new file mode 100644 index 0000000000..2f2ab8b54e --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/captured-output-worker.cjs @@ -0,0 +1,5 @@ +const { parentPort } = require("node:worker_threads"); + +process.stdout.write("worker-stdout\n"); +process.stderr.write("worker-stderr\n"); +parentPort.postMessage("written"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/captured-output.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/captured-output.ts new file mode 100644 index 0000000000..cd9a3fd111 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/captured-output.ts @@ -0,0 +1,37 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function main() { + const worker = new Worker("./captured-output-worker.cjs", { + stdout: true, + stderr: true, + }); + let stdout = ""; + let stderr = ""; + let message = "missing"; + + worker.stdout.setEncoding("utf8"); + worker.stderr.setEncoding("utf8"); + worker.stdout.on("data", (chunk) => stdout += chunk); + worker.stderr.on("data", (chunk) => stderr += chunk); + worker.on("message", (value) => message = value); + + const exit = new Promise((resolve) => worker.on("exit", resolve)); + const stdoutEnd = new Promise((resolve) => + worker.stdout.on("end", resolve) + ); + const stderrEnd = new Promise((resolve) => + worker.stderr.on("end", resolve) + ); + const [code] = await Promise.all([exit, stdoutEnd, stderrEnd]); + + console.log("message:", message); + console.log("stdout:", stdout.trim()); + console.log("stderr:", stderr.trim()); + console.log("exit:", code); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/eval-basic.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-basic.ts new file mode 100644 index 0000000000..37f8339b13 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-basic.ts @@ -0,0 +1,30 @@ +import { Worker } from "node:worker_threads"; + +const events: string[] = []; +const worker = new Worker( + ` + const { parentPort, workerData } = require("node:worker_threads"); + const path = require("node:path"); + parentPort.postMessage({ + sum: workerData.left + workerData.right, + basename: path.basename("/tmp/value.txt"), + }); + `, + { + eval: true, + workerData: { left: 2, right: 3 }, + name: "eval-parity", + }, +); + +worker.on("online", () => events.push("online")); +worker.on("message", (message) => { + events.push(`message:${message.sum}:${message.basename}`); +}); +worker.on("error", (error: any) => { + events.push(`error:${error?.name}`); +}); +worker.on("exit", (code) => { + events.push(`exit:${code}`); + console.log("events:", events.join(",")); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/eval-error.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-error.ts new file mode 100644 index 0000000000..7e21a141a3 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-error.ts @@ -0,0 +1,18 @@ +import { Worker } from "node:worker_threads"; + +const worker = new Worker( + ` + throw new TypeError("eval-boom"); + `, + { eval: true }, +); + +const events: string[] = []; +worker.on("online", () => events.push("online")); +worker.on("error", (error: any) => { + events.push(`error:${error?.name}:${error?.message}`); +}); +worker.on("exit", (code) => { + events.push(`exit:${code}`); + console.log("events:", events.join(",")); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts new file mode 100644 index 0000000000..20a6348e98 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts @@ -0,0 +1,40 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function run(label: string, options: Record): Promise { + return new Promise((resolve) => { + const worker = new Worker("./execargv-worker.cjs", options); + worker.on("message", (message) => console.log(label, message)); + worker.on( + "error", + (error: any) => + console.log(label, "error", error?.name, error?.code ?? ""), + ); + worker.on("exit", (code) => { + console.log(label, "exit", code); + resolve(); + }); + }); +} + +async function main() { + console.log("parent:", JSON.stringify(process.execArgv)); + await run("inherited:", {}); + await run("empty:", { execArgv: [] }); + await run("custom:", { execArgv: ["--no-warnings"] }); + await run("nonstrings:", { execArgv: [1, null, true] as any }); + + try { + new Worker("./execargv-worker.cjs", { + execArgv: ["--not-a-real-node-flag"], + }); + console.log("invalid flag: ok"); + } catch (error: any) { + console.log("invalid flag:", error?.name, error?.code ?? ""); + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-worker.cjs new file mode 100644 index 0000000000..3685a944e2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-worker.cjs @@ -0,0 +1,3 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.postMessage(JSON.stringify(process.execArgv)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-explicit.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-explicit.ts new file mode 100644 index 0000000000..50203e3162 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-explicit.ts @@ -0,0 +1,14 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./exit-codes-worker.cjs", { + workerData: "explicit", +}); +const events: string[] = []; +worker.on("message", (message) => events.push(`message:${message}`)); +worker.on("error", (error: any) => events.push(`error:${error?.name}`)); +worker.on("exit", (code) => { + events.push(`exit:${code}`); + console.log("explicit:", events.join(",")); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-property.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-property.ts new file mode 100644 index 0000000000..40194a2989 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-code-property.ts @@ -0,0 +1,14 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./exit-codes-worker.cjs", { + workerData: "exitCode", +}); +const events: string[] = []; +worker.on("message", (message) => events.push(`message:${message}`)); +worker.on("error", (error: any) => events.push(`error:${error?.name}`)); +worker.on("exit", (code) => { + events.push(`exit:${code}`); + console.log("exitCode:", events.join(",")); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/exit-codes-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-codes-worker.cjs new file mode 100644 index 0000000000..304f5dbb19 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-codes-worker.cjs @@ -0,0 +1,8 @@ +const { parentPort, workerData } = require("node:worker_threads"); + +if (workerData === "explicit") { + process.exit(5); +} else { + process.exitCode = 7; + parentPort.postMessage("set"); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/file-url-unicode.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/file-url-unicode.ts new file mode 100644 index 0000000000..70371f9e54 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/file-url-unicode.ts @@ -0,0 +1,21 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const filename = path.resolve("url-Ï€-worker.cjs"); +try { + const worker = new Worker(pathToFileURL(filename), { + workerData: "unicode-ok", + }); + + worker.on("message", (message) => console.log("message:", message)); + worker.on( + "error", + (error: any) => console.log("error:", error?.name, error?.code ?? ""), + ); + worker.on("exit", (code) => console.log("exit:", code)); +} catch (error: any) { + console.log("construct:", error?.name, error?.code ?? ""); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/listener-validation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/listener-validation.ts new file mode 100644 index 0000000000..fe958565cf --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/listener-validation.ts @@ -0,0 +1,25 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./termination-worker.cjs"); + +function outcome(label: string, fn: () => void) { + try { + fn(); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +outcome("on null:", () => (worker as any).on("message", null)); +outcome("once number:", () => (worker as any).once("message", 1)); +outcome( + "addListener object:", + () => (worker as any).addListener("message", {}), +); +outcome("off undefined:", () => (worker as any).off("message", undefined)); + +worker.terminate().then((code) => console.log("terminate:", code)); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/multiple-worker-ids.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/multiple-worker-ids.ts new file mode 100644 index 0000000000..030203aa89 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/multiple-worker-ids.ts @@ -0,0 +1,38 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function observe( + worker: Worker, +): Promise<{ reported: number | undefined; exit: number }> { + return new Promise((resolve) => { + let reported: number | undefined; + worker.on("message", (message) => reported = message.threadId); + worker.on("exit", (exit) => resolve({ reported, exit })); + }); +} + +async function main() { + const first = new Worker("./thread-metadata-worker.cjs", { name: "first" }); + const second = new Worker("./thread-metadata-worker.cjs", { name: "second" }); + const parentIds = [first.threadId, second.threadId]; + const observations = await Promise.all([observe(first), observe(second)]); + + console.log("parent positive:", parentIds.every((id) => id > 0)); + console.log("parent unique:", new Set(parentIds).size === 2); + console.log( + "reported:", + observations.map((item) => item.reported ?? "missing").join(","), + ); + console.log( + "reported match:", + observations[0].reported === parentIds[0], + observations[1].reported === parentIds[1], + ); + console.log("exits:", observations.map((item) => item.exit).join(",")); + console.log("increasing:", parentIds[1] > parentIds[0]); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage-worker.cjs new file mode 100644 index 0000000000..f6e7dd40d2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage-worker.cjs @@ -0,0 +1,13 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.onmessage = (event) => { + parentPort.postMessage({ + type: event.type, + data: event.data * 2, + target: event.target === parentPort, + source: event.source === null, + }); + parentPort.postMessage(undefined); + parentPort.postMessage(null); + parentPort.postMessage(event.ports.length); +}; diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage.ts new file mode 100644 index 0000000000..db676a5c92 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-onmessage.ts @@ -0,0 +1,23 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./parent-port-onmessage-worker.cjs"); +const values: string[] = []; +let valuesLogged = false; + +worker.on("message", (message) => { + values.push(message === undefined ? "undefined" : JSON.stringify(message)); + if (values.length === 4) { + console.log("values:", values.join(",")); + valuesLogged = true; + worker.terminate().then((code) => console.log("terminate:", code)); + } +}); +worker.on("exit", (code) => { + if (!valuesLogged) { + console.log("values: missing"); + } + console.log("exit:", code); +}); +worker.postMessage(2); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref-worker.cjs new file mode 100644 index 0000000000..9594899673 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref-worker.cjs @@ -0,0 +1,14 @@ +const { parentPort } = require("node:worker_threads"); + +const hasRef = typeof parentPort.hasRef === "function"; +const initial = hasRef ? parentPort.hasRef() : "unsupported"; +const unrefReturn = typeof parentPort.unref === "function" + ? parentPort.unref() === parentPort + : "unsupported"; +const unrefed = hasRef ? parentPort.hasRef() : "unsupported"; +const refReturn = typeof parentPort.ref === "function" + ? parentPort.ref() === parentPort + : "unsupported"; +const refed = hasRef ? parentPort.hasRef() : "unsupported"; + +parentPort.postMessage({ initial, unrefReturn, unrefed, refReturn, refed }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref.ts new file mode 100644 index 0000000000..c3ef628a84 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/parent-port-ref.ts @@ -0,0 +1,17 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./parent-port-ref-worker.cjs"); +worker.on("message", (message) => { + console.log( + "states:", + message.initial, + message.unrefReturn, + message.unrefed, + message.refReturn, + message.refed, + ); + worker.terminate().then((code) => console.log("terminate:", code)); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer-worker.cjs new file mode 100644 index 0000000000..6d6a7329cb --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer-worker.cjs @@ -0,0 +1,12 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.once("message", ({ shared }) => { + const view = new Uint8Array(shared); + const before = Array.from(view).join(","); + view[1] = 8; + parentPort.postMessage({ + brand: shared instanceof SharedArrayBuffer, + before, + after: Array.from(view).join(","), + }); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer.ts new file mode 100644 index 0000000000..2398a2cb2f --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-shared-array-buffer.ts @@ -0,0 +1,16 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const shared = new SharedArrayBuffer(4); +const view = new Uint8Array(shared); +view.set([1, 2, 3, 4]); + +const worker = new Worker("./postmessage-shared-array-buffer-worker.cjs"); +worker.on("online", () => worker.postMessage({ shared })); +worker.on("message", (message) => { + console.log("worker:", message.brand, message.before, message.after); + console.log("parent:", Array.from(view).join(",")); + worker.terminate().then((code) => console.log("terminate:", code)); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs new file mode 100644 index 0000000000..878b5b4100 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs @@ -0,0 +1,18 @@ +const { parentPort } = require("node:worker_threads"); + +function outcome(fn) { + try { + fn(); + return "ok"; + } catch (error) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const currentMask = process.umask(); +parentPort.postMessage({ + chdirDisabled: process.chdir.disabled === true, + abortDisabled: process.abort.disabled === true, + chdir: outcome(() => process.chdir(process.cwd())), + umask: outcome(() => process.umask(currentMask)), +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts new file mode 100644 index 0000000000..c84401c2a4 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts @@ -0,0 +1,15 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./process-unsupported-worker.cjs"); +worker.on("message", (message) => { + console.log("disabled:", message.chdirDisabled, message.abortDisabled); + console.log("chdir:", message.chdir); + console.log("umask:", message.umask); +}); +worker.on( + "error", + (error: any) => console.log("error:", error?.name, error?.code ?? ""), +); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive-worker.cjs new file mode 100644 index 0000000000..1d617cca67 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive-worker.cjs @@ -0,0 +1,18 @@ +const { + receiveMessageOnPort, + parentPort, + workerData, +} = require("node:worker_threads"); + +const packet = receiveMessageOnPort(workerData.port); +const empty = receiveMessageOnPort(workerData.port); +parentPort.postMessage({ + text: packet?.message?.text ?? "missing", + count: packet?.message?.count ?? "missing", + empty: empty === undefined, +}); +try { + workerData.port.close(); +} catch (error) { + parentPort.postMessage({ closeError: error?.name ?? "Error" }); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive.ts new file mode 100644 index 0000000000..549e5d835e --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/queued-port-receive.ts @@ -0,0 +1,27 @@ +import { MessageChannel, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const { port1, port2 } = new MessageChannel(); +port1.postMessage({ text: "queued", count: 2 }); + +const worker = new Worker("./queued-port-receive-worker.cjs", { + workerData: { port: port2 }, + transferList: [port2], +}); + +worker.on("message", (message) => { + if (message.closeError) { + console.log("close error:", message.closeError); + return; + } + console.log("received:", message.text, message.count, message.empty); +}); +worker.on( + "error", + (error: any) => console.log("error:", error?.name, error?.code ?? ""), +); +worker.on("exit", (code) => { + console.log("exit:", code); + port1.close(); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/unreferenced-arraybuffer-transfer.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/unreferenced-arraybuffer-transfer.ts new file mode 100644 index 0000000000..feb113a66f --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/unreferenced-arraybuffer-transfer.ts @@ -0,0 +1,16 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const buffer = new ArrayBuffer(8); +new Uint8Array(buffer)[0] = 9; +const worker = new Worker("./natural-exit-worker.cjs", { + transferList: [buffer], +}); + +console.log("detached:", buffer.byteLength); +worker.on( + "error", + (error: any) => console.log("error:", error?.name, error?.code ?? ""), +); +worker.on("exit", (code) => console.log("exit:", code)); diff --git "a/test-parity/node-suite/worker_threads/worker-lifecycle/url-\317\200-worker.cjs" "b/test-parity/node-suite/worker_threads/worker-lifecycle/url-\317\200-worker.cjs" new file mode 100644 index 0000000000..dc63d606fe --- /dev/null +++ "b/test-parity/node-suite/worker_threads/worker-lifecycle/url-\317\200-worker.cjs" @@ -0,0 +1,4 @@ +const path = require("node:path"); +const { parentPort, workerData } = require("node:worker_threads"); + +parentPort.postMessage(`${workerData}:${path.basename(__filename)}`); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/worker-eventemitter-surface.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-eventemitter-surface.ts new file mode 100644 index 0000000000..83c8bf4437 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-eventemitter-surface.ts @@ -0,0 +1,48 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./termination-worker.cjs"); +const names = [ + "addListener", + "emit", + "eventNames", + "getMaxListeners", + "listenerCount", + "listeners", + "off", + "on", + "once", + "prependListener", + "prependOnceListener", + "rawListeners", + "removeAllListeners", + "removeListener", + "setMaxListeners", +]; + +console.log( + "methods:", + names.map((name) => `${name}:${typeof (worker as any)[name]}`).join(","), +); +const eventNames = typeof (worker as any).eventNames === "function" + ? JSON.stringify((worker as any).eventNames()) + : "unsupported"; +console.log("event names:", eventNames); + +if ( + typeof (worker as any).getMaxListeners === "function" && + typeof (worker as any).setMaxListeners === "function" +) { + console.log( + "max listeners:", + typeof (worker as any).getMaxListeners(), + (worker as any).setMaxListeners(7) === worker, + ); + console.log("updated max:", (worker as any).getMaxListeners()); +} else { + console.log("max listeners: unsupported"); +} + +worker.terminate().then((code) => console.log("terminate:", code)); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index f707fa51df..cbe1b8de6a 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; overall inventory metadata applies its cumulative measured +11 pass / +67 total delta to the prior clean snapshot, yielding 2821/2930 (96.3%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2813/2955 (95.2%)." }, "overall": { - "pass": 2821, - "total": 2930, - "pct": 96.3 + "pass": 2813, + "total": 2955, + "pct": 95.2 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 28, - "total": 84 + "pass": 29, + "total": 109 }, "zlib": { "pass": 58, From 430f48e420978c4ab07a3840fd4ef5c8822dcd1a Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 09:54:11 +0200 Subject: [PATCH 05/24] test: cover advanced worker_threads event contracts --- .../node-suite/worker_threads/README.md | 81 ++++++++++++------- .../direct-message/handler-error-worker.cjs | 7 ++ .../direct-message/handler-error.ts | 23 ++++++ .../uncloneable-construction.ts | 2 +- .../main-thread/process-worker-event.ts | 14 ++++ .../message-port/close-callback-order.ts | 40 +++++++++ .../listener-first-registration.ts | 44 ++++++++++ .../message-port/listener-scope.ts | 27 +++++++ .../message-port/off-pending-once.ts | 21 +++++ .../tampered-listener-registry.ts | 55 +++++++++++++ .../message-port/transfer-target.ts | 17 ++++ .../eval-stack-getter-error.ts | 23 ++++++ .../eval-unserializable-error.ts | 25 ++++++ .../post-exit-safe-methods.ts | 30 +++++++ .../share-env-sibling-worker.cjs | 22 +++++ .../worker-lifecycle/share-env-siblings.ts | 47 +++++++++++ .../workerdata-port-surface-worker.cjs | 17 ++++ .../workerdata-port-surface.ts | 27 +++++++ test-parity/node_suite_baseline.json | 12 +-- 19 files changed, 497 insertions(+), 37 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/direct-message/handler-error-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/direct-message/handler-error.ts create mode 100644 test-parity/node-suite/worker_threads/main-thread/process-worker-event.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/close-callback-order.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/listener-first-registration.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/listener-scope.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/off-pending-once.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-target.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/eval-stack-getter-error.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/eval-unserializable-error.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-safe-methods.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-sibling-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-siblings.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index fefab28d56..51dd026c8d 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -7,13 +7,28 @@ through its Node-compat stdlib shim. The expansion was compared against these primary-source snapshots: -- Node.js [`34c28d5`](https://github.com/nodejs/node/tree/34c28d5a69f4f00cd599adcbe57834435d3a683b/test/parallel), especially the `test-worker-message-port-*`, `test-worker-message-channel*`, `test-worker-message-mark-as-uncloneable`, `test-worker-invalid-workerdata`, `test-worker-environmentdata`, `test-worker-event`, and `test-worker-broadcastchannel` cases. -- Deno [`f8a17c8`](https://github.com/denoland/deno/tree/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/specs/node/worker_threads) and its [`worker_threads_test.ts`](https://github.com/denoland/deno/blob/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/unit_node/worker_threads_test.ts) selection, including port transfer, listener removal/deduplication, `unref`, auto-exit, and broadcast coverage. -- Bun [`0bffb47`](https://github.com/oven-sh/bun/tree/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads), especially [`worker_threads.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker_threads.test.ts) and [`worker-transfer-list.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker-transfer-list.test.ts). +- Node.js + [`34c28d5`](https://github.com/nodejs/node/tree/34c28d5a69f4f00cd599adcbe57834435d3a683b/test/parallel), + especially the `test-worker-message-port-*`, `test-worker-message-channel*`, + `test-worker-message-mark-as-uncloneable`, `test-worker-invalid-workerdata`, + `test-worker-environmentdata`, `test-worker-event`, and + `test-worker-broadcastchannel` cases. +- Deno + [`f8a17c8`](https://github.com/denoland/deno/tree/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/specs/node/worker_threads) + and its + [`worker_threads_test.ts`](https://github.com/denoland/deno/blob/f8a17c8171569fa2870d740030aaa59c91fdf9ee/tests/unit_node/worker_threads_test.ts) + selection, including port transfer, listener removal/deduplication, `unref`, + auto-exit, and broadcast coverage. +- Bun + [`0bffb47`](https://github.com/oven-sh/bun/tree/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads), + especially + [`worker_threads.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker_threads.test.ts) + and + [`worker-transfer-list.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker-transfer-list.test.ts). The inventory review covered all 141 `test-worker-*` files in that Node -snapshot, all 48 cases in Deno's unit selection plus its directory fixtures, -and the 72 declarations in Bun's two central worker test files. Cases are +snapshot, all 48 cases in Deno's unit selection plus its directory fixtures, and +the 72 declarations in Bun's two central worker test files. Cases are represented here by observable contract rather than copied one-for-one when several upstream files exercise the same behavior. @@ -24,20 +39,23 @@ that oracle. ## Coverage added - `main-thread/`: complete namespace export availability and types, main-thread - values, Worker and MessagePort prototype descriptors, and constructor brands. + values, Worker and MessagePort prototype descriptors, constructor brands, and + the process-level `worker` event. - `environment-data/`: key identity, live main-thread values, deletion, worker snapshot cloning, built-in Map/Set/Date values, nested-worker inheritance, non-cloneable construction rejection, and parent/worker mutation isolation. - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, transfer detachment, MessagePort ownership, overloads, and atomic rollback. -- `message-port/`: synchronous FIFO receives, invalid-port validation, - explicit `start()`, event fields/ports, listener deduplication, close callback +- `message-port/`: synchronous FIFO receives, invalid-port validation, explicit + `start()`, event fields/ports, listener deduplication, close callback ordering, listener removal/`once`, `onmessage` replacement, method receiver validation, NodeEventTarget surface/listener validation, iterable MessageEvent ports and transfer options, dispatch-time close flushing, duplicate/self/closed transfers, atomic clone rollback, transfer-state - validation, queued delivery, overload validation, and + validation, queued delivery, overload validation, first-listener registration, + pending `once` removal, per-event listener scope, close-callback registration + order, tamper-resistant listener bookkeeping, transfer targets, and `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, @@ -50,9 +68,11 @@ that oracle. id/name uniqueness and lifecycle, parentPort `onmessage`/ref state, Worker EventEmitter surface and listener validation, process restrictions, captured stdout/stderr, explicit and `process.exitCode` exits, unsupported paths, - method receivers, postMessage clone rollback, `online`/`message`/`error`/`exit` - ordering, ref return values, natural exit, and deterministic repeated - termination settlement. + method receivers, postMessage clone rollback, + `online`/`message`/`error`/`exit` ordering, ref return values, natural exit, + and deterministic repeated termination settlement, transferred workerData port + methods, sequential `SHARE_ENV` sibling visibility, post-exit method safety, + and custom-stack and non-Error serialization. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -64,8 +84,8 @@ that oracle. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending the existing surface, option, query, callback settlement/cleanup, independent-name concurrency, and shared/exclusive ordering cases. -- `direct-message/`: delivery, no-listener and timeout rejection, plus thread id - and timeout argument validation. +- `direct-message/`: delivery, no-listener, timeout, and handler-failure + rejection, plus thread id and timeout argument validation. Every asynchronous fixture uses a message, close, exit, stream-EOF, or promise-settlement barrier. No added fixture uses a sleep as a completion @@ -73,8 +93,8 @@ condition. ## Stopping judgment -The remaining upstream cases were not copied because they are redundant with -the cases above or belong to a separate slow/risky runtime feature: +The remaining upstream cases were not copied because they are redundant with the +cases above or belong to a separate slow/risky runtime feature: - Resource-limit enforcement, stdio backpressure/large-write timing, heap snapshots, and CPU/heap profiling are resource- and platform-sensitive. Basic @@ -98,11 +118,11 @@ the cases above or belong to a separate slow/risky runtime feature: surface, validation, shared/exclusive ordering, `ifAvailable`, and query snapshots. In-flight abort races and cross-agent ownership remain excluded. - The existing `direct-message/` fixtures already cover deterministic - `postMessageToThread` delivery, rejection, and timeout behavior. + `postMessageToThread` delivery, rejection, handler failures, and timeout + behavior. -The measured focused result is `29/109`: all 17 pre-existing cases remain -green, 12 added cases pass, and 80 added cases expose stable diagnostic -differences. +The measured focused result is `30/122`: all 17 pre-existing cases remain green, +13 added cases pass, and 92 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -111,19 +131,20 @@ The passing additions are `broadcast-channel/fanout-fifo.ts`, `worker-lifecycle/worker-ref-state.ts`, `web-locks/web-locks-abort.ts`, and `web-locks/web-locks-steal.ts`. The latest broad pass also adds green coverage for `environment-data/value-identity.ts`, -`message-port/onmessage-replacement.ts`, -`worker-lifecycle/eval-basic.ts`, +`message-port/onmessage-replacement.ts`, `worker-lifecycle/eval-basic.ts`, `worker-lifecycle/method-receivers.ts`, and -`web-locks/web-locks-independent-names.ts`. The diagnostic differences are: +`web-locks/web-locks-independent-names.ts`. Sequential sibling `SHARE_ENV` +visibility in `worker-lifecycle/share-env-siblings.ts` also passes. The +diagnostic differences are: - All nine `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Twenty `message-port/` diagnostics: closing and dispatch flushing, queued +- Twenty-six `message-port/` diagnostics: closing and dispatch flushing, queued data/callbacks, NodeEventTarget surface, listener counts and validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. -- Thirty-two `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Thirty-six `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -135,7 +156,7 @@ for `environment-data/value-identity.ts`, - Five `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, and closed-channel posts differ. -- Nine additional diagnostics cover namespace/prototype completeness, +- Eleven additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction, direct-message argument validation, and Web Locks callback rejection cleanup. @@ -149,10 +170,10 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `29/109 (26.6%), diff=80`, with no compile failures or timeouts. -The SharedArrayBuffer/Atomics boundary is backed by the added same-process -channel and cross-worker clone/alias cases plus the existing -granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and +It reported `30/122 (24.6%), diff=92`, with no compile failures or timeouts. The +SharedArrayBuffer/Atomics boundary is backed by the added same-process channel +and cross-worker clone/alias cases plus the existing granular +`globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and `util/types/arraybuffer-sharedarraybuffer.ts` cases; cross-agent fixtures such as Node's `test-worker-message-channel-sharedarraybuffer.js` and Deno's `broadcast_channel_sab.mjs` remain outside this messaging-focused batch. A diff --git a/test-parity/node-suite/worker_threads/direct-message/handler-error-worker.cjs b/test-parity/node-suite/worker_threads/direct-message/handler-error-worker.cjs new file mode 100644 index 0000000000..1c8116c88b --- /dev/null +++ b/test-parity/node-suite/worker_threads/direct-message/handler-error-worker.cjs @@ -0,0 +1,7 @@ +const { parentPort } = require("node:worker_threads"); + +process.on("workerMessage", () => { + throw new Error("direct-handler-boom"); +}); +parentPort.once("message", () => {}); +parentPort.postMessage("ready"); diff --git a/test-parity/node-suite/worker_threads/direct-message/handler-error.ts b/test-parity/node-suite/worker_threads/direct-message/handler-error.ts new file mode 100644 index 0000000000..50553cecbd --- /dev/null +++ b/test-parity/node-suite/worker_threads/direct-message/handler-error.ts @@ -0,0 +1,23 @@ +import { postMessageToThread, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/direct-message"); + +const worker = new Worker("./handler-error-worker.cjs"); +worker.on("message", async (message) => { + if (message !== "ready") { + return; + } + + try { + await postMessageToThread(worker.threadId, "trigger"); + console.log("direct: resolved"); + } catch (error: any) { + console.log("direct:", error?.name, error?.code ?? ""); + } + worker.postMessage("close"); +}); +worker.on( + "error", + (error: any) => console.log("worker error:", error?.name, error?.code ?? ""), +); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts b/test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts index 345168fcb6..7fe2c5ae20 100644 --- a/test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts +++ b/test-parity/node-suite/worker_threads/environment-data/uncloneable-construction.ts @@ -2,8 +2,8 @@ import { setEnvironmentData, Worker } from "node:worker_threads"; process.chdir("test-parity/node-suite/worker_threads/environment-data"); -setEnvironmentData("uncloneable", () => "value"); try { + setEnvironmentData("uncloneable", () => "value"); new Worker("./worker-snapshot-worker.cjs"); console.log("construct: ok"); } catch (error: any) { diff --git a/test-parity/node-suite/worker_threads/main-thread/process-worker-event.ts b/test-parity/node-suite/worker_threads/main-thread/process-worker-event.ts new file mode 100644 index 0000000000..e3ed77cbde --- /dev/null +++ b/test-parity/node-suite/worker_threads/main-thread/process-worker-event.ts @@ -0,0 +1,14 @@ +import { Worker } from "node:worker_threads"; + +let observedThreadId: number | undefined; +process.once("worker", (created: any) => { + observedThreadId = created.threadId; +}); + +const worker = new Worker("", { eval: true }); +const initialThreadId = worker.threadId; +worker.on("exit", (code) => { + console.log("event observed:", observedThreadId !== undefined); + console.log("thread match:", observedThreadId === initialThreadId); + console.log("exit:", code); +}); diff --git a/test-parity/node-suite/worker_threads/message-port/close-callback-order.ts b/test-parity/node-suite/worker_threads/message-port/close-callback-order.ts new file mode 100644 index 0000000000..1ca3d9ebac --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/close-callback-order.ts @@ -0,0 +1,40 @@ +import { MessageChannel } from "node:worker_threads"; + +function first(): Promise { + return new Promise((resolve) => { + const { port1, port2 } = new MessageChannel(); + const order: string[] = []; + port1.on("close", () => order.push("A")); + port1.close(() => order.push("B")); + port1.on("close", () => { + order.push("C"); + console.log("first:", order.join(",")); + port2.close(); + resolve(); + }); + order.push("sync"); + }); +} + +function second(): Promise { + return new Promise((resolve) => { + const { port1, port2 } = new MessageChannel(); + const order: string[] = []; + port1.close(() => order.push("B")); + port1.on("close", () => { + order.push("C"); + console.log("second:", order.join(",")); + port2.close(); + resolve(); + }); + }); +} + +async function main() { + await first(); + await second(); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/message-port/listener-first-registration.ts b/test-parity/node-suite/worker_threads/message-port/listener-first-registration.ts new file mode 100644 index 0000000000..44923b63e1 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/listener-first-registration.ts @@ -0,0 +1,44 @@ +import { MessageChannel } from "node:worker_threads"; + +function run( + label: string, + first: "on" | "once", + second: "on" | "once", +): Promise { + return new Promise((resolve) => { + const { port1, port2 } = new MessageChannel(); + let calls = 0; + const listener = () => calls += 1; + + port1[first]("message", listener); + port1[second]("message", listener); + + const observer = (value: number) => { + if (value !== 2) return; + port1.off("message", observer); + const remaining = typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported"; + console.log(`${label}:`, calls, remaining); + port1.close(); + }; + port1.on("message", observer); + port1.on("close", () => { + port2.close(); + resolve(); + }); + port2.postMessage(1); + port2.postMessage(2); + }); +} + +async function main() { + await run("on+on", "on", "on"); + await run("on+once", "on", "once"); + await run("once+on", "once", "on"); + await run("once+once", "once", "once"); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/message-port/listener-scope.ts b/test-parity/node-suite/worker_threads/message-port/listener-scope.ts new file mode 100644 index 0000000000..d30bd00ed3 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/listener-scope.ts @@ -0,0 +1,27 @@ +import { MessageChannel } from "node:worker_threads"; + +const first = new MessageChannel(); +const second = new MessageChannel(); +const events: string[] = []; + +function shared(value: any) { + events.push(typeof value === "string" ? value : value?.type ?? "unknown"); +} + +first.port1.on("message", shared); +first.port1.on("close", shared); +second.port1.on("message", shared); +first.port1.off("message", shared); + +second.port1.on("message", () => { + first.port1.close(); +}); +first.port1.on("close", () => { + console.log("events:", events.join(",")); + first.port2.close(); + second.port1.close(); + second.port2.close(); +}); + +first.port2.postMessage("first-message"); +second.port2.postMessage("second-message"); diff --git a/test-parity/node-suite/worker_threads/message-port/off-pending-once.ts b/test-parity/node-suite/worker_threads/message-port/off-pending-once.ts new file mode 100644 index 0000000000..dcc99b0601 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/off-pending-once.ts @@ -0,0 +1,21 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +let calls = 0; +const listener = () => calls += 1; + +port1.once("message", listener); +const before = typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported"; +port1.off("message", listener); +const after = typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported"; + +port1.on("message", () => { + console.log("counts:", before, after, calls); + port1.close(); + port2.close(); +}); +port2.postMessage("probe"); diff --git a/test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts b/test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts new file mode 100644 index 0000000000..59035c3596 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts @@ -0,0 +1,55 @@ +import { MessageChannel } from "node:worker_threads"; + +function boom(name: string) { + return function () { + throw new Error(`tampered ${name}`); + }; +} + +for ( + const [constructor, names] of [ + [Map, [ + "get", + "set", + "delete", + "has", + "values", + "keys", + "entries", + "forEach", + ]], + [Set, ["add", "delete", "has", "values", "keys", "entries", "forEach"]], + [WeakMap, ["get", "set", "has", "delete"]], + ] as const +) { + for (const name of names) { + (constructor.prototype as any)[name] = boom(`${constructor.name}.${name}`); + } + if (constructor !== WeakMap) { + Object.defineProperty(constructor.prototype, "size", { + configurable: true, + get: boom(`${constructor.name}.size`), + }); + } + (constructor.prototype as any)[Symbol.iterator] = boom( + `${constructor.name}[Symbol.iterator]`, + ); +} + +try { + const { port1, port2 } = new MessageChannel(); + const listener = () => {}; + port1.on("message", listener); + const before = (port1 as any).listenerCount("message"); + port1.once("close", () => {}); + const names = (port1 as any).eventNames().sort().join(","); + port1.off("message", listener); + const after = (port1 as any).listenerCount("message"); + port1.removeAllListeners(); + const empty = (port1 as any).eventNames().length; + console.log("registry:", before, names, after, empty); + port1.close(); + port2.close(); +} catch (error: any) { + console.log("registry error:", error?.name, error?.message); +} diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-target.ts b/test-parity/node-suite/worker_threads/message-port/transfer-target.ts new file mode 100644 index 0000000000..80906ceb22 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-target.ts @@ -0,0 +1,17 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const buffer = new ArrayBuffer(8); +new Uint8Array(buffer)[0] = 5; + +try { + port2.postMessage({ port: port1, buffer }, [port1, buffer]); + console.log("post: ok"); +} catch (error: any) { + console.log("post:", error?.name, error?.code ?? ""); +} + +console.log("buffer detached:", buffer.byteLength); +console.log("target receives:", receiveMessageOnPort(port2)); +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/eval-stack-getter-error.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-stack-getter-error.ts new file mode 100644 index 0000000000..8c9030b0c7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-stack-getter-error.ts @@ -0,0 +1,23 @@ +import { Worker } from "node:worker_threads"; + +const worker = new Worker( + ` + Error.prepareStackTrace = (error) => { + if (error.message === "stack-boom") throw new Error("prepare-boom"); + return "custom-stack"; + }; + throw new Error("stack-boom"); + `, + { eval: true }, +); + +let errorSummary = "missing"; +worker.on("error", (error: any) => { + errorSummary = `${error?.name}:${error?.message}:${ + error?.stack === undefined + }`; +}); +worker.on("exit", (code) => { + console.log("error:", errorSummary); + console.log("exit:", code); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/eval-unserializable-error.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-unserializable-error.ts new file mode 100644 index 0000000000..0060a90d39 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-unserializable-error.ts @@ -0,0 +1,25 @@ +import { Worker } from "node:worker_threads"; + +const worker = new Worker( + ` + const { MessageChannel } = require("node:worker_threads"); + throw new MessageChannel().port1; + `, + { eval: true }, +); + +let summary = "missing"; +worker.on("error", (error: any) => { + summary = [ + typeof error, + error instanceof Error, + Object.getPrototypeOf(error) === null, + Object.prototype.toString.call(error), + typeof error?.name, + typeof error?.message, + ].join(":"); +}); +worker.on("exit", (code) => { + console.log("error:", summary); + console.log("exit:", code); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-safe-methods.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-safe-methods.ts new file mode 100644 index 0000000000..2c2aabebfb --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-safe-methods.ts @@ -0,0 +1,30 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./natural-exit-worker.cjs"); +const stdin = worker.stdin; +const stdout = worker.stdout; +const stderr = worker.stderr; + +function outcome(fn: () => any): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +worker.on("exit", (code) => { + console.log("exit:", code, worker.threadId, worker.threadName); + console.log("postMessage:", outcome(() => worker.postMessage("after-exit"))); + console.log("ref:", outcome(() => worker.ref())); + console.log("unref:", outcome(() => worker.unref())); + console.log( + "stream identity:", + worker.stdin === stdin, + worker.stdout === stdout, + worker.stderr === stderr, + ); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-sibling-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-sibling-worker.cjs new file mode 100644 index 0000000000..9ae9e93e6e --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-sibling-worker.cjs @@ -0,0 +1,22 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.on("message", (command) => { + if (command === "mutate") { + process.env.PERRY_SHARED_SIBLING = "worker-value"; + delete process.env.PERRY_SHARED_DELETE; + parentPort.postMessage({ + phase: "mutated", + value: process.env.PERRY_SHARED_SIBLING, + deleted: process.env.PERRY_SHARED_DELETE === undefined, + }); + return; + } + + parentPort.postMessage({ + phase: "inspected", + value: process.env.PERRY_SHARED_SIBLING, + deleted: process.env.PERRY_SHARED_DELETE === undefined, + enumerated: Object.keys(process.env).includes("PERRY_SHARED_SIBLING"), + }); +}); +parentPort.postMessage({ phase: "ready" }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-siblings.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-siblings.ts new file mode 100644 index 0000000000..30e127b14a --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-siblings.ts @@ -0,0 +1,47 @@ +import { SHARE_ENV, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +process.env.PERRY_SHARED_SIBLING = "parent"; +process.env.PERRY_SHARED_DELETE = "delete-me"; + +function run( + command: "mutate" | "inspect", +): Promise<{ message: any; code: number }> { + return new Promise((resolve) => { + const worker = new Worker("./share-env-sibling-worker.cjs", { + env: SHARE_ENV, + }); + worker.on("message", async (message: any) => { + if (message.phase === "ready") { + worker.postMessage(command); + return; + } + const code = await worker.terminate(); + resolve({ message, code }); + }); + }); +} + +async function main() { + const first = await run("mutate"); + console.log("first:", first.message.value, first.message.deleted); + + const second = await run("inspect"); + console.log( + "second:", + second.message.value, + second.message.deleted, + second.message.enumerated, + ); + console.log( + "parent:", + process.env.PERRY_SHARED_SIBLING, + process.env.PERRY_SHARED_DELETE === undefined, + ); + console.log("terminate:", first.code, second.code); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface-worker.cjs new file mode 100644 index 0000000000..9967642696 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface-worker.cjs @@ -0,0 +1,17 @@ +const { MessagePort, parentPort, workerData } = require("node:worker_threads"); + +const port = workerData?.port; +const methods = ["addListener", "off", "on", "once", "postMessage"] + .map((name) => `${name}:${typeof port?.[name]}`) + .join(","); + +if (typeof port?.postMessage === "function") { + port.postMessage("from-transferred-port"); +} +parentPort.postMessage({ + brand: port instanceof MessagePort, + methods, +}); +try { + port?.close?.(); +} catch {} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface.ts new file mode 100644 index 0000000000..5577a2f6ad --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-surface.ts @@ -0,0 +1,27 @@ +import { + MessageChannel, + receiveMessageOnPort, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const { port1, port2 } = new MessageChannel(); +const worker = new Worker("./workerdata-port-surface-worker.cjs", { + workerData: { port: port2 }, + transferList: [port2], +}); +let summary: any; + +worker.on("message", (message) => summary = message); +worker.on("exit", (code) => { + const packet = receiveMessageOnPort(port1); + console.log( + "surface:", + summary?.brand ?? "missing", + summary?.methods ?? "missing", + ); + console.log("peer:", packet ? packet.message : "missing"); + console.log("exit:", code); + port1.close(); +}); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index cbe1b8de6a..b14f8b0ce8 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2813/2955 (95.2%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2814/2968 (94.8%)." }, "overall": { - "pass": 2813, - "total": 2955, - "pct": 95.2 + "pass": 2814, + "total": 2968, + "pct": 94.8 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 29, - "total": 109 + "pass": 30, + "total": 122 }, "zlib": { "pass": 58, From d27c5be7b1ef89b433fad2de0e2094218a4b4328 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 10:12:15 +0200 Subject: [PATCH 06/24] test: extend worker_threads event edge coverage --- .../node-suite/worker_threads/README.md | 40 +++++++++++-------- .../process-worker-event-late-emit.ts | 18 +++++++++ .../message-port/once-registry-after-fire.ts | 30 ++++++++++++++ .../message-port/transfer-iterator-error.ts | 20 ++++++++++ .../worker-lifecycle/eval-syntax-error.ts | 19 +++++++++ .../share-env-indexed-worker.cjs | 11 +++++ .../worker-lifecycle/share-env-indexed.ts | 14 +++++++ test-parity/node_suite_baseline.json | 12 +++--- 8 files changed, 141 insertions(+), 23 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/main-thread/process-worker-event-late-emit.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/once-registry-after-fire.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-iterator-error.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/eval-syntax-error.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 51dd026c8d..bff28d8565 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -26,11 +26,12 @@ The expansion was compared against these primary-source snapshots: and [`worker-transfer-list.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker-transfer-list.test.ts). -The inventory review covered all 141 `test-worker-*` files in that Node -snapshot, all 48 cases in Deno's unit selection plus its directory fixtures, and -the 72 declarations in Bun's two central worker test files. Cases are -represented here by observable contract rather than copied one-for-one when -several upstream files exercise the same behavior. +The inventory review covered all 143 `test-worker-*` files across the parallel +and sequential directories in that Node snapshot, all 48 cases in Deno's unit +selection plus its directory fixtures, and the 72 declarations in Bun's two +central worker test files. Cases are represented here by observable contract +rather than copied one-for-one when several upstream files exercise the same +behavior. Node `26.5.0`, pinned by this repository, remains the executable differential oracle. The upstream snapshots above guide case selection rather than changing @@ -40,7 +41,8 @@ that oracle. - `main-thread/`: complete namespace export availability and types, main-thread values, Worker and MessagePort prototype descriptors, constructor brands, and - the process-level `worker` event. + the process-level `worker` event, including asynchronous late `process.emit` + lookup. - `environment-data/`: key identity, live main-thread values, deletion, worker snapshot cloning, built-in Map/Set/Date values, nested-worker inheritance, non-cloneable construction rejection, and parent/worker mutation isolation. @@ -55,7 +57,8 @@ that oracle. duplicate/self/closed transfers, atomic clone rollback, transfer-state validation, queued delivery, overload validation, first-listener registration, pending `once` removal, per-event listener scope, close-callback registration - order, tamper-resistant listener bookkeeping, transfer targets, and + order, tamper-resistant listener bookkeeping, post-dispatch `once` registry + state, throwing transfer iterators, transfer targets, and `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, @@ -72,7 +75,8 @@ that oracle. `online`/`message`/`error`/`exit` ordering, ref return values, natural exit, and deterministic repeated termination settlement, transferred workerData port methods, sequential `SHARE_ENV` sibling visibility, post-exit method safety, - and custom-stack and non-Error serialization. + indexed `SHARE_ENV` keys, eval syntax-error routing, and custom-stack and + non-Error serialization. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -121,8 +125,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `30/122`: all 17 pre-existing cases remain green, -13 added cases pass, and 92 added cases expose stable diagnostic differences. +The measured focused result is `31/127`: all 17 pre-existing cases remain green, +14 added cases pass, and 96 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -135,16 +139,18 @@ for `environment-data/value-identity.ts`, `worker-lifecycle/method-receivers.ts`, and `web-locks/web-locks-independent-names.ts`. Sequential sibling `SHARE_ENV` visibility in `worker-lifecycle/share-env-siblings.ts` also passes. The +indexed-key variant in `worker-lifecycle/share-env-indexed.ts` passes too. The diagnostic differences are: - All nine `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Twenty-six `message-port/` diagnostics: closing and dispatch flushing, queued - data/callbacks, NodeEventTarget surface, listener counts and validation, - receiver/options/iterables/transfers, MessageEvent construction and `ports`, - duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. -- Thirty-six `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Twenty-eight `message-port/` diagnostics: closing and dispatch flushing, + queued data/callbacks, NodeEventTarget surface, listener counts and + validation, receiver/options/iterables/transfers, MessageEvent construction + and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` + state differ. +- Thirty-seven `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -156,7 +162,7 @@ diagnostic differences are: - Five `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, and closed-channel posts differ. -- Eleven additional diagnostics cover namespace/prototype completeness, +- Twelve additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction, direct-message argument validation, and Web Locks callback rejection cleanup. @@ -170,7 +176,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `30/122 (24.6%), diff=92`, with no compile failures or timeouts. The +It reported `31/127 (24.4%), diff=96`, with no compile failures or timeouts. The SharedArrayBuffer/Atomics boundary is backed by the added same-process channel and cross-worker clone/alias cases plus the existing granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and diff --git a/test-parity/node-suite/worker_threads/main-thread/process-worker-event-late-emit.ts b/test-parity/node-suite/worker_threads/main-thread/process-worker-event-late-emit.ts new file mode 100644 index 0000000000..290fb2cbb1 --- /dev/null +++ b/test-parity/node-suite/worker_threads/main-thread/process-worker-event-late-emit.ts @@ -0,0 +1,18 @@ +import { Worker } from "node:worker_threads"; + +const originalEmit = process.emit; +let worker: Worker; +let synchronous = true; + +worker = new Worker("", { eval: true }); +(process as any).emit = function (event: string, value: any) { + if (event === "worker") { + process.emit = originalEmit; + console.log("late emit:", !synchronous, value === worker); + return true; + } + return originalEmit.apply(this, arguments as any); +}; +synchronous = false; + +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/message-port/once-registry-after-fire.ts b/test-parity/node-suite/worker_threads/message-port/once-registry-after-fire.ts new file mode 100644 index 0000000000..d69d8c3af5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/once-registry-after-fire.ts @@ -0,0 +1,30 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +let fired = 0; + +function count() { + return typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported"; +} + +function named() { + return typeof (port1 as any).eventNames === "function" + ? (port1 as any).eventNames().includes("message") + : "unsupported"; +} + +port1.once("message", () => { + fired += 1; + console.log( + "after fire:", + fired, + count(), + named(), + ); + port1.close(); + port2.close(); +}); +console.log("before:", count()); +port2.postMessage("trigger"); diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-iterator-error.ts b/test-parity/node-suite/worker_threads/message-port/transfer-iterator-error.ts new file mode 100644 index 0000000000..e7f00f977f --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-iterator-error.ts @@ -0,0 +1,20 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const expected = new Error("iterator-boom"); + +try { + port1.postMessage({}, { + transfer: { + *[Symbol.iterator]() { + throw expected; + }, + }, + } as any); + console.log("post: ok"); +} catch (error: any) { + console.log("same error:", error === expected, error?.message); +} + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/eval-syntax-error.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-syntax-error.ts new file mode 100644 index 0000000000..d1e501fff5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/eval-syntax-error.ts @@ -0,0 +1,19 @@ +import { Worker } from "node:worker_threads"; + +const source = ["const broken = ", ")"].join(""); +let summary = "missing"; +let worker: Worker | undefined; + +try { + worker = new Worker(source, { eval: true }); + worker.on("message", () => console.log("unexpected message")); + worker.on("error", (error: any) => { + summary = `${error?.constructor === SyntaxError}:${error?.name}`; + }); + worker.on("exit", (code) => { + console.log("error:", summary); + console.log("exit:", code); + }); +} catch (error: any) { + console.log("construct:", error?.name, error?.code ?? ""); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed-worker.cjs new file mode 100644 index 0000000000..6dc2b3863d --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed-worker.cjs @@ -0,0 +1,11 @@ +const { parentPort } = require("node:worker_threads"); + +const initial = process.env[123]; +process.env[456] = "from-worker"; +delete process.env[7]; +parentPort.postMessage({ + initial, + keys: Object.keys(process.env).filter((key) => + key === "123" || key === "456" + ), +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed.ts new file mode 100644 index 0000000000..cd5244d127 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-indexed.ts @@ -0,0 +1,14 @@ +import { SHARE_ENV, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); +process.env[123] = "from-main"; +process.env[7] = "delete-me"; + +const worker = new Worker("./share-env-indexed-worker.cjs", { env: SHARE_ENV }); +worker.on("message", (message: any) => { + console.log("worker:", message.initial, message.keys.join(",")); +}); +worker.on("exit", (code) => { + console.log("main:", process.env[123], process.env[456], process.env[7]); + console.log("exit:", code); +}); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index b14f8b0ce8..5738ac04e6 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2814/2968 (94.8%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2815/2973 (94.7%)." }, "overall": { - "pass": 2814, - "total": 2968, - "pct": 94.8 + "pass": 2815, + "total": 2973, + "pct": 94.7 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 30, - "total": 122 + "pass": 31, + "total": 127 }, "zlib": { "pass": 58, From 1c02e8254fbdf233bdd97f4fb6fbad44b933c448 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 10:14:11 +0200 Subject: [PATCH 07/24] test: guarantee worker port cleanup on errors --- .../message-port/tampered-listener-registry.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts b/test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts index 59035c3596..67d7729e8e 100644 --- a/test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts +++ b/test-parity/node-suite/worker_threads/message-port/tampered-listener-registry.ts @@ -36,8 +36,10 @@ for ( ); } +let channel: MessageChannel | undefined; try { - const { port1, port2 } = new MessageChannel(); + channel = new MessageChannel(); + const { port1 } = channel; const listener = () => {}; port1.on("message", listener); const before = (port1 as any).listenerCount("message"); @@ -48,8 +50,9 @@ try { port1.removeAllListeners(); const empty = (port1 as any).eventNames().length; console.log("registry:", before, names, after, empty); - port1.close(); - port2.close(); } catch (error: any) { console.log("registry error:", error?.name, error?.message); +} finally { + channel?.port1.close(); + channel?.port2.close(); } From ac13f7df7aede869dfd12cf871ad8a4835ded451 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 10:31:29 +0200 Subject: [PATCH 08/24] test: cover worker_threads receiver and clone edges --- .../node-suite/worker_threads/README.md | 56 ++++++++++--------- .../broadcast-channel/method-receivers.ts | 22 ++++++++ .../message-channel/move-closed-validation.ts | 34 +++++++++++ .../message-port/onmessage-nonfunction.ts | 27 +++++++++ .../message-port/ref-after-peer-close.ts | 19 +++++++ .../structured-clone/url-rejection.ts | 25 +++++++++ .../worker-lifecycle/exit-listener-error.ts | 14 +++++ .../postmessage-url-rejection.ts | 28 ++++++++++ test-parity/node_suite_baseline.json | 8 +-- 9 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/method-receivers.ts create mode 100644 test-parity/node-suite/worker_threads/message-channel/move-closed-validation.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/onmessage-nonfunction.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/ref-after-peer-close.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/url-rejection.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/exit-listener-error.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-url-rejection.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index bff28d8565..e481b23a2b 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -48,7 +48,8 @@ that oracle. non-cloneable construction rejection, and parent/worker mutation isolation. - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, - transfer detachment, MessagePort ownership, overloads, and atomic rollback. + transfer detachment, MessagePort ownership, unsupported URL rejection, + overloads, and atomic rollback. - `message-port/`: synchronous FIFO receives, invalid-port validation, explicit `start()`, event fields/ports, listener deduplication, close callback ordering, listener removal/`once`, `onmessage` replacement, method receiver @@ -58,11 +59,11 @@ that oracle. validation, queued delivery, overload validation, first-listener registration, pending `once` removal, per-event listener scope, close-callback registration order, tamper-resistant listener bookkeeping, post-dispatch `once` registry - state, throwing transfer iterators, transfer targets, and - `ref`/`unref`/`hasRef` state. + state, non-function `onmessage` clearing, peer-close ref behavior, throwing + transfer iterators, transfer targets, and `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, - and VM-context port movement. + and VM-context port movement with closed-port and argument validation. - `worker-lifecycle/`: constructor transfer validation, structured `workerData`, default and built-in workerData, MessagePort/ArrayBuffer transfer, environment snapshots, eval/CJS require, file URLs with Unicode names, argv and execArgv @@ -75,8 +76,9 @@ that oracle. `online`/`message`/`error`/`exit` ordering, ref return values, natural exit, and deterministic repeated termination settlement, transferred workerData port methods, sequential `SHARE_ENV` sibling visibility, post-exit method safety, - indexed `SHARE_ENV` keys, eval syntax-error routing, and custom-stack and - non-Error serialization. + indexed `SHARE_ENV` keys, eval syntax-error routing, URL postMessage + rejection, exit-listener exception routing, and custom-stack and non-Error + serialization. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -84,7 +86,7 @@ that oracle. - `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, listener management/`once`, name coercion, close/ref idempotence, typed-array and SharedArrayBuffer cloning, untransferable-value rejection, and - closed-channel validation. + closed-channel and method-receiver validation. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending the existing surface, option, query, callback settlement/cleanup, independent-name concurrency, and shared/exclusive ordering cases. @@ -106,8 +108,9 @@ cases above or belong to a separate slow/risky runtime feature: introducing pressure or timing assertions. - Basic eval/CJS require, execArgv, file-URL construction, process restrictions, and exit codes are now covered. Data-URL/ESM/custom loaders, preload chains, - signals, inspector integration, beforeExit/uncaught-exception variants, and - source maps remain separate loader, CLI, or process-subsystem work. + signals, inspector integration, beforeExit and worker-side uncaught-exception + variants, and source maps remain separate loader, CLI, or process-subsystem + work. - One controlled nested worker now covers environment-data inheritance. Deeper nesting stress, GC/finalization of unreachable ports, shared native handles, large message loops, and termination races remain scheduler-sensitive. @@ -125,8 +128,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `31/127`: all 17 pre-existing cases remain green, -14 added cases pass, and 96 added cases expose stable diagnostic differences. +The measured focused result is `31/134`: all 17 pre-existing cases remain green, +14 added cases pass, and 103 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -142,15 +145,14 @@ visibility in `worker-lifecycle/share-env-siblings.ts` also passes. The indexed-key variant in `worker-lifecycle/share-env-indexed.ts` passes too. The diagnostic differences are: -- All nine `structured-clone/` fixtures: Perry preserves some indexed values but +- All ten `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Twenty-eight `message-port/` diagnostics: closing and dispatch flushing, - queued data/callbacks, NodeEventTarget surface, listener counts and - validation, receiver/options/iterables/transfers, MessageEvent construction - and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` - state differ. -- Thirty-seven `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Thirty `message-port/` diagnostics: closing and dispatch flushing, queued + data/callbacks, NodeEventTarget surface, listener counts and validation, + receiver/options/iterables/transfers, MessageEvent construction and `ports`, + duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. +- Thirty-nine `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -159,10 +161,10 @@ diagnostic differences are: - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. -- Five `broadcast-channel/` diagnostics: name coercion, once listeners, - close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, - MessagePort validation, and closed-channel posts differ. -- Twelve additional diagnostics cover namespace/prototype completeness, +- Six `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref + behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort + validation, and closed-channel posts differ. +- Thirteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction, direct-message argument validation, and Web Locks callback rejection cleanup. @@ -176,10 +178,12 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `31/127 (24.4%), diff=96`, with no compile failures or timeouts. The -SharedArrayBuffer/Atomics boundary is backed by the added same-process channel -and cross-worker clone/alias cases plus the existing granular -`globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and +It reported `31/134 (23.1%), diff=103`, with no compile failures or timeouts. +The Worker URL-post diagnostic consistently exits by signal 11 in Perry while +Node rejects synchronously with `DataCloneError`, keeps the worker usable, and +terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the +added same-process channel and cross-worker clone/alias cases plus the existing +granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and `util/types/arraybuffer-sharedarraybuffer.ts` cases; cross-agent fixtures such as Node's `test-worker-message-channel-sharedarraybuffer.js` and Deno's `broadcast_channel_sab.mjs` remain outside this messaging-focused batch. A diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/method-receivers.ts b/test-parity/node-suite/worker_threads/broadcast-channel/method-receivers.ts new file mode 100644 index 0000000000..fb0b2ccdaf --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/method-receivers.ts @@ -0,0 +1,22 @@ +import { BroadcastChannel } from "node:worker_threads"; + +function outcome(fn: () => any): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const prototype = BroadcastChannel.prototype as any; +const nameGetter = Object.getOwnPropertyDescriptor(prototype, "name")?.get; +console.log( + "name getter:", + typeof nameGetter, + outcome(() => (nameGetter as any).call({})), +); + +for (const name of ["postMessage", "close", "ref", "unref"] as const) { + console.log(`${name}:`, outcome(() => prototype[name].call({}))); +} diff --git a/test-parity/node-suite/worker_threads/message-channel/move-closed-validation.ts b/test-parity/node-suite/worker_threads/message-channel/move-closed-validation.ts new file mode 100644 index 0000000000..644b457174 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-channel/move-closed-validation.ts @@ -0,0 +1,34 @@ +import * as vm from "node:vm"; +import { + MessageChannel, + MessagePort, + moveMessagePortToContext, +} from "node:worker_threads"; + +function outcome(fn: () => any): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const closed = new MessageChannel(); +closed.port1.close(); +const context = vm.createContext({ MessagePort }); + +console.log( + "closed:", + outcome(() => moveMessagePortToContext(closed.port1, context)), +); +console.log( + "invalid port:", + outcome(() => moveMessagePortToContext({} as any, context)), +); +console.log( + "invalid context:", + outcome(() => moveMessagePortToContext(closed.port2, {} as any)), +); + +closed.port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/onmessage-nonfunction.ts b/test-parity/node-suite/worker_threads/message-port/onmessage-nonfunction.ts new file mode 100644 index 0000000000..0d2fb89ed9 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/onmessage-nonfunction.ts @@ -0,0 +1,27 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const assigned = { not: "callable" }; + +port1.onmessage = () => console.log("unexpected handler"); +console.log( + "before:", + typeof port1.onmessage, + port1.hasRef(), + typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported", +); + +(port1 as any).onmessage = assigned; +console.log( + "after:", + port1.onmessage === assigned, + port1.hasRef(), + typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported", +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/ref-after-peer-close.ts b/test-parity/node-suite/worker_threads/message-port/ref-after-peer-close.ts new file mode 100644 index 0000000000..1dadbd218b --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/ref-after-peer-close.ts @@ -0,0 +1,19 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +port1.on("message", () => {}); +port1.on("close", () => { + port1.ref(); + port1.onmessage = () => {}; + console.log( + "after peer close:", + port1.hasRef(), + typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported", + typeof port1.onmessage, + ); + port1.close(); +}); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/url-rejection.ts b/test-parity/node-suite/worker_threads/structured-clone/url-rejection.ts new file mode 100644 index 0000000000..ddc45c9345 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/url-rejection.ts @@ -0,0 +1,25 @@ +import { + BroadcastChannel, + MessageChannel, + receiveMessageOnPort, +} from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const url = new URL("https://example.org/path?q=value"); +const channel = new MessageChannel(); +console.log("port:", outcome(() => channel.port1.postMessage(url))); +console.log("port queue:", receiveMessageOnPort(channel.port2)); +channel.port1.close(); +channel.port2.close(); + +const broadcast = new BroadcastChannel("url-rejection"); +console.log("broadcast:", outcome(() => broadcast.postMessage(url))); +broadcast.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/exit-listener-error.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-listener-error.ts new file mode 100644 index 0000000000..8c8ecc815c --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-listener-error.ts @@ -0,0 +1,14 @@ +import { Worker } from "node:worker_threads"; + +const events: string[] = []; + +process.once("uncaughtException", (error: any) => { + events.push(`uncaught:${error?.message}`); + console.log("events:", events.join(",")); +}); + +const worker = new Worker("", { eval: true }); +worker.on("exit", () => { + events.push("exit"); + throw new Error("exit-listener-boom"); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-url-rejection.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-url-rejection.ts new file mode 100644 index 0000000000..8f6c5c2d61 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-url-rejection.ts @@ -0,0 +1,28 @@ +import { Worker } from "node:worker_threads"; + +function outcome(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const worker = new Worker( + ` + const { parentPort } = require("node:worker_threads"); + parentPort.on("message", (message) => parentPort.postMessage(message)); + `, + { eval: true }, +); +worker.on("online", () => { + const url = new URL("https://example.org/path?q=value"); + console.log("worker:", outcome(() => worker.postMessage(url))); + worker.postMessage("still-alive"); +}); +worker.on("message", (message) => { + console.log("worker message:", message); + worker.terminate().then((code) => console.log("terminate:", code)); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 5738ac04e6..435a0f4bb0 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2815/2973 (94.7%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2815/2980 (94.5%)." }, "overall": { "pass": 2815, - "total": 2973, - "pct": 94.7 + "total": 2980, + "pct": 94.5 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 31, - "total": 127 + "total": 134 }, "zlib": { "pass": 58, From 2f9a9644b86c2115816d1c750ad0ff012c171e58 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 10:44:55 +0200 Subject: [PATCH 09/24] test: extend MessagePort validation coverage --- .../node-suite/worker_threads/README.md | 15 ++++++---- .../message-port/custom-event-emit.ts | 23 +++++++++++++++ .../message-port/transfer-iterator-shapes.ts | 29 +++++++++++++++++++ .../message-port/transfer-optional-forms.ts | 21 ++++++++++++++ test-parity/node_suite_baseline.json | 12 ++++---- 5 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/message-port/custom-event-emit.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-iterator-shapes.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-optional-forms.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index e481b23a2b..92e8d88231 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -60,7 +60,9 @@ that oracle. pending `once` removal, per-event listener scope, close-callback registration order, tamper-resistant listener bookkeeping, post-dispatch `once` registry state, non-function `onmessage` clearing, peer-close ref behavior, throwing - transfer iterators, transfer targets, and `ref`/`unref`/`hasRef` state. + and malformed transfer iterators, accepted empty transfer forms, custom-event + EventTarget/EventEmitter bridging, transfer targets, and + `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with closed-port and argument validation. @@ -128,8 +130,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `31/134`: all 17 pre-existing cases remain green, -14 added cases pass, and 103 added cases expose stable diagnostic differences. +The measured focused result is `32/137`: all 17 pre-existing cases remain green, +15 added cases pass, and 105 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -143,12 +145,13 @@ for `environment-data/value-identity.ts`, `web-locks/web-locks-independent-names.ts`. Sequential sibling `SHARE_ENV` visibility in `worker-lifecycle/share-env-siblings.ts` also passes. The indexed-key variant in `worker-lifecycle/share-env-indexed.ts` passes too. The -diagnostic differences are: +accepted optional transfer forms in `message-port/transfer-optional-forms.ts` +also pass. The diagnostic differences are: - All ten `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Thirty `message-port/` diagnostics: closing and dispatch flushing, queued +- Thirty-two `message-port/` diagnostics: closing and dispatch flushing, queued data/callbacks, NodeEventTarget surface, listener counts and validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. @@ -178,7 +181,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `31/134 (23.1%), diff=103`, with no compile failures or timeouts. +It reported `32/137 (23.4%), diff=105`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the diff --git a/test-parity/node-suite/worker_threads/message-port/custom-event-emit.ts b/test-parity/node-suite/worker_threads/message-port/custom-event-emit.ts new file mode 100644 index 0000000000..524092f205 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/custom-event-emit.ts @@ -0,0 +1,23 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; + +port2.addEventListener("custom", (event: any) => { + events.push( + `event:${event.type}:${event.detail}:${event.target === port2}`, + ); +}); +port2.on("custom", (detail) => events.push(`node:${detail}`)); + +const emit = (port2 as any).emit; +console.log( + "emit return:", + typeof emit === "function" + ? emit.call(port2, "custom", "value") + : "unsupported", +); +console.log("events:", events.join(",")); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-iterator-shapes.ts b/test-parity/node-suite/worker_threads/message-port/transfer-iterator-shapes.ts new file mode 100644 index 0000000000..334db96634 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-iterator-shapes.ts @@ -0,0 +1,29 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +function outcome(transfer: any): string { + try { + port1.postMessage("value", { transfer }); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +console.log( + "missing next:", + outcome({ [Symbol.iterator]: () => ({}) }), +); +console.log( + "number next:", + outcome({ [Symbol.iterator]: () => ({ next: 42 }) }), +); +console.log( + "null next:", + outcome({ [Symbol.iterator]: () => ({ next: null }) }), +); +console.log("queue:", receiveMessageOnPort(port2)); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-optional-forms.ts b/test-parity/node-suite/worker_threads/message-port/transfer-optional-forms.ts new file mode 100644 index 0000000000..9359f3ebd3 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-optional-forms.ts @@ -0,0 +1,21 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +port1.postMessage(1, null as any); +port1.postMessage(2, undefined); +port1.postMessage(3, []); +port1.postMessage(4, {}); +port1.postMessage(5, { transfer: undefined }); +port1.postMessage(6, { transfer: [] }); + +const received: number[] = []; +for (let index = 0; index < 6; index += 1) { + const packet = receiveMessageOnPort(port2); + if (packet) received.push(packet.message); +} +console.log("received:", received.join(",")); +console.log("drained:", receiveMessageOnPort(port2)); + +port1.close(); +port2.close(); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 435a0f4bb0..49b94d2788 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2815/2980 (94.5%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2983 (94.4%)." }, "overall": { - "pass": 2815, - "total": 2980, - "pct": 94.5 + "pass": 2816, + "total": 2983, + "pct": 94.4 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 31, - "total": 134 + "pass": 32, + "total": 137 }, "zlib": { "pass": 58, From 0986eaa8e6691229f856ff80f6095d3f605a864f Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 10:59:00 +0200 Subject: [PATCH 10/24] test: cover worker_threads listener control contracts --- .../node-suite/worker_threads/README.md | 42 +++++++------- .../onmessage-replacement.ts | 23 ++++++++ .../message-port/max-listeners-remove-all.ts | 56 +++++++++++++++++++ .../multiple-shared-arraybuffers.ts | 30 ++++++++++ test-parity/node_suite_baseline.json | 8 +-- 5 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/onmessage-replacement.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/max-listeners-remove-all.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/multiple-shared-arraybuffers.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 92e8d88231..6840a26dc9 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -48,8 +48,8 @@ that oracle. non-cloneable construction rejection, and parent/worker mutation isolation. - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, - transfer detachment, MessagePort ownership, unsupported URL rejection, - overloads, and atomic rollback. + multiple SharedArrayBuffers, transfer detachment, MessagePort ownership, + unsupported URL rejection, overloads, and atomic rollback. - `message-port/`: synchronous FIFO receives, invalid-port validation, explicit `start()`, event fields/ports, listener deduplication, close callback ordering, listener removal/`once`, `onmessage` replacement, method receiver @@ -61,8 +61,8 @@ that oracle. order, tamper-resistant listener bookkeeping, post-dispatch `once` registry state, non-function `onmessage` clearing, peer-close ref behavior, throwing and malformed transfer iterators, accepted empty transfer forms, custom-event - EventTarget/EventEmitter bridging, transfer targets, and - `ref`/`unref`/`hasRef` state. + EventTarget/EventEmitter bridging, max-listener controls, scoped + `removeAllListeners`, transfer targets, and `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with closed-port and argument validation. @@ -86,9 +86,9 @@ that oracle. the distinction between marking a port uncloneable and transferring it, plus private, unforgeable, and permanent marker behavior. - `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, - listener management/`once`, name coercion, close/ref idempotence, typed-array - and SharedArrayBuffer cloning, untransferable-value rejection, and - closed-channel and method-receiver validation. + listener management/`once`, `onmessage` replacement, name coercion, close/ref + idempotence, typed-array and SharedArrayBuffer cloning, untransferable-value + rejection, and closed-channel and method-receiver validation. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending the existing surface, option, query, callback settlement/cleanup, independent-name concurrency, and shared/exclusive ordering cases. @@ -130,8 +130,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `32/137`: all 17 pre-existing cases remain green, -15 added cases pass, and 105 added cases expose stable diagnostic differences. +The measured focused result is `32/140`: all 17 pre-existing cases remain green, +15 added cases pass, and 108 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -148,13 +148,15 @@ indexed-key variant in `worker-lifecycle/share-env-indexed.ts` passes too. The accepted optional transfer forms in `message-port/transfer-optional-forms.ts` also pass. The diagnostic differences are: -- All ten `structured-clone/` fixtures: Perry preserves some indexed values but - loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects - cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Thirty-two `message-port/` diagnostics: closing and dispatch flushing, queued - data/callbacks, NodeEventTarget surface, listener counts and validation, - receiver/options/iterables/transfers, MessageEvent construction and `ports`, - duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. +- All eleven `structured-clone/` fixtures: Perry preserves some indexed values + but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, + rejects cycles/BigInt, does not detach or move ownership, and accepts invalid + lists. +- Thirty-three `message-port/` diagnostics: closing and dispatch flushing, + queued data/callbacks, NodeEventTarget surface, listener counts and + validation, receiver/options/iterables/transfers, MessageEvent construction + and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` + state differ. - Thirty-nine `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, @@ -164,9 +166,9 @@ also pass. The diagnostic differences are: - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. -- Six `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref - behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort - validation, and closed-channel posts differ. +- Seven `broadcast-channel/` diagnostics: name coercion, once listeners, + close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, + MessagePort validation, and closed-channel posts differ. - Thirteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction, direct-message argument validation, and Web Locks callback @@ -181,7 +183,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/137 (23.4%), diff=105`, with no compile failures or timeouts. +It reported `32/140 (22.9%), diff=108`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/onmessage-replacement.ts b/test-parity/node-suite/worker_threads/broadcast-channel/onmessage-replacement.ts new file mode 100644 index 0000000000..c0cf7ef83d --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/onmessage-replacement.ts @@ -0,0 +1,23 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const sender = new BroadcastChannel("onmessage-replacement"); +const receiver = new BroadcastChannel("onmessage-replacement"); +const events: string[] = []; + +receiver.onmessage = (event) => events.push(`first:${event.data}`); +receiver.onmessage = (event) => { + events.push(`second:${event.data}`); + receiver.onmessage = null; + sender.postMessage("after-null"); +}; +receiver.addEventListener("message", (event) => { + events.push(`observer:${event.data}`); + if (event.data === "after-null") { + console.log("events:", events.join(",")); + console.log("handler:", receiver.onmessage); + sender.close(); + receiver.close(); + } +}); + +sender.postMessage("value"); diff --git a/test-parity/node-suite/worker_threads/message-port/max-listeners-remove-all.ts b/test-parity/node-suite/worker_threads/message-port/max-listeners-remove-all.ts new file mode 100644 index 0000000000..969e2dc430 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/max-listeners-remove-all.ts @@ -0,0 +1,56 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const getMaxListeners = (port1 as any).getMaxListeners; +const setMaxListeners = (port1 as any).setMaxListeners; + +console.log("methods:", typeof getMaxListeners, typeof setMaxListeners); +if ( + typeof getMaxListeners === "function" && + typeof setMaxListeners === "function" +) { + console.log("initial max:", getMaxListeners.call(port1)); + console.log("set return:", setMaxListeners.call(port1, 3)); + console.log("updated max:", getMaxListeners.call(port1)); +} + +const messageListener = () => {}; +const customListener = () => {}; +port1.on("message", messageListener); +port1.on("custom", customListener); + +const removeAllListeners = (port1 as any).removeAllListeners; +console.log( + "remove return:", + typeof removeAllListeners === "function" + ? removeAllListeners.call(port1, "message") === port1 + : "unsupported", +); +console.log( + "remaining:", + typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("message") + : "unsupported", + typeof (port1 as any).listenerCount === "function" + ? (port1 as any).listenerCount("custom") + : "unsupported", +); + +function invalid(label: string, value: any) { + try { + if (typeof setMaxListeners !== "function") { + throw new TypeError("unsupported"); + } + setMaxListeners.call(port1, value); + console.log(label, "ok"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} + +invalid("negative:", -1); +invalid("string:", "3"); +invalid("nan:", Number.NaN); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/multiple-shared-arraybuffers.ts b/test-parity/node-suite/worker_threads/structured-clone/multiple-shared-arraybuffers.ts new file mode 100644 index 0000000000..6168e9a630 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/multiple-shared-arraybuffers.ts @@ -0,0 +1,30 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const value = [ + [new SharedArrayBuffer(0), new SharedArrayBuffer(1)], + [new SharedArrayBuffer(2), new SharedArrayBuffer(3)], +]; +const { port1, port2 } = new MessageChannel(); + +port1.postMessage(value); +const packet = receiveMessageOnPort(port2); +const received = packet?.message; +const flattened = Array.isArray(received) ? received.flat() : []; + +console.log( + "brands:", + flattened.map((item: any) => item instanceof SharedArrayBuffer).join(","), +); +console.log( + "lengths:", + flattened.map((item: any) => item?.byteLength ?? "missing").join(","), +); +console.log( + "shape:", + received?.length, + received?.[0]?.length, + received?.[1]?.length, +); + +port1.close(); +port2.close(); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 49b94d2788..3fe7db47c5 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2983 (94.4%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2986 (94.3%)." }, "overall": { "pass": 2816, - "total": 2983, - "pct": 94.4 + "total": 2986, + "pct": 94.3 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 32, - "total": 137 + "total": 140 }, "zlib": { "pass": 58, From ba65cd183d374a409bf9bb802a4adbb5c98bfd87 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 11:12:07 +0200 Subject: [PATCH 11/24] test: cover worker_threads inheritance brands --- .../node-suite/worker_threads/README.md | 36 +++++++++---------- .../inheritance-brands-worker.cjs | 6 ++++ .../worker-lifecycle/inheritance-brands.ts | 23 ++++++++++++ test-parity/node_suite_baseline.json | 6 ++-- 4 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 6840a26dc9..5c4ac319c0 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -72,15 +72,15 @@ that oracle. coercion/validation, inherited/explicit/shared environments, SharedArrayBuffer through workerData and postMessage, queued transferred-port receive, thread id/name uniqueness and lifecycle, parentPort `onmessage`/ref state, Worker - EventEmitter surface and listener validation, process restrictions, captured - stdout/stderr, explicit and `process.exitCode` exits, unsupported paths, - method receivers, postMessage clone rollback, - `online`/`message`/`error`/`exit` ordering, ref return values, natural exit, - and deterministic repeated termination settlement, transferred workerData port - methods, sequential `SHARE_ENV` sibling visibility, post-exit method safety, - indexed `SHARE_ENV` keys, eval syntax-error routing, URL postMessage - rejection, exit-listener exception routing, and custom-stack and non-Error - serialization. + EventEmitter surface, MessagePort/EventTarget inheritance brands and listener + validation, process restrictions, captured stdout/stderr, explicit and + `process.exitCode` exits, unsupported paths, method receivers, postMessage + clone rollback, `online`/`message`/`error`/`exit` ordering, ref return values, + natural exit, and deterministic repeated termination settlement, transferred + workerData port methods, sequential `SHARE_ENV` sibling visibility, post-exit + method safety, indexed `SHARE_ENV` keys, eval syntax-error routing, URL + postMessage rejection, exit-listener exception routing, and custom-stack and + non-Error serialization. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -130,8 +130,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `32/140`: all 17 pre-existing cases remain green, -15 added cases pass, and 108 added cases expose stable diagnostic differences. +The measured focused result is `32/141`: all 17 pre-existing cases remain green, +15 added cases pass, and 109 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -157,12 +157,12 @@ also pass. The diagnostic differences are: validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. -- Thirty-nine `worker-lifecycle/` diagnostics: invalid constructor payloads and - paths, execArgv, captured stdio, process restrictions/exit codes, shared - environments and nested messages, worker/parentPort EventEmitter behavior, - metadata/unique ids, repeated termination, workerData and postMessage SAB - sharing, queued port receive, rollback, post-exit values, and error routing - differ. Basic eval with CJS require passes. +- Forty `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, + execArgv, captured stdio, process restrictions/exit codes, shared environments + and nested messages, worker/parentPort EventEmitter behavior, metadata/unique + ids, repeated termination, workerData and postMessage SAB sharing, queued port + receive, rollback, post-exit values, and error routing differ. Basic eval with + CJS require passes. - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. @@ -183,7 +183,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/140 (22.9%), diff=108`, with no compile failures or timeouts. +It reported `32/141 (22.7%), diff=109`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands-worker.cjs new file mode 100644 index 0000000000..d3b2e3897d --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands-worker.cjs @@ -0,0 +1,6 @@ +const { MessagePort, parentPort } = require("node:worker_threads"); + +parentPort.postMessage({ + eventTarget: parentPort instanceof EventTarget, + messagePort: parentPort instanceof MessagePort, +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands.ts new file mode 100644 index 0000000000..4057c1eb89 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/inheritance-brands.ts @@ -0,0 +1,23 @@ +import { MessageChannel, MessagePort, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const { port1, port2 } = new MessageChannel(); +console.log( + "main port:", + port1 instanceof MessagePort, + port1 instanceof EventTarget, +); +port1.close(); +port2.close(); + +const worker = new Worker("./inheritance-brands-worker.cjs"); +console.log("worker:", worker instanceof EventTarget); +worker.on("message", (message: any) => { + console.log( + "parent port:", + message.messagePort, + message.eventTarget, + ); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 3fe7db47c5..33995bee7d 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,11 +2,11 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2986 (94.3%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2987 (94.3%)." }, "overall": { "pass": 2816, - "total": 2986, + "total": 2987, "pct": 94.3 }, "modules": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 32, - "total": 140 + "total": 141 }, "zlib": { "pass": 58, From d4133a90db76e9b0a5c501500606cd262fc84785 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 11:31:08 +0200 Subject: [PATCH 12/24] test: cover MessagePort closing transfer state --- .../node-suite/worker_threads/README.md | 18 +++++----- .../transfer-during-close-flush.ts | 33 +++++++++++++++++++ test-parity/node_suite_baseline.json | 8 ++--- 3 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/message-port/transfer-during-close-flush.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 5c4ac319c0..0a1431eda2 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -62,7 +62,8 @@ that oracle. state, non-function `onmessage` clearing, peer-close ref behavior, throwing and malformed transfer iterators, accepted empty transfer forms, custom-event EventTarget/EventEmitter bridging, max-listener controls, scoped - `removeAllListeners`, transfer targets, and `ref`/`unref`/`hasRef` state. + `removeAllListeners`, transfer rejection during close flushing, transfer + targets, and `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with closed-port and argument validation. @@ -130,8 +131,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `32/141`: all 17 pre-existing cases remain green, -15 added cases pass, and 109 added cases expose stable diagnostic differences. +The measured focused result is `32/142`: all 17 pre-existing cases remain green, +15 added cases pass, and 110 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -152,11 +153,10 @@ also pass. The diagnostic differences are: but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Thirty-three `message-port/` diagnostics: closing and dispatch flushing, - queued data/callbacks, NodeEventTarget surface, listener counts and - validation, receiver/options/iterables/transfers, MessageEvent construction - and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` - state differ. +- Thirty-four `message-port/` diagnostics: closing and dispatch flushing, queued + data/callbacks, NodeEventTarget surface, listener counts and validation, + receiver/options/iterables/transfers, MessageEvent construction and `ports`, + duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. - Forty `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique @@ -183,7 +183,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/141 (22.7%), diff=109`, with no compile failures or timeouts. +It reported `32/142 (22.5%), diff=110`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the diff --git a/test-parity/node-suite/worker_threads/message-port/transfer-during-close-flush.ts b/test-parity/node-suite/worker_threads/message-port/transfer-during-close-flush.ts new file mode 100644 index 0000000000..f6cdf732c9 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/transfer-during-close-flush.ts @@ -0,0 +1,33 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const closing = new MessageChannel(); +const carrier = new MessageChannel(); +let deliveries = 0; +let transfer = "missing"; + +closing.port1.on("message", () => { + deliveries += 1; + if (deliveries === 1) { + closing.port1.close(); + return; + } + + try { + carrier.port1.postMessage(null, [closing.port1]); + transfer = "ok"; + } catch (error: any) { + transfer = `${error?.name}:${error?.code ?? ""}`; + } +}); + +closing.port1.on("close", () => { + console.log("deliveries:", deliveries); + console.log("transfer:", transfer); + console.log("carrier queue:", receiveMessageOnPort(carrier.port2)); + closing.port2.close(); + carrier.port1.close(); + carrier.port2.close(); +}); + +closing.port2.postMessage("first"); +closing.port2.postMessage("second"); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 33995bee7d..dced1ec1b3 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2987 (94.3%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2988 (94.2%)." }, "overall": { "pass": 2816, - "total": 2987, - "pct": 94.3 + "total": 2988, + "pct": 94.2 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 32, - "total": 141 + "total": 142 }, "zlib": { "pass": 58, From 7786ffefde9b7e21f707524f365336a6ce9f2f9d Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 11:40:58 +0200 Subject: [PATCH 13/24] test: cover BroadcastChannel coercion edges --- .../node-suite/worker_threads/README.md | 11 ++++++----- .../constructor-coercion-edges.ts | 19 +++++++++++++++++++ .../broadcast-channel/error-precedence.ts | 15 +++++++++++++++ test-parity/node_suite_baseline.json | 6 +++--- 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/constructor-coercion-edges.ts create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/error-precedence.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 0a1431eda2..6d5b12b9e0 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -89,7 +89,8 @@ that oracle. - `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, listener management/`once`, `onmessage` replacement, name coercion, close/ref idempotence, typed-array and SharedArrayBuffer cloning, untransferable-value - rejection, and closed-channel and method-receiver validation. + rejection, clone-versus-closed error precedence, and closed-channel and + method-receiver validation. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending the existing surface, option, query, callback settlement/cleanup, independent-name concurrency, and shared/exclusive ordering cases. @@ -131,8 +132,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `32/142`: all 17 pre-existing cases remain green, -15 added cases pass, and 110 added cases expose stable diagnostic differences. +The measured focused result is `32/144`: all 17 pre-existing cases remain green, +15 added cases pass, and 112 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -166,7 +167,7 @@ also pass. The diagnostic differences are: - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. -- Seven `broadcast-channel/` diagnostics: name coercion, once listeners, +- Nine `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, and closed-channel posts differ. - Thirteen additional diagnostics cover namespace/prototype completeness, @@ -183,7 +184,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/142 (22.5%), diff=110`, with no compile failures or timeouts. +It reported `32/144 (22.2%), diff=112`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/constructor-coercion-edges.ts b/test-parity/node-suite/worker_threads/broadcast-channel/constructor-coercion-edges.ts new file mode 100644 index 0000000000..415ca053f5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/constructor-coercion-edges.ts @@ -0,0 +1,19 @@ +import { BroadcastChannel } from "node:worker_threads"; + +for ( + const [label, value] of [ + ["null", null], + ["bigint", 1n], + ["boolean", false], + ["infinity", Number.POSITIVE_INFINITY], + ["symbol", Symbol("channel")], + ] as const +) { + try { + const channel = new BroadcastChannel(value as any); + console.log(label, "name", channel.name); + channel.close(); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } +} diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/error-precedence.ts b/test-parity/node-suite/worker_threads/broadcast-channel/error-precedence.ts new file mode 100644 index 0000000000..0159564aad --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/error-precedence.ts @@ -0,0 +1,15 @@ +import { BroadcastChannel } from "node:worker_threads"; + +function outcome(channel: BroadcastChannel, value: any): string { + try { + channel.postMessage(value); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const channel = new BroadcastChannel("error-precedence"); +console.log("open symbol:", outcome(channel, Symbol("value"))); +channel.close(); +console.log("closed symbol:", outcome(channel, Symbol("value"))); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index dced1ec1b3..4236c4bc42 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,11 +2,11 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2988 (94.2%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2990 (94.2%)." }, "overall": { "pass": 2816, - "total": 2988, + "total": 2990, "pct": 94.2 }, "modules": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 32, - "total": 142 + "total": 144 }, "zlib": { "pass": 58, From ddb735af19d7eaffd99d48a71791ad61a50658c7 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 12:03:51 +0200 Subject: [PATCH 14/24] test: validate missing worker message arguments --- .../node-suite/worker_threads/README.md | 38 +++++++++---------- .../broadcast-channel/post-message-missing.ts | 10 +++++ .../message-port/post-message-missing.ts | 14 +++++++ .../worker-lifecycle/postmessage-missing.ts | 19 ++++++++++ test-parity/node_suite_baseline.json | 8 ++-- 5 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/post-message-missing.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/post-message-missing.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-missing.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 6d5b12b9e0..179f7d522d 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -62,8 +62,8 @@ that oracle. state, non-function `onmessage` clearing, peer-close ref behavior, throwing and malformed transfer iterators, accepted empty transfer forms, custom-event EventTarget/EventEmitter bridging, max-listener controls, scoped - `removeAllListeners`, transfer rejection during close flushing, transfer - targets, and `ref`/`unref`/`hasRef` state. + `removeAllListeners`, missing-message validation, transfer rejection during + close flushing, transfer targets, and `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with closed-port and argument validation. @@ -80,8 +80,8 @@ that oracle. natural exit, and deterministic repeated termination settlement, transferred workerData port methods, sequential `SHARE_ENV` sibling visibility, post-exit method safety, indexed `SHARE_ENV` keys, eval syntax-error routing, URL - postMessage rejection, exit-listener exception routing, and custom-stack and - non-Error serialization. + postMessage rejection, missing postMessage validation, exit-listener exception + routing, and custom-stack and non-Error serialization. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -89,8 +89,8 @@ that oracle. - `broadcast-channel/`: same-name fanout/FIFO isolation, sender exclusion, listener management/`once`, `onmessage` replacement, name coercion, close/ref idempotence, typed-array and SharedArrayBuffer cloning, untransferable-value - rejection, clone-versus-closed error precedence, and closed-channel and - method-receiver validation. + rejection, missing-message validation, clone-versus-closed error precedence, + and closed-channel and method-receiver validation. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending the existing surface, option, query, callback settlement/cleanup, independent-name concurrency, and shared/exclusive ordering cases. @@ -132,8 +132,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `32/144`: all 17 pre-existing cases remain green, -15 added cases pass, and 112 added cases expose stable diagnostic differences. +The measured focused result is `32/147`: all 17 pre-existing cases remain green, +15 added cases pass, and 115 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -154,22 +154,22 @@ also pass. The diagnostic differences are: but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Thirty-four `message-port/` diagnostics: closing and dispatch flushing, queued +- Thirty-five `message-port/` diagnostics: closing and dispatch flushing, queued data/callbacks, NodeEventTarget surface, listener counts and validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. -- Forty `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, - execArgv, captured stdio, process restrictions/exit codes, shared environments - and nested messages, worker/parentPort EventEmitter behavior, metadata/unique - ids, repeated termination, workerData and postMessage SAB sharing, queued port - receive, rollback, post-exit values, and error routing differ. Basic eval with - CJS require passes. +- Forty-one `worker-lifecycle/` diagnostics: invalid constructor payloads and + paths, execArgv, captured stdio, process restrictions/exit codes, shared + environments and nested messages, worker/parentPort EventEmitter behavior, + metadata/unique ids, repeated termination, workerData and postMessage SAB + sharing, queued port receive, rollback, post-exit values, and error routing + differ. Basic eval with CJS require passes. - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. -- Nine `broadcast-channel/` diagnostics: name coercion, once listeners, - close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, - MessagePort validation, and closed-channel posts differ. +- Ten `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref + behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort + validation, and closed-channel posts differ. - Thirteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction, direct-message argument validation, and Web Locks callback @@ -184,7 +184,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/144 (22.2%), diff=112`, with no compile failures or timeouts. +It reported `32/147 (21.8%), diff=115`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/post-message-missing.ts b/test-parity/node-suite/worker_threads/broadcast-channel/post-message-missing.ts new file mode 100644 index 0000000000..324ac538d5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/post-message-missing.ts @@ -0,0 +1,10 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const channel = new BroadcastChannel("missing-message"); +try { + (channel.postMessage as any)(); + console.log("post: ok"); +} catch (error: any) { + console.log("post:", error?.name, error?.code ?? ""); +} +channel.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/post-message-missing.ts b/test-parity/node-suite/worker_threads/message-port/post-message-missing.ts new file mode 100644 index 0000000000..f6720beca8 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/post-message-missing.ts @@ -0,0 +1,14 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +try { + (port1.postMessage as any)(); + console.log("post: ok"); +} catch (error: any) { + console.log("post:", error?.name, error?.code ?? ""); +} +console.log("queue:", receiveMessageOnPort(port2)); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-missing.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-missing.ts new file mode 100644 index 0000000000..59927f8833 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/postmessage-missing.ts @@ -0,0 +1,19 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./postmessage-errors-worker.cjs"); +worker.on("online", () => { + try { + (worker.postMessage as any)(); + console.log("post: ok"); + } catch (error: any) { + console.log("post:", error?.name, error?.code ?? ""); + } + worker.postMessage("finish"); +}); +worker.on("message", (message) => { + console.log("message:", message); + worker.terminate().then((code) => console.log("terminate:", code)); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 4236c4bc42..bc78aa9168 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2990 (94.2%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2993 (94.1%)." }, "overall": { "pass": 2816, - "total": 2990, - "pct": 94.2 + "total": 2993, + "pct": 94.1 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 32, - "total": 144 + "total": 147 }, "zlib": { "pass": 58, From 67a3da065af68b869bb00f7a2f930e6cfadeecb5 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 16:59:35 +0200 Subject: [PATCH 15/24] test: deepen worker clone and option coverage --- .../node-suite/worker_threads/README.md | 48 ++++++++++------ .../worker_threads/message-channel/globals.ts | 8 +++ .../frozen-eventtarget-prototype.ts | 20 +++++++ .../messageevent-defaults-coercion.ts | 56 +++++++++++++++++++ .../message-port/post-message-validation.ts | 5 ++ .../structured-clone/detached-retransfer.ts | 27 +++++++++ .../worker-lifecycle/execargv-options.ts | 3 + .../global-postmessage-override.ts | 16 ++++++ .../global-postmessage-worker.cjs | 12 ++++ .../node-options-env-validation.ts | 17 ++++++ .../worker-lifecycle/unsupported-paths.ts | 4 ++ .../workerdata-alias-view-worker.cjs | 14 +++++ .../worker-lifecycle/workerdata-alias-view.ts | 34 +++++++++++ .../workerdata-cycle-worker.cjs | 6 ++ .../worker-lifecycle/workerdata-cycle.ts | 25 +++++++++ test-parity/node_suite_baseline.json | 6 +- 16 files changed, 282 insertions(+), 19 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/message-port/frozen-eventtarget-prototype.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/messageevent-defaults-coercion.ts create mode 100644 test-parity/node-suite/worker_threads/structured-clone/detached-retransfer.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-override.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/node-options-env-validation.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 179f7d522d..c569b9faa7 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -48,8 +48,9 @@ that oracle. non-cloneable construction rejection, and parent/worker mutation isolation. - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, - multiple SharedArrayBuffers, transfer detachment, MessagePort ownership, - unsupported URL rejection, overloads, and atomic rollback. + multiple SharedArrayBuffers, transfer detachment, detached-buffer retransfer + rejection, MessagePort ownership, unsupported URL rejection, overloads, and + atomic rollback. - `message-port/`: synchronous FIFO receives, invalid-port validation, explicit `start()`, event fields/ports, listener deduplication, close callback ordering, listener removal/`once`, `onmessage` replacement, method receiver @@ -63,7 +64,8 @@ that oracle. and malformed transfer iterators, accepted empty transfer forms, custom-event EventTarget/EventEmitter bridging, max-listener controls, scoped `removeAllListeners`, missing-message validation, transfer rejection during - close flushing, transfer targets, and `ref`/`unref`/`hasRef` state. + close flushing, frozen EventTarget prototype resilience, MessageEvent + defaults/coercion, transfer targets, and `ref`/`unref`/`hasRef` state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with closed-port and argument validation. @@ -81,7 +83,9 @@ that oracle. workerData port methods, sequential `SHARE_ENV` sibling visibility, post-exit method safety, indexed `SHARE_ENV` keys, eval syntax-error routing, URL postMessage rejection, missing postMessage validation, exit-listener exception - routing, and custom-stack and non-Error serialization. + routing, global `postMessage` replacement, workerData alias/view/cycle + cloning, broader filename, falsy execArgv, and disallowed `NODE_OPTIONS` + validation, and custom-stack and non-Error serialization. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -121,10 +125,19 @@ cases above or belong to a separate slow/risky runtime feature: - SharedArrayBuffer aliasing is covered through same-thread MessagePort and BroadcastChannel paths plus cross-agent workerData and postMessage. Atomics wait/notify coordination remains separate scheduler-aware infrastructure. +- A Node `markAsUncloneable(Blob)` candidate was stable under Node 26.5.0 but + exceeded the 120-second Perry per-case compilation threshold. It belongs with + Blob runtime/compilation coverage rather than turning a marker diagnostic into + a granular timeout. - A cross-worker BroadcastChannel delivery candidate was rejected after Node required cross-agent scheduling turns while Perry's absent delivery left no deterministic completion event. It belongs with scheduler-aware cross-agent infrastructure rather than a sleep/timeout-based fixture here. +- A Node live-`process.cwd()` propagation candidate passed three oracle runs, + but Perry could not shut its worker down deterministically: `parentPort.close` + is unsupported, a consumed `once` listener retained the worker, and explicit + exit variably dropped the final message. That contract needs worker-shutdown + infrastructure rather than a timeout-prone granular fixture. - Web Locks now cover deterministic pre-abort and steal behavior in addition to surface, validation, shared/exclusive ordering, `ifAvailable`, and query snapshots. In-flight abort races and cross-agent ownership remain excluded. @@ -132,8 +145,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `32/147`: all 17 pre-existing cases remain green, -15 added cases pass, and 115 added cases expose stable diagnostic differences. +The measured focused result is `32/154`: all 17 pre-existing cases remain green, +15 added cases pass, and 122 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -150,15 +163,16 @@ indexed-key variant in `worker-lifecycle/share-env-indexed.ts` passes too. The accepted optional transfer forms in `message-port/transfer-optional-forms.ts` also pass. The diagnostic differences are: -- All eleven `structured-clone/` fixtures: Perry preserves some indexed values +- All twelve `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Thirty-five `message-port/` diagnostics: closing and dispatch flushing, queued - data/callbacks, NodeEventTarget surface, listener counts and validation, - receiver/options/iterables/transfers, MessageEvent construction and `ports`, - duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. -- Forty-one `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Thirty-seven `message-port/` diagnostics: closing and dispatch flushing, + queued data/callbacks, NodeEventTarget surface, listener counts and + validation, receiver/options/iterables/transfers, MessageEvent construction + and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` + state differ. +- Forty-five `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -184,12 +198,14 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/147 (21.8%), diff=115`, with no compile failures or timeouts. +It reported `32/154 (20.8%), diff=122`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and -terminates cleanly. The SharedArrayBuffer/Atomics boundary is backed by the -added same-process channel and cross-worker clone/alias cases plus the existing -granular `globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and +terminates cleanly. Cyclic `workerData` construction also consistently exits by +signal 11 in Perry while Node preserves both cyclic references and exits zero. +The SharedArrayBuffer/Atomics boundary is backed by the added same-process +channel and cross-worker clone/alias cases plus the existing granular +`globals/atomics-*.ts`, `buffer/from/shared-array-buffer.ts`, and `util/types/arraybuffer-sharedarraybuffer.ts` cases; cross-agent fixtures such as Node's `test-worker-message-channel-sharedarraybuffer.js` and Deno's `broadcast_channel_sab.mjs` remain outside this messaging-focused batch. A diff --git a/test-parity/node-suite/worker_threads/message-channel/globals.ts b/test-parity/node-suite/worker_threads/message-channel/globals.ts index 861e3d2657..ffc16b0838 100644 --- a/test-parity/node-suite/worker_threads/message-channel/globals.ts +++ b/test-parity/node-suite/worker_threads/message-channel/globals.ts @@ -54,6 +54,14 @@ try { } console.log("MessageChannel call:", messageChannelCall); +let messagePortCall = "did-not-throw"; +try { + (globalThis.MessagePort as any)(); +} catch (_err) { + messagePortCall = "threw"; +} +console.log("MessagePort call:", messagePortCall); + let messagePortNew = "did-not-throw"; try { new (globalThis.MessagePort as any)(); diff --git a/test-parity/node-suite/worker_threads/message-port/frozen-eventtarget-prototype.ts b/test-parity/node-suite/worker_threads/message-port/frozen-eventtarget-prototype.ts new file mode 100644 index 0000000000..6228d68da0 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/frozen-eventtarget-prototype.ts @@ -0,0 +1,20 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +Object.freeze(EventTarget.prototype); +console.log("frozen:", Object.isFrozen(EventTarget.prototype)); + +const { port1, port2 } = new MessageChannel(); +try { + const listener = () => console.log("unexpected async listener"); + port2.on("message", listener); + console.log("registered:", port2.listenerCount("message")); + port1.postMessage("value"); + console.log("received:", receiveMessageOnPort(port2)?.message); + port2.off("message", listener); + console.log("removed:", port2.listenerCount("message")); +} catch (error) { + console.log("error:", (error as any)?.name, (error as any)?.code ?? ""); +} finally { + port1.close(); + port2.close(); +} diff --git a/test-parity/node-suite/worker_threads/message-port/messageevent-defaults-coercion.ts b/test-parity/node-suite/worker_threads/message-port/messageevent-defaults-coercion.ts new file mode 100644 index 0000000000..a42406eff7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/messageevent-defaults-coercion.ts @@ -0,0 +1,56 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); + +for ( + const [label, event] of [ + ["default", new MessageEvent("message")], + [ + "undefined", + new MessageEvent("message", { data: undefined, origin: "origin" }), + ], + [ + "coerced", + new MessageEvent("message", { + data: 2, + origin: 1 as any, + lastEventId: 0 as any, + }), + ], + [ + "source", + new MessageEvent("messageerror", { + lastEventId: "event", + source: port1, + }), + ], + ] as const +) { + console.log( + label, + event.type, + event.data, + event.origin, + event.lastEventId, + event.source === port1 ? "port" : event.source, + event.ports?.length ?? "missing", + ); +} + +for ( + const [label, init] of [ + ["number source", { source: 1 }], + ["noniterable ports", { ports: 0 }], + ["null port", { ports: [null] }], + ] as const +) { + try { + new MessageEvent("message", init as any); + console.log(label, "ok"); + } catch (error) { + console.log(label, (error as any)?.name, (error as any)?.code ?? ""); + } +} + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/post-message-validation.ts b/test-parity/node-suite/worker_threads/message-port/post-message-validation.ts index 667a782f78..2013ed07ae 100644 --- a/test-parity/node-suite/worker_threads/message-port/post-message-validation.ts +++ b/test-parity/node-suite/worker_threads/message-port/post-message-validation.ts @@ -17,8 +17,13 @@ const { port1, port2 } = new MessageChannel(); console.log("function:", outcome(() => {})); console.log("symbol:", outcome(Symbol("value"))); console.log("number transfer:", outcome("value", 1)); +console.log("false transfer:", outcome("value", false)); console.log("string transfer:", outcome("value", "bad")); +console.log("symbol transfer:", outcome("value", Symbol("bad"))); console.log("options transfer:", outcome("value", { transfer: null })); +console.log("zero options transfer:", outcome("value", { transfer: 0 })); +console.log("false options transfer:", outcome("value", { transfer: false })); +console.log("object options transfer:", outcome("value", { transfer: {} })); port1.close(); port2.close(); diff --git a/test-parity/node-suite/worker_threads/structured-clone/detached-retransfer.ts b/test-parity/node-suite/worker_threads/structured-clone/detached-retransfer.ts new file mode 100644 index 0000000000..2e009c3e94 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/detached-retransfer.ts @@ -0,0 +1,27 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const buffer = new ArrayBuffer(8); +const view = new Uint32Array(buffer); +view[0] = 0x12345678; + +port1.postMessage(view, [buffer]); +console.log("detached:", buffer.byteLength, view.byteLength); + +try { + port1.postMessage(view, [buffer]); + console.log("retransfer: ok"); +} catch (error: any) { + console.log("retransfer:", error?.name, error?.code ?? ""); +} + +const first = receiveMessageOnPort(port2); +console.log( + "first:", + first?.message instanceof Uint32Array, + first?.message?.[0], +); +console.log("second:", receiveMessageOnPort(port2)); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts index 20a6348e98..77aadaae6c 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/execargv-options.ts @@ -21,6 +21,9 @@ function run(label: string, options: Record): Promise { async function main() { console.log("parent:", JSON.stringify(process.execArgv)); await run("inherited:", {}); + await run("null:", { execArgv: null }); + await run("zero:", { execArgv: 0 }); + await run("false:", { execArgv: false }); await run("empty:", { execArgv: [] }); await run("custom:", { execArgv: ["--no-warnings"] }); await run("nonstrings:", { execArgv: [1, null, true] as any }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-override.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-override.ts new file mode 100644 index 0000000000..a2b7320ec0 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-override.ts @@ -0,0 +1,16 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); +const worker = new Worker("./global-postmessage-worker.cjs"); +worker.on("message", (message) => { + if (message === "ready") { + worker.postMessage("value"); + return; + } + console.log("message:", message); + worker.terminate(); +}); +worker.on("error", (error) => { + console.log("error:", error.name, (error as any).code ?? ""); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-worker.cjs new file mode 100644 index 0000000000..f4133c55f5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/global-postmessage-worker.cjs @@ -0,0 +1,12 @@ +const { parentPort } = require("node:worker_threads"); + +Object.assign(globalThis, { + postMessage(message) { + parentPort.postMessage(message); + }, +}); + +parentPort.once("message", (message) => { + globalThis.postMessage(`echo:${message}`); +}); +parentPort.postMessage("ready"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/node-options-env-validation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/node-options-env-validation.ts new file mode 100644 index 0000000000..6da5dd5368 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/node-options-env-validation.ts @@ -0,0 +1,17 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +try { + const worker = new Worker("./natural-exit-worker.cjs", { + env: { NODE_OPTIONS: "--title=worker" }, + }); + console.log("construct: ok"); + worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), + ); + worker.on("exit", (code) => console.log("exit:", code)); +} catch (error) { + console.log("construct:", (error as any)?.name, (error as any)?.code ?? ""); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts index 78e681f0fd..0e80ea8835 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts @@ -3,8 +3,12 @@ import { Worker } from "node:worker_threads"; const values: Array<[string, any]> = [ ["undefined", undefined], ["null", null], + ["boolean", false], ["number", 1], + ["symbol", Symbol("worker")], ["object", {}], + ["array", []], + ["function", () => {}], ["bare relative", "worker.cjs"], ["http string", "https://example.com/worker.js"], ["http URL", new URL("https://example.com/worker.js")], diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view-worker.cjs new file mode 100644 index 0000000000..fbf234b3ef --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view-worker.cjs @@ -0,0 +1,14 @@ +const { parentPort, workerData } = require("node:worker_threads"); +const left = workerData?.left; +const right = workerData?.right; +const view = workerData?.view; +const buffer = workerData?.buffer; + +parentPort.postMessage({ + alias: left !== undefined && left === right, + viewBrand: view instanceof Uint16Array, + bufferBrand: buffer instanceof ArrayBuffer, + backing: view !== undefined && view.buffer === buffer, + value: view?.[0] ?? "missing", + lengths: [buffer?.byteLength ?? "missing", view?.byteLength ?? "missing"], +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view.ts new file mode 100644 index 0000000000..77709979f6 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-alias-view.ts @@ -0,0 +1,34 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const shared = { value: 7 }; +const buffer = new ArrayBuffer(4); +const view = new Uint16Array(buffer); +view[0] = 0x1234; + +const worker = new Worker("./workerdata-alias-view-worker.cjs", { + workerData: { + left: shared, + right: shared, + buffer, + view, + }, +}); +console.log("parent:", buffer.byteLength, view.byteLength, view[0]); +worker.on("message", (message) => { + console.log( + "worker:", + message.alias, + message.viewBrand, + message.bufferBrand, + message.backing, + message.value, + message.lengths.join(","), + ); +}); +worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), +); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle-worker.cjs new file mode 100644 index 0000000000..33266dc2dd --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle-worker.cjs @@ -0,0 +1,6 @@ +const { parentPort, workerData } = require("node:worker_threads"); +parentPort.postMessage({ + cycle: workerData.self === workerData, + nested: workerData.child.parent === workerData, + value: workerData.child.value, +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle.ts new file mode 100644 index 0000000000..4dc66fd8de --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-cycle.ts @@ -0,0 +1,25 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const data: any = { child: { value: 9 } }; +data.self = data; +data.child.parent = data; + +try { + const worker = new Worker("./workerdata-cycle-worker.cjs", { + workerData: data, + }); + worker.on( + "message", + (message) => + console.log("cycle:", message.cycle, message.nested, message.value), + ); + worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), + ); + worker.on("exit", (code) => console.log("exit:", code)); +} catch (error) { + console.log("construct:", (error as any)?.name, (error as any)?.code ?? ""); +} diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index bc78aa9168..7d5e516e0a 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -6,8 +6,8 @@ }, "overall": { "pass": 2816, - "total": 2993, - "pct": 94.1 + "total": 3000, + "pct": 93.9 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 32, - "total": 147 + "total": 154 }, "zlib": { "pass": 58, From 5217fb9d7cd0ebe0051c95f26cf887927f8895e4 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 17:27:59 +0200 Subject: [PATCH 16/24] test: cover worker port graph cloning --- .../node-suite/worker_threads/README.md | 18 ++++---- .../graph-snapshot-worker.cjs | 11 +++++ .../environment-data/graph-snapshot.ts | 28 +++++++++++++ .../workerdata-multiple-ports-worker.cjs | 20 +++++++++ .../workerdata-multiple-ports.ts | 39 ++++++++++++++++++ .../workerdata-port-alias-worker.cjs | 19 +++++++++ .../worker-lifecycle/workerdata-port-alias.ts | 36 ++++++++++++++++ .../workerdata-port-container-worker.cjs | 32 +++++++++++++++ .../worker-lifecycle/workerdata-port-map.ts | 41 +++++++++++++++++++ .../worker-lifecycle/workerdata-port-set.ts | 41 +++++++++++++++++++ test-parity/node_suite_baseline.json | 6 +-- 11 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/environment-data/graph-snapshot.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-container-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-map.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-set.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index c569b9faa7..764e3f7c31 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -45,7 +45,8 @@ that oracle. lookup. - `environment-data/`: key identity, live main-thread values, deletion, worker snapshot cloning, built-in Map/Set/Date values, nested-worker inheritance, - non-cloneable construction rejection, and parent/worker mutation isolation. + cyclic and aliased graph snapshots, non-cloneable construction rejection, and + parent/worker mutation isolation. - `structured-clone/`: ArrayBuffer cloning, typed-array backing identity, built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, multiple SharedArrayBuffers, transfer detachment, detached-buffer retransfer @@ -84,8 +85,9 @@ that oracle. method safety, indexed `SHARE_ENV` keys, eval syntax-error routing, URL postMessage rejection, missing postMessage validation, exit-listener exception routing, global `postMessage` replacement, workerData alias/view/cycle - cloning, broader filename, falsy execArgv, and disallowed `NODE_OPTIONS` - validation, and custom-stack and non-Error serialization. + cloning, multiple and aliased MessagePorts, MessagePorts nested in Map/Set, + broader filename, falsy execArgv, and disallowed `NODE_OPTIONS` validation, + and custom-stack and non-Error serialization. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -145,8 +147,8 @@ cases above or belong to a separate slow/risky runtime feature: `postMessageToThread` delivery, rejection, handler failures, and timeout behavior. -The measured focused result is `32/154`: all 17 pre-existing cases remain green, -15 added cases pass, and 122 added cases expose stable diagnostic differences. +The measured focused result is `32/159`: all 17 pre-existing cases remain green, +15 added cases pass, and 127 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -172,7 +174,7 @@ also pass. The diagnostic differences are: validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state differ. -- Forty-five `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Forty-nine `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -184,7 +186,7 @@ also pass. The diagnostic differences are: - Ten `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, and closed-channel posts differ. -- Thirteen additional diagnostics cover namespace/prototype completeness, +- Fourteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction, direct-message argument validation, and Web Locks callback rejection cleanup. @@ -198,7 +200,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/154 (20.8%), diff=122`, with no compile failures or timeouts. +It reported `32/159 (20.1%), diff=127`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs b/test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs new file mode 100644 index 0000000000..33d99bf8a0 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs @@ -0,0 +1,11 @@ +const { + getEnvironmentData, + parentPort, +} = require("node:worker_threads"); + +const graph = getEnvironmentData("graph-snapshot"); +parentPort.postMessage({ + cycle: graph?.self === graph, + alias: graph?.left !== undefined && graph.left === graph.right, + value: graph?.left?.value ?? "missing", +}); diff --git a/test-parity/node-suite/worker_threads/environment-data/graph-snapshot.ts b/test-parity/node-suite/worker_threads/environment-data/graph-snapshot.ts new file mode 100644 index 0000000000..4b6fbe9b78 --- /dev/null +++ b/test-parity/node-suite/worker_threads/environment-data/graph-snapshot.ts @@ -0,0 +1,28 @@ +import { setEnvironmentData, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/environment-data"); + +const shared = { value: 11 }; +const graph: any = { left: shared, right: shared }; +graph.self = graph; +setEnvironmentData("graph-snapshot", graph); + +try { + const worker = new Worker("./graph-snapshot-worker.cjs"); + worker.on( + "message", + (message) => + console.log("graph:", message.cycle, message.alias, message.value), + ); + worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), + ); + worker.on("exit", (code) => { + console.log("exit:", code); + setEnvironmentData("graph-snapshot", undefined); + }); +} catch (error) { + console.log("construct:", (error as any)?.name, (error as any)?.code ?? ""); + setEnvironmentData("graph-snapshot", undefined); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports-worker.cjs new file mode 100644 index 0000000000..44022a667f --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports-worker.cjs @@ -0,0 +1,20 @@ +const { + MessagePort, + parentPort, + workerData, +} = require("node:worker_threads"); + +const first = workerData?.first; +const second = workerData?.second; + +if (typeof first?.postMessage === "function") first.postMessage("first"); +if (typeof second?.postMessage === "function") second.postMessage("second"); + +parentPort.postMessage({ + firstBrand: first instanceof MessagePort, + secondBrand: second instanceof MessagePort, + distinct: first !== undefined && second !== undefined && first !== second, +}); + +first?.close?.(); +second?.close?.(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts new file mode 100644 index 0000000000..809eeba50b --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts @@ -0,0 +1,39 @@ +import { + MessageChannel, + receiveMessageOnPort, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const first = new MessageChannel(); +const second = new MessageChannel(); +const worker = new Worker("./workerdata-multiple-ports-worker.cjs", { + workerData: { first: first.port1, second: second.port1 }, + transferList: [first.port1, second.port1], +}); + +worker.on("message", (message) => { + console.log( + "ports:", + message.firstBrand, + message.secondBrand, + message.distinct, + ); + console.log( + "delivery:", + receiveMessageOnPort(first.port2)?.message, + receiveMessageOnPort(second.port2)?.message, + ); +}); +worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), +); +worker.on("exit", (code) => { + console.log("exit:", code); + first.port1.close(); + first.port2.close(); + second.port1.close(); + second.port2.close(); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias-worker.cjs new file mode 100644 index 0000000000..25210508f4 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias-worker.cjs @@ -0,0 +1,19 @@ +const { + MessagePort, + parentPort, + workerData, +} = require("node:worker_threads"); + +const left = workerData?.left; +const right = workerData?.right; + +if (typeof left?.postMessage === "function") left.postMessage("left"); +if (typeof right?.postMessage === "function") right.postMessage("right"); + +parentPort.postMessage({ + leftBrand: left instanceof MessagePort, + rightBrand: right instanceof MessagePort, + alias: left !== undefined && left === right, +}); + +left?.close?.(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts new file mode 100644 index 0000000000..012ecfca30 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts @@ -0,0 +1,36 @@ +import { + MessageChannel, + receiveMessageOnPort, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const channel = new MessageChannel(); +const worker = new Worker("./workerdata-port-alias-worker.cjs", { + workerData: { left: channel.port1, right: channel.port1 }, + transferList: [channel.port1], +}); + +worker.on("message", (message) => { + console.log( + "port alias:", + message.leftBrand, + message.rightBrand, + message.alias, + ); + console.log( + "delivery:", + receiveMessageOnPort(channel.port2)?.message, + receiveMessageOnPort(channel.port2)?.message, + ); +}); +worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), +); +worker.on("exit", (code) => { + console.log("exit:", code); + channel.port1.close(); + channel.port2.close(); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-container-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-container-worker.cjs new file mode 100644 index 0000000000..8c8818d341 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-container-worker.cjs @@ -0,0 +1,32 @@ +const { + MessagePort, + parentPort, + workerData, +} = require("node:worker_threads"); + +const kind = workerData?.kind; +const container = workerData?.container; +let port; +let extractionError = "none"; + +try { + if (kind === "map" && typeof container?.get === "function") { + port = container.get("port"); + } else if (kind === "set" && typeof container?.values === "function") { + const iterator = container.values(); + if (typeof iterator?.next === "function") port = iterator.next()?.value; + } +} catch (error) { + extractionError = error?.name ?? "unknown"; +} + +if (typeof port?.postMessage === "function") port.postMessage(kind); +parentPort.postMessage({ + kind, + containerBrand: kind === "map" + ? container instanceof Map + : container instanceof Set, + portBrand: port instanceof MessagePort, + extractionError, +}); +port?.close?.(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-map.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-map.ts new file mode 100644 index 0000000000..7fed82e86f --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-map.ts @@ -0,0 +1,41 @@ +import { + MessageChannel, + receiveMessageOnPort, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const channel = new MessageChannel(); +try { + const worker = new Worker("./workerdata-port-container-worker.cjs", { + workerData: { + kind: "map", + container: new Map([["port", channel.port1]]), + }, + transferList: [channel.port1], + }); + worker.on("message", (message) => { + console.log( + "map:", + message.kind, + message.containerBrand, + message.portBrand, + message.extractionError, + ); + console.log("delivery:", receiveMessageOnPort(channel.port2)?.message); + }); + worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), + ); + worker.on("exit", (code) => { + console.log("exit:", code); + channel.port1.close(); + channel.port2.close(); + }); +} catch (error) { + console.log("construct:", (error as any)?.name, (error as any)?.code ?? ""); + channel.port1.close(); + channel.port2.close(); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-set.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-set.ts new file mode 100644 index 0000000000..839cd6288d --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-set.ts @@ -0,0 +1,41 @@ +import { + MessageChannel, + receiveMessageOnPort, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const channel = new MessageChannel(); +try { + const worker = new Worker("./workerdata-port-container-worker.cjs", { + workerData: { + kind: "set", + container: new Set([channel.port1]), + }, + transferList: [channel.port1], + }); + worker.on("message", (message) => { + console.log( + "set:", + message.kind, + message.containerBrand, + message.portBrand, + message.extractionError, + ); + console.log("delivery:", receiveMessageOnPort(channel.port2)?.message); + }); + worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), + ); + worker.on("exit", (code) => { + console.log("exit:", code); + channel.port1.close(); + channel.port2.close(); + }); +} catch (error) { + console.log("construct:", (error as any)?.name, (error as any)?.code ?? ""); + channel.port1.close(); + channel.port2.close(); +} diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 7d5e516e0a..ac680159a0 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -6,8 +6,8 @@ }, "overall": { "pass": 2816, - "total": 3000, - "pct": 93.9 + "total": 3005, + "pct": 93.7 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 32, - "total": 154 + "total": 159 }, "zlib": { "pass": 58, From 45ce1edf202f3718ed5279043cb066e4dce8c1b1 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 17:44:40 +0200 Subject: [PATCH 17/24] test: harden worker port graph barriers --- .../graph-snapshot-worker.cjs | 2 +- .../workerdata-multiple-ports.ts | 20 ++++++++++--------- .../worker-lifecycle/workerdata-port-alias.ts | 20 ++++++++++--------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs b/test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs index 33d99bf8a0..ac4d6c206e 100644 --- a/test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs +++ b/test-parity/node-suite/worker_threads/environment-data/graph-snapshot-worker.cjs @@ -5,7 +5,7 @@ const { const graph = getEnvironmentData("graph-snapshot"); parentPort.postMessage({ - cycle: graph?.self === graph, + cycle: graph !== undefined && graph.self === graph, alias: graph?.left !== undefined && graph.left === graph.right, value: graph?.left?.value ?? "missing", }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts index 809eeba50b..bd59f9b408 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-multiple-ports.ts @@ -12,25 +12,27 @@ const worker = new Worker("./workerdata-multiple-ports-worker.cjs", { workerData: { first: first.port1, second: second.port1 }, transferList: [first.port1, second.port1], }); +let summary: any; worker.on("message", (message) => { + summary = message; +}); +worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), +); +worker.on("exit", (code) => { console.log( "ports:", - message.firstBrand, - message.secondBrand, - message.distinct, + summary?.firstBrand, + summary?.secondBrand, + summary?.distinct, ); console.log( "delivery:", receiveMessageOnPort(first.port2)?.message, receiveMessageOnPort(second.port2)?.message, ); -}); -worker.on( - "error", - (error) => console.log("error:", error.name, (error as any).code ?? ""), -); -worker.on("exit", (code) => { console.log("exit:", code); first.port1.close(); first.port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts index 012ecfca30..d51adcd030 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-port-alias.ts @@ -11,25 +11,27 @@ const worker = new Worker("./workerdata-port-alias-worker.cjs", { workerData: { left: channel.port1, right: channel.port1 }, transferList: [channel.port1], }); +let summary: any; worker.on("message", (message) => { + summary = message; +}); +worker.on( + "error", + (error) => console.log("error:", error.name, (error as any).code ?? ""), +); +worker.on("exit", (code) => { console.log( "port alias:", - message.leftBrand, - message.rightBrand, - message.alias, + summary?.leftBrand, + summary?.rightBrand, + summary?.alias, ); console.log( "delivery:", receiveMessageOnPort(channel.port2)?.message, receiveMessageOnPort(channel.port2)?.message, ); -}); -worker.on( - "error", - (error) => console.log("error:", error.name, (error as any).code ?? ""), -); -worker.on("exit", (code) => { console.log("exit:", code); channel.port1.close(); channel.port2.close(); From 9873b3a55496cf79d332732c977751f416582e43 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 18:13:40 +0200 Subject: [PATCH 18/24] test: cover worker disposal and messaging edges --- .../node-suite/worker_threads/README.md | 86 ++++++++++++------- .../close-queued-delivery.ts | 19 ++++ .../create-during-dispatch.ts | 20 +++++ .../broadcast-channel/custom-inspect.ts | 17 ++++ .../direct-message/transferables-worker.cjs | 21 +++++ .../direct-message/transferables.ts | 36 ++++++++ .../message-port/custom-inspect-ref-state.ts | 23 +++++ .../worker-lifecycle/async-dispose.ts | 29 +++++++ test-parity/node_suite_baseline.json | 12 +-- 9 files changed, 227 insertions(+), 36 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/close-queued-delivery.ts create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/create-during-dispatch.ts create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/custom-inspect.ts create mode 100644 test-parity/node-suite/worker_threads/direct-message/transferables-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/direct-message/transferables.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/custom-inspect-ref-state.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/async-dispose.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 764e3f7c31..206a021cde 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -26,12 +26,24 @@ The expansion was compared against these primary-source snapshots: and [`worker-transfer-list.test.ts`](https://github.com/oven-sh/bun/blob/0bffb4767dd13b4f5aaf119b13dcf37bd094e2f1/test/js/node/worker_threads/worker-transfer-list.test.ts). -The inventory review covered all 143 `test-worker-*` files across the parallel -and sequential directories in that Node snapshot, all 48 cases in Deno's unit -selection plus its directory fixtures, and the 72 declarations in Bun's two -central worker test files. Cases are represented here by observable contract -rather than copied one-for-one when several upstream files exercise the same -behavior. +The follow-up inventory was refreshed against the 2026-07-16 heads of Node +[`cf882a79042c`](https://github.com/nodejs/node/tree/cf882a79042cba4146acfdb7993b6a97c21e7239/test), +Deno +[`68d51a1fc8f3`](https://github.com/denoland/deno/tree/68d51a1fc8f32faf200ac4c5ba7b69648699c610/tests/unit_node), +and Bun +[`aca54d5c2b87`](https://github.com/oven-sh/bun/tree/aca54d5c2b874ac304a3bbe1d67630e4daf17b43/test/js/node/worker_threads). +That pass added Node's Worker asynchronous-disposal, MessagePort and +BroadcastChannel custom-inspection, direct-message transferable graph, and +BroadcastChannel dispatch-time lifecycle contracts. Bun independently selects +Worker asynchronous disposal and Node's worker corpus, while Deno's unit suite +provides a second executable Node-compat comparison for the same core objects. + +The inventory review covered all 148 current `test-worker-*` files across Node's +parallel and sequential directories, all 48 cases in Deno's unit selection plus +its directory fixtures, and 82 declarations across Bun's current +`worker_threads/*.test.ts` files. Cases are represented here by observable +contract rather than copied one-for-one when several upstream files exercise the +same behavior. Node `26.5.0`, pinned by this repository, remains the executable differential oracle. The upstream snapshots above guide case selection rather than changing @@ -66,7 +78,8 @@ that oracle. EventTarget/EventEmitter bridging, max-listener controls, scoped `removeAllListeners`, missing-message validation, transfer rejection during close flushing, frozen EventTarget prototype resilience, MessageEvent - defaults/coercion, transfer targets, and `ref`/`unref`/`hasRef` state. + defaults/coercion, transfer targets, `ref`/`unref`/`hasRef` state, and custom + inspection of active/refed state. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with closed-port and argument validation. @@ -87,7 +100,7 @@ that oracle. routing, global `postMessage` replacement, workerData alias/view/cycle cloning, multiple and aliased MessagePorts, MessagePorts nested in Map/Set, broader filename, falsy execArgv, and disallowed `NODE_OPTIONS` validation, - and custom-stack and non-Error serialization. + custom-stack and non-Error serialization, and Worker asynchronous disposal. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -96,12 +109,15 @@ that oracle. listener management/`once`, `onmessage` replacement, name coercion, close/ref idempotence, typed-array and SharedArrayBuffer cloning, untransferable-value rejection, missing-message validation, clone-versus-closed error precedence, - and closed-channel and method-receiver validation. + closed-channel and method-receiver validation, custom inspection, channel + creation during dispatch, and suppression of delivery already queued for a + channel that closes. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending the existing surface, option, query, callback settlement/cleanup, independent-name concurrency, and shared/exclusive ordering cases. - `direct-message/`: delivery, no-listener, timeout, and handler-failure - rejection, plus thread id and timeout argument validation. + rejection, plus thread id and timeout argument validation and atomic + ArrayBuffer/MessagePort transfer with observable ownership. Every asynchronous fixture uses a message, close, exit, stream-EOF, or promise-settlement barrier. No added fixture uses a sleep as a completion @@ -131,6 +147,10 @@ cases above or belong to a separate slow/risky runtime feature: exceeded the 120-second Perry per-case compilation threshold. It belongs with Blob runtime/compilation coverage rather than turning a marker diagnostic into a granular timeout. +- A self-contained WebAssembly.Module worker-post candidate produced the same + module brand and result under Node 26.5.0, Deno 2.9.2, and Bun 1.2.18, but + exceeded the 120-second Perry compilation threshold. Like the Blob candidate, + that is WebAssembly/compiler coverage rather than a useful runtime diagnostic. - A cross-worker BroadcastChannel delivery candidate was rejected after Node required cross-agent scheduling turns while Perry's absent delivery left no deterministic completion event. It belongs with scheduler-aware cross-agent @@ -144,11 +164,15 @@ cases above or belong to a separate slow/risky runtime feature: surface, validation, shared/exclusive ordering, `ifAvailable`, and query snapshots. In-flight abort races and cross-agent ownership remain excluded. - The existing `direct-message/` fixtures already cover deterministic - `postMessageToThread` delivery, rejection, handler failures, and timeout - behavior. + `postMessageToThread` delivery, transferable ownership, rejection, handler + failures, and timeout behavior. +- Bun's current non-function `process.emit` worker regression was not selected: + the pinned Node 26.5.0 oracle exits fatally from `process._fatalException` + before an `uncaughtException` handler can establish the recovery contract that + Bun asserts. It is therefore not a Node parity case. -The measured focused result is `32/159`: all 17 pre-existing cases remain green, -15 added cases pass, and 127 added cases expose stable diagnostic differences. +The measured focused result is `34/165`: all 17 pre-existing cases remain green, +17 added cases pass, and 131 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -163,33 +187,35 @@ for `environment-data/value-identity.ts`, visibility in `worker-lifecycle/share-env-siblings.ts` also passes. The indexed-key variant in `worker-lifecycle/share-env-indexed.ts` passes too. The accepted optional transfer forms in `message-port/transfer-optional-forms.ts` -also pass. The diagnostic differences are: +also pass. The latest refresh adds passing dispatch-time coverage in +`broadcast-channel/create-during-dispatch.ts` and +`broadcast-channel/close-queued-delivery.ts`. The diagnostic differences are: - All twelve `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Thirty-seven `message-port/` diagnostics: closing and dispatch flushing, +- Thirty-eight `message-port/` diagnostics: closing and dispatch flushing, queued data/callbacks, NodeEventTarget surface, listener counts and validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` - state differ. -- Forty-nine `worker-lifecycle/` diagnostics: invalid constructor payloads and - paths, execArgv, captured stdio, process restrictions/exit codes, shared - environments and nested messages, worker/parentPort EventEmitter behavior, - metadata/unique ids, repeated termination, workerData and postMessage SAB - sharing, queued port receive, rollback, post-exit values, and error routing - differ. Basic eval with CJS require passes. + state and custom inspection differ. +- Fifty `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, + execArgv, captured stdio, process restrictions/exit codes, shared environments + and nested messages, worker/parentPort EventEmitter behavior, metadata/unique + ids, repeated termination, workerData and postMessage SAB sharing, queued port + receive, rollback, post-exit values, error routing, and Worker asynchronous + disposal differ. Basic eval with CJS require passes. - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. -- Ten `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref - behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort - validation, and closed-channel posts differ. -- Fourteen additional diagnostics cover namespace/prototype completeness, +- Eleven `broadcast-channel/` diagnostics: name coercion, once listeners, + close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, + MessagePort validation, closed-channel posts, and custom inspection differ. +- Fifteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel - construction, direct-message argument validation, and Web Locks callback - rejection cleanup. + construction, direct-message argument validation/transfer ownership, and Web + Locks callback rejection cleanup. The clean measurement used: @@ -200,7 +226,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `32/159 (20.1%), diff=127`, with no compile failures or timeouts. +It reported `34/165 (20.6%), diff=131`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/close-queued-delivery.ts b/test-parity/node-suite/worker_threads/broadcast-channel/close-queued-delivery.ts new file mode 100644 index 0000000000..bf645546c2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/close-queued-delivery.ts @@ -0,0 +1,19 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const sender = new BroadcastChannel("close-queued-delivery"); +const closed = new BroadcastChannel("close-queued-delivery"); +const barrier = new BroadcastChannel("close-queued-delivery"); +let closedCalls = 0; + +sender.unref(); +closed.onmessage = () => { + closedCalls += 1; +}; +barrier.onmessage = (event) => { + console.log("barrier:", event.data, "closed calls:", closedCalls); + sender.close(); + barrier.close(); +}; + +sender.postMessage("queued"); +closed.close(); diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/create-during-dispatch.ts b/test-parity/node-suite/worker_threads/broadcast-channel/create-during-dispatch.ts new file mode 100644 index 0000000000..d4ef229f51 --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/create-during-dispatch.ts @@ -0,0 +1,20 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const sender = new BroadcastChannel("create-during-dispatch"); +const first = new BroadcastChannel("create-during-dispatch"); + +sender.unref(); +first.onmessage = (event) => { + console.log("first:", event.data); + first.close(); + + const created = new BroadcastChannel("create-during-dispatch"); + created.onmessage = (nextEvent) => { + console.log("created:", nextEvent.data); + created.close(); + sender.close(); + }; + sender.postMessage("second"); +}; + +sender.postMessage("first"); diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/custom-inspect.ts b/test-parity/node-suite/worker_threads/broadcast-channel/custom-inspect.ts new file mode 100644 index 0000000000..005a4a86fc --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/custom-inspect.ts @@ -0,0 +1,17 @@ +import { BroadcastChannel } from "node:worker_threads"; +import { inspect } from "node:util"; + +function outcome(fn: () => unknown): string { + try { + return String(fn()); + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const channel = new BroadcastChannel("inspect-channel"); +const custom = (channel as any)[inspect.custom]; +console.log("custom:", typeof custom, outcome(() => custom.call({}))); +console.log("active:", inspect(channel), inspect(channel, { depth: -1 })); +channel.close(); +console.log("closed:", inspect(channel)); diff --git a/test-parity/node-suite/worker_threads/direct-message/transferables-worker.cjs b/test-parity/node-suite/worker_threads/direct-message/transferables-worker.cjs new file mode 100644 index 0000000000..51dc730bec --- /dev/null +++ b/test-parity/node-suite/worker_threads/direct-message/transferables-worker.cjs @@ -0,0 +1,21 @@ +const { + MessagePort, + parentPort, +} = require("node:worker_threads"); + +process.on("workerMessage", (value, source) => { + const port = value?.port; + const buffer = value?.buffer; + port?.postMessage({ + portBrand: port instanceof MessagePort, + bufferBrand: buffer instanceof ArrayBuffer, + values: buffer instanceof ArrayBuffer + ? Array.from(new Uint8Array(buffer)).join(",") + : "missing", + source, + }); + port?.close(); +}); + +parentPort.on("message", () => {}); +parentPort.postMessage("ready"); diff --git a/test-parity/node-suite/worker_threads/direct-message/transferables.ts b/test-parity/node-suite/worker_threads/direct-message/transferables.ts new file mode 100644 index 0000000000..780c0bfee8 --- /dev/null +++ b/test-parity/node-suite/worker_threads/direct-message/transferables.ts @@ -0,0 +1,36 @@ +import { + MessageChannel, + postMessageToThread, + receiveMessageOnPort, + Worker, +} from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/direct-message"); + +const worker = new Worker("./transferables-worker.cjs"); +const { port1, port2 } = new MessageChannel(); +const buffer = new Uint8Array([2, 4, 6, 8]).buffer; + +worker.once("message", async () => { + try { + await postMessageToThread( + worker.threadId, + { buffer, port: port2 }, + [buffer, port2], + ); + const delivered = receiveMessageOnPort(port1)?.message; + console.log( + "delivered:", + delivered?.portBrand, + delivered?.bufferBrand, + delivered?.values, + delivered?.source === 0, + ); + console.log("ownership:", buffer.byteLength, true); + } catch (error: any) { + console.log("direct:", error?.name, error?.code ?? ""); + } + port1.close(); + port2.close(); + worker.terminate().then((code) => console.log("terminate:", code)); +}); diff --git a/test-parity/node-suite/worker_threads/message-port/custom-inspect-ref-state.ts b/test-parity/node-suite/worker_threads/message-port/custom-inspect-ref-state.ts new file mode 100644 index 0000000000..13ba1c6fc2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/custom-inspect-ref-state.ts @@ -0,0 +1,23 @@ +import { MessageChannel } from "node:worker_threads"; +import { inspect } from "node:util"; + +const { port1, port2 } = new MessageChannel(); + +function state(label: string) { + const output = inspect(port1); + console.log( + label, + output.includes("active: true"), + output.includes("refed: true"), + output.includes("refed: false"), + ); +} + +console.log("custom:", typeof (port1 as any)[inspect.custom]); +state("initial:"); +port1.ref(); +state("refed:"); +port1.unref(); +state("unrefed:"); +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/async-dispose.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/async-dispose.ts new file mode 100644 index 0000000000..d21ccd828c --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/async-dispose.ts @@ -0,0 +1,29 @@ +import { Worker } from "node:worker_threads"; + +const worker = new Worker( + ` + const { parentPort } = require("node:worker_threads"); + parentPort.on("message", () => {}); + parentPort.postMessage("ready"); +`, + { eval: true }, +); + +worker.once("message", async () => { + const key = (Symbol as any).asyncDispose; + const dispose = (worker as any)[key]; + console.log("method:", typeof dispose); + + if (typeof dispose !== "function") { + console.log("fallback terminate:", await worker.terminate()); + return; + } + + let exitCode: number | "pending" = "pending"; + worker.once("exit", (code) => { + exitCode = code; + }); + + const result = await Reflect.apply(dispose, worker, []); + console.log("settled:", result, exitCode, worker.threadId); +}); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index ac680159a0..6bea30586c 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2816/2993 (94.1%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2818/3011 (93.6%)." }, "overall": { - "pass": 2816, - "total": 3005, - "pct": 93.7 + "pass": 2818, + "total": 3011, + "pct": 93.6 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 32, - "total": 159 + "pass": 34, + "total": 165 }, "zlib": { "pass": 58, From 1301b1943de82c4c4e736a7c4e758ec470035308 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 18:58:55 +0200 Subject: [PATCH 19/24] test: cover worker construction and realm boundaries --- .../node-suite/worker_threads/README.md | 53 ++++++++++++------ .../move-context-realm-brand.ts | 18 ++++++ .../message-port/receive-consumes-event.ts | 18 ++++++ .../worker-lifecycle/argv-base-worker.cjs | 7 +++ .../worker-lifecycle/argv-base.ts | 50 +++++++++++++++++ .../worker-lifecycle/constructor-rollback.ts | 56 +++++++++++++++++++ .../worker-lifecycle/env-isolation-worker.cjs | 11 ++++ .../worker-lifecycle/env-isolation.ts | 38 +++++++++++++ test-parity/node_suite_baseline.json | 12 ++-- 9 files changed, 240 insertions(+), 23 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/message-channel/move-context-realm-brand.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/receive-consumes-event.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/argv-base-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/argv-base.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/constructor-rollback.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 206a021cde..2c91b87fb4 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -78,11 +78,13 @@ that oracle. EventTarget/EventEmitter bridging, max-listener controls, scoped `removeAllListeners`, missing-message validation, transfer rejection during close flushing, frozen EventTarget prototype resilience, MessageEvent - defaults/coercion, transfer targets, `ref`/`unref`/`hasRef` state, and custom - inspection of active/refed state. + defaults/coercion, transfer targets, `ref`/`unref`/`hasRef` state, custom + inspection of active/refed state, and proof that synchronous draining + suppresses later event delivery. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, - and VM-context port movement with closed-port and argument validation. + and VM-context port movement with cross-realm branding, closed-port, and + argument validation. - `worker-lifecycle/`: constructor transfer validation, structured `workerData`, default and built-in workerData, MessagePort/ArrayBuffer transfer, environment snapshots, eval/CJS require, file URLs with Unicode names, argv and execArgv @@ -100,7 +102,9 @@ that oracle. routing, global `postMessage` replacement, workerData alias/view/cycle cloning, multiple and aliased MessagePorts, MessagePorts nested in Map/Set, broader filename, falsy execArgv, and disallowed `NODE_OPTIONS` validation, - custom-stack and non-Error serialization, and Worker asynchronous disposal. + custom-stack and non-Error serialization, Worker asynchronous disposal, + construction-time transfer rollback, default environment isolation, and the + base file/eval `process.argv` shape. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -170,9 +174,21 @@ cases above or belong to a separate slow/risky runtime feature: the pinned Node 26.5.0 oracle exits fatally from `process._fatalException` before an `uncaughtException` handler can establish the recovery contract that Bun asserts. It is therefore not a Node parity case. +- A main-to-grandchild `postMessageToThread` candidate completed under Node and + Deno, but Perry's nested worker never reached the explicit ready/inspection + barrier and retained the event loop beyond 30 seconds. It needs nested-worker + readiness/shutdown infrastructure rather than a granular timeout. +- Operating on the original MessagePort after `moveMessagePortToContext` was + rejected because Node 26.5.0 itself exited by signal 11. The retained + cross-realm-brand case observes the moved port without probing undefined + moved-from behavior. +- Invalid-path transfer rollback was not retained: a literal nonexistent Worker + target fails Perry compilation, while hiding it behind a dynamic path only + exercises the already covered unresolved-path fallback and leaves the buffer + untouched vacuously. -The measured focused result is `34/165`: all 17 pre-existing cases remain green, -17 added cases pass, and 131 added cases expose stable diagnostic differences. +The measured focused result is `35/170`: all 17 pre-existing cases remain green, +18 added cases pass, and 135 added cases expose stable diagnostic differences. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -189,7 +205,9 @@ indexed-key variant in `worker-lifecycle/share-env-indexed.ts` passes too. The accepted optional transfer forms in `message-port/transfer-optional-forms.ts` also pass. The latest refresh adds passing dispatch-time coverage in `broadcast-channel/create-during-dispatch.ts` and -`broadcast-channel/close-queued-delivery.ts`. The diagnostic differences are: +`broadcast-channel/close-queued-delivery.ts`. Synchronously drained messages are +also proven not to reappear through the event listener in +`message-port/receive-consumes-event.ts`. The diagnostic differences are: - All twelve `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, @@ -200,22 +218,23 @@ also pass. The latest refresh adds passing dispatch-time coverage in validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state and custom inspection differ. -- Fifty `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, - execArgv, captured stdio, process restrictions/exit codes, shared environments - and nested messages, worker/parentPort EventEmitter behavior, metadata/unique - ids, repeated termination, workerData and postMessage SAB sharing, queued port - receive, rollback, post-exit values, error routing, and Worker asynchronous - disposal differ. Basic eval with CJS require passes. +- Fifty-three `worker-lifecycle/` diagnostics: invalid constructor payloads and + paths, execArgv, captured stdio, process restrictions/exit codes, shared + environments and nested messages, worker/parentPort EventEmitter behavior, + metadata/unique ids, repeated termination, workerData and postMessage SAB + sharing, queued port receive, rollback, post-exit values, error routing, + Worker asynchronous disposal, default environment isolation, process argv + shape, and constructor rollback differ. Basic eval with CJS require passes. - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. - Eleven `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, closed-channel posts, and custom inspection differ. -- Fifteen additional diagnostics cover namespace/prototype completeness, +- Sixteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel - construction, direct-message argument validation/transfer ownership, and Web - Locks callback rejection cleanup. + construction and VM-context branding, direct-message argument + validation/transfer ownership, and Web Locks callback rejection cleanup. The clean measurement used: @@ -226,7 +245,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `34/165 (20.6%), diff=131`, with no compile failures or timeouts. +It reported `35/170 (20.6%), diff=135`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/message-channel/move-context-realm-brand.ts b/test-parity/node-suite/worker_threads/message-channel/move-context-realm-brand.ts new file mode 100644 index 0000000000..9296428567 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-channel/move-context-realm-brand.ts @@ -0,0 +1,18 @@ +import * as vm from "node:vm"; +import { + MessageChannel, + MessagePort, + moveMessagePortToContext, +} from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const moved = moveMessagePortToContext(port1, vm.createContext({})); + +console.log( + "realm brand:", + moved instanceof MessagePort, + moved.constructor.name, +); + +moved.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/receive-consumes-event.ts b/test-parity/node-suite/worker_threads/message-port/receive-consumes-event.ts new file mode 100644 index 0000000000..2fa2720c9f --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/receive-consumes-event.ts @@ -0,0 +1,18 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; + +port2.on("message", (message) => { + events.push(String(message)); + if (message === "barrier") { + console.log("events:", events.join(",")); + port1.close(); + port2.close(); + } +}); + +port1.postMessage("synchronous"); +const packet = receiveMessageOnPort(port2); +console.log("received:", packet?.message); +port1.postMessage("barrier"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/argv-base-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-base-worker.cjs new file mode 100644 index 0000000000..83dd7c2d9f --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-base-worker.cjs @@ -0,0 +1,7 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.postMessage({ + exec: process.argv[0] === process.execPath, + script: process.argv[1]?.endsWith("argv-base-worker.cjs"), + tail: process.argv.slice(2), +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/argv-base.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-base.ts new file mode 100644 index 0000000000..a86ff10202 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/argv-base.ts @@ -0,0 +1,50 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function run(label: string, worker: Worker) { + let message: any; + let error: any; + worker.once("message", (value) => { + message = value; + }); + worker.once("error", (value) => { + error = value; + }); + const code = await new Promise((resolve) => + worker.once("exit", resolve) + ); + console.log( + label, + message?.exec, + message?.script, + JSON.stringify(message?.tail), + error?.name ?? "no-error", + code, + ); +} + +async function main() { + await run( + "file:", + new Worker("./argv-base-worker.cjs", { argv: [null, 42] as any }), + ); + await run( + "eval:", + new Worker( + ` + const { parentPort } = require("node:worker_threads"); + parentPort.postMessage({ + exec: process.argv[0] === process.execPath, + script: process.argv[1] === "[worker eval]", + tail: process.argv.slice(2), + }); + `, + { eval: true, argv: [null, 42] as any }, + ), + ); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-rollback.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-rollback.ts new file mode 100644 index 0000000000..924e9f06cd --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/constructor-rollback.ts @@ -0,0 +1,56 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function values(buffer: ArrayBuffer): string { + try { + return Array.from(new Uint8Array(buffer)).join(","); + } catch { + return "detached"; + } +} + +function probe( + label: string, + create: (buffer: ArrayBuffer) => Worker, +) { + const buffer = new Uint8Array([9, 8, 7]).buffer; + let status: string; + let worker: Worker | undefined; + + try { + worker = create(buffer); + status = "created"; + } catch (error: any) { + status = `${error?.name}:${error?.code ?? ""}`; + } + + console.log(label, status, buffer.byteLength, values(buffer)); + worker?.terminate().catch(() => {}); +} + +probe( + "name:", + (buffer) => + new Worker("./constructor-validation-worker.cjs", { + name: {} as any, + workerData: buffer, + transferList: [buffer], + }), +); +probe( + "invalid list:", + (buffer) => + new Worker("./constructor-validation-worker.cjs", { + workerData: buffer, + transferList: [buffer, null as any], + }), +); +probe( + "clone rollback:", + (buffer) => + new Worker("./constructor-validation-worker.cjs", { + workerData: { buffer, uncloneable: () => {} }, + transferList: [buffer], + }), +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation-worker.cjs new file mode 100644 index 0000000000..34baa6ad27 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation-worker.cjs @@ -0,0 +1,11 @@ +const { parentPort } = require("node:worker_threads"); + +const inherited = process.env.PERRY_ISOLATED_PARENT; +process.env.PERRY_ISOLATED_PARENT = "worker-change"; +process.env.PERRY_ISOLATED_WORKER = "worker-value"; + +parentPort.postMessage({ + inherited, + changed: process.env.PERRY_ISOLATED_PARENT, + worker: process.env.PERRY_ISOLATED_WORKER, +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation.ts new file mode 100644 index 0000000000..f574ac84dd --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/env-isolation.ts @@ -0,0 +1,38 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const originalParent = process.env.PERRY_ISOLATED_PARENT; +const originalWorker = process.env.PERRY_ISOLATED_WORKER; +process.env.PERRY_ISOLATED_PARENT = "parent-value"; +delete process.env.PERRY_ISOLATED_WORKER; + +let observed: any; +let workerError: any; +const worker = new Worker("./env-isolation-worker.cjs"); +worker.once("message", (message) => { + observed = message; +}); +worker.once("error", (error) => { + workerError = error; +}); +worker.once("exit", (code) => { + console.log( + "worker:", + observed?.inherited, + observed?.changed, + observed?.worker, + workerError?.name ?? "no-error", + ); + console.log( + "parent:", + process.env.PERRY_ISOLATED_PARENT, + process.env.PERRY_ISOLATED_WORKER, + code, + ); + + if (originalParent === undefined) delete process.env.PERRY_ISOLATED_PARENT; + else process.env.PERRY_ISOLATED_PARENT = originalParent; + if (originalWorker === undefined) delete process.env.PERRY_ISOLATED_WORKER; + else process.env.PERRY_ISOLATED_WORKER = originalWorker; +}); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 6bea30586c..8e58d5166c 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2818/3011 (93.6%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2819/3016 (93.5%)." }, "overall": { - "pass": 2818, - "total": 3011, - "pct": 93.6 + "pass": 2819, + "total": 3016, + "pct": 93.5 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 34, - "total": 165 + "pass": 35, + "total": 170 }, "zlib": { "pass": 58, From 22ad0ae3379ecab0ef6bab276de20df1cd708f0e Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 19:29:00 +0200 Subject: [PATCH 20/24] test: cover worker listener and metadata contracts --- .../node-suite/worker_threads/README.md | 53 +++++++++++++------ .../message-channel/constructor-call-rules.ts | 22 ++++++++ .../message-port/on-listener-dedup.ts | 26 +++++++++ .../message-port/on-listener-event-scope.ts | 24 +++++++++ .../message-port/prototype-own-surface.ts | 22 ++++++++ .../message-port/queued-before-peer-close.ts | 37 +++++++++++++ .../message-port/receive-after-start.ts | 18 +++++++ .../unread-transferred-port-close.ts | 14 +++++ .../worker-lifecycle/filename-validation.ts | 30 +++++++++++ .../process-env-option-worker.cjs | 5 ++ .../worker-lifecycle/process-env-option.ts | 32 +++++++++++ .../resource-limits-metadata-worker.cjs | 3 ++ .../resource-limits-metadata.ts | 25 +++++++++ test-parity/node_suite_baseline.json | 12 ++--- 14 files changed, 300 insertions(+), 23 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/message-channel/constructor-call-rules.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/on-listener-dedup.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/on-listener-event-scope.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/prototype-own-surface.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/queued-before-peer-close.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/receive-after-start.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/unread-transferred-port-close.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filename-validation.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 2c91b87fb4..71fa75e841 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -37,6 +37,11 @@ BroadcastChannel custom-inspection, direct-message transferable graph, and BroadcastChannel dispatch-time lifecycle contracts. Bun independently selects Worker asynchronous disposal and Node's worker corpus, while Deno's unit suite provides a second executable Node-compat comparison for the same core objects. +The latest deterministic audit also selected Node's filename and constructor +validation, MessagePort prototype/Event brands, and resource-limit metadata; +Deno's same-reference listener deduplication, pending synchronous receives, and +`process.env` option regression; and Bun's queued peer-close and orphaned +transferred-port regressions. The inventory review covered all 148 current `test-worker-*` files across Node's parallel and sequential directories, all 48 cases in Deno's unit selection plus @@ -80,7 +85,10 @@ that oracle. close flushing, frozen EventTarget prototype resilience, MessageEvent defaults/coercion, transfer targets, `ref`/`unref`/`hasRef` state, custom inspection of active/refed state, and proof that synchronous draining - suppresses later event delivery. + suppresses later event delivery, same-reference `.on()` deduplication and + event-name scoping, synchronous drains after `start()`, queued-message + ordering before a peer close, orphaned-transfer peer notification, and the + exact MessagePort/Event prototype brands. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with cross-realm branding, closed-port, and @@ -104,7 +112,9 @@ that oracle. broader filename, falsy execArgv, and disallowed `NODE_OPTIONS` validation, custom-stack and non-Error serialization, Worker asynchronous disposal, construction-time transfer rollback, default environment isolation, and the - base file/eval `process.argv` shape. + base file/eval `process.argv` shape, complete invalid filename types, + `process.env` as an explicit environment option, and deterministic + `resourceLimits` snapshots/reset without memory-pressure enforcement. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -134,8 +144,8 @@ cases above or belong to a separate slow/risky runtime feature: - Resource-limit enforcement, stdio backpressure/large-write timing, heap snapshots, and CPU/heap profiling are resource- and platform-sensitive. Basic - captured stdout/stderr and the diagnostic method surface are covered without - introducing pressure or timing assertions. + resource-limit metadata/reset, captured stdout/stderr, and the diagnostic + method surface are covered without introducing pressure or timing assertions. - Basic eval/CJS require, execArgv, file-URL construction, process restrictions, and exit codes are now covered. Data-URL/ESM/custom loaders, preload chains, signals, inspector integration, beforeExit and worker-side uncaught-exception @@ -187,8 +197,13 @@ cases above or belong to a separate slow/risky runtime feature: exercises the already covered unresolved-path fallback and leaves the buffer untouched vacuously. -The measured focused result is `35/170`: all 17 pre-existing cases remain green, -18 added cases pass, and 135 added cases expose stable diagnostic differences. +The measured focused result is `36/180`: all 17 pre-existing cases remain green, +19 added cases pass, and 144 added cases expose stable diagnostic differences. +The latest ten cases were each repeated three times under Node 26.5.0 and Perry +with stable output and exit status. Deno 2.9.2 was stable across the same matrix. +Bun 1.2.18 was stable for nine cases; its older orphaned-transfer implementation +did not emit the peer close and hit the external three-second comparison timeout +in three runs, while current Bun upstream carries that exact regression test. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -207,33 +222,37 @@ also pass. The latest refresh adds passing dispatch-time coverage in `broadcast-channel/create-during-dispatch.ts` and `broadcast-channel/close-queued-delivery.ts`. Synchronously drained messages are also proven not to reappear through the event listener in -`message-port/receive-consumes-event.ts`. The diagnostic differences are: +`message-port/receive-consumes-event.ts`. Passing `process.env` directly as a +Worker environment option is also covered by +`worker-lifecycle/process-env-option.ts`. The diagnostic differences are: - All twelve `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, and accepts invalid lists. -- Thirty-eight `message-port/` diagnostics: closing and dispatch flushing, - queued data/callbacks, NodeEventTarget surface, listener counts and - validation, receiver/options/iterables/transfers, MessageEvent construction - and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` - state and custom inspection differ. -- Fifty-three `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Forty-four `message-port/` diagnostics: closing and dispatch flushing, queued + data/callbacks, NodeEventTarget surface, listener counts and validation, + receiver/options/iterables/transfers, MessageEvent construction and `ports`, + duplicate/self/closed rollback, moved ownership, and `hasRef()` state and + custom inspection differ, as do `.on()` deduplication, pending synchronous + receives, queued peer-close ordering, orphaned transfers, and Event brands. +- Fifty-five `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB sharing, queued port receive, rollback, post-exit values, error routing, Worker asynchronous disposal, default environment isolation, process argv - shape, and constructor rollback differ. Basic eval with CJS require passes. + shape, constructor rollback, invalid filename types, and resource-limit reset + differ. Basic eval with CJS require and explicit `process.env` both pass. - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. - Eleven `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, closed-channel posts, and custom inspection differ. -- Sixteen additional diagnostics cover namespace/prototype completeness, +- Seventeen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel - construction and VM-context branding, direct-message argument + construction/callability and VM-context branding, direct-message argument validation/transfer ownership, and Web Locks callback rejection cleanup. The clean measurement used: @@ -245,7 +264,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `35/170 (20.6%), diff=135`, with no compile failures or timeouts. +It reported `36/180 (20.0%), diff=144`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/message-channel/constructor-call-rules.ts b/test-parity/node-suite/worker_threads/message-channel/constructor-call-rules.ts new file mode 100644 index 0000000000..512382f395 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-channel/constructor-call-rules.ts @@ -0,0 +1,22 @@ +import { MessageChannel, MessagePort } from "node:worker_threads"; + +function outcome(fn: () => unknown): string { + try { + fn(); + return "created"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const channel = new MessageChannel(); +console.log( + "brand:", + channel.port1 instanceof MessagePort, + channel.port1.constructor === MessagePort, +); +console.log("port call:", outcome(() => (MessagePort as any)())); +console.log("port new:", outcome(() => new (MessagePort as any)())); +console.log("channel call:", outcome(() => (MessageChannel as any)())); +channel.port1.close(); +channel.port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/on-listener-dedup.ts b/test-parity/node-suite/worker_threads/message-port/on-listener-dedup.ts new file mode 100644 index 0000000000..dddb75d5fe --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/on-listener-dedup.ts @@ -0,0 +1,26 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const sharedCalls: string[] = []; + +function shared(value: unknown) { + sharedCalls.push(String(value)); +} + +port1.on("message", shared); +port1.on("message", shared); +port1.on("message", shared); + +port1.on("message", (value) => { + if (value === "first") { + console.log("first:", sharedCalls.join(",")); + port1.off("message", shared); + port2.postMessage("after-off"); + } else if (value === "after-off") { + console.log("after off:", sharedCalls.join(",")); + port1.close(); + port2.close(); + } +}); + +port2.postMessage("first"); diff --git a/test-parity/node-suite/worker_threads/message-port/on-listener-event-scope.ts b/test-parity/node-suite/worker_threads/message-port/on-listener-event-scope.ts new file mode 100644 index 0000000000..383d592ab2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/on-listener-event-scope.ts @@ -0,0 +1,24 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; + +function shared(value?: unknown) { + events.push(typeof value === "string" ? `message:${value}` : "close"); +} + +port1.on("message", shared); +port1.on("message", shared); +port1.on("close", shared); +port1.on("close", shared); + +port1.on("message", () => { + port1.off("message", shared); + port1.close(); + port2.close(); +}); +port1.on("close", () => { + console.log("events:", events.join(",")); +}); + +port2.postMessage("value"); diff --git a/test-parity/node-suite/worker_threads/message-port/prototype-own-surface.ts b/test-parity/node-suite/worker_threads/message-port/prototype-own-surface.ts new file mode 100644 index 0000000000..eb1f0bd628 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/prototype-own-surface.ts @@ -0,0 +1,22 @@ +import { MessageChannel, MessagePort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +console.log( + "own names:", + Object.getOwnPropertyNames(MessagePort.prototype).sort().join(","), +); +console.log( + "prototype identity:", + Object.getPrototypeOf(port1) === MessagePort.prototype, +); + +const event = new MessageEvent("message"); +console.log( + "event brands:", + event instanceof MessageEvent, + event instanceof Event, + Object.getPrototypeOf(event) === MessageEvent.prototype, +); + +port1.close(); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/queued-before-peer-close.ts b/test-parity/node-suite/worker_threads/message-port/queued-before-peer-close.ts new file mode 100644 index 0000000000..9385e68d48 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/queued-before-peer-close.ts @@ -0,0 +1,37 @@ +import { MessageChannel } from "node:worker_threads"; + +function run(label: string, closeFirst: boolean): Promise { + return new Promise((resolve) => { + const { port1, port2 } = new MessageChannel(); + const events: string[] = []; + + port2.postMessage("first"); + port2.postMessage("second"); + port2.close(); + + const onMessage = (value: unknown) => events.push(`message:${value}`); + const onClose = () => { + events.push("close"); + console.log(`${label}:`, events.join(",")); + port1.close(); + resolve(); + }; + + if (closeFirst) { + port1.on("close", onClose); + port1.on("message", onMessage); + } else { + port1.on("message", onMessage); + port1.on("close", onClose); + } + }); +} + +async function main() { + await run("message-first", false); + await run("close-first", true); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/message-port/receive-after-start.ts b/test-parity/node-suite/worker_threads/message-port/receive-after-start.ts new file mode 100644 index 0000000000..155c9b9331 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/receive-after-start.ts @@ -0,0 +1,18 @@ +import { MessageChannel, receiveMessageOnPort } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +console.log("empty:", receiveMessageOnPort(port1)); +port1.start(); +port2.postMessage({ sequence: 1 }); +port2.postMessage({ sequence: 2 }); + +const first = receiveMessageOnPort(port1)?.message; +const second = receiveMessageOnPort(port1)?.message; +console.log("received:", first?.sequence, second?.sequence); + +port1.on("message", (value) => { + console.log("event:", value); + port1.close(); + port2.close(); +}); +port2.postMessage("barrier"); diff --git a/test-parity/node-suite/worker_threads/message-port/unread-transferred-port-close.ts b/test-parity/node-suite/worker_threads/message-port/unread-transferred-port-close.ts new file mode 100644 index 0000000000..ca57aeab97 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/unread-transferred-port-close.ts @@ -0,0 +1,14 @@ +import { MessageChannel } from "node:worker_threads"; + +const carrier = new MessageChannel(); +const movable = new MessageChannel(); + +movable.port2.ref(); +movable.port2.on("close", () => { + console.log("peer closed"); + movable.port2.close(); + carrier.port1.close(); +}); + +carrier.port1.postMessage(movable.port1, [movable.port1]); +carrier.port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filename-validation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/filename-validation.ts new file mode 100644 index 0000000000..0ff8d83715 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filename-validation.ts @@ -0,0 +1,30 @@ +import { Worker } from "node:worker_threads"; + +const values: Array<[string, any]> = [ + ["undefined", undefined], + ["null", null], + ["false", false], + ["zero", 0], + ["symbol", Symbol("worker")], + ["object", {}], + ["array", []], + ["function", () => {}], +]; + +async function main() { + for (const [label, value] of values) { + let worker: Worker | undefined; + try { + worker = new Worker(value); + console.log(label, "created"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } finally { + await worker?.terminate().catch(() => {}); + } + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option-worker.cjs new file mode 100644 index 0000000000..b45e57bb34 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option-worker.cjs @@ -0,0 +1,5 @@ +const { parentPort } = require("node:worker_threads"); + +const inherited = process.env.PERRY_PROCESS_ENV_OPTION; +process.env.PERRY_PROCESS_ENV_OPTION = "worker-change"; +parentPort.postMessage(`${inherited}:${process.env.PERRY_PROCESS_ENV_OPTION}`); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option.ts new file mode 100644 index 0000000000..286e52be57 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option.ts @@ -0,0 +1,32 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const previous = process.env.PERRY_PROCESS_ENV_OPTION; +process.env.PERRY_PROCESS_ENV_OPTION = "parent-value"; + +function restore() { + if (previous === undefined) { + delete process.env.PERRY_PROCESS_ENV_OPTION; + } else { + process.env.PERRY_PROCESS_ENV_OPTION = previous; + } +} + +try { + const worker = new Worker("./process-env-option-worker.cjs", { + env: process.env, + }); + + worker.on("message", (value) => console.log("worker:", value)); + worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); + }); + worker.on("exit", (code) => { + console.log("exit:", code, process.env.PERRY_PROCESS_ENV_OPTION); + restore(); + }); +} catch (error: any) { + console.log("construction:", error?.name, error?.code ?? ""); + restore(); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata-worker.cjs new file mode 100644 index 0000000000..0c0d8643f8 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata-worker.cjs @@ -0,0 +1,3 @@ +const { parentPort, resourceLimits } = require("node:worker_threads"); + +parentPort.postMessage(resourceLimits); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata.ts new file mode 100644 index 0000000000..fbbef3ebdd --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/resource-limits-metadata.ts @@ -0,0 +1,25 @@ +import { resourceLimits, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const requested = { + maxOldGenerationSizeMb: 16, + maxYoungGenerationSizeMb: 4, + codeRangeSizeMb: 16, + stackSizeMb: 1, +}; + +console.log("main:", JSON.stringify(resourceLimits)); +const worker = new Worker("./resource-limits-metadata-worker.cjs", { + resourceLimits: requested, +}); +console.log("parent live:", JSON.stringify(worker.resourceLimits)); +worker.on("message", (value) => { + console.log("worker:", JSON.stringify(value)); +}); +worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); +}); +worker.on("exit", (code) => { + console.log("exit:", code, JSON.stringify(worker.resourceLimits)); +}); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 8e58d5166c..135a30e5b6 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2819/3016 (93.5%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2820/3026 (93.2%)." }, "overall": { - "pass": 2819, - "total": 3016, - "pct": 93.5 + "pass": 2820, + "total": 3026, + "pct": 93.2 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 35, - "total": 170 + "pass": 36, + "total": 180 }, "zlib": { "pass": 58, From 3e871e5d9371eaa64c0cbeae357b33fe67941c78 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 20:37:02 +0200 Subject: [PATCH 21/24] test: cover worker file handle ownership --- .../node-suite/worker_threads/README.md | 56 +++++++++----- .../structured-clone/filehandle-transfer.ts | 75 +++++++++++++++++++ .../filehandle-construction-rollback.ts | 55 ++++++++++++++ .../filehandle-workerdata-alias-worker.cjs | 27 +++++++ .../filehandle-workerdata-alias.ts | 46 ++++++++++++ .../process-unsupported-worker.cjs | 49 +++++++++++- .../worker-lifecycle/process-unsupported.ts | 43 ++++++++--- .../worker-lifecycle/thread-name-defaults.ts | 32 ++++++++ .../worker-lifecycle/unsupported-paths.ts | 29 +++---- test-parity/node_suite_baseline.json | 8 +- 10 files changed, 370 insertions(+), 50 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/structured-clone/filehandle-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-construction-rollback.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/thread-name-defaults.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 71fa75e841..9674e3d1df 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -41,7 +41,11 @@ The latest deterministic audit also selected Node's filename and constructor validation, MessagePort prototype/Event brands, and resource-limit metadata; Deno's same-reference listener deduplication, pending synchronous receives, and `process.env` option regression; and Bun's queued peer-close and orphaned -transferred-port regressions. +transferred-port regressions. The next audit selected Node's deterministic +FileHandle clone/transfer ownership and process restrictions, plus Bun's +FileHandle construction rollback and workerData alias regressions. +Resource-consuming in-use-handle races remain separate; these cases use small +controlled read-only files and explicit message/exit barriers. The inventory review covered all 148 current `test-worker-*` files across Node's parallel and sequential directories, all 48 cases in Deno's unit selection plus @@ -68,7 +72,8 @@ that oracle. built-in brands, cycles/aliasing, SharedArrayBuffer sharing, multiple views, multiple SharedArrayBuffers, transfer detachment, detached-buffer retransfer rejection, MessagePort ownership, unsupported URL rejection, overloads, and - atomic rollback. + atomic rollback, and FileHandle clone rejection, transfer detachment, + received-handle readability, and parent ownership loss. - `message-port/`: synchronous FIFO receives, invalid-port validation, explicit `start()`, event fields/ports, listener deduplication, close callback ordering, listener removal/`once`, `onmessage` replacement, method receiver @@ -114,7 +119,9 @@ that oracle. construction-time transfer rollback, default environment isolation, and the base file/eval `process.argv` shape, complete invalid filename types, `process.env` as an explicit environment option, and deterministic - `resourceLimits` snapshots/reset without memory-pressure enforcement. + `resourceLimits` snapshots/reset without memory-pressure enforcement, + FileHandle construction rollback and workerData alias identity, default/falsy + thread names, and the complete worker-disabled process stub/getter surface. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -152,8 +159,13 @@ cases above or belong to a separate slow/risky runtime feature: variants, and source maps remain separate loader, CLI, or process-subsystem work. - One controlled nested worker now covers environment-data inheritance. Deeper - nesting stress, GC/finalization of unreachable ports, shared native handles, - large message loops, and termination races remain scheduler-sensitive. + nesting stress, GC/finalization of unreachable ports, shared native-handle + races, large message loops, and termination races remain scheduler-sensitive. +- Basic FileHandle clone rejection, transfer ownership, construction rollback, + and workerData graph aliasing are covered with controlled read-only handles. + Pending read/close transfer races, descriptor recycling/leak detection, and + cross-context disposal remain shared-handle stress rather than granular parity + cases. - SharedArrayBuffer aliasing is covered through same-thread MessagePort and BroadcastChannel paths plus cross-agent workerData and postMessage. Atomics wait/notify coordination remains separate scheduler-aware infrastructure. @@ -197,13 +209,19 @@ cases above or belong to a separate slow/risky runtime feature: exercises the already covered unresolved-path fallback and leaves the buffer untouched vacuously. -The measured focused result is `36/180`: all 17 pre-existing cases remain green, -19 added cases pass, and 144 added cases expose stable diagnostic differences. -The latest ten cases were each repeated three times under Node 26.5.0 and Perry -with stable output and exit status. Deno 2.9.2 was stable across the same matrix. -Bun 1.2.18 was stable for nine cases; its older orphaned-transfer implementation -did not emit the peer close and hit the external three-second comparison timeout -in three runs, while current Bun upstream carries that exact regression test. +The measured focused result is `36/184`: all 17 pre-existing cases remain green, +19 added cases pass, and 148 added cases expose stable diagnostic differences. +The prior ten cases were each repeated three times under Node 26.5.0 and Perry +with stable output and exit status. Deno 2.9.2 was stable across the same +matrix. Bun 1.2.18 was stable for nine cases; its older orphaned-transfer +implementation did not emit the peer close and hit the external three-second +comparison timeout in three runs, while current Bun upstream carries that exact +regression test. The latest four additions plus the two strengthened +path/process cases were also stable in three runs per runtime under Node, Perry, +Deno, and Bun. Bun 1.2.18 still rejects FileHandle workerData transfer selected +by current Bun upstream, and its bare-relative Worker path exits before the +remaining path matrix; both behaviors were stable and are recorded as +old-runtime comparison gaps. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -226,24 +244,26 @@ also proven not to reappear through the event listener in Worker environment option is also covered by `worker-lifecycle/process-env-option.ts`. The diagnostic differences are: -- All twelve `structured-clone/` fixtures: Perry preserves some indexed values +- All thirteen `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, - rejects cycles/BigInt, does not detach or move ownership, and accepts invalid - lists. + rejects cycles/BigInt, does not detach or move ownership, accepts invalid + lists, and treats FileHandles as cloneable plain objects. - Forty-four `message-port/` diagnostics: closing and dispatch flushing, queued data/callbacks, NodeEventTarget surface, listener counts and validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state and custom inspection differ, as do `.on()` deduplication, pending synchronous receives, queued peer-close ordering, orphaned transfers, and Event brands. -- Fifty-five `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Fifty-eight `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB sharing, queued port receive, rollback, post-exit values, error routing, Worker asynchronous disposal, default environment isolation, process argv shape, constructor rollback, invalid filename types, and resource-limit reset - differ. Basic eval with CJS require and explicit `process.env` both pass. + differ. FileHandle rollback/aliasing, default/falsy thread names, and the full + disabled process surface also differ. Basic eval with CJS require and explicit + `process.env` both pass. - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. @@ -264,7 +284,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `36/180 (20.0%), diff=144`, with no compile failures or timeouts. +It reported `36/184 (19.6%), diff=148`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/structured-clone/filehandle-transfer.ts b/test-parity/node-suite/worker_threads/structured-clone/filehandle-transfer.ts new file mode 100644 index 0000000000..24bf9be470 --- /dev/null +++ b/test-parity/node-suite/worker_threads/structured-clone/filehandle-transfer.ts @@ -0,0 +1,75 @@ +import { open } from "node:fs/promises"; +import { MessageChannel } from "node:worker_threads"; + +const fixture = + "test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option-worker.cjs"; + +async function main() { + const handle = await open(fixture, "r"); + const clone = new MessageChannel(); + + try { + try { + clone.port1.postMessage(handle); + console.log("clone: accepted", handle.fd >= 0); + } catch (error: any) { + console.log( + "clone:", + error?.name, + error?.code ?? "", + handle.fd >= 0, + ); + } finally { + clone.port1.close(); + clone.port2.close(); + } + + const transfer = new MessageChannel(); + const received = new Promise((resolve) => { + transfer.port2.on("message", async (value: any) => { + console.log( + "received:", + value?.constructor?.name, + typeof value?.fd === "number" && value.fd >= 0, + ); + try { + const text = await value.readFile({ encoding: "utf8" }); + console.log("received read:", text.startsWith("const { parentPort")); + } catch (error: any) { + console.log("received read:", error?.name, error?.code ?? ""); + } + await value?.close?.().catch?.(() => {}); + try { + await handle.readFile(); + console.log("parent read: ok"); + } catch (error: any) { + console.log("parent read:", error?.name, error?.code ?? ""); + } + transfer.port1.close(); + transfer.port2.close(); + resolve(); + }); + }); + + try { + transfer.port1.postMessage(handle, [handle as any]); + console.log("parent detached:", handle.fd === -1); + await received; + } catch (error: any) { + console.log( + "transfer:", + error?.name, + error?.code ?? "", + handle.fd === -1, + ); + transfer.port1.close(); + transfer.port2.close(); + } + } finally { + await handle.close().catch(() => {}); + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-construction-rollback.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-construction-rollback.ts new file mode 100644 index 0000000000..ea9dc620e6 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-construction-rollback.ts @@ -0,0 +1,55 @@ +import { open } from "node:fs/promises"; +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function readable(handle: any): Promise { + try { + const buffer = Buffer.alloc(5); + const result = await handle.read(buffer, 0, buffer.length, 0); + return `${result.bytesRead}:${buffer.toString("utf8")}`; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +async function attempt( + label: string, + workerData: (handle: any) => unknown, + transferList: (handle: any) => any[], +) { + const handle = await open("./process-env-option-worker.cjs", "r"); + let worker: Worker | undefined; + let result = "created"; + + try { + worker = new Worker("./constructor-validation-worker.cjs", { + workerData: workerData(handle), + transferList: transferList(handle), + }); + } catch (error: any) { + result = `${error?.name}:${error?.code ?? ""}`; + } finally { + await worker?.terminate().catch(() => {}); + } + + console.log(label, result, handle.fd >= 0, await readable(handle)); + await handle.close().catch(() => {}); +} + +async function main() { + await attempt( + "clone rollback:", + (handle) => ({ handle, bad: () => {} }), + (handle) => [handle], + ); + await attempt( + "duplicate rollback:", + (handle) => ({ handle }), + (handle) => [handle, handle], + ); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias-worker.cjs new file mode 100644 index 0000000000..bdfeb5e19b --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias-worker.cjs @@ -0,0 +1,27 @@ +const { parentPort, workerData } = require("node:worker_threads"); + +async function main() { + const fromMap = workerData.map?.get?.("handle"); + const fromSet = workerData.set ? Array.from(workerData.set)[0] : undefined; + const values = [workerData.direct, workerData.alias, fromMap, fromSet]; + let read; + + try { + const buffer = Buffer.alloc(5); + const result = await workerData.direct.read(buffer, 0, buffer.length, 0); + read = `${result.bytesRead}:${buffer.toString("utf8")}`; + } catch (error) { + read = `${error?.name}:${error?.code ?? ""}`; + } + + parentPort.postMessage({ + same: values.every((value) => value === values[0]), + brand: workerData.direct?.constructor?.name, + read, + }); + await workerData.direct?.close?.().catch?.(() => {}); +} + +main().catch((error) => { + parentPort.postMessage({ unexpected: `${error?.name}:${error?.message}` }); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias.ts new file mode 100644 index 0000000000..dc6cb16a3b --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-workerdata-alias.ts @@ -0,0 +1,46 @@ +import { open } from "node:fs/promises"; +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function main() { + const handle = await open("./process-env-option-worker.cjs", "r"); + let worker: Worker; + try { + worker = new Worker("./filehandle-workerdata-alias-worker.cjs", { + workerData: { + direct: handle, + alias: handle, + map: new Map([["handle", handle]]), + set: new Set([handle]), + }, + transferList: [handle as any], + }); + } catch (error: any) { + console.log("construction:", error?.name, error?.code ?? ""); + await handle.close().catch(() => {}); + return; + } + + console.log("parent detached:", handle.fd === -1); + worker.on("message", async (message) => { + console.log("worker:", JSON.stringify(message)); + try { + await handle.readFile(); + console.log("parent read: ok"); + } catch (error: any) { + console.log("parent read:", error?.name, error?.code ?? ""); + } + }); + worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); + }); + worker.on("exit", async (code) => { + console.log("exit:", code); + await handle.close().catch(() => {}); + }); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs index 878b5b4100..180f46fb8c 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported-worker.cjs @@ -9,10 +9,53 @@ function outcome(fn) { } } +const originalTitle = process.title; +process.title = `${originalTitle} worker`; +const title = process.title === `${originalTitle} worker`; + +const originalDebugPort = process.debugPort; +process.debugPort = originalDebugPort + 1; +const debugPort = process.debugPort === originalDebugPort + 1; + const currentMask = process.umask(); +const names = ["abort", "chdir", "send", "disconnect"]; +for ( + const name of [ + "setuid", + "seteuid", + "setgid", + "setegid", + "setgroups", + "initgroups", + ] +) { + if (typeof process[name] === "function") names.push(name); +} + +const stubs = {}; +for (const name of names) { + stubs[name] = { + disabled: process[name]?.disabled === true, + call: outcome(() => process[name]()), + }; +} + +const getters = {}; +for (const name of ["channel", "connected"]) { + getters[name] = outcome(() => process[name]); +} + parentPort.postMessage({ - chdirDisabled: process.chdir.disabled === true, - abortDisabled: process.abort.disabled === true, - chdir: outcome(() => process.chdir(process.cwd())), + title, + debugPort, + stubs, + getters, umask: outcome(() => process.umask(currentMask)), + internals: [ + "_startProfilerIdleNotifier", + "_stopProfilerIdleNotifier", + "_debugProcess", + "_debugPause", + "_debugEnd", + ].every((name) => !(name in process)), }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts index c84401c2a4..2f18f32b14 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-unsupported.ts @@ -2,14 +2,35 @@ import { Worker } from "node:worker_threads"; process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); -const worker = new Worker("./process-unsupported-worker.cjs"); -worker.on("message", (message) => { - console.log("disabled:", message.chdirDisabled, message.abortDisabled); - console.log("chdir:", message.chdir); - console.log("umask:", message.umask); -}); -worker.on( - "error", - (error: any) => console.log("error:", error?.name, error?.code ?? ""), -); -worker.on("exit", (code) => console.log("exit:", code)); +const previousChannel = process.env.NODE_CHANNEL_FD; +process.env.NODE_CHANNEL_FD = "worker-parity"; + +function restore() { + if (previousChannel === undefined) { + delete process.env.NODE_CHANNEL_FD; + } else { + process.env.NODE_CHANNEL_FD = previousChannel; + } +} + +try { + const worker = new Worker("./process-unsupported-worker.cjs"); + worker.on("message", (message) => { + console.log("writable:", message.title, message.debugPort); + console.log("stubs:", JSON.stringify(message.stubs)); + console.log("getters:", JSON.stringify(message.getters)); + console.log("umask:", message.umask); + console.log("internals:", message.internals); + }); + worker.on( + "error", + (error: any) => console.log("error:", error?.name, error?.code ?? ""), + ); + worker.on("exit", (code) => { + console.log("exit:", code); + restore(); + }); +} catch (error: any) { + console.log("construction:", error?.name, error?.code ?? ""); + restore(); +} diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/thread-name-defaults.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/thread-name-defaults.ts new file mode 100644 index 0000000000..5eacbaf56c --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/thread-name-defaults.ts @@ -0,0 +1,32 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const cases: Array<[string, boolean, any]> = [ + ["missing", false, undefined], + ["empty", true, ""], + ["false", true, false], + ["zero", true, 0], + ["null", true, null], + ["nan", true, Number.NaN], +]; + +async function run(label: string, includeName: boolean, name: any) { + const options = includeName ? { name } : {}; + const worker = new Worker("./thread-metadata-worker.cjs", options); + + const message = await new Promise((resolve, reject) => { + worker.once("message", resolve); + worker.once("error", reject); + }); + console.log(label, worker.threadName, message.threadName); + await new Promise((resolve) => worker.once("exit", () => resolve())); +} + +async function main() { + for (const entry of cases) await run(...entry); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.code ?? "") +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts index 0e80ea8835..5d5a8531f2 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/unsupported-paths.ts @@ -1,24 +1,25 @@ import { Worker } from "node:worker_threads"; const values: Array<[string, any]> = [ - ["undefined", undefined], - ["null", null], - ["boolean", false], - ["number", 1], - ["symbol", Symbol("worker")], - ["object", {}], - ["array", []], - ["function", () => {}], ["bare relative", "worker.cjs"], ["http string", "https://example.com/worker.js"], ["http URL", new URL("https://example.com/worker.js")], ]; -for (const [label, value] of values) { - try { - new Worker(value); - console.log(label, "ok"); - } catch (error: any) { - console.log(label, error?.name, error?.code ?? ""); +async function main() { + for (const [label, value] of values) { + let worker: Worker | undefined; + try { + worker = new Worker(value); + console.log(label, "created"); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? ""); + } finally { + await worker?.terminate().catch(() => {}); + } } } + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 135a30e5b6..eac30727b5 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2820/3026 (93.2%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2820/3030 (93.1%)." }, "overall": { "pass": 2820, - "total": 3026, - "pct": 93.2 + "total": 3030, + "pct": 93.1 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 36, - "total": 180 + "total": 184 }, "zlib": { "pass": 58, From f1364a1f4961c7044a2170203e60ceaa8fd4ddd9 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 20:56:55 +0200 Subject: [PATCH 22/24] test: cover worker environment tree isolation --- .../node-suite/worker_threads/README.md | 48 +++++++++----- .../message-port/listeners-while-closing.ts | 31 +++++++++ .../share-env-founder-a-worker.cjs | 18 ++++++ .../share-env-founder-b-worker.cjs | 1 + .../worker-lifecycle/share-env-founder.ts | 34 ++++++++++ .../share-env-tree-a-worker.cjs | 10 +++ .../share-env-tree-b-worker.cjs | 1 + .../share-env-tree-c-worker.cjs | 3 + .../worker-lifecycle/share-env-tree.ts | 64 +++++++++++++++++++ test-parity/node_suite_baseline.json | 12 ++-- 10 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/message-port/listeners-while-closing.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-a-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-b-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-a-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-b-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-c-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 9674e3d1df..13f493555b 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -45,7 +45,11 @@ transferred-port regressions. The next audit selected Node's deterministic FileHandle clone/transfer ownership and process restrictions, plus Bun's FileHandle construction rollback and workerData alias regressions. Resource-consuming in-use-handle races remain separate; these cases use small -controlled read-only files and explicit message/exit barriers. +controlled read-only files and explicit message/exit barriers. The following +pass selected Node's closing/closed-port listener safety and Bun's +spawning-thread `SHARE_ENV` tree isolation/founder regressions. Those +environment fixtures coordinate only through worker exit and scoped environment +stores, not nested-message scheduling. The inventory review covered all 148 current `test-worker-*` files across Node's parallel and sequential directories, all 48 cases in Deno's unit selection plus @@ -93,7 +97,8 @@ that oracle. suppresses later event delivery, same-reference `.on()` deduplication and event-name scoping, synchronous drains after `start()`, queued-message ordering before a peer close, orphaned-transfer peer notification, and the - exact MessagePort/Event prototype brands. + exact MessagePort/Event prototype brands. Listener aliases are also exercised + while both endpoints are closing and from the close callback after closure. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with cross-realm branding, closed-port, and @@ -121,7 +126,8 @@ that oracle. `process.env` as an explicit environment option, and deterministic `resourceLimits` snapshots/reset without memory-pressure enforcement, FileHandle construction rollback and workerData alias identity, default/falsy - thread names, and the complete worker-disabled process stub/getter surface. + thread names, the complete worker-disabled process stub/getter surface, and + disjoint/founder `SHARE_ENV` spawning-tree semantics. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -158,9 +164,11 @@ cases above or belong to a separate slow/risky runtime feature: signals, inspector integration, beforeExit and worker-side uncaught-exception variants, and source maps remain separate loader, CLI, or process-subsystem work. -- One controlled nested worker now covers environment-data inheritance. Deeper - nesting stress, GC/finalization of unreachable ports, shared native-handle - races, large message loops, and termination races remain scheduler-sensitive. +- Controlled nested workers now cover environment-data inheritance and + spawning-thread `SHARE_ENV` tree isolation without nested message scheduling. + Deeper nesting stress, GC/finalization of unreachable ports, shared + native-handle races, large message loops, and termination races remain + scheduler-sensitive. - Basic FileHandle clone rejection, transfer ownership, construction rollback, and workerData graph aliasing are covered with controlled read-only handles. Pending read/close transfer races, descriptor recycling/leak detection, and @@ -209,8 +217,8 @@ cases above or belong to a separate slow/risky runtime feature: exercises the already covered unresolved-path fallback and leaves the buffer untouched vacuously. -The measured focused result is `36/184`: all 17 pre-existing cases remain green, -19 added cases pass, and 148 added cases expose stable diagnostic differences. +The measured focused result is `37/187`: all 17 pre-existing cases remain green, +20 added cases pass, and 150 added cases expose stable diagnostic differences. The prior ten cases were each repeated three times under Node 26.5.0 and Perry with stable output and exit status. Deno 2.9.2 was stable across the same matrix. Bun 1.2.18 was stable for nine cases; its older orphaned-transfer @@ -221,7 +229,11 @@ path/process cases were also stable in three runs per runtime under Node, Perry, Deno, and Bun. Bun 1.2.18 still rejects FileHandle workerData transfer selected by current Bun upstream, and its bare-relative Worker path exits before the remaining path matrix; both behaviors were stable and are recorded as -old-runtime comparison gaps. +old-runtime comparison gaps. The latest closed-listener and two `SHARE_ENV` tree +cases were each repeated three times under all four runtimes with stable output +and exit status. Deno shares the process-wide tree-isolation gap, while Bun +1.2.18 rejects nested `SHARE_ENV`; Perry exposes process-wide leakage but passes +founder preservation. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -242,19 +254,22 @@ also pass. The latest refresh adds passing dispatch-time coverage in also proven not to reappear through the event listener in `message-port/receive-consumes-event.ts`. Passing `process.env` directly as a Worker environment option is also covered by -`worker-lifecycle/process-env-option.ts`. The diagnostic differences are: +`worker-lifecycle/process-env-option.ts`. Explicit-env `SHARE_ENV` founder +preservation in `worker-lifecycle/share-env-founder.ts` passes too. The +diagnostic differences are: - All thirteen `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, accepts invalid lists, and treats FileHandles as cloneable plain objects. -- Forty-four `message-port/` diagnostics: closing and dispatch flushing, queued +- Forty-five `message-port/` diagnostics: closing and dispatch flushing, queued data/callbacks, NodeEventTarget surface, listener counts and validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state and custom inspection differ, as do `.on()` deduplication, pending synchronous - receives, queued peer-close ordering, orphaned transfers, and Event brands. -- Fifty-eight `worker-lifecycle/` diagnostics: invalid constructor payloads and + receives, queued peer-close ordering, orphaned transfers, Event brands, and + listener alias behavior after closure. +- Fifty-nine `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -262,8 +277,9 @@ Worker environment option is also covered by Worker asynchronous disposal, default environment isolation, process argv shape, constructor rollback, invalid filename types, and resource-limit reset differ. FileHandle rollback/aliasing, default/falsy thread names, and the full - disabled process surface also differ. Basic eval with CJS require and explicit - `process.env` both pass. + disabled process surface also differ, as does spawning-tree `SHARE_ENV` + isolation. Basic eval with CJS require, explicit `process.env`, and preserving + an explicit-env founder value all pass. - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. @@ -284,7 +300,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `36/184 (19.6%), diff=148`, with no compile failures or timeouts. +It reported `37/187 (19.8%), diff=150`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/message-port/listeners-while-closing.ts b/test-parity/node-suite/worker_threads/message-port/listeners-while-closing.ts new file mode 100644 index 0000000000..d0e9663601 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/listeners-while-closing.ts @@ -0,0 +1,31 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const dummy = () => {}; + +function outcome(fn: () => unknown): string { + try { + fn(); + return "ok"; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +function exercise(port: any): string { + return [ + outcome(() => port.on("message", dummy)), + outcome(() => port.off("message", dummy)), + outcome(() => port.addListener("message", dummy)), + outcome(() => port.removeListener("message", dummy)), + ].join(","); +} + +port1.on("message", dummy); +port1.close(() => { + console.log("closed port:", exercise(port1)); + console.log("closed peer:", exercise(port2)); + port2.close(); +}); +console.log("closing port:", exercise(port1)); +console.log("closing peer:", exercise(port2)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-a-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-a-worker.cjs new file mode 100644 index 0000000000..e9971ec8fd --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-a-worker.cjs @@ -0,0 +1,18 @@ +const { SHARE_ENV, Worker } = require("node:worker_threads"); + +const before = process.env.PERRY_FOUNDER_KEY; +const worker = new Worker("./share-env-founder-b-worker.cjs", { + env: SHARE_ENV, +}); + +worker.on("error", () => { + process.exitCode = 1; +}); +worker.on("exit", code => { + if ( + code !== 0 || before !== "from-A" || + process.env.PERRY_FOUNDER_KEY !== "from-A" + ) { + process.exitCode = 1; + } +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-b-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-b-worker.cjs new file mode 100644 index 0000000000..c2f606ca8a --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder-b-worker.cjs @@ -0,0 +1 @@ +if (process.env.PERRY_FOUNDER_KEY !== "from-A") process.exitCode = 2; diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder.ts new file mode 100644 index 0000000000..69da5375a5 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-founder.ts @@ -0,0 +1,34 @@ +import { SHARE_ENV, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function wait(worker: Worker): Promise { + return new Promise((resolve) => { + worker.on("error", () => {}); + worker.on("exit", resolve); + }); +} + +async function main() { + const previous = process.env.PERRY_FOUNDER_KEY; + process.env.PERRY_FOUNDER_KEY = "from-main"; + + try { + await wait( + new Worker("./constructor-validation-worker.cjs", { env: SHARE_ENV }), + ); + const code = await wait( + new Worker("./share-env-founder-a-worker.cjs", { + env: { PERRY_FOUNDER_KEY: "from-A" }, + }), + ); + console.log("founder:", code, process.env.PERRY_FOUNDER_KEY); + } finally { + if (previous === undefined) delete process.env.PERRY_FOUNDER_KEY; + else process.env.PERRY_FOUNDER_KEY = previous; + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-a-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-a-worker.cjs new file mode 100644 index 0000000000..7c120cf166 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-a-worker.cjs @@ -0,0 +1,10 @@ +const { SHARE_ENV, Worker } = require("node:worker_threads"); + +process.env.PERRY_TREE_A = "a"; +const worker = new Worker("./share-env-tree-b-worker.cjs", { env: SHARE_ENV }); +worker.on("error", () => { + process.exitCode = 1; +}); +worker.on("exit", code => { + if (code !== 0) process.exitCode = 1; +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-b-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-b-worker.cjs new file mode 100644 index 0000000000..5da78dc8f8 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-b-worker.cjs @@ -0,0 +1 @@ +process.env.PERRY_TREE_B = "b"; diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-c-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-c-worker.cjs new file mode 100644 index 0000000000..9288df119b --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree-c-worker.cjs @@ -0,0 +1,3 @@ +process.env.PERRY_TREE_C = "c"; +process.env.PERRY_TREE_C_SEES_B = process.env.PERRY_TREE_B ?? "missing"; +process.env.PERRY_TREE_C_SEES_MAIN = process.env.PERRY_TREE_MAIN ?? "missing"; diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree.ts new file mode 100644 index 0000000000..5461e09948 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/share-env-tree.ts @@ -0,0 +1,64 @@ +import { SHARE_ENV, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function wait(worker: Worker): Promise { + return new Promise((resolve, reject) => { + worker.on("error", reject); + worker.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error("exit:" + code)); + }); + }); +} + +async function main() { + const previousMain = process.env.PERRY_TREE_MAIN; + const previousA = process.env.PERRY_TREE_A; + const previousB = process.env.PERRY_TREE_B; + const previousC = process.env.PERRY_TREE_C; + const previousCSeesB = process.env.PERRY_TREE_C_SEES_B; + const previousCSeesMain = process.env.PERRY_TREE_C_SEES_MAIN; + process.env.PERRY_TREE_MAIN = "main"; + delete process.env.PERRY_TREE_A; + delete process.env.PERRY_TREE_B; + delete process.env.PERRY_TREE_C; + delete process.env.PERRY_TREE_C_SEES_B; + delete process.env.PERRY_TREE_C_SEES_MAIN; + try { + await wait(new Worker("./share-env-tree-a-worker.cjs")); + await wait( + new Worker("./share-env-tree-c-worker.cjs", { env: SHARE_ENV }), + ); + console.log( + "C observations:", + process.env.PERRY_TREE_C_SEES_B === "missing" + ? null + : process.env.PERRY_TREE_C_SEES_B ?? null, + process.env.PERRY_TREE_C_SEES_MAIN ?? null, + ); + console.log( + "main:", + process.env.PERRY_TREE_B ?? null, + process.env.PERRY_TREE_C ?? null, + ); + } finally { + for ( + const [key, value] of [ + ["PERRY_TREE_MAIN", previousMain], + ["PERRY_TREE_A", previousA], + ["PERRY_TREE_B", previousB], + ["PERRY_TREE_C", previousC], + ["PERRY_TREE_C_SEES_B", previousCSeesB], + ["PERRY_TREE_C_SEES_MAIN", previousCSeesMain], + ] as const + ) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index eac30727b5..db52f18e53 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2820/3030 (93.1%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2821/3033 (93.0%)." }, "overall": { - "pass": 2820, - "total": 3030, - "pct": 93.1 + "pass": 2821, + "total": 3033, + "pct": 93.0 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 36, - "total": 184 + "pass": 37, + "total": 187 }, "zlib": { "pass": 58, From 42d4f535aca367c651c58dbeeafab1978b329959 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 21:25:28 +0200 Subject: [PATCH 23/24] test: cover worker validation and handle ownership --- .../node-suite/worker_threads/README.md | 67 +++++++++++++------ .../move-context-filehandle-error.ts | 52 ++++++++++++++ .../closed-transfer-error-brand.ts | 27 ++++++++ .../message-port/distinct-listener-order.ts | 15 +++++ .../message-port/unref-delivery.ts | 20 ++++++ .../filehandle-name-validation.ts | 49 ++++++++++++++ .../filehandle-orphan-rollback.ts | 49 ++++++++++++++ .../filehandle-orphan-transfer.ts | 49 ++++++++++++++ .../worker-lifecycle/internal-thread-flag.ts | 20 ++++++ .../internal-thread-worker.cjs | 7 ++ .../option-validation-precedence.ts | 52 ++++++++++++++ .../worker-lifecycle/url-eval-validation.ts | 18 +++++ test-parity/node_suite_baseline.json | 6 +- 13 files changed, 407 insertions(+), 24 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/message-channel/move-context-filehandle-error.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/closed-transfer-error-brand.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/distinct-listener-order.ts create mode 100644 test-parity/node-suite/worker_threads/message-port/unref-delivery.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-name-validation.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-rollback.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-transfer.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-flag.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/option-validation-precedence.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/url-eval-validation.ts diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 13f493555b..773665047c 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -49,7 +49,13 @@ controlled read-only files and explicit message/exit barriers. The following pass selected Node's closing/closed-port listener safety and Bun's spawning-thread `SHARE_ENV` tree isolation/founder regressions. Those environment fixtures coordinate only through worker exit and scoped environment -stores, not nested-message scheduling. +stores, not nested-message scheduling. The latest pass adds Node's +regular-worker `isInternalThread` state, URL/eval and competing-option +constructor validation, closed-port `DOMException` branding, VM-context +FileHandle deserialization failure, and orphaned FileHandle ownership/rollback. +Deno's ordered listener and unref delivery selections and Bun's invalid-name +FileHandle rollback regression provide independent coverage for the same +lifecycle boundaries. The inventory review covered all 148 current `test-worker-*` files across Node's parallel and sequential directories, all 48 cases in Deno's unit selection plus @@ -99,10 +105,14 @@ that oracle. ordering before a peer close, orphaned-transfer peer notification, and the exact MessagePort/Event prototype brands. Listener aliases are also exercised while both endpoints are closing and from the close callback after closure. + Distinct listener registration order, delivery while the receiving port is + explicitly unrefed, and the full closed-transfer `DOMException` brand are + checked separately. - `message-channel/`: module/global identity, construction rules, port brands, asynchronous and synchronous delivery, BroadcastChannel synchronous receive, - and VM-context port movement with cross-realm branding, closed-port, and - argument validation. + and VM-context port movement with cross-realm branding, closed-port, argument + validation, and FileHandle deserialization failure routing through + `messageerror`. - `worker-lifecycle/`: constructor transfer validation, structured `workerData`, default and built-in workerData, MessagePort/ArrayBuffer transfer, environment snapshots, eval/CJS require, file URLs with Unicode names, argv and execArgv @@ -127,7 +137,10 @@ that oracle. `resourceLimits` snapshots/reset without memory-pressure enforcement, FileHandle construction rollback and workerData alias identity, default/falsy thread names, the complete worker-disabled process stub/getter surface, and - disjoint/founder `SHARE_ENV` spawning-tree semantics. + disjoint/founder `SHARE_ENV` spawning-tree semantics, regular-worker + `isInternalThread` state, URL/eval rejection, constructor option-validation + precedence with transfer ownership, and referenced/unreferenced FileHandle + construction ownership and rollback. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -159,11 +172,11 @@ cases above or belong to a separate slow/risky runtime feature: snapshots, and CPU/heap profiling are resource- and platform-sensitive. Basic resource-limit metadata/reset, captured stdout/stderr, and the diagnostic method surface are covered without introducing pressure or timing assertions. -- Basic eval/CJS require, execArgv, file-URL construction, process restrictions, - and exit codes are now covered. Data-URL/ESM/custom loaders, preload chains, - signals, inspector integration, beforeExit and worker-side uncaught-exception - variants, and source maps remain separate loader, CLI, or process-subsystem - work. +- Basic eval/CJS require, execArgv, file-URL construction, synchronous URL/eval + rejection, process restrictions, and exit codes are now covered. + Data-URL/ESM/custom loaders, preload chains, signals, inspector integration, + beforeExit and worker-side uncaught-exception variants, and source maps remain + separate loader, CLI, or process-subsystem work. - Controlled nested workers now cover environment-data inheritance and spawning-thread `SHARE_ENV` tree isolation without nested message scheduling. Deeper nesting stress, GC/finalization of unreachable ports, shared @@ -171,9 +184,10 @@ cases above or belong to a separate slow/risky runtime feature: scheduler-sensitive. - Basic FileHandle clone rejection, transfer ownership, construction rollback, and workerData graph aliasing are covered with controlled read-only handles. - Pending read/close transfer races, descriptor recycling/leak detection, and - cross-context disposal remain shared-handle stress rather than granular parity - cases. + Orphaned transfer ownership, rollback after a later clone failure, and + invalid-name prevalidation are also covered. Pending read/close transfer + races, descriptor recycling/leak detection, and cross-context disposal remain + shared-handle stress rather than granular parity cases. - SharedArrayBuffer aliasing is covered through same-thread MessagePort and BroadcastChannel paths plus cross-agent workerData and postMessage. Atomics wait/notify coordination remains separate scheduler-aware infrastructure. @@ -199,7 +213,10 @@ cases above or belong to a separate slow/risky runtime feature: snapshots. In-flight abort races and cross-agent ownership remain excluded. - The existing `direct-message/` fixtures already cover deterministic `postMessageToThread` delivery, transferable ownership, rejection, handler - failures, and timeout behavior. + failures, and timeout behavior. Bun's prototype-tampering regression was not + retained: worker-to-main delivery can remain pending in Perry without a + non-timing completion event, so a faithful fixture would rely on an arbitrary + timeout. - Bun's current non-function `process.emit` worker regression was not selected: the pinned Node 26.5.0 oracle exits fatally from `process._fatalException` before an `uncaughtException` handler can establish the recovery contract that @@ -215,10 +232,14 @@ cases above or belong to a separate slow/risky runtime feature: - Invalid-path transfer rollback was not retained: a literal nonexistent Worker target fails Perry compilation, while hiding it behind a dynamic path only exercises the already covered unresolved-path fallback and leaves the buffer - untouched vacuously. + untouched vacuously. Bun's peer-close-while-in-transit case and Deno's + cross-worker unref liveness case were also not copied literally: the missing + event in Perry retains the worker indefinitely. Their deterministic local + subcontracts are covered through peer-close ordering and unrefed delivery + without adding timeout-based completion. -The measured focused result is `37/187`: all 17 pre-existing cases remain green, -20 added cases pass, and 150 added cases expose stable diagnostic differences. +The measured focused result is `37/197`: all 17 pre-existing cases remain green, +20 added cases pass, and 160 added cases expose stable diagnostic differences. The prior ten cases were each repeated three times under Node 26.5.0 and Perry with stable output and exit status. Deno 2.9.2 was stable across the same matrix. Bun 1.2.18 was stable for nine cases; its older orphaned-transfer @@ -233,7 +254,11 @@ old-runtime comparison gaps. The latest closed-listener and two `SHARE_ENV` tree cases were each repeated three times under all four runtimes with stable output and exit status. Deno shares the process-wide tree-isolation gap, while Bun 1.2.18 rejects nested `SHARE_ENV`; Perry exposes process-wide leakage but passes -founder preservation. +founder preservation. The latest ten cases were likewise repeated three times +under Node 26.5.0, Perry, Deno 2.9.2, and Bun 1.2.18. Bun's older unrefed-port +implementation retained the external six-second comparison process in all three +runs; every retained Node/Perry case completed through an explicit event or exit +barrier. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -262,14 +287,14 @@ diagnostic differences are: but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, rejects cycles/BigInt, does not detach or move ownership, accepts invalid lists, and treats FileHandles as cloneable plain objects. -- Forty-five `message-port/` diagnostics: closing and dispatch flushing, queued +- Forty-eight `message-port/` diagnostics: closing and dispatch flushing, queued data/callbacks, NodeEventTarget surface, listener counts and validation, receiver/options/iterables/transfers, MessageEvent construction and `ports`, duplicate/self/closed rollback, moved ownership, and `hasRef()` state and custom inspection differ, as do `.on()` deduplication, pending synchronous receives, queued peer-close ordering, orphaned transfers, Event brands, and listener alias behavior after closure. -- Fifty-nine `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Sixty-five `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -286,7 +311,7 @@ diagnostic differences are: - Eleven `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, MessagePort validation, closed-channel posts, and custom inspection differ. -- Seventeen additional diagnostics cover namespace/prototype completeness, +- Eighteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction/callability and VM-context branding, direct-message argument validation/transfer ownership, and Web Locks callback rejection cleanup. @@ -300,7 +325,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `37/187 (19.8%), diff=150`, with no compile failures or timeouts. +It reported `37/197 (18.8%), diff=160`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/message-channel/move-context-filehandle-error.ts b/test-parity/node-suite/worker_threads/message-channel/move-context-filehandle-error.ts new file mode 100644 index 0000000000..1c55aa574e --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-channel/move-context-filehandle-error.ts @@ -0,0 +1,52 @@ +import { open } from "node:fs/promises"; +import { MessageChannel, moveMessagePortToContext } from "node:worker_threads"; +import { createContext } from "node:vm"; + +const fixture = + "test-parity/node-suite/worker_threads/worker-lifecycle/process-env-option-worker.cjs"; + +async function main() { + const handle = await open(fixture, "r"); + const { port1, port2 } = new MessageChannel(); + let moved: any; + + try { + moved = moveMessagePortToContext(port2, createContext({})); + } catch (error: any) { + console.log("setup:", error?.name, error?.code ?? ""); + port1.close(); + port2.close(); + await handle.close().catch(() => {}); + return; + } + + const events: string[] = []; + moved.onmessageerror = (event: any) => { + events.push(`error:${event?.data?.code ?? "missing"}`); + }; + moved.onmessage = async (event: any) => { + if (event.data === "barrier") { + console.log("events:", events.join(",")); + moved.close(); + port1.close(); + await handle.close().catch(() => {}); + return; + } + events.push( + `message:${event.data?.constructor?.name ?? typeof event.data}`, + ); + }; + moved.start(); + + try { + port1.postMessage(handle, [handle as any]); + console.log("transfer: ok", handle.fd === -1); + } catch (error: any) { + console.log("transfer:", error?.name, error?.code ?? "", handle.fd === -1); + } + port1.postMessage("barrier"); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/message-port/closed-transfer-error-brand.ts b/test-parity/node-suite/worker_threads/message-port/closed-transfer-error-brand.ts new file mode 100644 index 0000000000..c5015744cb --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/closed-transfer-error-brand.ts @@ -0,0 +1,27 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const { port1: target1, port2: target2 } = new MessageChannel(); +const buffer = new ArrayBuffer(8); + +port2.close(); + +try { + target1.postMessage({ buffer, port: port2 }, [buffer, port2]); + console.log("transfer: ok"); +} catch (error: any) { + console.log( + "error:", + error?.name, + error?.code ?? "", + error instanceof Error, + error instanceof DOMException, + error?.constructor?.name, + ); +} +console.log("rollback:", buffer.byteLength, port2 instanceof MessagePort); + +port1.close(); +port2.close(); +target1.close(); +target2.close(); diff --git a/test-parity/node-suite/worker_threads/message-port/distinct-listener-order.ts b/test-parity/node-suite/worker_threads/message-port/distinct-listener-order.ts new file mode 100644 index 0000000000..6ca627ba93 --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/distinct-listener-order.ts @@ -0,0 +1,15 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; + +port1.on("message", (value) => events.push(`first:${value}`)); +port1.on("message", (value) => events.push(`second:${value}`)); +port1.on("message", (value) => { + events.push(`third:${value}`); + console.log("events:", events.join(",")); + port1.close(); + port2.close(); +}); + +port2.postMessage("value"); diff --git a/test-parity/node-suite/worker_threads/message-port/unref-delivery.ts b/test-parity/node-suite/worker_threads/message-port/unref-delivery.ts new file mode 100644 index 0000000000..7277e9b24c --- /dev/null +++ b/test-parity/node-suite/worker_threads/message-port/unref-delivery.ts @@ -0,0 +1,20 @@ +import { MessageChannel } from "node:worker_threads"; + +const { port1, port2 } = new MessageChannel(); +const events: string[] = []; + +port1.on("message", (value) => { + events.push(String(value)); + if (events.length === 3) { + console.log("events:", events.join(",")); + console.log("refs:", port1.hasRef(), port2.hasRef()); + port1.close(); + port2.close(); + } +}); +port1.unref(); +port2.ref(); + +port2.postMessage("a"); +port2.postMessage("b"); +port2.postMessage("c"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-name-validation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-name-validation.ts new file mode 100644 index 0000000000..c2f09909e3 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-name-validation.ts @@ -0,0 +1,49 @@ +import { open } from "node:fs/promises"; +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function main() { + const handle = await open("./process-env-option-worker.cjs", "r"); + try { + const worker = new Worker("./natural-exit-worker.cjs", { + name: {} as any, + workerData: { handle }, + transferList: [handle as any], + }); + console.log("construction: ok", handle.fd === -1); + worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); + }); + worker.on("exit", async (code) => { + console.log("exit:", code, handle.fd === -1); + const buffer = Buffer.alloc(1); + try { + const result = await handle.read(buffer, 0, 1, 0); + console.log("parent readable:", result.bytesRead, buffer[0]); + } catch (readError: any) { + console.log("parent read:", readError?.code ?? readError?.name); + } + await handle.close().catch(() => {}); + }); + } catch (error: any) { + console.log( + "construction:", + error?.name, + error?.code ?? "", + handle.fd === -1, + ); + const buffer = Buffer.alloc(1); + try { + const result = await handle.read(buffer, 0, 1, 0); + console.log("parent readable:", result.bytesRead, buffer[0]); + } catch (readError: any) { + console.log("parent read:", readError?.code ?? readError?.name); + } + await handle.close().catch(() => {}); + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-rollback.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-rollback.ts new file mode 100644 index 0000000000..cd702f1fe8 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-rollback.ts @@ -0,0 +1,49 @@ +import { open } from "node:fs/promises"; +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function main() { + const handle = await open("./process-env-option-worker.cjs", "r"); + let worker: Worker | undefined; + try { + worker = new Worker("./natural-exit-worker.cjs", { + workerData: () => "not cloneable", + transferList: [handle as any], + }); + console.log("construction: ok", handle.fd === -1); + worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); + }); + worker.on("exit", async (code) => { + console.log("exit:", code, handle.fd === -1); + const buffer = Buffer.alloc(1); + try { + const result = await handle.read(buffer, 0, 1, 0); + console.log("parent readable:", result.bytesRead, buffer[0]); + } catch (readError: any) { + console.log("parent read:", readError?.code ?? readError?.name); + } + await handle.close().catch(() => {}); + }); + } catch (error: any) { + console.log( + "construction:", + error?.name, + error?.code ?? "", + handle.fd === -1, + ); + const buffer = Buffer.alloc(1); + try { + const result = await handle.read(buffer, 0, 1, 0); + console.log("parent readable:", result.bytesRead, buffer[0]); + } catch (readError: any) { + console.log("parent read:", readError?.code ?? readError?.name); + } + await handle.close().catch(() => {}); + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-transfer.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-transfer.ts new file mode 100644 index 0000000000..8a46cc7fb1 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-orphan-transfer.ts @@ -0,0 +1,49 @@ +import { open } from "node:fs/promises"; +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function main() { + const handle = await open("./process-env-option-worker.cjs", "r"); + try { + const worker = new Worker("./natural-exit-worker.cjs", { + workerData: {}, + transferList: [handle as any], + }); + console.log("construction: ok", handle.fd === -1); + worker.on("message", (value) => console.log("message:", value)); + worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); + }); + worker.on("exit", async (code) => { + console.log("exit:", code, handle.fd === -1); + const buffer = Buffer.alloc(1); + try { + const result = await handle.read(buffer, 0, 1, 0); + console.log("parent readable:", result.bytesRead, buffer[0]); + } catch (readError: any) { + console.log("parent read:", readError?.code ?? readError?.name); + } + await handle.close().catch(() => {}); + }); + } catch (error: any) { + console.log( + "construction:", + error?.name, + error?.code ?? "", + handle.fd === -1, + ); + const buffer = Buffer.alloc(1); + try { + const result = await handle.read(buffer, 0, 1, 0); + console.log("parent readable:", result.bytesRead, buffer[0]); + } catch (readError: any) { + console.log("parent read:", readError?.code ?? readError?.name); + } + await handle.close().catch(() => {}); + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-flag.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-flag.ts new file mode 100644 index 0000000000..d2e100e513 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-flag.ts @@ -0,0 +1,20 @@ +import * as workerThreads from "node:worker_threads"; +import { isInternalThread, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +console.log( + "main:", + isInternalThread, + workerThreads.isInternalThread, + workerThreads.isMainThread, +); + +const worker = new Worker("./internal-thread-worker.cjs"); +worker.on("message", (value: any) => { + console.log("worker:", value.named, value.namespace, value.main); +}); +worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-worker.cjs new file mode 100644 index 0000000000..f203e73ded --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/internal-thread-worker.cjs @@ -0,0 +1,7 @@ +const workerThreads = require("node:worker_threads"); + +workerThreads.parentPort.postMessage({ + named: workerThreads.isInternalThread, + namespace: require("node:worker_threads").isInternalThread, + main: workerThreads.isMainThread, +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/option-validation-precedence.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/option-validation-precedence.ts new file mode 100644 index 0000000000..9a2d53efd4 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/option-validation-precedence.ts @@ -0,0 +1,52 @@ +import { Worker } from "node:worker_threads"; + +function construct(label: string, options: Record): Promise { + return new Promise((resolve) => { + try { + const worker = new Worker("./internal-thread-worker.cjs", options); + console.log(label, "ok"); + worker.on("error", (error: any) => { + console.log(label, "error", error?.name, error?.code ?? ""); + }); + worker.on("exit", (code) => { + console.log(label, "exit", code); + resolve(); + }); + worker.terminate(); + } catch (error: any) { + console.log(label, error?.name, error?.code ?? "", error?.message); + resolve(); + } + }); +} + +async function main() { + await construct("argv before env:", { + argv: [{ + toString() { + throw new Error("argv-coercion"); + }, + }], + env: 42, + }); + + const envBuffer = new ArrayBuffer(8); + await construct("env before transfer:", { + env: 42, + workerData: envBuffer, + transferList: [envBuffer], + }); + console.log("env ownership:", envBuffer.byteLength); + + const nameBuffer = new ArrayBuffer(8); + await construct("name before transfer:", { + name: {}, + workerData: nameBuffer, + transferList: [nameBuffer], + }); + console.log("name ownership:", nameBuffer.byteLength); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/url-eval-validation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/url-eval-validation.ts new file mode 100644 index 0000000000..432eedb5e2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/url-eval-validation.ts @@ -0,0 +1,18 @@ +import { Worker } from "node:worker_threads"; + +const filename = new URL( + "./internal-thread-worker.cjs", + import.meta.url, +); + +try { + const worker = new Worker(filename, { eval: true }); + console.log("construct: ok"); + worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); + }); + worker.on("exit", (code) => console.log("exit:", code)); + worker.terminate(); +} catch (error: any) { + console.log("construct:", error?.name, error?.code ?? ""); +} diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index db52f18e53..c15057559c 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -6,8 +6,8 @@ }, "overall": { "pass": 2821, - "total": 3033, - "pct": 93.0 + "total": 3043, + "pct": 92.7 }, "modules": { "assert": { @@ -216,7 +216,7 @@ }, "worker_threads": { "pass": 37, - "total": 187 + "total": 197 }, "zlib": { "pass": 58, From 389d8c1b884ca0cf77d262a688e5abbb8fc74e18 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 22:01:31 +0200 Subject: [PATCH 24/24] test: cover worker reverse ownership and diagnostics --- .../node-suite/worker_threads/README.md | 55 ++++++++++++------- .../messageevent-metadata.ts | 28 ++++++++++ .../worker-lifecycle/builtin-data-worker.cjs | 4 ++ .../worker-lifecycle/cpu-usage-validation.ts | 33 +++++++++++ .../diagnostics-hold-worker.cjs | 4 ++ .../worker-lifecycle/exit-unreachable-code.ts | 17 ++++++ .../exit-unreachable-worker.cjs | 6 ++ .../filehandle-map-set-worker.cjs | 24 ++++++++ .../worker-lifecycle/filehandle-map-set.ts | 55 +++++++++++++++++++ .../worker-lifecycle/post-exit-diagnostics.ts | 42 ++++++++++++++ .../process-env-descriptor-worker.cjs | 24 ++++++++ .../process-env-descriptors.ts | 12 ++++ .../worker-created-buffers-after-exit.ts | 35 ++++++++++++ .../worker-created-buffers-worker.cjs | 9 +++ .../worker-created-port-after-exit.ts | 23 ++++++++ .../worker-created-port-worker.cjs | 6 ++ .../worker-lifecycle/workerdata-builtins.ts | 6 ++ test-parity/node_suite_baseline.json | 12 ++-- 18 files changed, 370 insertions(+), 25 deletions(-) create mode 100644 test-parity/node-suite/worker_threads/broadcast-channel/messageevent-metadata.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/cpu-usage-validation.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/diagnostics-hold-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-code.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-diagnostics.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptor-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptors.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-after-exit.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-worker.cjs create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-after-exit.ts create mode 100644 test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-worker.cjs diff --git a/test-parity/node-suite/worker_threads/README.md b/test-parity/node-suite/worker_threads/README.md index 773665047c..75e9166f30 100644 --- a/test-parity/node-suite/worker_threads/README.md +++ b/test-parity/node-suite/worker_threads/README.md @@ -55,11 +55,16 @@ constructor validation, closed-port `DOMException` branding, VM-context FileHandle deserialization failure, and orphaned FileHandle ownership/rollback. Deno's ordered listener and unref delivery selections and Bun's invalid-name FileHandle rollback regression provide independent coverage for the same -lifecycle boundaries. +lifecycle boundaries. The latest audit adds Node's worker-created buffer +lifetime, post-exit diagnostics, CPU-usage validation, process-environment +descriptors, and code-after-`process.exit()` contracts; Node's BroadcastChannel +WPT event metadata; and Bun's nested Map/Set FileHandle transfer regression. A +reverse worker-created MessagePort case closes the remaining deterministic +ownership direction without relying on scheduler timing. -The inventory review covered all 148 current `test-worker-*` files across Node's +The inventory review covered all 146 current `test-worker-*` files across Node's parallel and sequential directories, all 48 cases in Deno's unit selection plus -its directory fixtures, and 82 declarations across Bun's current +its directory fixtures, and 88 `test`/`it` declarations across Bun's current `worker_threads/*.test.ts` files. Cases are represented here by observable contract rather than copied one-for-one when several upstream files exercise the same behavior. @@ -112,7 +117,9 @@ that oracle. asynchronous and synchronous delivery, BroadcastChannel synchronous receive, and VM-context port movement with cross-realm branding, closed-port, argument validation, and FileHandle deserialization failure routing through - `messageerror`. + `messageerror`. Worker-created MessagePorts are also drained synchronously + after the creating worker exits, proving queued data and ownership survive the + source realm. - `worker-lifecycle/`: constructor transfer validation, structured `workerData`, default and built-in workerData, MessagePort/ArrayBuffer transfer, environment snapshots, eval/CJS require, file URLs with Unicode names, argv and execArgv @@ -140,7 +147,11 @@ that oracle. disjoint/founder `SHARE_ENV` spawning-tree semantics, regular-worker `isInternalThread` state, URL/eval rejection, constructor option-validation precedence with transfer ownership, and referenced/unreferenced FileHandle - construction ownership and rollback. + construction ownership and rollback. Reverse worker-created + ArrayBuffer/SharedArrayBuffer lifetime, process-environment descriptor rules, + post-exit diagnostic behavior, CPU-usage argument validation, Map/Set-nested + FileHandle traversal, and the hard boundary after `process.exit()` are covered + with exit or termination barriers. - `transfer-markers/`: marker return/value semantics, inheritance boundaries, clone rejection, transfer rejection, retained ownership after rejection, and the distinction between marking a port uncloneable and transferring it, plus @@ -149,9 +160,9 @@ that oracle. listener management/`once`, `onmessage` replacement, name coercion, close/ref idempotence, typed-array and SharedArrayBuffer cloning, untransferable-value rejection, missing-message validation, clone-versus-closed error precedence, - closed-channel and method-receiver validation, custom inspection, channel - creation during dispatch, and suppression of delivery already queued for a - channel that closes. + closed-channel and method-receiver validation, custom inspection, complete + MessageEvent metadata, channel creation during dispatch, and suppression of + delivery already queued for a channel that closes. - `web-locks/`: deterministic pre-aborted requests and lock stealing, extending the existing surface, option, query, callback settlement/cleanup, independent-name concurrency, and shared/exclusive ordering cases. @@ -170,8 +181,9 @@ cases above or belong to a separate slow/risky runtime feature: - Resource-limit enforcement, stdio backpressure/large-write timing, heap snapshots, and CPU/heap profiling are resource- and platform-sensitive. Basic - resource-limit metadata/reset, captured stdout/stderr, and the diagnostic - method surface are covered without introducing pressure or timing assertions. + resource-limit metadata/reset, captured stdout/stderr, CPU-usage validation, + and live/post-exit diagnostic method behavior are covered without introducing + pressure or timing assertions. - Basic eval/CJS require, execArgv, file-URL construction, synchronous URL/eval rejection, process restrictions, and exit codes are now covered. Data-URL/ESM/custom loaders, preload chains, signals, inspector integration, @@ -238,8 +250,8 @@ cases above or belong to a separate slow/risky runtime feature: subcontracts are covered through peer-close ordering and unrefed delivery without adding timeout-based completion. -The measured focused result is `37/197`: all 17 pre-existing cases remain green, -20 added cases pass, and 160 added cases expose stable diagnostic differences. +The measured focused result is `38/205`: all 17 pre-existing cases remain green, +21 added cases pass, and 167 added cases expose stable diagnostic differences. The prior ten cases were each repeated three times under Node 26.5.0 and Perry with stable output and exit status. Deno 2.9.2 was stable across the same matrix. Bun 1.2.18 was stable for nine cases; its older orphaned-transfer @@ -258,7 +270,10 @@ founder preservation. The latest ten cases were likewise repeated three times under Node 26.5.0, Perry, Deno 2.9.2, and Bun 1.2.18. Bun's older unrefed-port implementation retained the external six-second comparison process in all three runs; every retained Node/Perry case completed through an explicit event or exit -barrier. +barrier. The latest eight cases and strengthened workerData built-in fixture +were also stable in three runs under all four runtimes. Bun 1.2.18 emits an +expected not-implemented warning for Worker performance; all retained processes +terminate without an external comparison timeout. The passing additions are `broadcast-channel/fanout-fifo.ts`, `broadcast-channel/listener-management.ts`, @@ -280,8 +295,9 @@ also proven not to reappear through the event listener in `message-port/receive-consumes-event.ts`. Passing `process.env` directly as a Worker environment option is also covered by `worker-lifecycle/process-env-option.ts`. Explicit-env `SHARE_ENV` founder -preservation in `worker-lifecycle/share-env-founder.ts` passes too. The -diagnostic differences are: +preservation in `worker-lifecycle/share-env-founder.ts` passes too. Worker +CPU-usage argument validation in `worker-lifecycle/cpu-usage-validation.ts` also +matches Node. The diagnostic differences are: - All thirteen `structured-clone/` fixtures: Perry preserves some indexed values but loses built-in/ArrayBuffer/view/SharedArrayBuffer brands and aliasing, @@ -294,7 +310,7 @@ diagnostic differences are: custom inspection differ, as do `.on()` deduplication, pending synchronous receives, queued peer-close ordering, orphaned transfers, Event brands, and listener alias behavior after closure. -- Sixty-five `worker-lifecycle/` diagnostics: invalid constructor payloads and +- Seventy-one `worker-lifecycle/` diagnostics: invalid constructor payloads and paths, execArgv, captured stdio, process restrictions/exit codes, shared environments and nested messages, worker/parentPort EventEmitter behavior, metadata/unique ids, repeated termination, workerData and postMessage SAB @@ -308,9 +324,10 @@ diagnostic differences are: - All five `transfer-markers/` fixtures: primitives are reported as marked, nested clone rejection, private/permanent marker enforcement, ArrayBuffer exceptions, and marked transferables/uncloneable ports differ. -- Eleven `broadcast-channel/` diagnostics: name coercion, once listeners, +- Twelve `broadcast-channel/` diagnostics: name coercion, once listeners, close/ref behavior, typed-array/SharedArrayBuffer branding and sharing, - MessagePort validation, closed-channel posts, and custom inspection differ. + MessagePort validation, closed-channel posts, custom inspection, and complete + MessageEvent metadata differ. - Eighteen additional diagnostics cover namespace/prototype completeness, environment-data built-ins/inheritance/construction, MessageChannel construction/callability and VM-context branding, direct-message argument @@ -325,7 +342,7 @@ python3 scripts/node_suite_run.py \ "$PWD/target/perry-dev/perry" "$PWD" worker_threads ``` -It reported `37/197 (18.8%), diff=160`, with no compile failures or timeouts. +It reported `38/205 (18.5%), diff=167`, with no compile failures or timeouts. The Worker URL-post diagnostic consistently exits by signal 11 in Perry while Node rejects synchronously with `DataCloneError`, keeps the worker usable, and terminates cleanly. Cyclic `workerData` construction also consistently exits by diff --git a/test-parity/node-suite/worker_threads/broadcast-channel/messageevent-metadata.ts b/test-parity/node-suite/worker_threads/broadcast-channel/messageevent-metadata.ts new file mode 100644 index 0000000000..7faf35362b --- /dev/null +++ b/test-parity/node-suite/worker_threads/broadcast-channel/messageevent-metadata.ts @@ -0,0 +1,28 @@ +import { BroadcastChannel } from "node:worker_threads"; + +const sender = new BroadcastChannel("event-metadata"); +const receiver = new BroadcastChannel("event-metadata"); + +receiver.onmessage = (event: MessageEvent) => { + console.log( + "event:", + event instanceof MessageEvent, + event.type, + event.data, + event.target === receiver, + event.currentTarget === receiver, + ); + console.log( + "defaults:", + JSON.stringify({ + origin: event.origin ?? "missing", + lastEventId: event.lastEventId ?? "missing", + source: event.source ?? null, + ports: event.ports?.length ?? "missing", + }), + ); + sender.close(); + receiver.close(); +}; + +sender.postMessage("payload"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs index 0b964d5422..3cbb465268 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/builtin-data-worker.cjs @@ -6,8 +6,12 @@ parentPort.postMessage({ dateValue: data.date instanceof Date ? data.date.toISOString() : undefined, map: data.map instanceof Map, mapValue: data.map instanceof Map ? data.map.get("key") : undefined, + set: data.set instanceof Set, + setValue: data.set instanceof Set ? data.set.has(3) : undefined, regexp: data.regexp instanceof RegExp, regexpValue: data.regexp instanceof RegExp ? data.regexp.source : undefined, bigintType: typeof data.bigint, bigintValue: String(data.bigint), + error: data.error instanceof TypeError, + errorValue: data.error?.message, }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/cpu-usage-validation.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/cpu-usage-validation.ts new file mode 100644 index 0000000000..18262fc5b9 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/cpu-usage-validation.ts @@ -0,0 +1,33 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +function outcome(fn: () => any): string { + try { + const value = fn(); + value?.catch?.(() => {}); + return `ok:${value?.constructor?.name ?? typeof value}`; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +const worker = new Worker("./diagnostics-hold-worker.cjs"); +worker.once("message", async () => { + console.log( + "accepted:", + [undefined, null, Number.NaN].map((value) => + outcome(() => worker.cpuUsage(value as any)) + ).join(","), + ); + console.log( + "rejected:", + [-1, 1.1, {}, [], true, Infinity].map((value) => + outcome(() => worker.cpuUsage(value as any)) + ).join(","), + ); + console.log("terminate:", await worker.terminate()); +}); +worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/diagnostics-hold-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/diagnostics-hold-worker.cjs new file mode 100644 index 0000000000..86dee53f2c --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/diagnostics-hold-worker.cjs @@ -0,0 +1,4 @@ +const { parentPort } = require("node:worker_threads"); + +parentPort.on("message", () => {}); +parentPort.postMessage("ready"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-code.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-code.ts new file mode 100644 index 0000000000..383e886246 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-code.ts @@ -0,0 +1,17 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const shared = new SharedArrayBuffer(4); +const view = new Int32Array(shared); +const events: string[] = []; +const worker = new Worker("./exit-unreachable-worker.cjs", { + workerData: shared, +}); + +worker.on("message", (value) => events.push(`message:${value}`)); +worker.on("error", (error: any) => events.push(`error:${error?.name}`)); +worker.on("exit", (code) => { + console.log("events:", events.join(",") || "none"); + console.log("exit:", code, Atomics.load(view, 0)); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-worker.cjs new file mode 100644 index 0000000000..ea534bc3fd --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/exit-unreachable-worker.cjs @@ -0,0 +1,6 @@ +const { parentPort, workerData } = require("node:worker_threads"); + +const view = new Int32Array(workerData); +process.exit(4); +view[0] = 99; +parentPort.postMessage("unreachable"); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set-worker.cjs new file mode 100644 index 0000000000..75eaf89585 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set-worker.cjs @@ -0,0 +1,24 @@ +const { parentPort, workerData } = require("node:worker_threads"); + +async function main() { + const mapHandle = workerData?.map?.get?.("handle"); + const setHandle = workerData?.set ? Array.from(workerData.set)[0] : undefined; + const result = { + mapBrand: workerData?.map?.constructor?.name, + setBrand: workerData?.set?.constructor?.name, + handleBrand: mapHandle?.constructor?.name, + same: mapHandle === setHandle, + read: "unsupported", + }; + if (typeof mapHandle?.read === "function") { + const buffer = Buffer.alloc(1); + const read = await mapHandle.read(buffer, 0, 1, 0); + result.read = `${read.bytesRead}:${buffer[0]}`; + await mapHandle.close(); + } + parentPort.postMessage(result); +} + +main().catch((error) => { + parentPort.postMessage({ error: `${error?.name}:${error?.code || ""}` }); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set.ts new file mode 100644 index 0000000000..86d82147c7 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/filehandle-map-set.ts @@ -0,0 +1,55 @@ +import { open } from "node:fs/promises"; +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function main() { + const handle = await open("./process-env-option-worker.cjs", "r"); + try { + const worker = new Worker("./filehandle-map-set-worker.cjs", { + workerData: { + map: new Map([["handle", handle]]), + set: new Set([handle]), + }, + transferList: [handle as any], + }); + console.log("construction: ok", handle.fd === -1); + worker.on("message", (value: any) => { + console.log( + "worker:", + value?.mapBrand, + value?.setBrand, + value?.handleBrand, + value?.same, + value?.read, + value?.error ?? "no-error", + ); + }); + worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); + }); + worker.on("exit", async (code) => { + console.log("exit:", code, handle.fd === -1); + await handle.close().catch(() => {}); + }); + } catch (error: any) { + console.log( + "construction:", + error?.name, + error?.code ?? "", + handle.fd === -1, + ); + const buffer = Buffer.alloc(1); + try { + const read = await handle.read(buffer, 0, 1, 0); + console.log("parent readable:", read.bytesRead, buffer[0]); + } catch (readError: any) { + console.log("parent read:", readError?.code ?? readError?.name); + } + await handle.close().catch(() => {}); + } +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-diagnostics.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-diagnostics.ts new file mode 100644 index 0000000000..8b31af7dc2 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/post-exit-diagnostics.ts @@ -0,0 +1,42 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +async function outcome(fn: () => Promise): Promise { + try { + const result = await fn(); + return `ok:${result?.constructor?.name ?? typeof result}`; + } catch (error: any) { + return `${error?.name}:${error?.code ?? ""}`; + } +} + +async function main() { + const worker = new Worker("./natural-exit-worker.cjs"); + const code = await new Promise((resolve) => + worker.once("exit", resolve) + ); + try { + const utilization = worker.performance.eventLoopUtilization(); + console.log( + "utilization:", + Object.keys(utilization).sort().join(","), + [ + typeof utilization.idle, + typeof utilization.active, + typeof utilization.utilization, + ].join(","), + utilization.idle === 0 && utilization.active === 0 && + utilization.utilization === 0, + ); + } catch (error: any) { + console.log("utilization:", error?.name, error?.code ?? ""); + } + console.log("heap:", await outcome(() => worker.getHeapStatistics())); + console.log("cpu:", await outcome(() => worker.cpuUsage())); + console.log("exit:", code); +} + +main().catch((error) => + console.log("unexpected:", error?.name, error?.message) +); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptor-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptor-worker.cjs new file mode 100644 index 0000000000..02be0b1515 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptor-worker.cjs @@ -0,0 +1,24 @@ +const { parentPort } = require("node:worker_threads"); + +function define(label, descriptor) { + const key = `PERRY_WORKER_DESCRIPTOR_${label.toUpperCase().replace("-", "_")}`; + try { + Object.defineProperty(process.env, key, descriptor); + return `${label}:ok:${process.env[key]}:${ + Object.keys(process.env).includes(key) + }`; + } catch (error) { + return `${label}:${error.name}:${error.code || ""}`; + } +} + +parentPort.postMessage([ + define("missing-flags", { value: 42 }), + define("accessor", { get: () => "getter", configurable: true }), + define("valid", { + value: 42, + configurable: true, + writable: true, + enumerable: true, + }), +]); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptors.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptors.ts new file mode 100644 index 0000000000..8375f88ff3 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/process-env-descriptors.ts @@ -0,0 +1,12 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +const worker = new Worker("./process-env-descriptor-worker.cjs"); +worker.on("message", (value: any) => { + console.log("descriptors:", value.join("|")); +}); +worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); +}); +worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-after-exit.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-after-exit.ts new file mode 100644 index 0000000000..eb02f21e20 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-after-exit.ts @@ -0,0 +1,35 @@ +import { Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +let arrayBuffer: any; +let sharedBuffer: any; +let detachedLength: any = "missing"; +const worker = new Worker("./worker-created-buffers-worker.cjs"); + +worker.on("message", (value: any) => { + if ("detachedLength" in value) { + detachedLength = value.detachedLength; + return; + } + arrayBuffer = value.arrayBuffer; + sharedBuffer = value.sharedBuffer; +}); +worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); +}); +worker.on("exit", (code) => { + const arrayValues = arrayBuffer instanceof ArrayBuffer + ? Array.from(new Uint8Array(arrayBuffer)).join(",") + : "missing"; + const sharedValues = sharedBuffer instanceof SharedArrayBuffer + ? Array.from(new Uint8Array(sharedBuffer)).join(",") + : "missing"; + console.log( + "brands:", + arrayBuffer instanceof ArrayBuffer, + sharedBuffer instanceof SharedArrayBuffer, + ); + console.log("values:", arrayValues, sharedValues, detachedLength); + console.log("exit:", code); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-worker.cjs new file mode 100644 index 0000000000..0efd4b6187 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-buffers-worker.cjs @@ -0,0 +1,9 @@ +const { parentPort } = require("node:worker_threads"); + +const arrayBuffer = new ArrayBuffer(4); +new Uint8Array(arrayBuffer).set([1, 2, 3, 4]); +const sharedBuffer = new SharedArrayBuffer(4); +new Uint8Array(sharedBuffer).set([5, 6, 7, 8]); + +parentPort.postMessage({ arrayBuffer, sharedBuffer }, [arrayBuffer]); +parentPort.postMessage({ detachedLength: arrayBuffer.byteLength }); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-after-exit.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-after-exit.ts new file mode 100644 index 0000000000..0a72540c56 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-after-exit.ts @@ -0,0 +1,23 @@ +import { MessagePort, receiveMessageOnPort, Worker } from "node:worker_threads"; + +process.chdir("test-parity/node-suite/worker_threads/worker-lifecycle"); + +let receivedPort: any; +const worker = new Worker("./worker-created-port-worker.cjs"); +worker.on("message", (value: any) => { + receivedPort = value?.port; +}); +worker.on("error", (error: any) => { + console.log("error:", error?.name, error?.code ?? ""); +}); +worker.on("exit", (code) => { + let message: any = "missing"; + try { + message = receiveMessageOnPort(receivedPort)?.message ?? "empty"; + } catch (error: any) { + message = `${error?.name}:${error?.code ?? ""}`; + } + console.log("port:", receivedPort instanceof MessagePort, message); + console.log("exit:", code); + receivedPort?.close?.(); +}); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-worker.cjs b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-worker.cjs new file mode 100644 index 0000000000..adcc8ccb93 --- /dev/null +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/worker-created-port-worker.cjs @@ -0,0 +1,6 @@ +const { MessageChannel, parentPort } = require("node:worker_threads"); + +const { port1, port2 } = new MessageChannel(); +port2.postMessage("queued-before-exit"); +parentPort.postMessage({ port: port1 }, [port1]); +port2.close(); diff --git a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts index e22ef28d71..b8c8063a39 100644 --- a/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts +++ b/test-parity/node-suite/worker_threads/worker-lifecycle/workerdata-builtins.ts @@ -6,8 +6,10 @@ const worker = new Worker("./builtin-data-worker.cjs", { workerData: { date: new Date("2021-02-03T04:05:06.000Z"), map: new Map([["key", 17]]), + set: new Set([2, 3]), regexp: /data/i, bigint: 12345678901234567890n, + error: new TypeError("worker-data-error"), }, }); @@ -16,15 +18,19 @@ worker.on("message", (message: any) => { "brands:", message?.date, message?.map, + message?.set, message?.regexp, message?.bigintType, + message?.error, ); console.log( "values:", message?.dateValue, message?.mapValue, + message?.setValue, message?.regexpValue, message?.bigintValue, + message?.errorValue, ); }); worker.on("exit", (code) => console.log("exit:", code)); diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index c15057559c..b98562e0f7 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -2,12 +2,12 @@ "_schema": { "description": "Floor baseline for scripts/node_suite_regression_check.py. Each module's run must produce pass >= floor.pass; dropping below is a regression (exit 1). Improvements are always accepted and reported as ratchet candidates. Captured in the node-26 environment with scripts/node_suite_run.py (pre-warm + fast/slow lanes).", "oracle": "node v26.5.0 (pinned by .node-version)", - "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2821/3033 (93.0%)." + "note": "Deterministic modules are floored at full pass. Timing/racy modules (http2, net, stream, diagnostics_channel, fs-promises) carry a small margin below observed pass so ordinary flake does not false-alarm; the guard still catches real regressions, which are large (e.g. dns 6->0, http 19->9). node_suite_run.normalize() scrubs environment-variant tokens (console.time hrtime durations, stack-trace frame lines) symmetrically before the stdout compare, so console is floored at full pass (119) on its deterministic content. http is verified 19/19 in isolation but the full-suite harness flakes to 17 under port contention, so it is floored at 17 (flake margin, not a regression); a real http break is a much larger drop. The worker_threads floor was refreshed from a clean focused Node 26.5.0 run; aggregate metadata is recomputed directly from the committed module floors, yielding 2822/3051 (92.5%)." }, "overall": { - "pass": 2821, - "total": 3043, - "pct": 92.7 + "pass": 2822, + "total": 3051, + "pct": 92.5 }, "modules": { "assert": { @@ -215,8 +215,8 @@ "total": 3 }, "worker_threads": { - "pass": 37, - "total": 197 + "pass": 38, + "total": 205 }, "zlib": { "pass": 58,