mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-23 06:10:02 +08:00
- Remove C emission, debugging options, and automatic backend fallback. - Complete LLVM lowering and native bootstrap coverage across supported targets. - Link executables and libraries with precompiled C runtime packs, including iOS and Android.
200 lines
8.7 KiB
TypeScript
200 lines
8.7 KiB
TypeScript
/* Both LLVM optimization modes must compile every corpus program and match
|
|
* Node's stdout, successful stderr, and expected exit status. Any backend
|
|
* refusal is a failure. SCRIPTC_SAN=1 instruments emitted code and runtime. */
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { globSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { describe, expect, test } from "vitest";
|
|
import ts5 from "typescript";
|
|
import { compile } from "@scriptc/compiler";
|
|
import { shardSelect, shardSuffix } from "./shard.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repoRoot = join(import.meta.dirname, "../..");
|
|
const corpusDir = join(repoRoot, "tests/corpus");
|
|
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
|
|
|
|
// Same corpus, same SCRIPTC_TEST_SHARD slice as differential.test.ts (the
|
|
// two files split identically, so a shard's compile cache serves both lanes).
|
|
const ENTRY_EXTS = ["ts", "js", "mjs", "cjs"];
|
|
const files = shardSelect(
|
|
ENTRY_EXTS.flatMap((ext) => [
|
|
...globSync(join(corpusDir, `*.${ext}`)),
|
|
...globSync(join(corpusDir, `*/main.${ext}`)),
|
|
]).sort(),
|
|
(f) => f.slice(corpusDir.length + 1),
|
|
);
|
|
const sanitize = process.env["SCRIPTC_SAN"] === "1";
|
|
const requestedMode = process.env["SCRIPTC_LLVM_TEST_MODE"];
|
|
if (requestedMode !== undefined && requestedMode !== "release" && requestedMode !== "dev") {
|
|
throw new Error("SCRIPTC_LLVM_TEST_MODE must be release or dev when set");
|
|
}
|
|
// Combined CI lanes cover release in differential.test.ts. Standalone and
|
|
// packaged-artifact runs retain both modes unless one is explicitly selected.
|
|
const optimizationModes: readonly ("release" | "dev")[] = requestedMode === undefined
|
|
? ["release", "dev"] : [requestedMode];
|
|
|
|
// Same known-env contract as the main differential suite.
|
|
process.env["SCRIPTC_TEST_ENV"] = "from-harness";
|
|
|
|
interface RunResult {
|
|
stdout: Buffer;
|
|
stderr: Buffer;
|
|
exitCode: number;
|
|
}
|
|
|
|
/** First TWO lines — a program can combine directives, one per line
|
|
* (differential.test.ts's directiveHead). */
|
|
function directiveHead(file: string): string[] {
|
|
return readFileSync(file, "utf8").split("\n", 2);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function wantsDynamic(file: string): boolean {
|
|
return directiveHead(file).some((l) => /^\/\/ @dynamic\s*$/.test(l));
|
|
}
|
|
|
|
async function runBinary(cmd: string, args: string[]): Promise<RunResult> {
|
|
// Linux fork/exec race (observed on the sandbox lane): a sibling
|
|
// worker's fork can inherit the freshly-linked binary's write fd across
|
|
// its own spawn window, and exec answers ETXTBSY until that fd closes.
|
|
// The condition is transient by construction — retry briefly, the
|
|
// npm/cargo stance.
|
|
for (let attempt = 0; ; attempt++) {
|
|
const pending = execFileAsync(cmd, args, { encoding: "buffer" });
|
|
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 (e.code === "ETXTBSY" && attempt < 10) {
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
continue;
|
|
}
|
|
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 };
|
|
}
|
|
}
|
|
}
|
|
|
|
function comparableStderr(stderr: Buffer): Buffer {
|
|
if (!sanitize) return stderr;
|
|
const lines = stderr.toString("utf8").split("\n");
|
|
const kept = lines.filter(
|
|
(l) =>
|
|
!l.startsWith("scriptc RC audit skipped:") &&
|
|
// Linux ASan's once-per-process fiber-swapcontext warning (see
|
|
// differential.test.ts's comparableStderr).
|
|
!/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext/.test(l),
|
|
);
|
|
return Buffer.from(kept.join("\n"), "utf8");
|
|
}
|
|
|
|
const comptimeShim = pathToFileURL(join(import.meta.dirname, "comptime-shim.mjs")).href;
|
|
const islandShim = pathToFileURL(join(import.meta.dirname, "island-shim.mjs")).href;
|
|
|
|
/** differential.test.ts's twin: the Node oracle runs with
|
|
* --experimental-transform-types for corpus programs using non-erasable
|
|
* syntax — the `// @transform-types` directive (namespaces) OR any enum
|
|
* declaration (strip-only mode refuses to parse enums, no directive
|
|
* needed; a pure function of the program bytes). */
|
|
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 twin): decorators are the
|
|
* one supported construct Node cannot execute at all, so the oracle runs
|
|
* tsc's deterministic ES2022 downlevel materialized under the test cache. */
|
|
function wantsTscDecorators(file: string): boolean {
|
|
return directiveHead(file).some((l) => /^\/\/ @tsc-decorators\s*$/.test(l));
|
|
}
|
|
|
|
function nodeOracleFile(file: string): string {
|
|
if (!wantsTscDecorators(file)) return file;
|
|
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 path = join(cacheDir, `dec-oracle-${key}.mjs`);
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
// Atomic publish: concurrent suites (the other flavor's full run, or
|
|
// another lane over the same program) write 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;
|
|
}
|
|
|
|
function wantsNoDeprecation(file: string): boolean {
|
|
return directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l));
|
|
}
|
|
|
|
function nodeOracleArgs(file: string): string[] {
|
|
const transform = wantsTransformTypes(file)
|
|
? ["--experimental-transform-types", "--disable-warning=ExperimentalWarning"]
|
|
: [];
|
|
const nodep = wantsNoDeprecation(file) ? ["--no-deprecation"] : [];
|
|
// --import makes Node load even a CJS entry through its ESM loader,
|
|
// changing an entry throw's uncaughtException origin to unhandledRejection.
|
|
const shims = directiveHead(file).includes("// @no-node-shims")
|
|
? [] : ["--import", comptimeShim, "--import", islandShim];
|
|
return [...transform, ...nodep, ...shims, nodeOracleFile(file)];
|
|
}
|
|
|
|
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 build(file: string, optimization: "release" | "dev") {
|
|
const hash = createHash("sha256");
|
|
for (const f of programInputs(file)) hash.update(f).update(readFileSync(f));
|
|
const key = hash.update(sanitize ? "san" : "plain")
|
|
.update(wantsDynamic(file) ? "dyn" : "").update("llvm-" + optimization).digest("hex").slice(0, 16);
|
|
const outDir = join(cacheDir, key);
|
|
mkdirSync(outDir, { recursive: true });
|
|
return compile(file, {
|
|
outPath: join(outDir, `program-llvm-${optimization}${sanitize ? "-san" : ""}`),
|
|
outDir, sanitize, dynamic: wantsDynamic(file), optimization,
|
|
});
|
|
}
|
|
|
|
describe(`llvm differential corpus (${files.length} programs, ${optimizationModes.join("+")}${sanitize ? ", sanitized" : ""}${shardSuffix()})`, () => {
|
|
test.for(files.map((f) => [f.slice(corpusDir.length + 1), f] as const))("%s", async ([rel, file]) => {
|
|
const oracle = await runBinary(process.execPath, nodeOracleArgs(file));
|
|
const expectedExit = expectedExitCode(file);
|
|
expect(oracle.exitCode).toBe(expectedExit);
|
|
for (const optimization of optimizationModes) {
|
|
const result = await build(file, optimization);
|
|
if (!result.ok) throw new Error(`${rel} (${optimization}): ` + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("; "));
|
|
expect(result.backend).toBe("llvm");
|
|
expect(result.llvmPath.endsWith(".ll")).toBe(true);
|
|
const actual = await runBinary(result.binaryPath, []);
|
|
expect(actual.stdout, `${optimization} stdout`).toEqual(oracle.stdout);
|
|
if (expectedExit === 0) expect(comparableStderr(actual.stderr), `${optimization} stderr`).toEqual(oracle.stderr);
|
|
expect(actual.exitCode, `${optimization} exit status`).toBe(expectedExit);
|
|
}
|
|
});
|
|
});
|