Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ea88a97
test: expand worker_threads parity coverage
TheHypnoo Jul 16, 2026
1497eb5
test: deepen worker_threads parity coverage
TheHypnoo Jul 16, 2026
c8c7d60
test: broaden worker_threads compatibility matrix
TheHypnoo Jul 16, 2026
b8f3209
test: extend worker_threads deterministic contracts
TheHypnoo Jul 16, 2026
430f48e
test: cover advanced worker_threads event contracts
TheHypnoo Jul 16, 2026
d27c5be
test: extend worker_threads event edge coverage
TheHypnoo Jul 16, 2026
1c02e82
test: guarantee worker port cleanup on errors
TheHypnoo Jul 16, 2026
ac13f7d
test: cover worker_threads receiver and clone edges
TheHypnoo Jul 16, 2026
2f9a964
test: extend MessagePort validation coverage
TheHypnoo Jul 16, 2026
0986eaa
test: cover worker_threads listener control contracts
TheHypnoo Jul 16, 2026
ba65cd1
test: cover worker_threads inheritance brands
TheHypnoo Jul 16, 2026
d4133a9
test: cover MessagePort closing transfer state
TheHypnoo Jul 16, 2026
7786ffe
test: cover BroadcastChannel coercion edges
TheHypnoo Jul 16, 2026
ddb735a
test: validate missing worker message arguments
TheHypnoo Jul 16, 2026
67a3da0
test: deepen worker clone and option coverage
TheHypnoo Jul 16, 2026
5217fb9
test: cover worker port graph cloning
TheHypnoo Jul 16, 2026
45ce1ed
test: harden worker port graph barriers
TheHypnoo Jul 16, 2026
9873b3a
test: cover worker disposal and messaging edges
TheHypnoo Jul 16, 2026
1301b19
test: cover worker construction and realm boundaries
TheHypnoo Jul 16, 2026
22ad0ae
test: cover worker listener and metadata contracts
TheHypnoo Jul 16, 2026
3e871e5
test: cover worker file handle ownership
TheHypnoo Jul 16, 2026
f1364a1
test: cover worker environment tree isolation
TheHypnoo Jul 16, 2026
42d4f53
test: cover worker validation and handle ownership
TheHypnoo Jul 16, 2026
389d8c1
test: cover worker reverse ownership and diagnostics
TheHypnoo Jul 16, 2026
87ceb7d
Merge remote-tracking branch 'origin/main' into test/expand-node-work…
Jul 18, 2026
47d286b
Merge branch 'main' into test/expand-node-worker-threads-parity
TheHypnoo Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions test-parity/node-suite/worker_threads/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,159 @@

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).

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, 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, 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, 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, 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 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.
- `direct-message/`: delivery, no-listener and timeout 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
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 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 `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`,
`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 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.
- 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.
- 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:

```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 `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
`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`.
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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 ?? "");
}
Original file line number Diff line number Diff line change
@@ -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 ?? "");
}
}
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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();
26 changes: 26 additions & 0 deletions test-parity/node-suite/worker_threads/direct-message/validation.ts
Original file line number Diff line number Diff line change
@@ -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)
);
Loading
Loading