mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-04 08:04:37 +08:00
Nearly every long-lived object becomes a cycle candidate once, soon after it is built: the constructing frame's release leaves it held only by the structure it was stored into. A nursery pass then walks it (about 75 ns per object), it survives, and it is re-buffered for the mature level, whose next pass walks it again. In tsc-ts single-threaded, nursery passes walked 6.0M objects on Excalidraw and 36.5M on Sentry and freed 7%; 99% of what they walked was promoted. Most of that walking goes to types whose objects never die in a young cycle: SymbolLinks, NodeLinks and Symbol were 2.0M of Excalidraw's candidates and the collector freed none of them, while young garbage comes from a few types (inference contexts and mappers, closures, some arrays). Types with a collector yield under 0.5% are 42-55% of what nursery passes walk. So the collector now keeps per-thread statistics per type and level. Each ScrCycType gains a `kind` index: emitted descriptors number themselves from 32 (FIRST_EMITTED_CYC_KIND / SCR_CYC_KIND_EMITTED), the runtime's own families have fixed kinds below that, 0 keeps no statistics. For the nursery and mature levels, a pass counts the candidates of each kind it walks and the objects of each kind it frees. After every 8192 walked candidates a kind bypasses that level for the next window if fewer than 1 in 128 of them were freed. A release that buffers an object at a level its type bypasses promotes it past the level first, and a nursery pass that re-buffers a survivor into a bypassed mature level moves it to the old level. One candidate in 16 stays put, so the yield keeps being measured and a type whose objects start dying young again is walked again after one window. Only the generation an object is judged in changes. Any assignment is sound (a restricted pass counts edges from older objects as external), a bypassing candidate stays buffered, and full passes walk everything they did before, so the old level's bounds hold unchanged. Cyclic garbage made only of objects of a bypassing type waits for the next pass at an older level. Programs that never complete a window (8192 walked candidates of one type) are collected exactly as before. The descriptor layout is part of the emitted-code/runtime ABI, so the program-object marker moves to scr_runtime_abi_v12. Pass log (instrumented build, single-threaded, one run each, with the preceding null-slot change): all collector passes Excalidraw 606 -> 313 ms (nursery 435 -> 153), TypeORM 629 -> 383 ms, tsc-ts self-check 377 -> 215 ms, Sentry 7.61 -> 5.75 s (nursery 2.92 -> 1.04 s, mature 1.17 -> 1.02 s; the full pass is unchanged at 3.6 s). tsc-ts (Linux sandbox, interleaved, medians; 10 rounds, Sentry 5 rounds pinned to 8 CPUs; both changes against int/best-v6): single-threaded Excalidraw -8.0%, TypeORM -4.2%, self-check -4.3%, Sentry -5.3%; --checkers 8 -12.2%, -0.8%, -2.3%, -1.6%. Peak RSS -0.8% to +0.6%. Diagnostics identical. Tests: test_cycle gains check_nursery_bypass, check_mature_bypass and check_rebuffer_past_mature (a test-only entry point, scr_cyc_pass_at under SCR_CYC_TESTING, runs passes at exact levels); each fails under the corresponding mutation (no bypass, a sticky decision, no sampling, no freed or walked counts, a bypassing candidate left unbuffered, survivors re-buffered into a bypassed level). Corpus 4690 builds a long-lived declaration graph large enough for both levels to bypass and lets objects of the same classes die in cycles; under --sanitize the exit RC audit proves they are reclaimed.
720 lines
28 KiB
TypeScript
720 lines
28 KiB
TypeScript
import { execFile, spawn } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
import { release as osRelease, tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
import { compile, compileC } from "../src/index.js";
|
|
import { MACOS_ARM64_TARGET } from "../src/backend/targets.js";
|
|
import * as llvmSplit from "../src/backend/llvm/split.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const supported =
|
|
process.platform === "darwin" &&
|
|
process.arch === "arm64" &&
|
|
Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24;
|
|
const repoRoot = join(import.meta.dirname, "../../..");
|
|
const require = createRequire(import.meta.url);
|
|
const helperPackage = supported ? require.resolve("@scriptc/llvm-darwin-arm64/package.json") : "";
|
|
const helper = supported ? join(dirname(helperPackage), "bin", "scriptc-llvm-codegen") : "";
|
|
const dirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
function helperArgs(input: string, output: string, target = MACOS_ARM64_TARGET.llvmTriple) {
|
|
return [
|
|
"emit",
|
|
"--input",
|
|
input,
|
|
"--output",
|
|
output,
|
|
"--filetype",
|
|
"obj",
|
|
"--target",
|
|
target,
|
|
"--opt-level",
|
|
"2",
|
|
"--relocation-model",
|
|
"pic",
|
|
"--diagnostic-format",
|
|
"json",
|
|
"--source-path",
|
|
"/src/original.ts",
|
|
];
|
|
}
|
|
|
|
async function run(command: string, args: string[]) {
|
|
try {
|
|
const result = await execFileAsync(command, args, { encoding: "buffer" });
|
|
return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
|
|
} catch (error) {
|
|
const failure = error as { stdout?: Buffer; stderr?: Buffer; code?: number };
|
|
return {
|
|
stdout: failure.stdout ?? Buffer.alloc(0),
|
|
stderr: failure.stderr ?? Buffer.alloc(0),
|
|
exitCode: typeof failure.code === "number" ? failure.code : 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
describe.runIf(supported)("LLVM native helper integration", () => {
|
|
test("dev helper-object emission does not prepare unused program shards", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-dev-"));
|
|
dirs.push(dir);
|
|
const split = vi.spyOn(llvmSplit, "splitLlvmProgram");
|
|
try {
|
|
const result = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), {
|
|
outDir: dir,
|
|
outPath: join(dir, "program"),
|
|
backend: "llvm",
|
|
optimization: "dev",
|
|
nativeProgramObject: true,
|
|
});
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
|
|
expect(split).not.toHaveBeenCalled();
|
|
expect(await run(result.binaryPath, [])).toEqual({
|
|
stdout: Buffer.from("hello world\n"),
|
|
stderr: Buffer.alloc(0),
|
|
exitCode: 0,
|
|
});
|
|
} finally {
|
|
split.mockRestore();
|
|
}
|
|
});
|
|
|
|
test("release codegen vectorizes independent scalar arithmetic without fast math", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-vectorize-"));
|
|
dirs.push(dir);
|
|
const input = join(dir, "pairs.ll");
|
|
const output = join(dir, "pairs.s");
|
|
await writeFile(
|
|
input,
|
|
`
|
|
define void @pairs(ptr noalias %out, ptr %a, ptr %b) {
|
|
%a1 = getelementptr double, ptr %a, i64 1
|
|
%b1 = getelementptr double, ptr %b, i64 1
|
|
%out1 = getelementptr double, ptr %out, i64 1
|
|
%x0 = load double, ptr %a
|
|
%x1 = load double, ptr %a1
|
|
%y0 = load double, ptr %b
|
|
%y1 = load double, ptr %b1
|
|
%p0 = fmul double %x0, %y0
|
|
%p1 = fmul double %x1, %y1
|
|
%r0 = fadd double %p0, %x0
|
|
%r1 = fadd double %p1, %x1
|
|
store double %r0, ptr %out
|
|
store double %r1, ptr %out1
|
|
ret void
|
|
}
|
|
`,
|
|
);
|
|
const args = helperArgs(input, output);
|
|
args[args.indexOf("--filetype") + 1] = "asm";
|
|
const result = await run(helper, args);
|
|
expect(result).toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 0 });
|
|
const assembly = await readFile(output, "utf8");
|
|
expect(assembly).toMatch(/fmul\.2d\b/);
|
|
expect(assembly).toMatch(/fadd\.2d\b/);
|
|
expect(assembly).not.toMatch(/\bfm(?:add|la)\b/);
|
|
});
|
|
|
|
test("malformed IR and unsupported targets are structured and preserve caller output", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-errors-"));
|
|
dirs.push(dir);
|
|
const input = join(dir, "bad.ll");
|
|
const output = join(dir, "out.o");
|
|
await writeFile(input, "this is not LLVM IR\n");
|
|
await writeFile(output, "existing\n");
|
|
const malformed = await run(helper, helperArgs(input, output));
|
|
expect(malformed.exitCode).toBe(1);
|
|
expect(JSON.parse(malformed.stderr.toString("utf8"))).toMatchObject({
|
|
ok: false,
|
|
code: "invalid_ir",
|
|
});
|
|
expect(await readFile(output, "utf8")).toBe("existing\n");
|
|
|
|
await writeFile(input, "define i32 @answer() { ret i32 42 }\n");
|
|
const unsupported = await run(helper, helperArgs(input, output, "riscv64-unknown-linux-gnu"));
|
|
expect(JSON.parse(unsupported.stderr.toString("utf8"))).toMatchObject({
|
|
ok: false,
|
|
code: "unsupported_target",
|
|
});
|
|
expect(await readFile(output, "utf8")).toBe("existing\n");
|
|
});
|
|
|
|
test("partitioned release objects link to the same program deterministically", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-partitions-"));
|
|
dirs.push(dir);
|
|
const input = join(dir, "program.ll");
|
|
// Internal callees and state must stay reachable across partitions.
|
|
await writeFile(
|
|
input,
|
|
`@total = internal global i64 0
|
|
${[0, 1, 2, 3, 4, 5]
|
|
.map(
|
|
(index) => `define internal void @add${index}(i64 %value) noinline {
|
|
%old = load i64, ptr @total
|
|
%scaled = mul i64 %value, ${index + 1}
|
|
%new = add i64 %old, %scaled
|
|
store i64 %new, ptr @total
|
|
ret void
|
|
}`,
|
|
)
|
|
.join("\n")}
|
|
define i32 @main() {
|
|
${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})`).join("\n")}
|
|
%total = load i64, ptr @total
|
|
%code = trunc i64 %total to i32
|
|
ret i32 %code
|
|
}
|
|
`,
|
|
);
|
|
const emit = async (outputs: string[]) => {
|
|
const args = helperArgs(input, outputs[0]!);
|
|
for (const output of outputs.slice(1)) args.push("--output", output);
|
|
expect((await run(helper, args)).exitCode).toBe(0);
|
|
return Promise.all(outputs.map((output) => readFile(output)));
|
|
};
|
|
const exitCode = async (objects: string[], name: string) => {
|
|
const executable = join(dir, name);
|
|
await execFileAsync("clang", [...objects, "-o", executable]);
|
|
return (await run(executable, [])).exitCode;
|
|
};
|
|
const partitions = ["a.o", "b.o", "c.o"].map((name) => join(dir, name));
|
|
const first = await emit(partitions);
|
|
const again = await emit(["d.o", "e.o", "f.o"].map((name) => join(dir, name)));
|
|
expect(again).toEqual(first);
|
|
await emit([join(dir, "whole.o")]);
|
|
expect(await exitCode(partitions, "partitioned")).toBe(
|
|
await exitCode([join(dir, "whole.o")], "whole"),
|
|
);
|
|
expect(await exitCode(partitions, "partitioned")).toBe(112);
|
|
|
|
const refused = await run(helper, [
|
|
...helperArgs(input, join(dir, "x.s")).map((arg) => (arg === "obj" ? "asm" : arg)),
|
|
"--output",
|
|
join(dir, "y.s"),
|
|
]);
|
|
expect(refused.exitCode).toBe(1);
|
|
expect(JSON.parse(refused.stderr.toString("utf8"))).toMatchObject({
|
|
ok: false,
|
|
code: "invalid_partitions",
|
|
});
|
|
});
|
|
|
|
test("LLVM fatal diagnostics remain process-isolated JSON", async () => {
|
|
const result = await execFileAsync(helper, ["version", "--format=json"], {
|
|
encoding: "utf8",
|
|
env: { ...process.env, SCRIPTC_LLVM_TEST_FATAL: "1" },
|
|
}).then(
|
|
() => null,
|
|
(error: { stderr?: string; code?: number }) => error,
|
|
);
|
|
expect(result?.code).toBe(70);
|
|
expect(JSON.parse(result?.stderr ?? "{}")).toMatchObject({
|
|
ok: false,
|
|
code: "llvm_fatal",
|
|
});
|
|
});
|
|
|
|
test("an interrupted helper cannot truncate an existing caller artifact", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-interrupt-"));
|
|
dirs.push(dir);
|
|
const input = join(dir, "large.ll");
|
|
const output = join(dir, "out.o");
|
|
const functions = Array.from(
|
|
{ length: 100_000 },
|
|
(_, i) => `define i32 @f${i}() { ret i32 ${i} }`,
|
|
).join("\n");
|
|
await writeFile(input, `${functions}\n`);
|
|
await writeFile(output, "existing\n");
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn(helper, helperArgs(input, output), { stdio: "ignore" });
|
|
child.once("error", reject);
|
|
child.once("spawn", () => child.kill("SIGKILL"));
|
|
child.once("close", () => resolve());
|
|
});
|
|
expect(await readFile(output, "utf8")).toBe("existing\n");
|
|
});
|
|
|
|
test("helper and clang objects have matching metadata and identical runtime behavior", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-parity-"));
|
|
dirs.push(dir);
|
|
const entry = join(repoRoot, "tests/corpus/001-hello.ts");
|
|
const helperExe = join(dir, "helper-program");
|
|
const result = await compile(entry, {
|
|
outDir: dir,
|
|
outPath: helperExe,
|
|
backend: "llvm",
|
|
nativeProgramObject: true,
|
|
});
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
|
|
const llvm = join(dir, "001-hello.ll");
|
|
const helperObject = join(dir, "001-hello.helper.o");
|
|
const clangObject = join(dir, "001-hello.clang.o");
|
|
await execFileAsync("clang", [
|
|
"-O2",
|
|
"-Wno-override-module",
|
|
"-target",
|
|
MACOS_ARM64_TARGET.llvmTriple,
|
|
"-c",
|
|
llvm,
|
|
"-o",
|
|
clangObject,
|
|
]);
|
|
|
|
for (const object of [helperObject, clangObject]) {
|
|
await expect(execFileAsync("file", [object], { encoding: "utf8" })).resolves.toMatchObject({
|
|
stdout: expect.stringContaining("Mach-O 64-bit object arm64"),
|
|
});
|
|
const loadCommands = (await execFileAsync("otool", ["-l", object], { encoding: "utf8" }))
|
|
.stdout;
|
|
expect(loadCommands).toMatch(/LC_BUILD_VERSION[\s\S]*minos 14\.0/);
|
|
}
|
|
const sections = async (object: string) =>
|
|
[
|
|
...(await execFileAsync("otool", ["-l", object], { encoding: "utf8" })).stdout.matchAll(
|
|
/sectname (\S+)[\s\S]*?segname (\S+)/g,
|
|
),
|
|
]
|
|
.map((match) => `${match[2]},${match[1]}`)
|
|
.sort();
|
|
for (const object of [helperObject, clangObject]) {
|
|
expect(await sections(object)).toEqual(
|
|
expect.arrayContaining(["__TEXT,__text", "__TEXT,__eh_frame", "__LD,__compact_unwind"]),
|
|
);
|
|
}
|
|
const relocations = async (object: string) =>
|
|
(await execFileAsync("otool", ["-rv", object], { encoding: "utf8" })).stdout
|
|
.split("\n")
|
|
.filter((line) => /\b(?:BR26|PAGE21|PAGOF12|GOTLDP|GOTLDPOF|SUB|UNSIGND)\b/.test(line))
|
|
.map((line) => line.replace(/^\S+\s+/, "").trim())
|
|
.sort();
|
|
const [helperRelocations, clangRelocations] = await Promise.all([
|
|
relocations(helperObject),
|
|
relocations(clangObject),
|
|
]);
|
|
for (const kind of ["BR26", "PAGE21", "PAGOF12", "GOTLDP", "GOTLDPOF", "SUB", "UNSIGND"]) {
|
|
expect(
|
|
helperRelocations.some((line) => line.includes(kind)),
|
|
`helper lacks ${kind}`,
|
|
).toBe(clangRelocations.some((line) => line.includes(kind)));
|
|
}
|
|
const symbols = async (object: string, args: string[]) =>
|
|
(await execFileAsync("nm", [...args, object], { encoding: "utf8" })).stdout
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.sort();
|
|
expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"]));
|
|
expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"]));
|
|
expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]);
|
|
expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v12");
|
|
|
|
const clangExe = join(dir, "clang-program");
|
|
const linkDriver = join(dir, "clang-driver.c");
|
|
await writeFile(linkDriver, "/* clang object link driver */\n");
|
|
await compileC({ cPath: linkDriver, outPath: clangExe, linkInputs: [clangObject] });
|
|
const [helperRun, clangRun, nodeRun] = await Promise.all([
|
|
run(helperExe, []),
|
|
run(clangExe, []),
|
|
run(process.execPath, [entry]),
|
|
]);
|
|
expect(helperRun).toEqual(nodeRun);
|
|
expect(clangRun).toEqual(nodeRun);
|
|
});
|
|
|
|
test("helper-object validation isolates concurrent link inputs", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-concurrent-"));
|
|
dirs.push(dir);
|
|
const firstEntry = join(dir, "first", "main.ts");
|
|
const secondEntry = join(dir, "second", "main.ts");
|
|
await Promise.all([mkdir(dirname(firstEntry)), mkdir(dirname(secondEntry))]);
|
|
await Promise.all([
|
|
writeFile(firstEntry, 'console.log("first concurrent helper");\n'),
|
|
writeFile(secondEntry, 'console.log("second concurrent helper");\n'),
|
|
]);
|
|
const build = (entry: string, output: string) =>
|
|
compile(entry, {
|
|
outDir: dir,
|
|
outPath: output,
|
|
backend: "llvm",
|
|
nativeProgramObject: true,
|
|
});
|
|
const firstExe = join(dir, "first-program");
|
|
const secondExe = join(dir, "second-program");
|
|
const [first, second] = await Promise.all([
|
|
build(firstEntry, firstExe),
|
|
build(secondEntry, secondExe),
|
|
]);
|
|
if (!first.ok || !second.ok) throw new Error("concurrent helper builds failed");
|
|
const [firstRun, secondRun] = await Promise.all([run(firstExe, []), run(secondExe, [])]);
|
|
expect(firstRun).toMatchObject({
|
|
stdout: Buffer.from("first concurrent helper\n"),
|
|
exitCode: 0,
|
|
});
|
|
expect(secondRun).toMatchObject({
|
|
stdout: Buffer.from("second concurrent helper\n"),
|
|
exitCode: 0,
|
|
});
|
|
});
|
|
|
|
test("partial executable-cache hits still emit the program object through the helper", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-cache-hit-"));
|
|
dirs.push(dir);
|
|
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
|
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
|
const oldFatal = process.env["SCRIPTC_LLVM_TEST_FATAL"];
|
|
try {
|
|
process.env["SCRIPTC_CACHE_DIR"] = join(dir, "cache");
|
|
delete process.env["SCRIPTC_NO_CACHE"];
|
|
delete process.env["SCRIPTC_LLVM_TEST_FATAL"];
|
|
const options = {
|
|
outDir: dir,
|
|
outPath: join(dir, "program"),
|
|
backend: "llvm" as const,
|
|
nativeProgramObject: true,
|
|
};
|
|
const first = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), options);
|
|
if (!first.ok) throw new Error(first.diagnostics.map((d) => d.message).join("\n"));
|
|
await rm(join(dir, "cache", "native-codegen-v1"), { recursive: true, force: true });
|
|
|
|
// Helper-object links deliberately retain a partial early-cache entry
|
|
// because the caller-owned object disables final-binary caching. Evict
|
|
// the independent helper-object cache so the hit must regenerate that
|
|
// object rather than compiling the restored .ll directly through clang.
|
|
process.env["SCRIPTC_LLVM_TEST_FATAL"] = "1";
|
|
const second = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), options);
|
|
expect(second).toMatchObject({
|
|
ok: false,
|
|
diagnostics: [{ code: "SC3004", message: expect.stringContaining("LLVM fatal") }],
|
|
});
|
|
} finally {
|
|
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
|
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
|
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
|
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
|
if (oldFatal === undefined) delete process.env["SCRIPTC_LLVM_TEST_FATAL"];
|
|
else process.env["SCRIPTC_LLVM_TEST_FATAL"] = oldFatal;
|
|
}
|
|
});
|
|
|
|
test("helper-object validation does not reuse an ordinary runtime-pack executable entry", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-cache-identity-"));
|
|
dirs.push(dir);
|
|
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
|
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
|
try {
|
|
process.env["SCRIPTC_CACHE_DIR"] = join(dir, "cache");
|
|
delete process.env["SCRIPTC_NO_CACHE"];
|
|
const entry = join(repoRoot, "tests/corpus/001-hello.ts");
|
|
const options = { outDir: dir, outPath: join(dir, "program"), backend: "llvm" as const };
|
|
const ordinary = await compile(entry, options);
|
|
if (!ordinary.ok) throw new Error(ordinary.diagnostics.map((d) => d.message).join("\n"));
|
|
|
|
const validation = await compile(entry, { ...options, nativeProgramObject: true });
|
|
if (!validation.ok) throw new Error(validation.diagnostics.map((d) => d.message).join("\n"));
|
|
expect((await stat(join(dir, "001-hello.helper.o"))).size).toBeGreaterThan(0);
|
|
} finally {
|
|
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
|
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
|
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
|
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
|
}
|
|
});
|
|
|
|
test("runtime-pack executable proofs include the helper package and binary", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-cache-proof-"));
|
|
dirs.push(dir);
|
|
const cache = join(dir, "cache");
|
|
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
|
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
|
try {
|
|
process.env["SCRIPTC_CACHE_DIR"] = cache;
|
|
delete process.env["SCRIPTC_NO_CACHE"];
|
|
const result = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), {
|
|
outDir: dir,
|
|
outPath: join(dir, "program"),
|
|
backend: "llvm",
|
|
});
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
|
|
|
|
const stampName = (await readdir(join(cache, "early-exe"), { recursive: true })).find(
|
|
(path) => path.endsWith("stamp.json"),
|
|
);
|
|
expect(stampName).toBeDefined();
|
|
const stamp = JSON.parse(await readFile(join(cache, "early-exe", stampName!), "utf8")) as {
|
|
nativeDependencies: { path: string }[];
|
|
};
|
|
const dependencies = stamp.nativeDependencies.map((entry) => entry.path);
|
|
expect(dependencies).toContain(helperPackage);
|
|
expect(dependencies).toContain(join(dirname(helperPackage), "bin", "scriptc-llvm-codegen"));
|
|
} finally {
|
|
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
|
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
|
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
|
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
|
}
|
|
});
|
|
|
|
test("runtime-pack FFI system libraries are relinked after an in-place rebuild", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-ffi-cache-"));
|
|
dirs.push(dir);
|
|
const entry = join(dir, "main.ts");
|
|
const profile = join(dir, "ffi.json");
|
|
const source = join(dir, "probe.c");
|
|
const object = join(dir, "probe.o");
|
|
const library = join(dir, "libscriptc_cache_probe.a");
|
|
const cache = join(dir, "cache");
|
|
const output = join(dir, "program");
|
|
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
|
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
|
const oldLibraryPath = process.env["LIBRARY_PATH"];
|
|
const rebuildLibrary = async (value: number) => {
|
|
await writeFile(source, `double scriptc_cache_probe(void) { return ${value}; }\n`);
|
|
await execFileAsync("clang", ["-c", source, "-o", object]);
|
|
await execFileAsync("ar", ["rcs", library, object]);
|
|
};
|
|
try {
|
|
await writeFile(
|
|
entry,
|
|
["declare function nativeValue(): number;", "console.log(nativeValue());", ""].join("\n"),
|
|
);
|
|
await writeFile(
|
|
profile,
|
|
JSON.stringify({
|
|
ffi_format: 1,
|
|
functions: [
|
|
{
|
|
name: "nativeValue",
|
|
symbol: "scriptc_cache_probe",
|
|
params: [],
|
|
returns: "f64",
|
|
},
|
|
],
|
|
libraries: [],
|
|
system_libraries: ["scriptc_cache_probe"],
|
|
}),
|
|
);
|
|
process.env["SCRIPTC_CACHE_DIR"] = cache;
|
|
delete process.env["SCRIPTC_NO_CACHE"];
|
|
process.env["LIBRARY_PATH"] = dir;
|
|
const options = {
|
|
outDir: dir,
|
|
outPath: output,
|
|
backend: "llvm" as const,
|
|
ffiProfilePath: profile,
|
|
};
|
|
|
|
await rebuildLibrary(1);
|
|
const first = await compile(entry, options);
|
|
if (!first.ok) throw new Error(first.diagnostics.map((d) => d.message).join("\n"));
|
|
expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout.trim()).toBe("1");
|
|
|
|
await rebuildLibrary(2);
|
|
const second = await compile(entry, options);
|
|
if (!second.ok) throw new Error(second.diagnostics.map((d) => d.message).join("\n"));
|
|
expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout.trim()).toBe("2");
|
|
} finally {
|
|
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
|
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
|
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
|
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
|
if (oldLibraryPath === undefined) delete process.env["LIBRARY_PATH"];
|
|
else process.env["LIBRARY_PATH"] = oldLibraryPath;
|
|
}
|
|
});
|
|
|
|
test("runtime-pack PATH linker wrappers cannot restore hidden link inputs", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-linker-cache-"));
|
|
dirs.push(dir);
|
|
const entry = join(dir, "main.ts");
|
|
const wrapper = join(dir, "clang");
|
|
const firstSource = join(dir, "first.c");
|
|
const secondSource = join(dir, "second.c");
|
|
const firstObject = join(dir, "first.o");
|
|
const secondObject = join(dir, "second.o");
|
|
const cache = join(dir, "cache");
|
|
const output = join(dir, "program");
|
|
const oldPath = process.env["PATH"];
|
|
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
|
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
|
const oldLinkInput = process.env["SCRIPTC_TEST_LINK_INPUT"];
|
|
try {
|
|
await Promise.all([
|
|
writeFile(entry, 'console.log("program");\n'),
|
|
writeFile(
|
|
firstSource,
|
|
[
|
|
"#include <unistd.h>",
|
|
'__attribute__((constructor)) static void marker(void) { write(1, "first\\n", 6); }',
|
|
"",
|
|
].join("\n"),
|
|
),
|
|
writeFile(
|
|
secondSource,
|
|
[
|
|
"#include <unistd.h>",
|
|
'__attribute__((constructor)) static void marker(void) { write(1, "second\\n", 7); }',
|
|
"",
|
|
].join("\n"),
|
|
),
|
|
writeFile(
|
|
wrapper,
|
|
[
|
|
"#!/bin/sh",
|
|
'for arg in "$@"; do',
|
|
' if [ "$arg" = "-c" ]; then exec /usr/bin/clang "$@"; fi',
|
|
"done",
|
|
'has_output=""',
|
|
'for arg in "$@"; do [ "$arg" = "-o" ] && has_output=1; done',
|
|
'if [ -n "$has_output" ] && [ -n "$SCRIPTC_TEST_LINK_INPUT" ]; then',
|
|
' exec /usr/bin/clang "$@" "$SCRIPTC_TEST_LINK_INPUT"',
|
|
"fi",
|
|
'exec /usr/bin/clang "$@"',
|
|
"",
|
|
].join("\n"),
|
|
),
|
|
]);
|
|
await chmod(wrapper, 0o755);
|
|
await Promise.all([
|
|
execFileAsync("/usr/bin/clang", [
|
|
"-target",
|
|
MACOS_ARM64_TARGET.llvmTriple,
|
|
"-c",
|
|
firstSource,
|
|
"-o",
|
|
firstObject,
|
|
]),
|
|
execFileAsync("/usr/bin/clang", [
|
|
"-target",
|
|
MACOS_ARM64_TARGET.llvmTriple,
|
|
"-c",
|
|
secondSource,
|
|
"-o",
|
|
secondObject,
|
|
]),
|
|
]);
|
|
process.env["PATH"] = `${dir}:${oldPath ?? "/usr/bin:/bin"}`;
|
|
process.env["SCRIPTC_CACHE_DIR"] = cache;
|
|
delete process.env["SCRIPTC_NO_CACHE"];
|
|
const options = { outDir: dir, outPath: output, backend: "llvm" as const };
|
|
|
|
process.env["SCRIPTC_TEST_LINK_INPUT"] = firstObject;
|
|
const first = await compile(entry, options);
|
|
if (!first.ok) throw new Error(first.diagnostics.map((d) => d.message).join("\n"));
|
|
expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout).toBe(
|
|
"first\nprogram\n",
|
|
);
|
|
|
|
process.env["SCRIPTC_TEST_LINK_INPUT"] = secondObject;
|
|
const second = await compile(entry, options);
|
|
if (!second.ok) throw new Error(second.diagnostics.map((d) => d.message).join("\n"));
|
|
expect((await execFileAsync(output, [], { encoding: "utf8" })).stdout).toBe(
|
|
"second\nprogram\n",
|
|
);
|
|
} finally {
|
|
if (oldPath === undefined) delete process.env["PATH"];
|
|
else process.env["PATH"] = oldPath;
|
|
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
|
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
|
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
|
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
|
if (oldLinkInput === undefined) delete process.env["SCRIPTC_TEST_LINK_INPUT"];
|
|
else process.env["SCRIPTC_TEST_LINK_INPUT"] = oldLinkInput;
|
|
}
|
|
});
|
|
|
|
test("object emission preserves outbound FFI declarations as native C ABI references", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-ffi-"));
|
|
dirs.push(dir);
|
|
const object = join(dir, "ffi.o");
|
|
const result = await compile(join(repoRoot, "tests/ffi/main.ts"), {
|
|
outDir: dir,
|
|
outPath: object,
|
|
outputKind: "obj",
|
|
ffiProfilePath: join(repoRoot, "tests/ffi/profile.json"),
|
|
});
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
|
|
const undefinedSymbols = (
|
|
await execFileAsync("nm", ["-u", object], { encoding: "utf8" })
|
|
).stdout
|
|
.trim()
|
|
.split("\n");
|
|
expect(undefinedSymbols).toContain("_sf_scale");
|
|
expect(undefinedSymbols).toContain("_sf_callback_mix");
|
|
expect(undefinedSymbols).toContain("_scr_runtime_abi_v12");
|
|
});
|
|
|
|
test("helper and clang object paths retain the same outbound FFI C ABI", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-ffi-parity-"));
|
|
dirs.push(dir);
|
|
const entry = join(dir, "main.ts");
|
|
const profile = join(dir, "ffi.json");
|
|
await writeFile(
|
|
entry,
|
|
[
|
|
"declare function nativeMix(value: number, text: string, bytes: Uint8Array): number;",
|
|
"console.log(nativeMix(2, 'ok', new Uint8Array([1, 2])));",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
await writeFile(
|
|
profile,
|
|
JSON.stringify({
|
|
ffi_format: 1,
|
|
functions: [
|
|
{
|
|
name: "nativeMix",
|
|
symbol: "native_mix",
|
|
params: ["f64", "string", "bytes"],
|
|
returns: "f64",
|
|
},
|
|
],
|
|
libraries: [],
|
|
system_libraries: [],
|
|
}),
|
|
);
|
|
const object = join(dir, "helper.o");
|
|
const result = await compile(entry, {
|
|
outDir: dir,
|
|
outPath: object,
|
|
outputKind: "obj",
|
|
ffiProfilePath: profile,
|
|
});
|
|
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
|
|
const llvm = join(dir, "main.ll");
|
|
const sourceResult = await compile(entry, {
|
|
outDir: dir,
|
|
outPath: llvm,
|
|
outputKind: "llvm",
|
|
ffiProfilePath: profile,
|
|
});
|
|
if (!sourceResult.ok)
|
|
throw new Error(sourceResult.diagnostics.map((d) => d.message).join("\n"));
|
|
const clangObject = join(dir, "clang.o");
|
|
await execFileAsync("clang", [
|
|
"-O2",
|
|
"-Wno-override-module",
|
|
"-target",
|
|
MACOS_ARM64_TARGET.llvmTriple,
|
|
"-c",
|
|
llvm,
|
|
"-o",
|
|
clangObject,
|
|
]);
|
|
const nativeMixSignature = (await readFile(llvm, "utf8"))
|
|
.split("\n")
|
|
.find((line) => line.includes("@native_mix("));
|
|
expect(nativeMixSignature).toBe("declare double @native_mix(double, ptr, i64, ptr, i64)");
|
|
for (const candidate of [object, clangObject]) {
|
|
const undefinedSymbols = (await execFileAsync("nm", ["-u", candidate], { encoding: "utf8" }))
|
|
.stdout;
|
|
expect(undefinedSymbols).toContain("_native_mix");
|
|
}
|
|
});
|
|
});
|