mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
* feat: add static BigInt support * fix: prevent BigInt width overflow on WASI * fix: harden BigInt formatting sizes * test: record BigInt WASI parity baseline
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
/* wasm32-wasi is a production LLVM target. This lane compares
|
|
* pointer-width-sensitive corpus seeds against Node and pins the backend
|
|
* selection independently of the host platform.
|
|
*/
|
|
import { execFile, execFileSync } from "node:child_process";
|
|
import { mkdtemp, readFile } from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
import { compile, compileLibrary } from "@scriptc/compiler";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repoRoot = join(import.meta.dirname, "../..");
|
|
const require = createRequire(import.meta.url);
|
|
const islandShim = join(import.meta.dirname, "island-shim.mjs");
|
|
|
|
function zigOnPath(): boolean {
|
|
try {
|
|
execFileSync("zig", ["version"], { stdio: "ignore" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function wasmCustomSectionNames(bytes: Uint8Array): string[] {
|
|
let offset = 8;
|
|
const names: string[] = [];
|
|
const readUleb = (end: number): number => {
|
|
let value = 0;
|
|
let shift = 0;
|
|
for (let count = 0; count < 5; count++) {
|
|
if (offset >= end) throw new Error("truncated wasm LEB128 value");
|
|
const byte = bytes[offset++]!;
|
|
value += (byte & 0x7f) * 2 ** shift;
|
|
if ((byte & 0x80) === 0) return value;
|
|
shift += 7;
|
|
}
|
|
throw new Error("oversized wasm LEB128 value");
|
|
};
|
|
while (offset < bytes.length) {
|
|
const sectionId = bytes[offset++]!;
|
|
const sectionSize = readUleb(bytes.length);
|
|
const sectionEnd = offset + sectionSize;
|
|
if (sectionEnd > bytes.length) throw new Error("truncated wasm section");
|
|
if (sectionId === 0) {
|
|
const nameLength = readUleb(sectionEnd);
|
|
const nameEnd = offset + nameLength;
|
|
if (nameEnd > sectionEnd) throw new Error("truncated wasm custom-section name");
|
|
names.push(Buffer.from(bytes.subarray(offset, nameEnd)).toString("utf8"));
|
|
}
|
|
offset = sectionEnd;
|
|
}
|
|
return names;
|
|
}
|
|
|
|
const WASI_RUNNER = [
|
|
'const fs=require("node:fs")',
|
|
'const {WASI}=require("node:wasi")',
|
|
'const p=process.argv[1]',
|
|
'const wasi=new WASI({version:"preview1",args:[p],env:{...process.env,PWD:"/",HOME:"/",TMPDIR:"/tmp"},preopens:{"/":process.cwd(),"/tmp":"/tmp"},returnOnExit:true})',
|
|
'const mod=new WebAssembly.Module(fs.readFileSync(p))',
|
|
'const instance=new WebAssembly.Instance(mod,wasi.getImportObject())',
|
|
'process.exitCode=wasi.start(instance)',
|
|
].join(";");
|
|
|
|
async function run(cmd: string, args: string[], input = ""): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
return await new Promise((resolve, reject) => {
|
|
const child = execFile(cmd, args, { encoding: "utf8" }, (error, stdout, stderr) => {
|
|
if (error === null) {
|
|
resolve({ stdout, stderr, exitCode: 0 });
|
|
} else if (typeof error.code === "number") {
|
|
resolve({ stdout, stderr, exitCode: error.code });
|
|
} else {
|
|
reject(error);
|
|
}
|
|
});
|
|
// A closed pipe is the corpus harness's standard stdin. Closing it
|
|
// explicitly also lets stdin/readline cases observe EOF under WASI.
|
|
child.stdin?.end(input);
|
|
});
|
|
}
|
|
|
|
function expectedExitCode(entry: string): number {
|
|
const source = require("node:fs").readFileSync(entry, "utf8") as string;
|
|
const match = /^\/\/ @exit:\s*(\d+)\s*$/m.exec(source);
|
|
return match === null ? 0 : Number(match[1]);
|
|
}
|
|
|
|
describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => {
|
|
let oldCc: string | undefined;
|
|
let oldTarget: string | undefined;
|
|
|
|
beforeAll(() => {
|
|
oldCc = process.env["SCRIPTC_CC"];
|
|
oldTarget = process.env["SCRIPTC_TARGET"];
|
|
process.env["SCRIPTC_CC"] = "zigcc";
|
|
process.env["SCRIPTC_TARGET"] = "wasm32-wasi";
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (oldCc === undefined) delete process.env["SCRIPTC_CC"];
|
|
else process.env["SCRIPTC_CC"] = oldCc;
|
|
if (oldTarget === undefined) delete process.env["SCRIPTC_TARGET"];
|
|
else process.env["SCRIPTC_TARGET"] = oldTarget;
|
|
});
|
|
|
|
test.each([
|
|
"2700-wasi-core.ts",
|
|
"1612-cjs-module-globals.cjs",
|
|
"992-fs-roundtrip.ts",
|
|
"751-cycle-records-mutual.ts",
|
|
"1000-json-stringify-basics.ts",
|
|
"1002-json-parse-cast.ts",
|
|
"1010-json-stringify-space.ts",
|
|
"1020-async-basics.ts",
|
|
"1021-async-ordering.ts",
|
|
"1022-async-exceptions.ts",
|
|
"1023-async-rc-stress.ts",
|
|
"1024-async-pending-exit.ts",
|
|
"1025-async-promise-capture.ts",
|
|
"1026-throw-promise.ts",
|
|
"1027-async-return-promise.ts",
|
|
"1028-async-return-record-literals.ts",
|
|
"1029-async-eager-chains.ts",
|
|
"1428-settled-await-order.ts",
|
|
"1429-promise-catch-finally.ts",
|
|
"1430-promise-race.ts",
|
|
"1438-promise-all.ts",
|
|
"1440-interval-basics.ts",
|
|
"1444-exit-listeners.ts",
|
|
"1447-stdin-closed-events.ts",
|
|
"1475-readline-closed-stdin.ts",
|
|
"1400-typedarray-basics.ts",
|
|
"1409-typedarray-integer-loops.ts",
|
|
"1551-dyn-receiver-methods.ts",
|
|
"1558-any-joins-and-dyn-validation.ts",
|
|
"1634-inspect-classes.ts",
|
|
"1644-ee-basics.ts",
|
|
"1666-dyn-fn-identity.ts",
|
|
"1800-immediate-basics.ts",
|
|
"1805-timer-callback-args.ts",
|
|
"2010-generators-basics.ts",
|
|
"2011-generators-forof.ts",
|
|
"2012-generators-return-throw.ts",
|
|
"2013-generators-sent-values.ts",
|
|
"2014-generators-values.ts",
|
|
"2015-generators-yieldstar.ts",
|
|
"2016-generators-rc-stress.ts",
|
|
"2017-generators-async.ts",
|
|
"2018-generators-uncaught.ts",
|
|
"2019-generators-loops.ts",
|
|
"2093-timers-promises.ts",
|
|
"2683-fs-rename-js.cjs",
|
|
"2906-bigint-wasi-width.ts",
|
|
])("the LLVM target runs %s byte-identically to Node", async (fixture) => {
|
|
const entry = join(repoRoot, "tests/corpus", fixture);
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-");
|
|
const outPath = join(outDir, "program.wasm");
|
|
const result = await compile(entry, { outDir, outPath });
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
expect(result.backend).toBe("llvm");
|
|
expect(result.cPath.endsWith(".ll")).toBe(true);
|
|
expect([...(await readFile(outPath)).subarray(0, 4)]).toEqual([0x00, 0x61, 0x73, 0x6d]);
|
|
|
|
const node = await run(process.execPath, ["--no-warnings", entry]);
|
|
const wasm = await run(
|
|
process.execPath,
|
|
["--no-warnings", "--experimental-wasi-unstable-preview1", "-e", WASI_RUNNER, outPath],
|
|
);
|
|
expect(wasm.stdout).toBe(node.stdout);
|
|
expect(wasm.exitCode).toBe(expectedExitCode(entry));
|
|
expect(node.exitCode).toBe(expectedExitCode(entry));
|
|
if (wasm.exitCode === 0) expect(wasm.stderr).toBe(node.stderr);
|
|
});
|
|
|
|
test("the explicit LLVM pin emits compact release wasm", async () => {
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-refusal-");
|
|
const outPath = join(outDir, "llvm.wasm");
|
|
const llvmResult = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), {
|
|
outDir,
|
|
outPath,
|
|
backend: "llvm",
|
|
});
|
|
expect(llvmResult.ok).toBe(true);
|
|
if (llvmResult.ok) {
|
|
expect(llvmResult.backend).toBe("llvm");
|
|
const wasm = await readFile(outPath);
|
|
expect(wasmCustomSectionNames(wasm).filter((name) => name.startsWith(".debug"))).toEqual([]);
|
|
expect(wasm.byteLength).toBeLessThan(256 * 1024);
|
|
}
|
|
});
|
|
|
|
test("library mode reports a target diagnostic instead of invoking the WASI toolchain", async () => {
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-library-");
|
|
const result = await compileLibrary({
|
|
profilePath: join(repoRoot, "tests/library-mode/scalars/profile.json"),
|
|
outDir,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.diagnostics).toHaveLength(1);
|
|
expect(result.diagnostics[0]?.code).toBe("SC3002");
|
|
expect(result.diagnostics[0]?.message).toMatch(/does not support library-mode archive builds/);
|
|
}
|
|
});
|
|
|
|
test("the explicit C backend still emits compact async-free wasm", async () => {
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-c-");
|
|
const outPath = join(outDir, "program.wasm");
|
|
const result = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), {
|
|
outDir,
|
|
outPath,
|
|
backend: "c",
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
}
|
|
expect(result.backend).toBe("c");
|
|
const wasm = await readFile(outPath);
|
|
expect(wasmCustomSectionNames(wasm).filter((name) => name.startsWith(".debug"))).toEqual([]);
|
|
expect(wasm.byteLength).toBeLessThan(256 * 1024);
|
|
});
|
|
|
|
test("the explicit C backend diagnoses coroutine-dependent programs", async () => {
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-c-refusal-");
|
|
const result = await compile(join(repoRoot, "tests/corpus/1020-async-basics.ts"), {
|
|
outDir,
|
|
outPath: join(outDir, "program.wasm"),
|
|
backend: "c",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.diagnostics).toHaveLength(1);
|
|
expect(result.diagnostics[0]?.code).toBe("SC3001");
|
|
expect(result.diagnostics[0]?.message).toMatch(
|
|
/c backend does not support an async function .* for wasm32-wasi; use --backend llvm/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("embedded source comments mentioning fetch do not trigger SC3002", async () => {
|
|
const entry = join(repoRoot, "tests/fixtures/npm/cases/island-web-plumbing/main.ts");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-fetch-comment-");
|
|
const result = await compile(entry, {
|
|
outDir,
|
|
outPath: join(outDir, "program.wasm"),
|
|
dynamic: true,
|
|
});
|
|
if (!result.ok) {
|
|
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
}
|
|
expect(result.backend).toBe("llvm");
|
|
});
|
|
|
|
test("embedded destructuring of global fetch reports SC3002", async () => {
|
|
const entry = join(repoRoot, "tests/fixtures/npm/cases/fetch-destructure/main.ts");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-fetch-destructure-");
|
|
const result = await compile(entry, {
|
|
outDir,
|
|
outPath: join(outDir, "program.wasm"),
|
|
dynamic: true,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.diagnostics).toHaveLength(1);
|
|
expect(result.diagnostics[0]?.code).toBe("SC3002");
|
|
expect(result.diagnostics[0]?.message).toMatch(/network-backed fetch/);
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
["1360-spawn-sync.ts", /child processes/],
|
|
["1443-signal-handlers.ts", /OS signals/],
|
|
["1564-fs-watch.ts", /filesystem watching/],
|
|
["1780-http-res-surface.ts", /network sockets/],
|
|
])("reports unavailable WASI capabilities for %s", async (fixture, message) => {
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-capability-");
|
|
const result = await compile(join(repoRoot, "tests/corpus", fixture), {
|
|
outDir,
|
|
outPath: join(outDir, "program.wasm"),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.diagnostics).toHaveLength(1);
|
|
expect(result.diagnostics[0]?.code).toBe("SC3002");
|
|
expect(result.diagnostics[0]?.message).toMatch(message);
|
|
}
|
|
});
|
|
|
|
test("the LLVM target supports the dynamic island", async () => {
|
|
const entry = join(repoRoot, "tests/corpus/1100-island-eval-basics.ts");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-dynamic-");
|
|
const outPath = join(outDir, "program.wasm");
|
|
const result = await compile(entry, { outDir, outPath, dynamic: true });
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
expect(result.backend).toBe("llvm");
|
|
|
|
const node = await run(process.execPath, ["--import", islandShim, entry]);
|
|
const wasm = await run(
|
|
process.execPath,
|
|
["--no-warnings", "--experimental-wasi-unstable-preview1", "-e", WASI_RUNNER, outPath],
|
|
);
|
|
expect(wasm).toEqual(node);
|
|
});
|
|
|
|
test("pending static promises cross into the dynamic island", async () => {
|
|
const entry = join(repoRoot, "tests/corpus/2633-island-promise-crossing.js");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-dynamic-promise-");
|
|
const outPath = join(outDir, "program.wasm");
|
|
const result = await compile(entry, { outDir, outPath, dynamic: true });
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
expect(result.backend).toBe("llvm");
|
|
|
|
const node = await run(process.execPath, ["--import", islandShim, entry]);
|
|
const wasm = await run(
|
|
process.execPath,
|
|
["--no-warnings", "--experimental-wasi-unstable-preview1", "-e", WASI_RUNNER, outPath],
|
|
);
|
|
expect(wasm).toEqual(node);
|
|
});
|
|
|
|
test("the LLVM target embeds npm packages for the dynamic island", async () => {
|
|
const entry = join(repoRoot, "tests/fixtures/npm/cases/namespace/main.ts");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-npm-");
|
|
const outPath = join(outDir, "program.wasm");
|
|
const result = await compile(entry, { outDir, outPath, dynamic: true });
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
expect(result.backend).toBe("llvm");
|
|
|
|
const node = await run(process.execPath, ["--no-warnings", entry]);
|
|
const wasm = await run(
|
|
process.execPath,
|
|
["--no-warnings", "--experimental-wasi-unstable-preview1", "-e", WASI_RUNNER, outPath],
|
|
);
|
|
expect(wasm).toEqual(node);
|
|
});
|
|
|
|
test.each([
|
|
"top-level-await-pending-exit-code",
|
|
"top-level-await-pending-exit-listener",
|
|
])("embedded npm preserves pending-module exit precedence for %s", async (fixture) => {
|
|
const entry = join(repoRoot, "tests/fixtures/npm/cases", fixture, "main.ts");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-npm-pending-");
|
|
const outPath = join(outDir, "program.wasm");
|
|
const result = await compile(entry, { outDir, outPath, dynamic: true });
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
expect(result.backend).toBe("llvm");
|
|
|
|
const node = await run(process.execPath, ["--no-warnings", entry]);
|
|
const wasm = await run(
|
|
process.execPath,
|
|
["--no-warnings", "--experimental-wasi-unstable-preview1", "-e", WASI_RUNNER, outPath],
|
|
);
|
|
expect(wasm).toEqual(node);
|
|
});
|
|
|
|
test("the LLVM target embeds compressed npm sources and builtin shims", async () => {
|
|
const entry = join(repoRoot, "tests/fixtures/npm/cases/zlib-shims/main.ts");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-npm-zlib-");
|
|
const outPath = join(outDir, "program.wasm");
|
|
const result = await compile(entry, { outDir, outPath, dynamic: true });
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
|
expect(result.backend).toBe("llvm");
|
|
|
|
const node = await run(process.execPath, ["--no-warnings", entry]);
|
|
const wasm = await run(
|
|
process.execPath,
|
|
["--no-warnings", "--experimental-wasi-unstable-preview1", "-e", WASI_RUNNER, outPath],
|
|
);
|
|
expect(wasm).toEqual(node);
|
|
});
|
|
|
|
test("scriptc run hosts the module with inherited stdio", async () => {
|
|
const entry = join(repoRoot, "tests/corpus/2700-wasi-core.ts");
|
|
const outDir = await mkdtemp("/tmp/scriptc-wasi-cli-");
|
|
const loader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
|
|
const result = await execFileAsync(process.execPath, [
|
|
"--import", loader,
|
|
join(repoRoot, "packages/cli/src/main.ts"),
|
|
"run", entry,
|
|
"--no-keep-c",
|
|
"-o", join(outDir, "program.wasm"),
|
|
]);
|
|
const node = await execFileAsync(process.execPath, [entry]);
|
|
expect(result.stdout).toBe(node.stdout);
|
|
expect(result.stderr).toBe(node.stderr);
|
|
});
|
|
});
|