From 047a1728ce4856bbc8f1e18383ea320c353da1f5 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 16 Jul 2026 16:11:59 +0200 Subject: [PATCH 1/2] test(cluster): expand granular Node parity coverage --- test-parity/node-suite/cluster/README.md | 24 ++++++ test-parity/node-suite/cluster/STATUS.md | 85 +++++++++++++++++++ .../cluster/default-import-shape.ts | 51 +++++++++-- .../cluster/disconnect/no-workers.ts | 8 ++ .../cluster/disconnect/two-workers.ts | 31 +++++++ .../cluster/events/emitter-ordering.ts | 21 +++++ .../node-suite/cluster/exports/descriptors.ts | 24 ++++++ .../cluster/exports/module-surface.ts | 27 ++++++ .../cluster/exports/primary-state.ts | 15 ++++ .../node-suite/cluster/exports/prototypes.ts | 23 +++++ .../cluster/exports/worker-constructor.ts | 31 +++++++ .../node-suite/cluster/fixtures/cwd/.gitkeep | 1 + .../cluster/fork/incrementing-ids.ts | 27 ++++++ .../node-suite/cluster/fork/respawn.ts | 26 ++++++ .../node-suite/cluster/fork/roles-and-env.ts | 22 +++++ .../cluster/fork/shape-and-registry.ts | 28 ++++++ .../cluster/ipc/primary-to-worker.ts | 18 ++++ .../node-suite/cluster/ipc/send-callback.ts | 23 +++++ .../cluster/ipc/worker-to-primary.ts | 24 ++++++ .../cluster/lifecycle/kill-sigterm.ts | 23 +++++ .../cluster/lifecycle/natural-exit.ts | 25 ++++++ .../cluster/lifecycle/online-order.ts | 21 +++++ .../cluster/lifecycle/worker-disconnect.ts | 24 ++++++ .../serialization/advanced-builtins.ts | 33 +++++++ .../cluster/serialization/advanced.ts | 31 +++++++ .../node-suite/cluster/serialization/json.ts | 27 ++++++ .../node-suite/cluster/setup/async-event.ts | 11 +++ .../node-suite/cluster/setup/cumulative.ts | 19 +++++ .../node-suite/cluster/setup/defaults.ts | 12 +++ .../node-suite/cluster/setup/options-shape.ts | 20 +++++ .../cluster/setup/validation-child-options.ts | 17 ++++ .../cluster/setup/validation-exec-args.ts | 13 +++ .../cluster/setup/validation-policy.ts | 10 +++ .../setup/validation-serialization-inspect.ts | 17 ++++ .../node-suite/cluster/setup/worker-cwd.ts | 21 +++++ .../cluster/setup/worker-env-args.ts | 21 +++++ .../cluster/setup/worker-exec-argv.ts | 16 ++++ .../node-suite/cluster/setup/worker-silent.ts | 29 +++++++ .../cluster/tcp/listening-address.ts | 32 +++++++ .../cluster/tcp/request-response.ts | 26 ++++++ .../cluster/workers/disconnected-send.ts | 19 +++++ .../cluster/workers/method-shape.ts | 29 +++++++ .../cluster/workers/process-channel.ts | 31 +++++++ .../cluster/workers/send-validation.ts | 22 +++++ test-parity/node_suite_baseline.json | 4 +- 45 files changed, 1051 insertions(+), 11 deletions(-) create mode 100644 test-parity/node-suite/cluster/README.md create mode 100644 test-parity/node-suite/cluster/STATUS.md create mode 100644 test-parity/node-suite/cluster/disconnect/no-workers.ts create mode 100644 test-parity/node-suite/cluster/disconnect/two-workers.ts create mode 100644 test-parity/node-suite/cluster/events/emitter-ordering.ts create mode 100644 test-parity/node-suite/cluster/exports/descriptors.ts create mode 100644 test-parity/node-suite/cluster/exports/module-surface.ts create mode 100644 test-parity/node-suite/cluster/exports/primary-state.ts create mode 100644 test-parity/node-suite/cluster/exports/prototypes.ts create mode 100644 test-parity/node-suite/cluster/exports/worker-constructor.ts create mode 100644 test-parity/node-suite/cluster/fixtures/cwd/.gitkeep create mode 100644 test-parity/node-suite/cluster/fork/incrementing-ids.ts create mode 100644 test-parity/node-suite/cluster/fork/respawn.ts create mode 100644 test-parity/node-suite/cluster/fork/roles-and-env.ts create mode 100644 test-parity/node-suite/cluster/fork/shape-and-registry.ts create mode 100644 test-parity/node-suite/cluster/ipc/primary-to-worker.ts create mode 100644 test-parity/node-suite/cluster/ipc/send-callback.ts create mode 100644 test-parity/node-suite/cluster/ipc/worker-to-primary.ts create mode 100644 test-parity/node-suite/cluster/lifecycle/kill-sigterm.ts create mode 100644 test-parity/node-suite/cluster/lifecycle/natural-exit.ts create mode 100644 test-parity/node-suite/cluster/lifecycle/online-order.ts create mode 100644 test-parity/node-suite/cluster/lifecycle/worker-disconnect.ts create mode 100644 test-parity/node-suite/cluster/serialization/advanced-builtins.ts create mode 100644 test-parity/node-suite/cluster/serialization/advanced.ts create mode 100644 test-parity/node-suite/cluster/serialization/json.ts create mode 100644 test-parity/node-suite/cluster/setup/async-event.ts create mode 100644 test-parity/node-suite/cluster/setup/cumulative.ts create mode 100644 test-parity/node-suite/cluster/setup/defaults.ts create mode 100644 test-parity/node-suite/cluster/setup/options-shape.ts create mode 100644 test-parity/node-suite/cluster/setup/validation-child-options.ts create mode 100644 test-parity/node-suite/cluster/setup/validation-exec-args.ts create mode 100644 test-parity/node-suite/cluster/setup/validation-policy.ts create mode 100644 test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts create mode 100644 test-parity/node-suite/cluster/setup/worker-cwd.ts create mode 100644 test-parity/node-suite/cluster/setup/worker-env-args.ts create mode 100644 test-parity/node-suite/cluster/setup/worker-exec-argv.ts create mode 100644 test-parity/node-suite/cluster/setup/worker-silent.ts create mode 100644 test-parity/node-suite/cluster/tcp/listening-address.ts create mode 100644 test-parity/node-suite/cluster/tcp/request-response.ts create mode 100644 test-parity/node-suite/cluster/workers/disconnected-send.ts create mode 100644 test-parity/node-suite/cluster/workers/method-shape.ts create mode 100644 test-parity/node-suite/cluster/workers/process-channel.ts create mode 100644 test-parity/node-suite/cluster/workers/send-validation.ts diff --git a/test-parity/node-suite/cluster/README.md b/test-parity/node-suite/cluster/README.md new file mode 100644 index 0000000000..1fd806048a --- /dev/null +++ b/test-parity/node-suite/cluster/README.md @@ -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. diff --git a/test-parity/node-suite/cluster/STATUS.md b/test-parity/node-suite/cluster/STATUS.md new file mode 100644 index 0000000000..e6a4fc0d67 --- /dev/null +++ b/test-parity/node-suite/cluster/STATUS.md @@ -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. diff --git a/test-parity/node-suite/cluster/default-import-shape.ts b/test-parity/node-suite/cluster/default-import-shape.ts index a61d79d429..11eb00fff6 100644 --- a/test-parity/node-suite/cluster/default-import-shape.ts +++ b/test-parity/node-suite/cluster/default-import-shape.ts @@ -12,22 +12,52 @@ const def = clusterDefault as Record; const ns = clusterNamespace as Record; 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. @@ -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(",")); diff --git a/test-parity/node-suite/cluster/disconnect/no-workers.ts b/test-parity/node-suite/cluster/disconnect/no-workers.ts new file mode 100644 index 0000000000..854de4c1a7 --- /dev/null +++ b/test-parity/node-suite/cluster/disconnect/no-workers.ts @@ -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; diff --git a/test-parity/node-suite/cluster/disconnect/two-workers.ts b/test-parity/node-suite/cluster/disconnect/two-workers.ts new file mode 100644 index 0000000000..dada61974e --- /dev/null +++ b/test-parity/node-suite/cluster/disconnect/two-workers.ts @@ -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); + } + }); + } +} diff --git a/test-parity/node-suite/cluster/events/emitter-ordering.ts b/test-parity/node-suite/cluster/events/emitter-ordering.ts new file mode 100644 index 0000000000..432a8756b8 --- /dev/null +++ b/test-parity/node-suite/cluster/events/emitter-ordering.ts @@ -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"), +); diff --git a/test-parity/node-suite/cluster/exports/descriptors.ts b/test-parity/node-suite/cluster/exports/descriptors.ts new file mode 100644 index 0000000000..fa30d3bb94 --- /dev/null +++ b/test-parity/node-suite/cluster/exports/descriptors.ts @@ -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, + ); +} diff --git a/test-parity/node-suite/cluster/exports/module-surface.ts b/test-parity/node-suite/cluster/exports/module-surface.ts new file mode 100644 index 0000000000..e3cc13bd4d --- /dev/null +++ b/test-parity/node-suite/cluster/exports/module-surface.ts @@ -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); diff --git a/test-parity/node-suite/cluster/exports/primary-state.ts b/test-parity/node-suite/cluster/exports/primary-state.ts new file mode 100644 index 0000000000..35f90ca166 --- /dev/null +++ b/test-parity/node-suite/cluster/exports/primary-state.ts @@ -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); diff --git a/test-parity/node-suite/cluster/exports/prototypes.ts b/test-parity/node-suite/cluster/exports/prototypes.ts new file mode 100644 index 0000000000..64a36415c6 --- /dev/null +++ b/test-parity/node-suite/cluster/exports/prototypes.ts @@ -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(","), +); diff --git a/test-parity/node-suite/cluster/exports/worker-constructor.ts b/test-parity/node-suite/cluster/exports/worker-constructor.ts new file mode 100644 index 0000000000..621cea98d4 --- /dev/null +++ b/test-parity/node-suite/cluster/exports/worker-constructor.ts @@ -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(","), +); diff --git a/test-parity/node-suite/cluster/fixtures/cwd/.gitkeep b/test-parity/node-suite/cluster/fixtures/cwd/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/test-parity/node-suite/cluster/fixtures/cwd/.gitkeep @@ -0,0 +1 @@ + diff --git a/test-parity/node-suite/cluster/fork/incrementing-ids.ts b/test-parity/node-suite/cluster/fork/incrementing-ids.ts new file mode 100644 index 0000000000..a01c1bddd4 --- /dev/null +++ b/test-parity/node-suite/cluster/fork/incrementing-ids.ts @@ -0,0 +1,27 @@ +// Worker ids are monotonic and cluster.workers tracks both live workers. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.on("message", () => process.disconnect!()); +} else { + const first = cluster.fork(); + const second = cluster.fork(); + const workers = [first, second]; + const watchdog = setTimeout(() => workers.forEach((w) => w.kill()), 5_000); + watchdog.unref(); + console.log("ids:", first.id, second.id); + console.log("registry:", Object.keys(cluster.workers ?? {}).sort().join(",")); + let online = 0; + let exited = 0; + for (const worker of workers) { + worker.once("online", () => { + if (++online === workers.length) workers.forEach((w) => w.send("stop")); + }); + worker.once("exit", () => { + if (++exited === workers.length) { + clearTimeout(watchdog); + console.log("empty:", Object.keys(cluster.workers ?? {}).length); + } + }); + } +} diff --git a/test-parity/node-suite/cluster/fork/respawn.ts b/test-parity/node-suite/cluster/fork/respawn.ts new file mode 100644 index 0000000000..a9847fd204 --- /dev/null +++ b/test-parity/node-suite/cluster/fork/respawn.ts @@ -0,0 +1,26 @@ +// A replacement worker receives the next id and the dead worker is absent from +// cluster.workers before respawn completes. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.disconnect!(); +} else { + const first = cluster.fork(); + const watchdog = setTimeout(() => { + first.kill(); + Object.values(cluster.workers ?? {}).forEach((worker) => worker?.kill()); + }, 5_000); + watchdog.unref(); + first.once("exit", () => { + console.log("first removed:", cluster.workers?.[first.id] === undefined); + const second = cluster.fork(); + console.log("ids:", first.id, second.id); + second.once("exit", () => { + clearTimeout(watchdog); + console.log( + "second removed:", + cluster.workers?.[second.id] === undefined, + ); + }); + }); +} diff --git a/test-parity/node-suite/cluster/fork/roles-and-env.ts b/test-parity/node-suite/cluster/fork/roles-and-env.ts new file mode 100644 index 0000000000..f54fca6d01 --- /dev/null +++ b/test-parity/node-suite/cluster/fork/roles-and-env.ts @@ -0,0 +1,22 @@ +// Worker-side role flags and NODE_UNIQUE_ID consumption from Node's basic test. +import cluster from "node:cluster"; + +if (cluster.isPrimary) { + const worker = cluster.fork({ CLUSTER_PROBE: "present" }); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("message", (message) => { + console.log(JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} else { + process.send!({ + isPrimary: cluster.isPrimary, + isMaster: cluster.isMaster, + isWorker: cluster.isWorker, + id: cluster.worker?.id, + env: process.env.CLUSTER_PROBE, + uniqueIdRemoved: process.env.NODE_UNIQUE_ID === undefined, + }); +} diff --git a/test-parity/node-suite/cluster/fork/shape-and-registry.ts b/test-parity/node-suite/cluster/fork/shape-and-registry.ts new file mode 100644 index 0000000000..5f34ce5706 --- /dev/null +++ b/test-parity/node-suite/cluster/fork/shape-and-registry.ts @@ -0,0 +1,28 @@ +// Node v26.5.0 primary.fork(): synchronous return shape and registry insertion. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.disconnect!(); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + console.log("worker:", worker instanceof cluster.Worker, worker.id); + console.log( + "process:", + worker.process === (worker as any).process, + typeof worker.process.pid, + ); + console.log("registered:", cluster.workers?.[worker.id] === worker); + console.log( + "initial:", + worker.state, + worker.exitedAfterDisconnect, + worker.isConnected(), + worker.isDead(), + ); + worker.once("exit", () => { + clearTimeout(watchdog); + console.log("removed:", cluster.workers?.[worker.id] === undefined); + }); +} diff --git a/test-parity/node-suite/cluster/ipc/primary-to-worker.ts b/test-parity/node-suite/cluster/ipc/primary-to-worker.ts new file mode 100644 index 0000000000..22bd6a9d30 --- /dev/null +++ b/test-parity/node-suite/cluster/ipc/primary-to-worker.ts @@ -0,0 +1,18 @@ +// Worker.send round trip with a self-contained JSON payload. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.once("message", (message) => process.send!({ echo: message })); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => { + console.log("send boolean:", worker.send({ text: "hello", n: 7 })); + }); + worker.once("message", (message) => { + console.log("reply:", JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/ipc/send-callback.ts b/test-parity/node-suite/cluster/ipc/send-callback.ts new file mode 100644 index 0000000000..8139360eec --- /dev/null +++ b/test-parity/node-suite/cluster/ipc/send-callback.ts @@ -0,0 +1,23 @@ +// The send callback is an explicit completion barrier for IPC queueing. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.once("message", () => process.send!("ack")); +} else { + const worker = cluster.fork(); + const order: string[] = []; + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => { + order.push("online"); + worker.send("ping", (error) => order.push(`callback:${error === null}`)); + }); + worker.once("message", () => { + order.push("message"); + worker.disconnect(); + }); + worker.once("exit", () => { + clearTimeout(watchdog); + console.log(order.join("|")); + }); +} diff --git a/test-parity/node-suite/cluster/ipc/worker-to-primary.ts b/test-parity/node-suite/cluster/ipc/worker-to-primary.ts new file mode 100644 index 0000000000..9571aa008a --- /dev/null +++ b/test-parity/node-suite/cluster/ipc/worker-to-primary.ts @@ -0,0 +1,24 @@ +// Node test-cluster-message.js: process.send reaches both Worker and cluster +// message listeners with the same worker and payload. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.send!({ kind: "ready", values: [1, 2, 3] }); +} else { + const worker = cluster.fork(); + const seen: string[] = []; + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("message", (message) => { + seen.push(`worker:${JSON.stringify(message)}`); + if (seen.length === 2) worker.disconnect(); + }); + cluster.once("message", (value, message) => { + seen.push(`cluster:${value === worker}:${JSON.stringify(message)}`); + if (seen.length === 2) worker.disconnect(); + }); + worker.once("exit", () => { + clearTimeout(watchdog); + console.log(seen.sort().join("|")); + }); +} diff --git a/test-parity/node-suite/cluster/lifecycle/kill-sigterm.ts b/test-parity/node-suite/cluster/lifecycle/kill-sigterm.ts new file mode 100644 index 0000000000..f3d058771a --- /dev/null +++ b/test-parity/node-suite/cluster/lifecycle/kill-sigterm.ts @@ -0,0 +1,23 @@ +// Portable default Worker.kill()/destroy() termination metadata. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + setInterval(() => {}, 1_000); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.process.kill("SIGKILL"), 5_000); + watchdog.unref(); + worker.once("online", () => { + console.log("kill returns:", worker.kill() === undefined); + }); + worker.once("exit", (code, signal) => { + clearTimeout(watchdog); + console.log("exit:", code, signal); + console.log( + "flags:", + worker.exitedAfterDisconnect, + worker.isConnected(), + worker.isDead(), + ); + }); +} diff --git a/test-parity/node-suite/cluster/lifecycle/natural-exit.ts b/test-parity/node-suite/cluster/lifecycle/natural-exit.ts new file mode 100644 index 0000000000..7f29c16398 --- /dev/null +++ b/test-parity/node-suite/cluster/lifecycle/natural-exit.ts @@ -0,0 +1,25 @@ +// A worker's normal exit reports disconnect before exit with stable payloads. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.disconnect!(); +} else { + const events: string[] = []; + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.on("disconnect", () => events.push("worker:disconnect")); + cluster.on( + "disconnect", + (value) => events.push(`cluster:disconnect:${value === worker}`), + ); + worker.on( + "exit", + (code, signal) => events.push(`worker:exit:${code}:${signal}`), + ); + cluster.on("exit", (value, code, signal) => { + events.push(`cluster:exit:${value === worker}:${code}:${signal}`); + clearTimeout(watchdog); + console.log(events.join("|")); + }); +} diff --git a/test-parity/node-suite/cluster/lifecycle/online-order.ts b/test-parity/node-suite/cluster/lifecycle/online-order.ts new file mode 100644 index 0000000000..19f1eb4814 --- /dev/null +++ b/test-parity/node-suite/cluster/lifecycle/online-order.ts @@ -0,0 +1,21 @@ +// Node emits fork on the cluster, then online on the Worker, then on cluster. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.disconnect!(); +} else { + const order: string[] = []; + cluster.once("fork", () => order.push("cluster:fork")); + cluster.once("online", () => order.push("cluster:online")); + const worker = cluster.fork(); + order.push("returned"); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", function () { + order.push(`worker:online:${this === worker}`); + }); + worker.once("exit", () => { + clearTimeout(watchdog); + console.log(order.join("|")); + }); +} diff --git a/test-parity/node-suite/cluster/lifecycle/worker-disconnect.ts b/test-parity/node-suite/cluster/lifecycle/worker-disconnect.ts new file mode 100644 index 0000000000..4e4f8f05aa --- /dev/null +++ b/test-parity/node-suite/cluster/lifecycle/worker-disconnect.ts @@ -0,0 +1,24 @@ +// Worker.disconnect() returns itself, marks intentional disconnect, and closes +// the IPC channel before the process exits. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.on("message", () => {}); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => { + const returned = worker.disconnect(); + console.log("return/self:", returned === worker); + console.log("intentional:", worker.exitedAfterDisconnect); + }); + worker.once( + "disconnect", + () => console.log("connected after:", worker.isConnected()), + ); + worker.once("exit", (code, signal) => { + clearTimeout(watchdog); + console.log("exit/dead:", code, signal, worker.isDead()); + }); +} diff --git a/test-parity/node-suite/cluster/serialization/advanced-builtins.ts b/test-parity/node-suite/cluster/serialization/advanced-builtins.ts new file mode 100644 index 0000000000..0349f1e43f --- /dev/null +++ b/test-parity/node-suite/cluster/serialization/advanced-builtins.ts @@ -0,0 +1,33 @@ +// Additional deterministic advanced-serialization built-ins selected from +// Node's and Bun's cluster suites. +import cluster from "node:cluster"; + +if (cluster.isPrimary) cluster.setupPrimary({ serialization: "advanced" }); +if (cluster.isWorker) { + process.once("message", (message: any) => + process.send!({ + regexp: message.regexp instanceof RegExp && + `${message.regexp.source}/${message.regexp.flags}`, + arrayBuffer: message.arrayBuffer instanceof ArrayBuffer && + Array.from(new Uint8Array(message.arrayBuffer)), + set: message.set instanceof Set && Array.from(message.set), + error: message.error instanceof Error && + [message.error.name, message.error.message], + })); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => + worker.send({ + regexp: /cluster/gi, + arrayBuffer: new Uint8Array([4, 8, 15]).buffer, + set: new Set(["a", "b"]), + error: new TypeError("probe"), + })); + worker.once("message", (message) => { + console.log(JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/serialization/advanced.ts b/test-parity/node-suite/cluster/serialization/advanced.ts new file mode 100644 index 0000000000..825ed4a7dd --- /dev/null +++ b/test-parity/node-suite/cluster/serialization/advanced.ts @@ -0,0 +1,31 @@ +// Node and Bun cluster advanced-serialization selections cover structured +// clone values that JSON cannot preserve. +import cluster from "node:cluster"; + +cluster.setupPrimary({ serialization: "advanced" }); +if (cluster.isWorker) { + process.once("message", (message: any) => { + process.send!({ + date: message.date instanceof Date && message.date.toISOString(), + map: message.map instanceof Map && Array.from(message.map.entries()), + bigint: typeof message.bigint === "bigint" && message.bigint.toString(), + typed: message.typed instanceof Uint16Array && Array.from(message.typed), + }); + }); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => + worker.send({ + date: new Date("2020-01-02T03:04:05.000Z"), + map: new Map([["key", 9]]), + bigint: 12345678901234567890n, + typed: new Uint16Array([2, 500]), + })); + worker.once("message", (message) => { + console.log(JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/serialization/json.ts b/test-parity/node-suite/cluster/serialization/json.ts new file mode 100644 index 0000000000..70837943b1 --- /dev/null +++ b/test-parity/node-suite/cluster/serialization/json.ts @@ -0,0 +1,27 @@ +// Default JSON serialization preserves JSON values and Buffer's toJSON form. +import cluster from "node:cluster"; +import { Buffer } from "node:buffer"; + +if (cluster.isWorker) { + process.once("message", (message: any) => { + process.send!({ + nested: message.nested, + bufferType: message.buffer?.type, + bufferData: message.buffer?.data, + }); + }); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once( + "online", + () => + worker.send({ nested: { ok: true }, buffer: Buffer.from([1, 2, 255]) }), + ); + worker.once("message", (message) => { + console.log(JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/setup/async-event.ts b/test-parity/node-suite/cluster/setup/async-event.ts new file mode 100644 index 0000000000..de3d1850f4 --- /dev/null +++ b/test-parity/node-suite/cluster/setup/async-event.ts @@ -0,0 +1,11 @@ +// Node v26.5.0 setupPrimary emits one asynchronous `setup` event per call, +// carrying the exact settings snapshot from that call. +import cluster from "node:cluster"; + +const seen: string[] = []; +cluster.on("setup", (settings) => seen.push(`event:${settings.exec}`)); +cluster.setupPrimary({ exec: "one" }); +seen.push(`sync:${cluster.settings.exec}`); +cluster.setupMaster({ exec: "two" }); +seen.push(`sync:${cluster.settings.exec}`); +setImmediate(() => console.log(seen.join("|"))); diff --git a/test-parity/node-suite/cluster/setup/cumulative.ts b/test-parity/node-suite/cluster/setup/cumulative.ts new file mode 100644 index 0000000000..9327805ffc --- /dev/null +++ b/test-parity/node-suite/cluster/setup/cumulative.ts @@ -0,0 +1,19 @@ +// Node setupPrimary creates a fresh settings snapshot while preserving prior +// keys not overridden by later calls. +import cluster from "node:cluster"; + +cluster.setupPrimary({ exec: "first", args: ["a"], silent: true }); +const first = cluster.settings; +cluster.setupPrimary({ args: ["b", "c"], serialization: "advanced" }); +const second = cluster.settings; +console.log("replaced:", first !== second); +console.log("preserved:", second.exec, second.silent); +console.log("overridden:", JSON.stringify(second.args)); +console.log("added:", second.serialization); +cluster.setupPrimary(); +console.log( + "no-option preserves:", + cluster.settings.exec, + JSON.stringify(cluster.settings.args), + cluster.settings.serialization, +); diff --git a/test-parity/node-suite/cluster/setup/defaults.ts b/test-parity/node-suite/cluster/setup/defaults.ts new file mode 100644 index 0000000000..48272838df --- /dev/null +++ b/test-parity/node-suite/cluster/setup/defaults.ts @@ -0,0 +1,12 @@ +// Node v26.5.0 test-cluster-setup-primary-cumulative.js: setupPrimary fills +// exactly the four default settings when no options were previously supplied. +import cluster from "node:cluster"; + +console.log("before:", Object.keys(cluster.settings).join(",")); +const returned = cluster.setupPrimary(); +console.log("returns undefined:", returned === undefined); +console.log("keys:", Object.keys(cluster.settings).sort().join(",")); +console.log("args array:", Array.isArray(cluster.settings.args)); +console.log("exec string:", typeof cluster.settings.exec); +console.log("execArgv array:", Array.isArray(cluster.settings.execArgv)); +console.log("silent:", cluster.settings.silent); diff --git a/test-parity/node-suite/cluster/setup/options-shape.ts b/test-parity/node-suite/cluster/setup/options-shape.ts new file mode 100644 index 0000000000..a825d76bc8 --- /dev/null +++ b/test-parity/node-suite/cluster/setup/options-shape.ts @@ -0,0 +1,20 @@ +// Observable setup settings cover the child_process.fork options forwarded by +// Node primary.js without actually launching a worker. +import cluster from "node:cluster"; + +const options: any = { + exec: "fixture.js", + args: ["one", "two"], + execArgv: ["--no-warnings"], + cwd: ".", + silent: true, + serialization: "advanced", + inspectPort: 0, + windowsHide: true, + env: { LOCAL: "yes" }, +}; +cluster.setupPrimary(options); +for (const key of Object.keys(options).sort()) { + const value = (cluster.settings as any)[key]; + console.log(key, typeof value, Array.isArray(value), value === options[key]); +} diff --git a/test-parity/node-suite/cluster/setup/validation-child-options.ts b/test-parity/node-suite/cluster/setup/validation-child-options.ts new file mode 100644 index 0000000000..c599d7e367 --- /dev/null +++ b/test-parity/node-suite/cluster/setup/validation-child-options.ts @@ -0,0 +1,17 @@ +// Synchronous child_process option validation reached through cluster.fork. +import cluster from "node:cluster"; + +for ( + const [name, value] of [["cwd", 1], ["stdio", "bad"], ["uid", "bad"], [ + "gid", + "bad", + ]] as const +) { + cluster.setupPrimary({ [name]: value } as any); + try { + cluster.fork(); + console.log(name, "accepted"); + } catch (error: any) { + console.log(name, error.name, error.code); + } +} diff --git a/test-parity/node-suite/cluster/setup/validation-exec-args.ts b/test-parity/node-suite/cluster/setup/validation-exec-args.ts new file mode 100644 index 0000000000..37e0ba0fec --- /dev/null +++ b/test-parity/node-suite/cluster/setup/validation-exec-args.ts @@ -0,0 +1,13 @@ +// Node primary.js delegates exec/args validation to child_process.fork before +// creating a child, so these failures are synchronous and leak no process. +import cluster from "node:cluster"; + +for (const [name, value] of [["exec", 1], ["args", 1]] as const) { + cluster.setupPrimary({ [name]: value } as any); + try { + cluster.fork(); + console.log(name, "accepted"); + } catch (error: any) { + console.log(name, error.name, error.code); + } +} diff --git a/test-parity/node-suite/cluster/setup/validation-policy.ts b/test-parity/node-suite/cluster/setup/validation-policy.ts new file mode 100644 index 0000000000..c1f464b3a5 --- /dev/null +++ b/test-parity/node-suite/cluster/setup/validation-policy.ts @@ -0,0 +1,10 @@ +// Node freezes and validates schedulingPolicy on the first setupPrimary call. +import cluster from "node:cluster"; + +cluster.schedulingPolicy = 99 as any; +try { + cluster.setupPrimary(); + console.log("accepted"); +} catch (error: any) { + console.log(error.name, error.code); +} diff --git a/test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts b/test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts new file mode 100644 index 0000000000..1abb12a4ca --- /dev/null +++ b/test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts @@ -0,0 +1,17 @@ +// Node validates serialization mode and a null inspect port before spawn. +import cluster from "node:cluster"; + +for ( + const [name, options] of [ + ["serialization", { serialization: "bad" }], + ["inspectPort", { inspectPort: null }], + ] as const +) { + cluster.setupPrimary(options as any); + try { + cluster.fork(); + console.log(name, "accepted"); + } catch (error: any) { + console.log(name, error.name, error.code); + } +} diff --git a/test-parity/node-suite/cluster/setup/worker-cwd.ts b/test-parity/node-suite/cluster/setup/worker-cwd.ts new file mode 100644 index 0000000000..32ce8ba4d6 --- /dev/null +++ b/test-parity/node-suite/cluster/setup/worker-cwd.ts @@ -0,0 +1,21 @@ +// Node test-cluster-cwd.js with a repository-controlled working directory. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.send!({ + suffix: process.cwd().endsWith( + "test-parity/node-suite/cluster/fixtures/cwd", + ), + absolute: process.cwd().startsWith("/"), + }); +} else { + cluster.setupPrimary({ cwd: "test-parity/node-suite/cluster/fixtures/cwd" }); + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("message", (message) => { + console.log(JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/setup/worker-env-args.ts b/test-parity/node-suite/cluster/setup/worker-env-args.ts new file mode 100644 index 0000000000..242825738d --- /dev/null +++ b/test-parity/node-suite/cluster/setup/worker-env-args.ts @@ -0,0 +1,21 @@ +// setupPrimary args plus per-fork env overlays are observable in the worker. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.send!({ + args: process.argv.slice(2), + shared: process.env.SHARED, + local: process.env.LOCAL, + }); +} else { + process.env.SHARED = "parent"; + cluster.setupPrimary({ args: ["alpha", "beta"] }); + const worker = cluster.fork({ LOCAL: "fork" }); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("message", (message) => { + console.log(JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/setup/worker-exec-argv.ts b/test-parity/node-suite/cluster/setup/worker-exec-argv.ts new file mode 100644 index 0000000000..7bdfb78b4d --- /dev/null +++ b/test-parity/node-suite/cluster/setup/worker-exec-argv.ts @@ -0,0 +1,16 @@ +// setupPrimary execArgv is forwarded independently from ordinary worker args. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.send!({ execArgv: process.execArgv, args: process.argv.slice(2) }); +} else { + cluster.setupPrimary({ execArgv: ["--no-warnings"], args: ["ordinary"] }); + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("message", (message) => { + console.log(JSON.stringify(message)); + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/setup/worker-silent.ts b/test-parity/node-suite/cluster/setup/worker-silent.ts new file mode 100644 index 0000000000..43ebd6334d --- /dev/null +++ b/test-parity/node-suite/cluster/setup/worker-silent.ts @@ -0,0 +1,29 @@ +// silent:true pipes worker stdio; the write callback plus data event form an +// explicit completion barrier before disconnect. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.stdout.write("worker-output\n", () => process.send!("written")); +} else { + cluster.setupPrimary({ silent: true }); + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + let data = ""; + let written = false; + (worker.process.stdout as any).setEncoding("utf8"); + worker.process.stdout!.on("data", (chunk) => { + data += chunk; + finish(); + }); + worker.once("message", () => { + written = true; + finish(); + }); + function finish() { + if (!written || !data.includes("worker-output")) return; + console.log("piped:", worker.process.stdout !== null, data.trim()); + worker.disconnect(); + } + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/tcp/listening-address.ts b/test-parity/node-suite/cluster/tcp/listening-address.ts new file mode 100644 index 0000000000..86f2f78b30 --- /dev/null +++ b/test-parity/node-suite/cluster/tcp/listening-address.ts @@ -0,0 +1,32 @@ +// A single worker listening on port 0 emits a normalized address descriptor. +import cluster from "node:cluster"; +import { createServer } from "node:net"; + +if (cluster.isWorker) { + createServer((socket) => socket.end()).listen(0, "127.0.0.1"); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("listening", (address) => { + console.log( + "worker address:", + address.address, + address.addressType, + Number.isInteger(address.port), + address.port > 0, + address.fd, + ); + worker.disconnect(); + }); + cluster.once("listening", (value, address) => { + console.log( + "cluster address:", + value === worker, + address.address, + address.addressType, + address.port > 0, + ); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/tcp/request-response.ts b/test-parity/node-suite/cluster/tcp/request-response.ts new file mode 100644 index 0000000000..1cc62f1938 --- /dev/null +++ b/test-parity/node-suite/cluster/tcp/request-response.ts @@ -0,0 +1,26 @@ +// End-to-end ephemeral TCP sharing foundation with socket/server completion +// barriers before graceful worker disconnect. +import cluster from "node:cluster"; +import { connect, createServer } from "node:net"; + +if (cluster.isWorker) { + createServer((socket) => socket.end("cluster-ok")).listen(0, "127.0.0.1"); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("listening", (address) => { + const socket = connect(address.port, "127.0.0.1"); + let data = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => data += chunk); + socket.once("end", () => { + console.log("response:", data); + worker.disconnect(); + }); + }); + worker.once("exit", (code, signal) => { + clearTimeout(watchdog); + console.log("exit:", code, signal); + }); +} diff --git a/test-parity/node-suite/cluster/workers/disconnected-send.ts b/test-parity/node-suite/cluster/workers/disconnected-send.ts new file mode 100644 index 0000000000..68ea1b7d0e --- /dev/null +++ b/test-parity/node-suite/cluster/workers/disconnected-send.ts @@ -0,0 +1,19 @@ +// After the disconnect event, send returns false and reports the closed-channel +// error through its callback instead of throwing or leaking the worker. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.on("message", () => {}); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => worker.disconnect()); + worker.once("disconnect", () => { + const returned = worker.send("late", (error: any) => { + console.log("callback:", error?.name, error?.code); + }); + console.log("returned:", returned); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/workers/method-shape.ts b/test-parity/node-suite/cluster/workers/method-shape.ts new file mode 100644 index 0000000000..e815f779f1 --- /dev/null +++ b/test-parity/node-suite/cluster/workers/method-shape.ts @@ -0,0 +1,29 @@ +// Real primary Worker method ownership, arity, and aliases. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.disconnect!(); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + for ( + const name of [ + "send", + "kill", + "destroy", + "disconnect", + "isConnected", + "isDead", + ] as const + ) { + console.log( + name, + typeof (worker as any)[name], + (worker as any)[name]?.length, + Object.hasOwn(worker, name), + ); + } + console.log("kill/destroy same:", worker.kill === worker.destroy); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/workers/process-channel.ts b/test-parity/node-suite/cluster/workers/process-channel.ts new file mode 100644 index 0000000000..4302d1905d --- /dev/null +++ b/test-parity/node-suite/cluster/workers/process-channel.ts @@ -0,0 +1,31 @@ +// The child process and IPC channel expose deterministic connection and +// ref/unref controls while the worker is online. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.on("message", () => process.disconnect!()); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => { + const channel = worker.process.channel as any; + console.log( + "process:", + worker.process.connected, + typeof worker.process.send, + typeof worker.process.disconnect, + typeof worker.process.kill, + ); + console.log( + "channel:", + channel !== null, + typeof channel?.ref, + typeof channel?.unref, + ); + channel?.unref(); + channel?.ref(); + worker.send("stop"); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node-suite/cluster/workers/send-validation.ts b/test-parity/node-suite/cluster/workers/send-validation.ts new file mode 100644 index 0000000000..058efd6bd2 --- /dev/null +++ b/test-parity/node-suite/cluster/workers/send-validation.ts @@ -0,0 +1,22 @@ +// Node Worker.send delegates to ChildProcess.send argument validation. +import cluster from "node:cluster"; + +if (cluster.isWorker) { + process.on("message", () => {}); +} else { + const worker = cluster.fork(); + const watchdog = setTimeout(() => worker.kill(), 5_000); + watchdog.unref(); + worker.once("online", () => { + for (const value of [undefined, Symbol("probe"), () => {}]) { + try { + worker.send(value as any); + console.log(typeof value, "accepted"); + } catch (error: any) { + console.log(typeof value, error.name, error.code); + } + } + worker.disconnect(); + }); + worker.once("exit", () => clearTimeout(watchdog)); +} diff --git a/test-parity/node_suite_baseline.json b/test-parity/node_suite_baseline.json index 15e974f4c1..a3c4123ccb 100644 --- a/test-parity/node_suite_baseline.json +++ b/test-parity/node_suite_baseline.json @@ -31,8 +31,8 @@ "total": 26 }, "cluster": { - "pass": 1, - "total": 1 + "pass": 15, + "total": 41 }, "console": { "pass": 119, From ee36743c2a21291d5c397cee9497f6546b0e9dc3 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 18 Jul 2026 03:35:39 -0700 Subject: [PATCH 2/2] test(cluster): reset setupPrimary defaults per case so each validates its own field Address CodeRabbit: setupPrimary merges into cluster.settings, so an invalid field from a prior iteration leaked into the next and masked its validation. Restore valid exec/args and serialization/inspectPort defaults each iteration. --- test-parity/node-suite/cluster/setup/validation-exec-args.ts | 5 ++++- .../cluster/setup/validation-serialization-inspect.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/test-parity/node-suite/cluster/setup/validation-exec-args.ts b/test-parity/node-suite/cluster/setup/validation-exec-args.ts index 37e0ba0fec..0676018f2e 100644 --- a/test-parity/node-suite/cluster/setup/validation-exec-args.ts +++ b/test-parity/node-suite/cluster/setup/validation-exec-args.ts @@ -3,7 +3,10 @@ import cluster from "node:cluster"; for (const [name, value] of [["exec", 1], ["args", 1]] as const) { - cluster.setupPrimary({ [name]: value } as any); + // setupPrimary merges into cluster.settings, so restore valid defaults for both + // fields each iteration and override only the current one with the invalid value; + // otherwise the previous iteration's bad field leaks in and masks this case. + cluster.setupPrimary({ exec: process.argv[1], args: [], [name]: value } as any); try { cluster.fork(); console.log(name, "accepted"); diff --git a/test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts b/test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts index 1abb12a4ca..015d18b589 100644 --- a/test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts +++ b/test-parity/node-suite/cluster/setup/validation-serialization-inspect.ts @@ -7,7 +7,10 @@ for ( ["inspectPort", { inspectPort: null }], ] as const ) { - cluster.setupPrimary(options as any); + // setupPrimary merges into cluster.settings, so restore valid defaults each + // iteration and spread the invalid field last; otherwise the previous case's bad + // value (e.g. serialization: "bad") leaks in and masks this case's validation. + cluster.setupPrimary({ serialization: "json", inspectPort: undefined, ...options } as any); try { cluster.fork(); console.log(name, "accepted");