Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions test-parity/node-suite/cluster/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# node:cluster granular parity suite

Deterministic differential cases for Perry's `node:cluster` compatibility layer.
The module is in `scripts/node_suite_run.py`'s sequential slow lane.

The suite uses self-contained primary/worker programs, ephemeral TCP ports,
event or IPC completion barriers, unref'ed watchdogs, and cleanup on every
terminal path. It does not depend on the historical broad parity files.

## Coverage

- module/default export identity, role flags, constants, descriptors, and
EventEmitter/Worker prototype shape;
- setup defaults, cumulative snapshots, aliases, setup events, fork option
forwarding, and synchronous validation;
- fork registry/id/respawn behavior and primary/worker role state;
- worker online/message/disconnect/exit ordering, payloads, methods, channel,
send callbacks/errors, kill metadata, and cluster-wide disconnect;
- JSON and advanced IPC serialization;
- single-worker TCP listening descriptors and request/response behavior on
port 0.

See [STATUS.md](./STATUS.md) for source provenance, measured gaps, and the
categories intentionally stopped before flaky or infrastructure-heavy tests.
85 changes: 85 additions & 0 deletions test-parity/node-suite/cluster/STATUS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# node:cluster source audit and stopping record

## Primary-source snapshot

Audited on 2026-07-16:

- Node.js v26.5.0 commit
[`1e320ec`](https://github.com/nodejs/node/tree/v26.5.0/test), including 83
`test/parallel/test-cluster-*.js`, five sequential cluster tests, the known
inspector-port-clash case, `lib/internal/cluster/*`, and the API docs.
- Deno main commit
[`803a3c9`](https://github.com/denoland/deno/tree/803a3c933e1e23e0972445293ec0b34b8da96ccc),
especially `tests/unit_node/cluster_test.ts` and
`ext/node/polyfills/internal/cluster/*`. Deno's selected unit test currently
checks the primary export surface; its implementation supplies additional
lifecycle comparison evidence.
- Bun main commit
[`aca54d5`](https://github.com/oven-sh/bun/tree/aca54d5c2b874ac304a3bbe1d67630e4daf17b43),
with 54 imported Node cluster tests plus three cluster-specific TypeScript
selections. Bun's own cases add advanced structured-clone and Worker
disconnect evidence.

The Node tag is the output oracle. Deno and Bun are comparison selections, not
substitute expected-output sources.

## Measured coverage

- 41 granular TypeScript fixtures (40 added over the original one).
- Node v26.5.0: 41/41 complete successfully in three consecutive direct rounds.
- Deno 2.9.2 local comparison: 41/41 complete successfully.
- Bun 1.2.18 local comparison: 40/41 complete successfully; its older local
release fails the `ChildProcess.channel` ref/unref probe. Current Bun source,
rather than this older binary, was used for selection evidence.
- Perry differential result: stable 15/41 (36.6%), with 25 behavioral
differences and one runtime timeout.

## Stable diagnostic boundaries

The granular cases intentionally report, rather than repair, mismatches in areas
such as Worker construction/prototypes, setup aliases/events, empty disconnect
timing, fork/event ordering, worker/cluster message forwarding, worker state and
exit payloads, option validation, and TCP listening.

## Stopping exclusions

The remaining upstream cases were reviewed and stopped in these categories:

- **Blocked by the single-worker TCP result:** round-robin versus shared
scheduling, multi-worker connection distribution, server restart, backlog,
pipe handles, socket transfer, and shared-handle races. Adding scheduler
assertions before basic listening/request-response parity would obscure the
primary gap.
- **UDP foundation / duplicate semantics:** dgram sharing, reuse, fd binding,
IPv6-only and unshared-UDP disconnect cases. These belong after TCP cluster
lifecycle is reliable and otherwise repeat the granular `node:dgram` suite.
- **Platform or privilege dependent:** Windows named pipes/quoting and
`windowsHide`, UID/GID execution, privileged ports, EACCES/EADDRINUSE text,
Unix-domain relative paths, and platform-specific signal behavior.
- **Inspector/tooling:** inspect/debug port allocation, preload/profiling,
coverage, and inspector port clashes. Only deterministic `inspectPort: null`
validation is retained.
- **Stress/resource pressure:** large IPC payloads, send deadlocks, infinite
loops, EMFILE/accept failure, leak probes, crash loops, and timing races.
- **Redundant foundation coverage:** generic child-process stdio/error/kill
behavior, generic net server options, HTTP/TLS ticket behavior, and raw dgram
semantics already have dedicated granular modules. Cluster cases are kept only
where worker coordination changes the contract.

No fixture uses a hard-coded port, exact PID, absolute repository path, internet
access, arbitrary readiness sleep, or scheduler-dependent worker ordering.

## Final verification

- `deno fmt --check test-parity/node-suite/cluster` — 43 files checked.
- Three direct Node v26.5.0 rounds — 41/41 exited successfully each round.
- Two consecutive warmed focused differential rounds — 15/41 each, 25 behavioral
differences and one Perry runtime timeout; no Node failures or compile/link
failures.
- A per-fixture diagnostic run reproduced the same 15 pass, 25 difference, one
runtime-timeout partition and identified the timeout as invalid
serialization/inspect-port validation.

Only the measured cluster module floor was changed in
`node_suite_baseline.json`; its historical full-suite aggregate remains the last
full-suite snapshot rather than an unmeasured extrapolation.
51 changes: 42 additions & 9 deletions test-parity/node-suite/cluster/default-import-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,52 @@ const def = clusterDefault as Record<string, any>;
const ns = clusterNamespace as Record<string, any>;

console.log("typeof default:", typeof clusterDefault);
console.log("isPrimary:", def.isPrimary, "isMaster:", def.isMaster, "isWorker:", def.isWorker);
console.log("schedulingPolicy:", def.schedulingPolicy, "SCHED_RR:", def.SCHED_RR, "SCHED_NONE:", def.SCHED_NONE);
console.log(
"isPrimary:",
def.isPrimary,
"isMaster:",
def.isMaster,
"isWorker:",
def.isWorker,
);
console.log(
"schedulingPolicy:",
def.schedulingPolicy,
"SCHED_RR:",
def.SCHED_RR,
"SCHED_NONE:",
def.SCHED_NONE,
);

// Default import exposes the EventEmitter surface; the namespace import does not.
for (const name of [
"fork", "disconnect", "setupPrimary", "setupMaster", "Worker",
"on", "addListener", "once", "emit", "eventNames", "listenerCount",
]) {
for (
const name of [
"fork",
"disconnect",
"setupPrimary",
"setupMaster",
"Worker",
"on",
"addListener",
"once",
"emit",
"eventNames",
"listenerCount",
]
) {
console.log(
`default ${name}: ${typeof def[name]}/${def[name]?.length} | namespace ${name}: ${typeof ns[name]}`,
`default ${name}: ${typeof def[name]}/${
def[name]?.length
} | namespace ${name}: ${typeof ns[name]}`,
);
}

// setupPrimary() updates cluster.settings with Node-compatible keys.
def.setupPrimary({ execArgv: ["--probe-flag"] });
console.log("settings.execArgv isArray:", Array.isArray(def.settings?.execArgv));
console.log(
"settings.execArgv isArray:",
Array.isArray(def.settings?.execArgv),
);
console.log("settings.execArgv:", JSON.stringify(def.settings?.execArgv));

// Default-import EventEmitter registration / bookkeeping.
Expand All @@ -44,6 +74,9 @@ console.log("fired:", fired);
def.removeListener("custom", listener);
console.log("listenerCount after remove:", def.listenerCount("custom"));
def.once("once-evt", () => {});
console.log("eventNames before removeAll:", def.eventNames().map(String).sort().join(","));
console.log(
"eventNames before removeAll:",
def.eventNames().map(String).sort().join(","),
);
def.removeAllListeners();
console.log("eventNames after removeAll:", def.eventNames().join(","));
8 changes: 8 additions & 0 deletions test-parity/node-suite/cluster/disconnect/no-workers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Node v26.5.0 test-cluster-disconnect-with-no-workers.js: completion is
// deferred even when there is nothing to disconnect.
import cluster from "node:cluster";

let sync = true;
const returned = cluster.disconnect(() => console.log("callback sync:", sync));
console.log("returns undefined:", returned === undefined);
sync = false;
31 changes: 31 additions & 0 deletions test-parity/node-suite/cluster/disconnect/two-workers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// cluster.disconnect waits for every live worker and calls its callback once.
import cluster from "node:cluster";

if (cluster.isWorker) {
process.on("message", () => {});
} else {
const workers = [cluster.fork(), cluster.fork()];
const watchdog = setTimeout(
() => workers.forEach((worker) => worker.kill()),
5_000,
);
watchdog.unref();
let online = 0;
let exits = 0;
for (const worker of workers) {
worker.once("online", () => {
if (++online === workers.length) {
console.log(
"disconnect return:",
cluster.disconnect(() => console.log("callback")) === undefined,
);
}
});
worker.once("exit", () => {
if (++exits === workers.length) {
clearTimeout(watchdog);
console.log("all exited:", Object.keys(cluster.workers ?? {}).length);
}
});
}
}
21 changes: 21 additions & 0 deletions test-parity/node-suite/cluster/events/emitter-ordering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// EventEmitter ordering/once/removal contracts on the cluster singleton.
import cluster from "node:cluster";

const order: string[] = [];
const regular = () => order.push("on");
cluster.on("probe", regular);
cluster.prependListener("probe", () => order.push("prepend"));
cluster.once("probe", () => order.push("once"));
cluster.prependOnceListener("probe", () => order.push("prepend-once"));
console.log("first:", cluster.emit("probe"), order.join(","));
order.length = 0;
console.log("second:", cluster.emit("probe"), order.join(","));
console.log("count:", cluster.listenerCount("probe"));
console.log("off returns self:", cluster.off("probe", regular) === cluster);
console.log("after off:", cluster.listenerCount("probe"));
cluster.removeAllListeners("probe");
console.log(
"after all:",
cluster.emit("probe"),
cluster.listenerCount("probe"),
);
24 changes: 24 additions & 0 deletions test-parity/node-suite/cluster/exports/descriptors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Node exposes primary state as ordinary writable/enumerable/configurable
// own data properties, not accessors.
import cluster from "node:cluster";

for (
const name of [
"isPrimary",
"isMaster",
"isWorker",
"schedulingPolicy",
"settings",
"workers",
]
) {
const d = Object.getOwnPropertyDescriptor(cluster, name)!;
console.log(
name,
!!d,
"value" in d,
d.writable,
d.enumerable,
d.configurable,
);
}
27 changes: 27 additions & 0 deletions test-parity/node-suite/cluster/exports/module-surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Node v26.5.0 lib/internal/cluster/primary.js and Deno's cluster_test.ts:
// primary exports and their aliases are stable singleton values.
import cluster from "node:cluster";
import * as namespace from "node:cluster";

const names = [
"Worker",
"disconnect",
"fork",
"setupMaster",
"setupPrimary",
"SCHED_NONE",
"SCHED_RR",
"isMaster",
"isPrimary",
"isWorker",
"schedulingPolicy",
"settings",
"workers",
];

for (const name of names) {
const value = (cluster as any)[name];
console.log(name, typeof value, value === (namespace as any)[name]);
}
console.log("setup alias:", cluster.setupPrimary === cluster.setupMaster);
console.log("default singleton:", namespace.default === cluster);
15 changes: 15 additions & 0 deletions test-parity/node-suite/cluster/exports/primary-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Node v26.5.0 primary.js constants and initial mutable registries.
import cluster from "node:cluster";

console.log("roles:", cluster.isPrimary, cluster.isMaster, cluster.isWorker);
console.log("constants:", cluster.SCHED_NONE, cluster.SCHED_RR);
console.log(
"policy is valid:",
[cluster.SCHED_NONE, cluster.SCHED_RR].includes(cluster.schedulingPolicy),
);
console.log("settings keys:", Object.keys(cluster.settings).sort().join(","));
console.log(
"workers keys:",
Object.keys(cluster.workers ?? {}).sort().join(","),
);
console.log("worker absent:", (cluster as any).worker === undefined);
23 changes: 23 additions & 0 deletions test-parity/node-suite/cluster/exports/prototypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Node v26.5.0 Worker is an EventEmitter subclass and its seven public
// prototype entries are supplied by internal/cluster/worker.js + primary.js.
import cluster from "node:cluster";
import { EventEmitter } from "node:events";

console.log("cluster EventEmitter:", cluster instanceof EventEmitter);
console.log(
"cluster prototype:",
Object.getPrototypeOf(cluster) === EventEmitter.prototype,
);
console.log(
"Worker typeof/length:",
typeof cluster.Worker,
cluster.Worker.length,
);
console.log(
"Worker extends EventEmitter:",
Object.getPrototypeOf(cluster.Worker.prototype) === EventEmitter.prototype,
);
console.log(
"Worker methods:",
Object.getOwnPropertyNames(cluster.Worker.prototype).sort().join(","),
);
31 changes: 31 additions & 0 deletions test-parity/node-suite/cluster/exports/worker-constructor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Adapted from Node v26.5.0 test-cluster-worker-constructor.js.
import cluster from "node:cluster";
import { EventEmitter } from "node:events";

const empty = new cluster.Worker();
console.log(
"empty:",
empty.id,
empty.state,
empty.process,
empty.exitedAfterDisconnect,
);
console.log(
"empty emitter:",
empty instanceof EventEmitter,
empty instanceof cluster.Worker,
);

const supplied = new cluster.Worker({ id: 3, state: "online", process } as any);
console.log(
"supplied:",
supplied.id,
supplied.state,
supplied.process === process,
);
console.log(
"methods:",
["send", "kill", "destroy", "disconnect", "isConnected", "isDead"].map((x) =>
typeof (supplied as any)[x]
).join(","),
);
1 change: 1 addition & 0 deletions test-parity/node-suite/cluster/fixtures/cwd/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading
Loading