Keep self-hosting emission tests from blocking the test worker (#883)

This commit is contained in:
Chris Tate
2026-10-12 01:06:52 -05:00
committed by GitHub
4 changed files with 115 additions and 55 deletions
+26 -16
View File
@@ -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<string> => {
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();
@@ -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(
@@ -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 });
+44
View File
@@ -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<SpawnResult> {
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),
}),
);
});
}