mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-16 05:00:59 +08:00
- Keep generated outputs from earlier build modes, including cache hits. - Cover WASI/native switching and document accumulated output files.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import { chmod, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, expect, test } from "vitest";
|
|
import { FrontendInputTracker, trackedFileExists, trackedReadFile } from "../frontend/input-tracker.js";
|
|
import { nativeArtifactDependenciesStillMatch } from "../backend/native-toolchain.js";
|
|
import {
|
|
publishEarlyExecutableCache,
|
|
readEarlyExecutableCache,
|
|
readRoutedExecutableCache,
|
|
type EarlyExecutableCacheOptions,
|
|
type EarlyExecutableNativeFeatures,
|
|
} from "./executable-cache.js";
|
|
|
|
const scratch: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(scratch.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
});
|
|
|
|
const native: EarlyExecutableNativeFeatures = {
|
|
backend: "llvm",
|
|
dynamic: false,
|
|
regex: false,
|
|
copying: false,
|
|
textDecoderLegacy: false,
|
|
fileHandle: false,
|
|
fetch: true,
|
|
netIsland: false,
|
|
zlib: false,
|
|
assert: false,
|
|
inspect: false,
|
|
dynInvoke: false,
|
|
dc: false,
|
|
dynAsync: false,
|
|
events: false,
|
|
emitter: false,
|
|
symbol: false,
|
|
bigint: false,
|
|
searchParams: false,
|
|
qs: false,
|
|
parseArgs: false,
|
|
stream: false,
|
|
net: false,
|
|
http: false,
|
|
http2: false,
|
|
dgram: false,
|
|
watch: false,
|
|
foreignFfi: false,
|
|
nodeTest: false,
|
|
tls: false,
|
|
tlsCa: false,
|
|
};
|
|
|
|
async function fixture(): Promise<{
|
|
root: string;
|
|
options: EarlyExecutableCacheOptions;
|
|
source: string;
|
|
missing: string;
|
|
cPath: string;
|
|
irPath: string;
|
|
}> {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-early-executable-"));
|
|
scratch.push(dir);
|
|
const source = join(dir, "entry.ts");
|
|
const cPath = join(dir, "entry.ll");
|
|
const irPath = join(dir, "entry.ir.json");
|
|
await Promise.all([
|
|
writeFile(source, 'console.log("hello");\n'),
|
|
writeFile(cPath, "; generated llvm\n"),
|
|
writeFile(irPath, '{"irVersion":6}\n'),
|
|
]);
|
|
return {
|
|
root: join(dir, "cache"),
|
|
source,
|
|
missing: join(dir, "missing.ts"),
|
|
cPath,
|
|
irPath,
|
|
options: {
|
|
entryPath: source,
|
|
outDir: dir,
|
|
outPath: join(dir, "entry"),
|
|
emitIr: true,
|
|
sanitize: false,
|
|
dynamic: false,
|
|
backend: "auto",
|
|
npmStatic: null,
|
|
ffiProfile: null,
|
|
target: "test",
|
|
compiler: ["clang"],
|
|
nativeEnvironment: "test-native-environment",
|
|
nodeVersion: "v24-test",
|
|
implementation: "a".repeat(64),
|
|
implementationDependencies: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
test("early executable cache restores the emitted TU, IR, and native feature gates", async () => {
|
|
const f = await fixture();
|
|
const tracker = new FrontendInputTracker();
|
|
tracker.run(() => {
|
|
trackedReadFile(f.source);
|
|
trackedFileExists(f.missing);
|
|
});
|
|
await publishEarlyExecutableCache(f.root, f.options, {
|
|
cPath: f.cPath,
|
|
irPath: f.irPath,
|
|
native,
|
|
executableRestored: false,
|
|
frontend: tracker.snapshot(),
|
|
});
|
|
await Promise.all([rm(f.cPath), rm(f.irPath)]);
|
|
const alternateC = join(f.options.outDir, "entry.c");
|
|
await writeFile(alternateC, "/* saved C backend */\n");
|
|
|
|
const hit = await readEarlyExecutableCache(f.root, f.options);
|
|
expect(hit?.native).toEqual(native);
|
|
expect(hit?.executableRestored).toBe(false);
|
|
expect(await readFile(f.cPath, "utf8")).toBe("; generated llvm\n");
|
|
expect(await readFile(f.irPath, "utf8")).toContain("irVersion");
|
|
expect(await readFile(alternateC, "utf8")).toBe("/* saved C backend */\n");
|
|
});
|
|
|
|
test("early executable cache misses on source and resolution changes", async () => {
|
|
const f = await fixture();
|
|
const tracker = new FrontendInputTracker();
|
|
tracker.run(() => {
|
|
trackedReadFile(f.source);
|
|
trackedFileExists(f.missing);
|
|
});
|
|
await publishEarlyExecutableCache(f.root, f.options, {
|
|
cPath: f.cPath,
|
|
irPath: f.irPath,
|
|
native,
|
|
executableRestored: false,
|
|
frontend: tracker.snapshot(),
|
|
});
|
|
|
|
await writeFile(f.source, 'console.log("edited");\n');
|
|
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
|
|
await writeFile(f.source, 'console.log("hello");\n');
|
|
expect(await readEarlyExecutableCache(f.root, f.options)).not.toBeNull();
|
|
await writeFile(f.missing, "export {};\n");
|
|
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
|
|
});
|
|
|
|
test("early executable keys isolate compile modes, paths, implementation, and FFI manifests", async () => {
|
|
const f = await fixture();
|
|
const tracker = new FrontendInputTracker();
|
|
tracker.run(() => trackedReadFile(f.source));
|
|
await publishEarlyExecutableCache(f.root, f.options, {
|
|
cPath: f.cPath,
|
|
irPath: f.irPath,
|
|
native,
|
|
executableRestored: false,
|
|
frontend: tracker.snapshot(),
|
|
});
|
|
const variants: EarlyExecutableCacheOptions[] = [
|
|
{ ...f.options, emitIr: false },
|
|
{ ...f.options, sanitize: true },
|
|
{ ...f.options, dynamic: true },
|
|
{ ...f.options, backend: "c" },
|
|
{ ...f.options, optimization: "dev" },
|
|
{ ...f.options, npmStatic: "auto" },
|
|
{ ...f.options, npmStatic: ["commander"] },
|
|
{ ...f.options, outPath: join(f.options.outDir, "other") },
|
|
{ ...f.options, target: "other" },
|
|
{ ...f.options, compiler: ["zig", "cc"] },
|
|
{ ...f.options, nativeEnvironment: "other-native-environment" },
|
|
{ ...f.options, nodeVersion: "v24-other" },
|
|
{ ...f.options, implementation: "other-implementation" },
|
|
{ ...f.options, ffiProfile: { path: join(f.options.outDir, "ffi.json"), bytes: Buffer.from("one") } },
|
|
];
|
|
for (const options of variants) {
|
|
expect(await readEarlyExecutableCache(f.root, options)).toBeNull();
|
|
}
|
|
});
|
|
|
|
test("early executable cache rejects corruption and refreshes payload LRU times", async () => {
|
|
const f = await fixture();
|
|
const tracker = new FrontendInputTracker();
|
|
tracker.run(() => trackedReadFile(f.source));
|
|
await publishEarlyExecutableCache(f.root, f.options, {
|
|
cPath: f.cPath,
|
|
irPath: f.irPath,
|
|
native,
|
|
executableRestored: false,
|
|
frontend: tracker.snapshot(),
|
|
});
|
|
const [key] = await readdir(join(f.root, "early-exe"));
|
|
const directory = join(f.root, "early-exe", key!);
|
|
const paths = [
|
|
join(directory, "stamp.json"),
|
|
join(directory, "program.tu"),
|
|
join(directory, "program.ir.json"),
|
|
];
|
|
const old = new Date("2000-01-01T00:00:00.000Z");
|
|
await Promise.all(paths.map((path) => utimes(path, old, old)));
|
|
expect(await readEarlyExecutableCache(f.root, f.options)).not.toBeNull();
|
|
for (const path of paths) expect((await stat(path)).mtimeMs).toBeGreaterThan(old.getTime());
|
|
|
|
await writeFile(join(directory, "program.tu"), "; poisoned\n");
|
|
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
|
|
});
|
|
|
|
test("early executable cache restores a validated final binary", async () => {
|
|
const f = await fixture();
|
|
const dependency = join(f.options.outDir, "native-tool");
|
|
await writeFile(dependency, "tool-v1\n");
|
|
const info = await stat(dependency);
|
|
const outMode = 0o777 & ~process.umask();
|
|
await writeFile(f.options.outPath, "native-v1\n", { mode: outMode });
|
|
await chmod(f.options.outPath, outMode);
|
|
const tracker = new FrontendInputTracker();
|
|
tracker.run(() => trackedReadFile(f.source));
|
|
await publishEarlyExecutableCache(f.root, f.options, {
|
|
cPath: f.cPath,
|
|
irPath: f.irPath,
|
|
native,
|
|
executableRestored: true,
|
|
nativeDependencies: [{
|
|
path: dependency,
|
|
kind: "file",
|
|
dev: info.dev,
|
|
ino: info.ino,
|
|
size: info.size,
|
|
mtimeMs: info.mtimeMs,
|
|
ctimeMs: info.ctimeMs,
|
|
}],
|
|
frontend: tracker.snapshot(),
|
|
});
|
|
await writeFile(f.options.outPath, "caller-modified\n");
|
|
|
|
const hit = await readEarlyExecutableCache(f.root, f.options);
|
|
expect(hit?.executableRestored).toBe(true);
|
|
expect(await readFile(f.options.outPath, "utf8")).toBe("native-v1\n");
|
|
|
|
await writeFile(dependency, "tool-v2\n");
|
|
await writeFile(f.options.outPath, "keep-current\n");
|
|
const staleNative = await readEarlyExecutableCache(f.root, f.options);
|
|
expect(staleNative?.executableRestored).toBe(false);
|
|
expect(await readFile(f.options.outPath, "utf8")).toBe("keep-current\n");
|
|
});
|
|
|
|
test("routed executable hits require an unchanged compiler implementation proof", async () => {
|
|
const f = await fixture();
|
|
const dependency = join(f.options.outDir, "compiler-file.js");
|
|
const nativeDependency = join(f.options.outDir, "native-tool");
|
|
await Promise.all([
|
|
writeFile(dependency, "compiler-v1\n"),
|
|
writeFile(nativeDependency, "tool-v1\n"),
|
|
writeFile(f.options.outPath, "native-v1\n"),
|
|
]);
|
|
const [implementationInfo, nativeInfo] = await Promise.all([
|
|
stat(dependency),
|
|
stat(nativeDependency),
|
|
]);
|
|
f.options.implementationDependencies = [{
|
|
path: dependency,
|
|
kind: "file",
|
|
dev: implementationInfo.dev,
|
|
ino: implementationInfo.ino,
|
|
size: implementationInfo.size,
|
|
mtimeMs: implementationInfo.mtimeMs,
|
|
ctimeMs: implementationInfo.ctimeMs,
|
|
}];
|
|
const tracker = new FrontendInputTracker();
|
|
tracker.run(() => trackedReadFile(f.source));
|
|
await publishEarlyExecutableCache(f.root, f.options, {
|
|
cPath: f.cPath,
|
|
irPath: f.irPath,
|
|
native,
|
|
executableRestored: true,
|
|
nativeDependencies: [{
|
|
path: nativeDependency,
|
|
kind: "file",
|
|
dev: nativeInfo.dev,
|
|
ino: nativeInfo.ino,
|
|
size: nativeInfo.size,
|
|
mtimeMs: nativeInfo.mtimeMs,
|
|
ctimeMs: nativeInfo.ctimeMs,
|
|
}],
|
|
frontend: tracker.snapshot(),
|
|
});
|
|
const route = { ...f.options };
|
|
delete (route as Partial<EarlyExecutableCacheOptions>).implementation;
|
|
delete (route as Partial<EarlyExecutableCacheOptions>).implementationDependencies;
|
|
const routeDirectory = join(f.root, "early-exe-route");
|
|
const [routeName] = await readdir(routeDirectory);
|
|
const routePath = join(routeDirectory, routeName!);
|
|
const proofInstallDirectory = join(f.root, "early-exe-implementation");
|
|
const [proofInstall] = await readdir(proofInstallDirectory);
|
|
const proofPath = join(proofInstallDirectory, proofInstall!, f.options.implementation);
|
|
const old = new Date("2000-01-01T00:00:00.000Z");
|
|
await Promise.all([routePath, proofPath].map((path) => utimes(path, old, old)));
|
|
expect(await readRoutedExecutableCache(f.root, route)).not.toBeNull();
|
|
for (const path of [routePath, proofPath]) {
|
|
expect((await stat(path)).mtimeMs).toBeGreaterThan(old.getTime());
|
|
}
|
|
|
|
// Publishing the same route again must replace its metadata on every
|
|
// platform (notably Windows, whose rename does not overwrite files).
|
|
await publishEarlyExecutableCache(f.root, f.options, {
|
|
cPath: f.cPath,
|
|
irPath: f.irPath,
|
|
native,
|
|
executableRestored: true,
|
|
nativeDependencies: [{
|
|
path: nativeDependency,
|
|
kind: "file",
|
|
dev: nativeInfo.dev,
|
|
ino: nativeInfo.ino,
|
|
size: nativeInfo.size,
|
|
mtimeMs: nativeInfo.mtimeMs,
|
|
ctimeMs: nativeInfo.ctimeMs,
|
|
}],
|
|
frontend: tracker.snapshot(),
|
|
});
|
|
expect(await readRoutedExecutableCache(f.root, route)).not.toBeNull();
|
|
await writeFile(dependency, "compiler-v2\n");
|
|
expect(await readRoutedExecutableCache(f.root, route)).toBeNull();
|
|
});
|
|
|
|
test("native dependency validation rejects malformed cache data", async () => {
|
|
expect(await nativeArtifactDependenciesStillMatch([
|
|
{ path: "/tmp/not-enough-fields", kind: "file" },
|
|
] as never)).toBe(false);
|
|
});
|