Files
scriptc/tests/harness/windows-differential.test.ts
T
Chris Tate 13718c7ac1 Preserve native class state and handle terminal process events (#571)
- Preserve fields, accessors, added properties, and cyclic references across untyped class boundaries.
- Support platform signal names and native exception handlers that resume queued work.
2026-09-29 09:09:34 -05:00

820 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* The Windows differential lane — env-gated (SCRIPTC_WIN=1), skipped
* everywhere else so the macOS lanes never see it. The workflow: corpus
* programs are CROSS-COMPILED on the host via `zig cc` (SCRIPTC_CC=zigcc,
* SCRIPTC_TARGET=x86_64-windows-gnu), the .exe and the program source ship
* to the Windows box over scp, and both sides run THERE over ssh — the
* binary, and the box's own WINDOWS Node as the oracle (Node on Windows is
* what a Windows user would compare against; the box pins the same
* .node-version as the macOS lanes). Same contract as differential.test.ts:
* stdout byte-equal, stderr byte-equal for exit-0 programs, exit codes
* agree with the `// @exit:` directive. NOTHING is normalized — a CRLF or
* path-separator difference is a finding, not noise.
*
* Scope: the whole corpus — `// @dynamic` programs included (the engine
* archive cross-builds per target, buildEngineArchiveCross) and zlib too
* (per-target vendored objects) — PLUS the listening-fixture legs
* (tests/fixtures/{server,dgram,fetch}, the linux lane's shape): fixture
* programs cross-compile, ship, and run ON THE BOX against the box's
* Node, with the per-case driver script (the box's node again) following
* the PORT protocol over a second ssh session — three legs compared
* byte-exactly (fixture stdout, exit code, driver stdout), loopback
* only. The fixture trees ship whole (the tls certs and drivers resolve
* relative paths), so cwd on the box is the lane dir for every leg. The
* FETCH fixture leg joins them (the linux lane's shape): the fixture
* servers run under the box's Node for the whole describe and every case
* runs both lanes on the box — the native fetch has no win32 gate left.
* One visible skip class remains:
* WINDOWS_SKIPS below names the programs whose
* Node-on-Windows behavior the runtime deliberately does not reproduce,
* each with its cause (posix-shaped spawn programs, uid/tty surfaces).
* The skip lists ARE the remaining-phases worklist.
*
* Box etiquette (shared machine): everything lives in ONE directory
* (C:/Users/rdp/work/scriptc-lane, recreated per run, deleted at the end),
* a machine-wide advisory lock keeps two lane runs from stampeding the
* box, and ssh connection multiplexing (ControlMaster) keeps the ~3 remote
* ops per program cheap. SCRIPTC_WIN_FILTER=<regex> narrows a run for
* triage; SCRIPTC_WIN_HOST overrides the ssh host alias (default
* windows-dev). */
import { execFile, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, globSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import ts5 from "typescript";
import { compile } from "@scriptc/compiler";
const execFileAsync = promisify(execFile);
const enabled = process.env["SCRIPTC_WIN"] === "1";
const repoRoot = join(import.meta.dirname, "../..");
const corpusDir = join(repoRoot, "tests/corpus");
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
const target = process.env["SCRIPTC_WIN_TARGET"] ?? "x86_64-windows-gnu";
const host = process.env["SCRIPTC_WIN_HOST"] ?? "windows-dev";
const laneDirWin = "C:\\Users\\rdp\\work\\scriptc-lane"; // remote commands (cmd.exe)
const laneDirScp = "C:/Users/rdp/work/scriptc-lane"; // scp destinations
/* ssh connection multiplexing: one master connection per run, every scp/ssh
* rides it (~0.1s per op instead of ~0.4s). The control socket lives in the
* OS tmpdir and dies with the run (ControlPersist=yes + explicit -O exit). */
const controlPath = join(tmpdir(), `scriptc-win-lane-${process.pid}.sock`);
const sshOpts = [
"-o", "ControlMaster=auto",
"-o", `ControlPath=${controlPath}`,
"-o", "ControlPersist=yes",
"-o", "ConnectTimeout=15",
];
/* Programs whose Node-on-Windows behavior the runtime deliberately does
* not reproduce yet — the reason is the inventory entry. Compile-time
* cross gates (events/net/...) skip themselves; this list covers what
* COMPILES but diverges at runtime by design. */
const WINDOWS_SKIPS: Record<string, string> = {
// child_process is REAL on win32 now (CreateProcessW + libuv's
// quote_cmd_arg + handle-inheritance stdio; 1644-1646 are this lane's
// platform-neutral coverage, spawning `node` — verified byte-exact
// against Windows Node). What remains skipped here is POSIX-SHAPED
// programs: their /bin/sh, sh, printf, cat, ln, true spawns are ENOENT
// on Windows NODE too, so both sides fail the same way but render
// differently — Node prints a stack for uncaught/unhandled errors
// where the runtime prints one line, and @exit can't say "0 on posix,
// 1 on win32". A second recurring class: a FAILED spawn's captured
// stdout/stderr read "" here where Node types them null (the
// documented spawnSync stance) — invisible on POSIX lanes where these
// spawns succeed, exposed here where every one fails.
"process-named-signals.cjs": "POSIX signal delivery: Windows Node cannot send SIGWINCH or register SIGSTOP",
"1360-spawn-sync.ts": "posix-shaped: every spawn is ENOENT on Windows Node too, exposing the documented spawn-failure \"\"-vs-null stdout stance (1644 covers spawnSync here)",
"1361-spawn-events.ts": "posix-shaped: the unlistened /bin/sh spawn failure crashes both sides, rendered differently (1646 covers spawn events here)",
"1362-spawn-timers.ts": "posix-shaped: the unlistened /bin/sh spawn failure crashes both sides, rendered differently",
"1462-exec-sync.ts": "posix-shaped: the uncaught printf ENOENT throw crashes both sides, rendered differently (1645 covers exec here)",
"1466-child-containers.ts": "posix-shaped: `true` is ENOENT on Windows Node too; the paths after the failed spawn diverge in rendering",
"1470-child-lifecycle.ts": "posix-shaped: kill() on the spawn-failed child crashes Windows Node (unhandled 'error'); the runtime answers false",
"1471-child-unref.ts": "posix-shaped: the unlistened sh spawn failure crashes both sides, rendered differently",
"1473-promisify-execfile.ts": "posix-shaped: the unhandled spawn-ENOENT rejection crashes both sides, rendered differently",
"1482-spawnsync-error.ts": "posix-shaped: /bin/sh fails ENOENT on Windows Node too — its null stdout .trim() crashes the oracle where the documented \"\" stance carries on",
"1522-spawnsync-options.ts": "posix-shaped: the sh capture case exposes the documented spawn-failure \"\"-vs-null stance (every other line agrees; 1644 covers the options here)",
"1523-spawn-options.ts": "posix-shaped: the unlistened sh spawn failure crashes both sides, rendered differently",
"1525-child-exit-signal.ts": "posix-shaped: the unlistened sh spawn failure crashes both sides, rendered differently (1646 covers kill/exit signals here)",
"1535-spawn-fd-stdio.ts": "posix-shaped: the unlistened sh spawn failure crashes both sides, rendered differently",
"1537-os-release-spawnsync-stdio.ts": "posix-shaped: the failed spawns expose the documented spawn-failure \"\"-vs-null stance",
"1552-exec-options-record.ts": "posix-shaped: the uncaught /bin/echo ENOENT throw crashes both sides, rendered differently",
"1562-spawn-conditional-spread.ts": "posix-shaped: the unlistened sh spawn failure crashes both sides, rendered differently",
"1565-spawn-pipe-streams.ts": "posix-shaped: the unlistened /bin/sh spawn failure crashes both sides, rendered differently (1646 covers pipe streams here)",
"1566-child-duck-interface.ts": "posix-shaped: the unlistened sh spawn failure crashes both sides, rendered differently",
"1570-child-unref-kill-reffed.ts": "posix-shaped: the unlistened sleep spawn failure crashes both sides, rendered differently",
"1573-promisify-execfile-env-spread.ts": "posix-shaped: the unhandled spawn-ENOENT rejection crashes both sides, rendered differently",
"1578-exec-input-optional.ts": "posix-shaped: the uncaught cat ENOENT throw crashes both sides, rendered differently (1645 covers input here)",
"1580-exec-env-conditional-spread.ts": "posix-shaped: the uncaught sh ENOENT throw crashes both sides, rendered differently (1645 covers env here)",
"1464-env-writes.ts": "posix-shaped: the observing child is ENOENT on Windows, exposing the documented spawn-failure \"\"-vs-null stance",
// Two programs the events unit's win32 arm surfaced (they compiled for
// the first time once the events gate lifted): both drive their signal
// listeners through a /bin/sh child, ENOENT on Windows Node too.
"1443-signal-handlers.ts": "posix-shaped: the /bin/sh signal driver is ENOENT on Windows Node too — no portable way to deliver a real SIGINT to both sides here",
"1469-remove-listener.ts": "posix-shaped: the unlistened sh spawn failure crashes both sides, rendered differently",
// Not child_process: a posix-shaped fs program from another lane.
// (The former POSIX-path-semantics skips are gone: a win32 target now
// binds path/path.sep/path.delimiter/os.EOL and the file-URL bridge to
// Node-on-Windows semantics — scr_path.c's path.win32 port and
// scr_url.c's drive arm, selected at compile time by the triple.)
// 1356 is a POSIX-SHAPED PROGRAM, not a runtime gap: its first line
// (fileURLToPath("file:///tmp/...")) throws "File URL path must be
// absolute" on Windows NODE too. Binary and Node crash with the SAME
// TypeError, but Node prints a source excerpt + stack for uncaught
// errors where the runtime prints one line, and @exit can't say "0 on
// posix, 1 on win32". 1611-url-file-bridge-neutral.ts covers the
// bridge on this lane.
"1356-url-file-bridge.ts": "posix-shaped: file:///tmp URLs crash Windows Node too (1611 covers the bridge here)",
"1612-cjs-module-globals.cjs": "__dirname/__filename are baked at compile time, so a cross-compiled binary carries build-host paths while the box's Node reports its staged directory; same-host lanes (macOS, the Linux mount) see identical paths on both sides",
// uid/gid surfaces: no Windows arm exists in Node either (the members
// are absent there); the runtime throws the same TypeError, but the
// ORACLE differs per box user anyway.
"1461-process-pid-getuid-kill.ts": "process.getuid absent on Windows (Node too)",
"1531-process-arch-versions.ts": "process.getuid/getgid absent on Windows Node (typed present here)",
"1571-optional-call-tostring-tail.ts": "process.getuid?.() short-circuits on Windows Node (member exists here)",
// The errno-spelling gap 1520 used to name is FIXED (scr_fs_throw
// translates the CRT's EACCES to EPERM on win32 — libuv's
// ERROR_ACCESS_DENIED map; the program's caught branches print
// matching codes on both sides now), but the program itself is
// posix-shaped: its "mode does NOT re-apply to an existing file" line
// is a POSIX truth — Windows Node re-applies the readonly bit and its
// next unguarded accessSync(W_OK) crashes BOTH sides identically at
// the same line, rendered differently (stack vs one line), where
// @exit says 0.
"1520-fs-wider-surface.ts": "posix-shaped: writeFileSync {mode} re-applies readonly on Windows (Node too) — the unguarded accessSync crashes both sides, rendered differently",
// (1480 left this list: GetAdaptersAddresses walks libuv's exact rows.)
// os.userInfo: shell is null on Windows Node; the scriptc surface types
// it string ("" here) — and username/homedir differ per box anyway.
"1540-os-userinfo.ts": "userInfo().shell null on Windows Node (typed string here)",
};
// The lane attempts the WHOLE corpus — `// @dynamic` programs included
// (the engine archive cross-builds for the windows triple). Still-gated
// features and WINDOWS_SKIPS skip visibly below.
function directiveHead(file: string): string[] {
return readFileSync(file, "utf8").split("\n", 2);
}
/** `// @dynamic` (differential.test.ts's directive): compile with the
* island engine embedded. The Node side needs no flag — the island shim
* covers the surface. */
function wantsDynamic(file: string): boolean {
return directiveHead(file).some((l) => /^\/\/ @dynamic\s*$/.test(l));
}
/** `// @transform-types` (differential.test.ts's directive): the Node
* oracle needs --experimental-transform-types for non-erasable syntax
* (namespaces). Enum-declaring programs ride the same flag without a
* directive — differential.test.ts's exact rule, mirrored so the two
* lanes hand the oracle identical argv. */
function wantsTransformTypes(file: string): boolean {
if (directiveHead(file).some((l) => /^\/\/ @transform-types\s*$/.test(l))) return true;
return programInputs(file).some((f) => /\benum\s+[A-Za-z_$]/.test(readFileSync(f, "utf8")));
}
/** `// @tsc-decorators` (differential.test.ts's directive): Node cannot
* execute decorators at all (V8 has not shipped the proposal), so the
* oracle runs tsc's deterministic ES2022 downlevel of the program —
* materialized beside the binary cache and SHIPPED to the box in the
* entry's place. Single-file programs only, like the macOS lane. */
function wantsTscDecorators(file: string): boolean {
return directiveHead(file).some((l) => /^\/\/ @tsc-decorators\s*$/.test(l));
}
/** The downleveled oracle entry's box-side name: unique per program, and
* .mjs so the box's module detection cannot re-guess. */
function decOracleName(file: string): string {
return `${laneName(file)}.dec-oracle.mjs`;
}
/** Materializes the decorator downlevel under the test cache (a pure
* function of the program bytes and the typescript version) and answers
* the local path to ship. */
function decOraclePath(file: string): string {
const src = readFileSync(file, "utf8");
const out = ts5.transpileModule(src, {
compilerOptions: { target: ts5.ScriptTarget.ES2022, module: ts5.ModuleKind.ESNext },
fileName: file,
}).outputText;
const key = createHash("sha256").update(ts5.version).update("\0").update(src).digest("hex").slice(0, 16);
const dir = join(cacheDir, "win-dec-oracle", key);
mkdirSync(dir, { recursive: true });
const path = join(dir, decOracleName(file));
// Atomic publish: a concurrent run writes this same content-keyed path;
// rename keeps readers from ever seeing a truncated oracle.
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, out);
renameSync(tmp, path);
return path;
}
const filter = process.env["SCRIPTC_WIN_FILTER"];
const ENTRY_EXTS = ["ts", "js", "mjs", "cjs"];
const files = enabled
? ENTRY_EXTS.flatMap((ext) => [
...globSync(join(corpusDir, `*.${ext}`)),
...globSync(join(corpusDir, `*/main.${ext}`)),
])
.sort()
.filter((f) => filter === undefined || new RegExp(filter).test(f))
: [];
if (enabled) {
// compileC reads these at call time; this file runs in its own worker
// process, so the macOS lanes never see them.
process.env["SCRIPTC_CC"] = "zigcc";
process.env["SCRIPTC_TARGET"] = target;
}
process.env["SCRIPTC_TEST_ENV"] = "from-harness";
interface RunResult {
stdout: Buffer;
stderr: Buffer;
exitCode: number;
}
function expectedExitCode(file: string): number {
for (const line of directiveHead(file)) {
const m = /^\/\/ @exit:\s*(\d+)\s*$/.exec(line);
if (m) return Number(m[1]);
}
return 0;
}
/** The program's lane name: the corpus file's own name, or the directory's
* for main.ts programs — unique within the corpus, and the .exe's name. */
function laneName(file: string): string {
const rel = file.slice(corpusDir.length + 1);
return /\/main\.(ts|js|mjs|cjs)$/.test(rel) ? rel.slice(0, rel.indexOf("/")) : rel;
}
/** Runs one command ON THE BOX via ssh (remote shell is cmd.exe), stdin
* ended immediately (differential.test.ts's contract), cwd = the lane dir,
* the harness env marker set for the child. */
async function runOnBox(remoteCmd: string, env?: Record<string, string>): Promise<RunResult> {
const sets = Object.entries(env ?? {})
.map(([k, v]) => `set ${k}=${v}&& `)
.join("");
const full = `cd /d ${laneDirWin} && set SCRIPTC_TEST_ENV=from-harness&& ${sets}${remoteCmd}`;
const pending = execFileAsync("ssh", [...sshOpts, host, full], {
encoding: "buffer",
maxBuffer: 64 * 1024 * 1024,
});
pending.child.stdin?.end();
try {
const { stdout, stderr } = await pending;
return { stdout, stderr, exitCode: 0 };
} catch (err) {
const e = err as { code?: unknown; stdout?: Buffer; stderr?: Buffer };
if (typeof e.code !== "number" || !Buffer.isBuffer(e.stdout) || !Buffer.isBuffer(e.stderr)) {
throw err;
}
return { stdout: e.stdout, stderr: e.stderr, exitCode: e.code };
}
}
/** Runs with a Windows pseudo-console instead of redirected stdio. The regular
* lane proves byte-exact pipe output; this catches terminal decoding bugs that
* are invisible once ssh captures the child through a pipe. */
async function runOnBoxConsole(remoteCmd: string): Promise<Buffer> {
const full = `cd /d ${laneDirWin} && set SCRIPTC_TEST_ENV=from-harness&& ${remoteCmd}`;
const pending = execFileAsync("ssh", [...sshOpts, "-tt", host, full], {
encoding: "buffer",
maxBuffer: 64 * 1024 * 1024,
});
pending.child.stdin?.end();
return (await pending).stdout;
}
async function scpToLane(paths: string[]): Promise<void> {
await execFileAsync("scp", ["-q", "-r", ...sshOpts, ...paths, `${host}:${laneDirScp}/`]);
}
/** The box's Node over the program shipped beside the binary — the same
* shims the macOS oracle uses, shipped once in beforeAll. Windows Node
* type-strips .ts natively (same pinned version as the macOS lanes). */
async function runWindowsNode(file: string): Promise<RunResult> {
const name = laneName(file);
const entry = wantsTscDecorators(file)
? decOracleName(file)
: name === basename(file)
? name
: `${name}\\${basename(file)}`;
const transform = wantsTransformTypes(file)
? "--experimental-transform-types --disable-warning=ExperimentalWarning "
: "";
const nodep = directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l))
? "--no-deprecation "
: "";
const shims = directiveHead(file).includes("// @no-node-shims")
? "" : "--import ./comptime-shim.mjs --import ./island-shim.mjs ";
return runOnBox(
`node ${transform}${nodep}${shims}${entry}`,
);
}
function programInputs(file: string): string[] {
if (!/\/main\.(ts|js|mjs|cjs)$/.test(file)) return [file];
return [
...ENTRY_EXTS.flatMap((ext) => globSync(join(file, `../**/*.${ext}`))),
...globSync(join(file, "../**/tsconfig.json")),
...globSync(join(file, "../**/package.json")),
].sort();
}
async function crossCompile(file: string): Promise<string> {
const hash = createHash("sha256");
for (const f of programInputs(file)) hash.update(f).update(readFileSync(f));
const key = hash.update("windows\0").update(target).digest("hex").slice(0, 16);
const outDir = join(cacheDir, key);
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, `${laneName(file)}.exe`);
// Pinned "c" here and at every compile below: the Windows lane is a
// C-reference suite — the cross-compile story is the C backend's.
const result = await compile(file, { outPath, outDir, dynamic: wantsDynamic(file), backend: "c" });
if (!result.ok) {
throw new Error(
"corpus program failed to cross-compile:\n" +
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
return result.binaryPath;
}
/** Cross-compiles and ships one program: the .exe and the program's own
* sources (the oracle's input) in ONE scp — directory programs ship whole
* (tsconfig and siblings ride along), single files ship as themselves.
* MUST complete before either side runs: the oracle reads the shipped
* sources. */
async function shipProgram(file: string): Promise<void> {
const binary = await crossCompile(file);
const programRoot = /\/main\.(ts|js|mjs|cjs)$/.test(file) ? join(file, "..") : file;
// Decorator programs additionally ship the tsc downlevel the oracle
// executes (the compiler still eats the real source above).
const oracleExtra = wantsTscDecorators(file) ? [decOraclePath(file)] : [];
await scpToLane([binary, programRoot, ...oracleExtra]);
}
/* ── the LISTENING-fixture legs (tests/fixtures/server, tests/fixtures/dgram)
* — server.test.ts's three-legged contract with every process ON THE BOX:
* the fixture program (the box's Node lane and the cross-compiled .exe),
* and the per-case driver script (the box's node) that talks to it over
* the PORT protocol (`PORT <n>` on stderr; stderr is never compared, so
* ephemeral ports stay out of every compared stream). Loopback only: the
* fixtures pin 127.0.0.1 and the driver runs on the same box. The
* fixture trees ship whole under the lane dir (beforeAll) because the
* tls cases read certs cwd-relative and the drivers resolve
* ../../certs relative to their own shipped location. */
interface FixtureRun {
stdout: Buffer;
exitCode: number;
/** The driver's stdout, "" for driver-less cases. */
driverStdout: string;
}
function fixtureCases(root: string): { name: string; entry: string; driver: string | null }[] {
if (!enabled) return [];
return globSync(join(root, "cases/*/main.ts"))
.sort()
.filter((f) => filter === undefined || new RegExp(filter).test(f))
.map((entry) => ({
name: entry.split("/").at(-2)!,
entry,
driver: existsSync(join(entry, "../driver.mjs")) ? join(entry, "../driver.mjs") : null,
}));
}
const serverCases = fixtureCases(join(repoRoot, "tests/fixtures/server"));
const dgramCases = fixtureCases(join(repoRoot, "tests/fixtures/dgram"));
const fetchCases = fixtureCases(join(repoRoot, "tests/fixtures/fetch"));
/** A repo-relative fixture path's spelling in a box-side command. */
function boxRel(hostPath: string): string {
if (!hostPath.startsWith(repoRoot)) throw new Error(`not under the repo: ${hostPath}`);
return hostPath.slice(repoRoot.length + 1).replaceAll("/", "\\");
}
/** Runs one fixture lane ON THE BOX: spawn the fixture over ssh (cwd = the
* lane dir, so cwd-relative cert reads resolve into the shipped tree),
* follow the PORT protocol when the case has a driver (a SECOND ssh
* session running the box's node against loopback), and collect all three
* compared legs. The PORT match tolerates a trailing \r — remote stderr
* rides ssh untranslated, but the tolerance costs nothing and decouples
* the harness from CRT text-mode trivia. */
function runBoxFixtureLane(remoteCmd: string, driver: string | null): Promise<FixtureRun> {
const full = `cd /d ${laneDirWin} && set SCRIPTC_TEST_ENV=from-harness&& ${remoteCmd}`;
return new Promise((resolve, reject) => {
const child = spawn("ssh", [...sshOpts, host, full], { stdio: ["ignore", "pipe", "pipe"] });
const out: Buffer[] = [];
let errText = "";
let driverStarted = false;
let driverStdout = "";
let driverDone: Promise<void> = Promise.resolve();
const timer = setTimeout(() => {
child.kill("SIGKILL");
// A hung LISTENER outlives its ssh session (observed: killing the
// local client leaves the remote .exe alive, wedging every later
// scp of the same name) — so the native leg also reaps its own
// process by image name, which is lane-unique (<case>.exe). The
// node leg gets no such sweep: node.exe is not ours to blanket-kill
// on a shared box.
const exe = /^(\S+\.exe)$/.exec(remoteCmd)?.[1];
const reaped = exe !== undefined ? runOnBox(`taskkill /F /IM ${exe}`).then(() => undefined, () => undefined) : Promise.resolve();
void reaped.finally(() =>
reject(new Error(`fixture timed out on the box\nstderr so far:\n${errText}`)),
);
}, 90_000);
child.stdout.on("data", (c: Buffer) => out.push(c));
child.stderr.on("data", (c: Buffer) => {
errText += c.toString("utf8");
if (driver !== null && !driverStarted) {
const m = /^PORT (\d+)\r?$/m.exec(errText);
if (m) {
driverStarted = true;
driverDone = runOnBox(`node ${boxRel(driver)} ${m[1]!}`).then((res) => {
driverStdout = res.stdout.toString("utf8");
if (res.exitCode !== 0) {
throw new Error(`driver exited ${res.exitCode}\n${res.stderr.toString("utf8")}`);
}
});
}
}
});
child.on("error", reject);
child.on("close", (code, signal) => {
clearTimeout(timer);
if (signal) {
reject(new Error(`fixture ssh session died to ${signal}\nstderr:\n${errText}`));
return;
}
if (driver !== null && !driverStarted) {
reject(new Error(`fixture exited without a PORT line\nstderr:\n${errText}`));
return;
}
driverDone.then(
() => resolve({ stdout: Buffer.concat(out), exitCode: code ?? 0, driverStdout }),
reject,
);
});
});
}
/** Cross-compiles one fixture and ships the .exe to the lane dir (the
* sources shipped once with the trees in beforeAll). */
async function shipFixture(c: { name: string; entry: string }): Promise<void> {
const hash = createHash("sha256");
for (const f of programInputs(c.entry)) hash.update(f).update(readFileSync(f));
const key = hash.update("windows\0").update(target).digest("hex").slice(0, 16);
const outDir = join(cacheDir, key);
mkdirSync(outDir, { recursive: true });
const result = await compile(c.entry, { outPath: join(outDir, `${c.name}.exe`), outDir, backend: "c" });
if (!result.ok) {
throw new Error(
"fixture failed to cross-compile:\n" +
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
await scpToLane([result.binaryPath]);
}
/* The machine-wide lane lock: two concurrent lane runs (parallel agents,
* a stray second invocation) would stampede the shared box, so the run
* queues behind an mkdir lock in the OS tmpdir. Stale locks from dead
* processes are stolen after a bounded wait. */
const laneLockDir = join(tmpdir(), "scriptc-win-lane.lock");
async function acquireLaneLock(): Promise<void> {
const deadline = Date.now() + 10 * 60 * 1000;
for (;;) {
try {
mkdirSync(laneLockDir);
return;
} catch {
if (Date.now() > deadline) {
throw new Error(
`another windows lane appears to be running (${laneLockDir} held for 10m) — remove the directory if it is stale.`,
);
}
await new Promise((r) => setTimeout(r, 2000));
}
}
}
describe.skipIf(!enabled)(`windows differential (${target})`, () => {
beforeAll(async () => {
// Fail loudly, not skip: SCRIPTC_WIN=1 promises a Windows verdict.
await execFileAsync("zig", ["version"]).catch(() => {
throw new Error("SCRIPTC_WIN=1 needs zig on PATH (zigup) — the lane cross-compiles with `zig cc`.");
});
await acquireLaneLock();
try {
let nodeV: string;
try {
nodeV = (await execFileAsync("ssh", [...sshOpts, host, "node --version"])).stdout.trim();
} catch (err) {
throw new Error(`SCRIPTC_WIN=1 needs ssh access to '${host}' (see ~/.ssh/config): ${String(err)}`);
}
const pinned = readFileSync(join(repoRoot, ".node-version"), "utf8").trim();
if (nodeV !== `v${pinned}`) {
throw new Error(
`the box's node is ${nodeV} but the lanes pin ${pinned} — the oracle must match the macOS lanes' version.`,
);
}
// Fresh lane dir per run, then the shims the oracle imports.
await execFileAsync("ssh", [...sshOpts, host, `cmd /c if exist ${laneDirWin} rmdir /S /Q ${laneDirWin}`]);
await execFileAsync("ssh", [...sshOpts, host, `cmd /c mkdir ${laneDirWin}`]);
await scpToLane([
join(repoRoot, "tests/harness/comptime-shim.mjs"),
join(repoRoot, "tests/harness/island-shim.mjs"),
// The corpus package.json ({"type":"module"}): without it the
// oracle's module detection can run export-less programs as sloppy
// CJS, where the macOS oracle (and the compiler) see strict ESM.
join(corpusDir, "package.json"),
]);
// The fixture trees ship WHOLE, preserving the repo-relative layout:
// the tls fixtures read certs cwd-relative from the lane dir and the
// drivers resolve ../../certs from their own shipped location.
const trees = [
// Fetch's shared server reads the loopback HTTPS proxy certificate
// from the server fixture tree. Keep that dependency staged even
// when SCRIPTC_WIN_FILTER narrows the run to fetch cases only.
...(serverCases.length > 0 || fetchCases.length > 0
? [join(repoRoot, "tests/fixtures/server")]
: []),
...(dgramCases.length > 0 ? [join(repoRoot, "tests/fixtures/dgram")] : []),
// The fetch tree ships whole too: the box's Node runs the case
// sources (their imports resolve into the shipped node_modules)
// and the servers script serves both lanes from the box.
...(fetchCases.length > 0 ? [join(repoRoot, "tests/fixtures/fetch")] : []),
];
if (trees.length > 0) {
await execFileAsync("ssh", [...sshOpts, host, `cmd /c mkdir ${laneDirWin}\\tests\\fixtures`]);
await execFileAsync("scp", ["-q", "-r", ...sshOpts, ...trees, `${host}:${laneDirScp}/tests/fixtures/`]);
}
} catch (err) {
rmdirSync(laneLockDir);
throw err;
}
}, 300_000);
afterAll(async () => {
// Box etiquette: delete the lane directory (every process the lane
// started is a console child of an ssh session and dies with it — the
// lane never launches anything detached), close the ssh master,
// release the lane lock.
await execFileAsync("ssh", [...sshOpts, host, `cmd /c rmdir /S /Q ${laneDirWin}`]).catch(() => undefined);
await execFileAsync("ssh", [...sshOpts, "-O", "exit", host]).catch(() => undefined);
try {
rmdirSync(laneLockDir);
} catch {
/* already released */
}
}, 120_000);
// skipIf(empty): a SCRIPTC_WIN_FILTER narrowing to fixtures only must not
// fail the corpus suite for having no tests (and vice versa).
describe.skipIf(files.length === 0)(`corpus (${files.length} programs)`, () => {
test.for(files.map((f) => [f.slice(corpusDir.length + 1), f] as const))(
"%s",
async ([rel, file], ctx) => {
const skip = WINDOWS_SKIPS[rel];
if (skip !== undefined) ctx.skip(skip);
let results: [RunResult, RunResult];
try {
await shipProgram(file); // compile gate + transport BEFORE either side runs
results = await Promise.all([runWindowsNode(file), runOnBox(`${laneName(file)}.exe`)]);
} catch (err) {
// Cross-gated features SKIP with the gate's reason (none exist
// today — the native fetch lifted the last win32 gate; the arm
// stays for whatever gates next): they are later-phase scope,
// not Windows failures. Everything else is a real failure.
if (err instanceof Error && err.message.includes("not supported under a cross target")) {
ctx.skip(err.message.split("\n").find((l) => l.includes("not supported")) ?? err.message);
}
throw err;
}
const [nodeRes, nativeRes] = results;
if (!nodeRes.stdout.equals(nativeRes.stdout)) {
expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8"));
expect.unreachable("stdout differed at byte level but not after utf8 decode");
}
const expectedExit = expectedExitCode(file);
if (expectedExit === 0) {
if (!nodeRes.stderr.equals(nativeRes.stderr)) {
expect(nativeRes.stderr.toString("utf8")).toBe(nodeRes.stderr.toString("utf8"));
expect.unreachable("stderr differed at byte level but not after utf8 decode");
}
}
expect(nodeRes.exitCode).toBe(expectedExit);
expect(nativeRes.exitCode).toBe(expectedExit);
},
);
const consoleProbe = join(corpusDir, "2892-windows-console-unicode.ts");
test.skipIf(!files.includes(consoleProbe))(
"native console renders UTF-8 stdout and stderr",
async () => {
await shipProgram(consoleProbe);
const output = await runOnBoxConsole(`chcp && ${laneName(consoleProbe)}.exe && chcp`);
const rendered = output.toString("utf8");
for (const marker of ["console.log", "console.error", "stdout.write", "stderr.write"]) {
expect(rendered).toContain(`${marker} ─ · › αβγ 你好 🎉`);
}
const codePages = [...rendered.matchAll(/Active code page: (\d+)/g)].map((match) => match[1]);
expect(codePages).toHaveLength(2);
expect(codePages[1]).toBe(codePages[0]);
},
120_000,
);
});
for (const [label, cases] of [
["server", serverCases],
["dgram", dgramCases],
] as const) {
describe.skipIf(cases.length === 0)(`${label} fixtures (${cases.length} cases)`, () => {
test.for(cases.map((c) => [c.name, c] as const))(
"%s",
async ([, c], ctx) => {
try {
await shipFixture(c); // compile gate + transport BEFORE either side runs
} catch (err) {
// A case needing a still-gated feature skips with the gate's
// reason (nothing in these fixture sets does today).
if (err instanceof Error && err.message.includes("not supported under a cross target")) {
ctx.skip(err.message.split("\n").find((l) => l.includes("not supported")) ?? err.message);
}
throw err;
}
// Sequential, not parallel: both lanes bind ephemeral loopback
// ports and drive real sockets on the same box (server.test.ts's
// stance).
const nodeRes = await runBoxFixtureLane(`node ${boxRel(c.entry)}`, c.driver);
const nativeRes = await runBoxFixtureLane(`${c.name}.exe`, c.driver);
expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8"));
if (!nodeRes.stdout.equals(nativeRes.stdout)) {
expect.unreachable("stdout differed at byte level but not after utf8 decode");
}
expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
expect(nativeRes.driverStdout).toBe(nodeRes.driverStdout);
},
240_000,
);
});
}
/* ── the fetch fixture leg (tests/fixtures/fetch) — fetch.test.ts's
* contract with every process ON THE BOX (the linux lane's shape): the
* fixture servers run under the box's Node for the whole describe
* (started over one long-lived ssh session; killed by PID at the end —
* a remote process can outlive its ssh client, the hung-listener
* lesson, and node.exe is not ours to blanket-kill on a shared box),
* and each case runs under the box's Node AND cross-compiled
* (--dynamic, the ambient fetch is island-backed), byte-compared, with
* the POISONED proxy env both fetches must ignore. The opt-in arm
* routes both lanes through the counting proxy and reads the count
* over the wire (the /__count query). */
describe.skipIf(fetchCases.length === 0)(`fetch fixtures (${fetchCases.length} cases)`, () => {
let serversChild: ReturnType<typeof spawn> | undefined;
let serversPid = "";
let base = "";
let refused = "";
let proxyBase = "";
const poison = (): Record<string, string> => ({
http_proxy: refused,
https_proxy: refused,
HTTP_PROXY: refused,
HTTPS_PROXY: refused,
});
beforeAll(async () => {
// Start the servers on the box and hold the session; the bootstrap
// prints the PID first so teardown can taskkill exactly this node.
const boot =
"const {startFetchServers}=await import('./tests/fixtures/fetch/servers.mjs');" +
"const s=await startFetchServers();" +
"process.stderr.write('PID '+process.pid+'\\nBASE '+s.baseUrl+'\\nREFUSED '+s.refusedUrl+'\\nPROXY '+s.proxyUrl+'\\n');";
const full = `cd /d ${laneDirWin} && node --input-type=module -e "${boot}"`;
const child = spawn("ssh", [...sshOpts, host, full], { stdio: ["ignore", "ignore", "pipe"] });
serversChild = child;
await new Promise<void>((resolve, reject) => {
let errText = "";
const timer = setTimeout(() => {
reject(new Error(`fetch servers never reported their ports\nstderr so far:\n${errText}`));
}, 60_000);
child.stderr!.on("data", (c: Buffer) => {
errText += c.toString("utf8");
const pid = /^PID (\d+)\r?$/m.exec(errText);
const b = /^BASE (\S+)\r?$/m.exec(errText);
const r = /^REFUSED (\S+)\r?$/m.exec(errText);
const p = /^PROXY (\S+)\r?$/m.exec(errText);
if (pid && b && r && p) {
[serversPid, base, refused, proxyBase] = [pid[1]!, b[1]!, r[1]!, p[1]!];
clearTimeout(timer);
resolve();
}
});
child.on("error", reject);
child.on("close", (code) => {
clearTimeout(timer);
reject(new Error(`fetch servers exited ${code} before reporting ports`));
});
});
}, 120_000);
afterAll(async () => {
if (serversPid !== "") {
await runOnBox(`taskkill /F /PID ${serversPid}`).catch(() => undefined);
}
serversChild?.kill("SIGKILL");
});
/** Cross-compiles one fetch fixture (fetch.test.ts's build: always
* --dynamic, the cache key covering the shared fixture node_modules)
* and ships the .exe. */
async function shipFetchFixture(c: { name: string; entry: string }): Promise<void> {
const hash = createHash("sha256");
const inputs = [
c.entry,
...globSync(join(repoRoot, "tests/fixtures/fetch/node_modules/**/*.{js,mjs,cjs,json,d.ts}")).sort(),
];
for (const f of inputs) hash.update(f).update(readFileSync(f));
const key = hash.update("windows-fetch\0").update(target).digest("hex").slice(0, 16);
const outDir = join(cacheDir, key);
mkdirSync(outDir, { recursive: true });
const result = await compile(c.entry, { outPath: join(outDir, `${c.name}.exe`), outDir, dynamic: true, backend: "c" });
if (!result.ok) {
throw new Error(
"fetch fixture failed to cross-compile:\n" +
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
await scpToLane([result.binaryPath]);
}
/** The proxy's relay count, read over the wire from the box. */
async function proxiedCount(): Promise<number> {
const res = await runOnBox(
`node -e "fetch(process.argv[1] + '/__count').then((r) => r.text()).then((t) => process.stdout.write(t))" ${proxyBase}`,
);
expect(res.exitCode).toBe(0);
return Number(res.stdout.toString("utf8"));
}
/** One case, both lanes on the box, byte-compared. Sequential, not
* parallel: both lanes drive real sockets against the same servers. */
async function runFetchCase(c: { name: string; entry: string }, env: Record<string, string>): Promise<void> {
await shipFetchFixture(c);
const argv = [base, refused];
// The fragment redirect route remembers a caller-provided key. Node
// and native must use distinct keys so the second lane also observes
// the redirect instead of inheriting the first lane's server state.
const nodeArgv = c.name === "redirect-resolution"
? [...argv, "redirect-resolution-node"]
: argv;
const nativeArgv = c.name === "redirect-resolution"
? [...argv, "redirect-resolution-native"]
: argv;
const nodeRes = await runOnBox(
`node ${boxRel(c.entry)} ${nodeArgv.join(" ")}`,
env,
);
const nativeRes = await runOnBox(
`${c.name}.exe ${nativeArgv.join(" ")}`,
env,
);
if (!nodeRes.stdout.equals(nativeRes.stdout)) {
expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8"));
expect.unreachable("stdout differed at byte level but not after utf8 decode");
}
expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
}
test.for(fetchCases.map((c) => [c.name, c] as const))(
"%s",
async ([, c]) => {
await runFetchCase(c, poison());
},
240_000,
);
test.skipIf(fetchCases.every((c) => c.name !== "proxy-optin"))(
"proxy-optin routes through http_proxy when opted in",
async () => {
const c = fetchCases.find((x) => x.name === "proxy-optin")!;
const before = await proxiedCount();
await runFetchCase(c, {
...poison(),
NODE_USE_ENV_PROXY: "1",
http_proxy: proxyBase,
HTTP_PROXY: proxyBase,
});
expect((await proxiedCount()) - before).toBe(2);
},
240_000,
);
});
});