mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-14 04:39:43 +08:00
* feat(runtime): ship precompiled macOS artifacts - Add a versioned macOS arm64 runtime pack with deterministic feature selection and artifact verification. - Link LLVM helper objects against release-built runtime and vendor inputs without compiling user-machine C. - Wire package publishing, cache validation, documentation, and full-gate coverage for the new path. * fix(runtime): harden precompiled artifact handling * fix(runtime): make precompiled artifacts reproducible * fix(runtime): reject opaque linkers from executable cache * fix(runtime): normalize precompiled archive metadata * fix(runtime): harden precompiled artifact caching * fix(runtime): close executable cache link race * fix(runtime): stage verified pack artifacts * fix(runtime): bracket helper cache inputs * fix(runtime): preserve executable link identity * fix(runtime): trace selected linker dependencies
517 lines
23 KiB
TypeScript
517 lines
23 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 } from "vitest";
|
|
import { compile, compileC } from "../src/index.js";
|
|
import { MACOS_ARM64_TARGET } from "../src/backend/targets.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("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, "x86_64-apple-macosx14.0.0"));
|
|
expect(JSON.parse(unsupported.stderr.toString("utf8"))).toMatchObject({
|
|
ok: false,
|
|
code: "unsupported_target",
|
|
});
|
|
expect(await readFile(output, "utf8")).toBe("existing\n");
|
|
});
|
|
|
|
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_v1");
|
|
|
|
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_v1");
|
|
});
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|