diff --git a/tests/harness/self-hosting-emission.test.ts b/tests/harness/self-hosting-emission.test.ts index 2105d5b4e..d245c136d 100644 --- a/tests/harness/self-hosting-emission.test.ts +++ b/tests/harness/self-hosting-emission.test.ts @@ -1,14 +1,15 @@ -import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; -import { analyze, compile, compileC, deserializeModule, serializeModule } from "@scriptc/compiler"; +import { compile, compileC, deserializeModule, serializeModule } from "@scriptc/compiler"; import { emitLlvmModule } from "../../packages/compiler/src/backend/llvm/emitter.js"; import { scalarizeNumericRecords } from "../../packages/compiler/src/ir/scalar-records.js"; import { everyStmtList } from "../../packages/compiler/src/ir/traverse.js"; import { type IrModule } from "../../packages/compiler/src/ir/ir.js"; +import { analyzeInChild, compileInChild } from "./self-hosting-compiler-process.js"; +import { spawnResult } from "./spawn-result.js"; import { moduleUsesCopying, moduleUsesDynInvoke, @@ -53,8 +54,8 @@ interface Emission { block: string; } -test("the production layout and metadata stage lowers statically", () => { - const { coverage } = analyze(entry, { dynamic: false }); +test("the production layout and metadata stage lowers statically", async () => { + const coverage = await analyzeInChild(entry, { dynamic: false }); expect(coverage.preflightFailed).toBe(false); expect(coverage.diagnostics).toEqual([]); expect(coverage.stats.statementsTotal).toBeGreaterThan(1200); @@ -72,7 +73,7 @@ for (const backend of ["llvm"] as const) { const executable = (name: string): string => join(dir, name + (process.platform === "win32" ? ".exe" : "")); try { - const built = await compile(entry, { + const built = await compileInChild(entry, { outDir: dir, outPath: executable("stage"), backend, @@ -83,14 +84,23 @@ for (const backend of ["llvm"] as const) { if (!built.ok) throw new Error(built.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); expect(built.backend).toBe(backend); - const run = (mod: IrModule, request: EmissionRequest, name: string, status = 0): string => { + const run = async ( + mod: IrModule, + request: EmissionRequest, + name: string, + status = 0, + ): Promise => { const input = join(dir, "input.json"); const config = join(dir, "config.json"); writeFileSync(input, serializeModule(mod)); writeFileSync(config, JSON.stringify(request)); const args = [input, config]; - const oracle = spawnSync(process.execPath, ["--import", "tsx", entry, ...args], options); - const native = spawnSync(built.binaryPath, args, options); + const oracle = await spawnResult( + process.execPath, + ["--import", "tsx", entry, ...args], + options, + ); + const native = await spawnResult(built.binaryPath, args, options); for (const result of [oracle, native]) { expect(result.error, name).toBeUndefined(); expect(result.signal, `${name}: ${result.stderr}`).toBeNull(); @@ -106,7 +116,7 @@ for (const backend of ["llvm"] as const) { }; for (const item of emissionCases()) { - const text = run(item.module, item.request, item.name); + const text = await run(item.module, item.request, item.name); const emitted = JSON.parse(text) as Emission; const definitions = [ ...emitted.declarations, @@ -143,12 +153,12 @@ for (const backend of ["llvm"] as const) { loc: { file: invalid.sourceFile, start: 0, end: 0 }, }, ]; - expect(run(invalid, emissionRequest(), "missing base", 1)).toContain( + expect(await run(invalid, emissionRequest(), "missing base", 1)).toContain( "undeclared base class Missing", ); const absent = emissionRequest(); absent.classObjects = ["Missing"]; - expect(run(emissionModule(), absent, "missing class object", 1)).toContain( + expect(await run(emissionModule(), absent, "missing class object", 1)).toContain( "class object for unknown class Missing", ); @@ -176,7 +186,7 @@ for (const backend of ["llvm"] as const) { }, ]; for (const { request, message } of invalidRequests) { - expect(run(emissionModule(), request, message, 1)).toContain(message); + expect(await run(emissionModule(), request, message, 1)).toContain(message); } const unsupported = emissionModule(); unsupported.unions = [ @@ -186,7 +196,7 @@ for (const backend of ["llvm"] as const) { }, ]; expect( - run( + await run( unsupported, { ...emissionRequest(), joinUnions: ["containers"] }, "unsupported union join", @@ -225,7 +235,7 @@ for (const backend of ["llvm"] as const) { return true; }, }); - const native = JSON.parse(run(mod, request, source)) as Emission; + const native = JSON.parse(await run(mod, request, source)) as Emission; // Splice the native-generated sections into the real program. The // full emitter still runs on Node; these type and helper definitions // are supplied by the compiled production stage under test. @@ -256,8 +266,8 @@ for (const backend of ["llvm"] as const) { regex: moduleUsesRegex(mod), copying: moduleUsesCopying(mod), }); - const oracle = spawnSync(process.execPath, [sourcePath], options); - const program = spawnSync(outPath, [], options); + const oracle = await spawnResult(process.execPath, [sourcePath], options); + const program = await spawnResult(outPath, [], options); for (const result of [oracle, program]) { expect(result.error, source).toBeUndefined(); expect(result.signal, `${source}: ${result.stderr}`).toBeNull(); diff --git a/tests/harness/self-hosting-frontend-services.test.ts b/tests/harness/self-hosting-frontend-services.test.ts index 8216c3aa0..d294f5cdd 100644 --- a/tests/harness/self-hosting-frontend-services.test.ts +++ b/tests/harness/self-hosting-frontend-services.test.ts @@ -1,12 +1,12 @@ -import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { analyze, compile } from "@scriptc/compiler"; import { expect, test } from "vitest"; import { ts7Executable } from "../../packages/compiler/src/frontend/ts7/rpc-api.js"; import { npmFetchCases } from "../../packages/compiler/test/npm-fetch-cases.js"; +import { analyzeInChild, compileInChild } from "./self-hosting-compiler-process.js"; +import { spawnResult } from "./spawn-result.js"; const root = join(import.meta.dirname, "../.."); const entry = join(root, "tests/fixtures/self-hosting/frontend-services.ts"); @@ -69,17 +69,23 @@ for (const backend of ["llvm"] as const) { ); try { const object = join(directory, "process.o"); - execFileSync("clang", [ - "-std=c11", - "-Wall", - "-Wextra", - "-Werror", - ...(sanitize ? ["-fsanitize=address"] : []), - "-c", - join(nativeSources, "ts7-process.c"), - "-o", - object, - ]); + const clang = await spawnResult( + "clang", + [ + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + ...(sanitize ? ["-fsanitize=address"] : []), + "-c", + join(nativeSources, "ts7-process.c"), + "-o", + object, + ], + { cwd: root, timeout: 120_000 }, + ); + expect(clang.error).toBeUndefined(); + expect(clang.status, clang.stderr.toString()).toBe(0); const profile = join(directory, "ffi.json"); writeFileSync( profile, @@ -88,13 +94,13 @@ for (const backend of ["llvm"] as const) { libraries: [object], }), ); - const { coverage } = analyze(entry, { dynamic: false, ffiProfilePath: profile }); + const coverage = await analyzeInChild(entry, { dynamic: false, ffiProfilePath: profile }); expect(coverage.preflightFailed, JSON.stringify(coverage.diagnostics)).toBe(false); expect(coverage.diagnostics).toEqual([]); expect(coverage.stats.statementsFailed).toBe(0); expect(coverage.stats.statementsIsland).toBe(0); expect(coverage.stats.functionsSkipped).toBe(0); - const built = await compile(entry, { + const built = await compileInChild(entry, { backend, dynamic: false, optimization: "dev", @@ -108,25 +114,25 @@ for (const backend of ["llvm"] as const) { const request = join(directory, "request.json"); writeFileSync(request, JSON.stringify(input(directory))); const expected = join(directory, "node.json"); - const node = spawnSync( + const node = await spawnResult( process.execPath, ["--import", "tsx", oracle, ts7Executable(), request, expected], - { encoding: "utf8", timeout: 45_000 }, + { cwd: root, timeout: 45_000 }, ); - expect(node.error, node.stderr).toBeUndefined(); - expect(node.status, node.stderr).toBe(0); - expect(node.stdout).toBe(""); - expect(node.stderr).toBe(""); + expect(node.error, node.stderr.toString()).toBeUndefined(); + expect(node.status, node.stderr.toString()).toBe(0); + expect(node.stdout.toString()).toBe(""); + expect(node.stderr.toString()).toBe(""); const report = join(directory, "native.json"); - const run = spawnSync(built.binaryPath, [ts7Executable(), request, report], { - encoding: "utf8", + const run = await spawnResult(built.binaryPath, [ts7Executable(), request, report], { + cwd: root, timeout: 45_000, }); - expect(run.error, run.stderr).toBeUndefined(); - expect(run.signal, run.stderr).toBeNull(); - expect(run.status, run.stderr).toBe(0); - expect(run.stdout).toBe(node.stdout); - expect(run.stderr).toBe(node.stderr); + expect(run.error, run.stderr.toString()).toBeUndefined(); + expect(run.signal, run.stderr.toString()).toBeNull(); + expect(run.status, run.stderr.toString()).toBe(0); + expect(run.stdout.toString()).toBe(node.stdout.toString()); + expect(run.stderr.toString()).toBe(node.stderr.toString()); const result = JSON.parse(readFileSync(report, "utf8")); expect(result).toEqual(JSON.parse(readFileSync(expected, "utf8"))); expect(result.fetch).toEqual( diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index 43a230682..b00a46da7 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -1,10 +1,10 @@ -import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; import { analyzeInChild, compileInChild } from "./self-hosting-compiler-process.js"; +import { spawnResult } from "./spawn-result.js"; import { nativeCodegenTarget } from "../../packages/compiler/src/backend/targets.js"; import type { NativeLinkFeatures } from "../../packages/compiler/src/backend/native-link-info.js"; import type { RuntimePackManifest } from "../../packages/compiler/src/backend/runtime-pack-core.js"; @@ -143,7 +143,7 @@ for (const backend of ["llvm"] as const) { default_target: target.helper.defaultTarget, data_layout: target.helper.defaultDataLayout, }; - const check = ( + const check = async ( status: number, message: string, action = "stage", @@ -153,11 +153,11 @@ for (const backend of ["llvm"] as const) { input, JSON.stringify({ toolchain, stage, features, action, version: helperVersion }), ); - const options = { cwd: root, timeout: 30_000, maxBuffer: 1024 * 1024 }; + const options = { cwd: root, timeout: 30_000 }; const results = []; for (const native of [false, true]) { rmSync(stage, { recursive: true, force: true }); - const result = spawnSync( + const result = await spawnResult( native ? built.binaryPath : process.execPath, native ? [input] : ["--import", "tsx", entry, input], native ? { ...options, env: { ...process.env, PATH: "" } } : options, @@ -171,24 +171,24 @@ for (const backend of ["llvm"] as const) { expect(results[1]!.stdout).toEqual(results[0]!.stdout); expect(results[1]!.stderr).toEqual(results[0]!.stderr); }; - check(0, "base.o"); + await check(0, "base.o"); expect(readFileSync(join(stage, artifact.path))).toEqual(bytes); // A later replacement of the installed input cannot change staged bytes. writeFileSync(join(pack, artifact.path), Buffer.from("changed")); expect(readFileSync(join(stage, artifact.path))).toEqual(bytes); - check(1, "hash mismatch"); + await check(1, "hash mismatch"); expect(existsSync(join(stage, artifact.path))).toBe(false); writeFileSync(join(pack, artifact.path), bytes); rmSync(join(pack, "license.txt")); - check(1, "license payload is incomplete"); + await check(1, "license payload is incomplete"); writeFileSync(join(pack, "license.txt"), "license"); rmSync(join(pack, artifact.path)); - check(1, "artifact is missing"); + await check(1, "artifact is missing"); writeFileSync(join(pack, artifact.path), bytes); writeFileSync(toolchain, JSON.stringify({ ...config, compiler_version: "other" })); - check(1, "version mismatch"); + await check(1, "version mismatch"); writeFileSync(toolchain, JSON.stringify(config)); - check(0, "1 1.2.3 22.1.8", "helper"); + await check(0, "1 1.2.3 22.1.8", "helper"); for (const [field, value] of Object.entries({ protocol_version: "2", llvm_version: "0", @@ -199,7 +199,7 @@ for (const backend of ["llvm"] as const) { data_layout: "other", host_triple: null, })) { - check(1, field, "helper", { ...version, [field]: value }); + await check(1, field, "helper", { ...version, [field]: value }); } } finally { rmSync(directory, { recursive: true, force: true }); diff --git a/tests/harness/spawn-result.ts b/tests/harness/spawn-result.ts new file mode 100644 index 000000000..3de656fe6 --- /dev/null +++ b/tests/harness/spawn-result.ts @@ -0,0 +1,44 @@ +import { spawn } from "node:child_process"; + +export interface SpawnResult { + error: Error | undefined; + signal: NodeJS.Signals | null; + status: number | null; + stdout: Buffer; + stderr: Buffer; +} + +/** spawnSync's result without blocking the worker: a test that runs child + * processes back to back with spawnSync starves Vitest's worker RPC, which + * fails the run with an unhandled timeout even when every test passes. */ +export function spawnResult( + file: string, + args: readonly string[], + options: { cwd: string; timeout: number; env?: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolve) => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let error: Error | undefined; + const child = spawn(file, args, { + cwd: options.cwd, + env: options.env, + timeout: options.timeout, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.on("error", (cause) => { + error = cause; + }); + child.on("close", (status, signal) => + resolve({ + error, + signal, + status, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }), + ); + }); +}