Files
scriptc/packages/compiler/src/native/executable-cache.test.ts
T
Chris Tate 205f099957 Keep an unchanged executable on its first Linux rebuild
On Linux the executable cache stored an executable only from a key's second
link on, so the first unchanged rebuild after a build linked again, and only
the build after that restored the executable.

- A key's first link on Linux is traced, in the same verbose link. Instead
  of storing the executable, the cache records the output's file identity
  together with the link's inputs once the output is in place.
- keep() answers an unchanged rebuild whose output still has that identity
  and whose inputs are unchanged. The build then neither links nor copies:
  the output stays as it is.
- A rebuild whose output changed or is missing links again; from that second
  link on, the executable is stored as before and restored where needed.
- The cache records the output after every Linux build that places one, so
  the next unchanged rebuild keeps it.

darwin keeps its existing behavior.
2026-10-24 10:26:38 -05:00

303 lines
11 KiB
TypeScript

import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
symlinkSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import { contentDigest, NativeCache } from "./cache.js";
import { NativeExecutableCache } from "./executable-cache.js";
vi.mock("node:child_process", async (original) => ({
...(await original<typeof import("node:child_process")>()),
spawnSync: vi.fn(),
}));
const directories: string[] = [];
afterEach(() => {
vi.resetAllMocks();
for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true });
});
function fixture(debug = false) {
const root = mkdtempSync(join(tmpdir(), "scriptc-native-executable-"));
directories.push(root);
const inputs = join(root, "inputs");
const sdk = join(root, "sdk");
const stage = join(root, "stage");
const restored = join(root, "restored");
for (const directory of [inputs, sdk, stage, restored]) mkdirSync(directory);
const runtime = join(inputs, "runtime.o");
const library = join(sdk, "libSystem.tbd");
writeFileSync(runtime, "runtime");
writeFileSync(library, "system library");
// A directory's identity is its stat. Where the file system stamps times
// at the kernel tick, a file added within the tick of the last write
// leaves the directory's times unchanged; an old time makes every later
// change to the SDK directory visible.
utimesSync(sdk, 0, 0);
const cache = new NativeCache(join(root, "cache"));
const key = contentDigest("llvm and options");
const open = () => new NativeExecutableCache(cache, key, "clang", debug, [runtime]);
vi.mocked(spawnSync).mockReturnValue({
pid: 1,
status: 0,
signal: null,
output: [],
stdout: library + "\n",
stderr: "",
});
const output = join(stage, "program");
writeFileSync(output, Buffer.from([0, 128, 255, 10]));
if (debug) {
const contents = join(output + ".dSYM", "Contents");
mkdirSync(join(contents, "Resources", "DWARF"), { recursive: true });
writeFileSync(join(contents, "Info.plist"), "property list");
writeFileSync(join(contents, "Resources", "DWARF", "program"), "debug information");
}
const publish = () => {
const entry = open();
expect(entry.trace(["-o", output], stage)).toBe(true);
entry.publish(output);
};
return {
root,
inputs,
sdk,
stage,
output,
destination: join(restored, "program"),
runtime,
library,
cache,
key,
open,
publish,
};
}
test.each([false, true])(
"restores a complete executable and its debug information (%s)",
(debug) => {
const f = fixture(debug);
f.publish();
vi.mocked(spawnSync).mockClear();
expect(f.open().restore(f.destination)).toBe(true);
expect(readFileSync(f.destination)).toEqual(readFileSync(f.output));
if (process.platform !== "win32") expect(statSync(f.destination).mode & 0o111).toBe(0o111);
expect(spawnSync).not.toHaveBeenCalled();
if (debug) {
expect(
readFileSync(join(f.destination + ".dSYM/Contents/Resources/DWARF/program"), "utf8"),
).toBe("debug information");
expect(readFileSync(join(f.destination + ".dSYM/Contents/Info.plist"), "utf8")).toBe(
"property list",
);
}
},
);
test.each(["runtime", "library"] as const)(
"invalidates %s replacement even with restored size and mtime",
(input) => {
const f = fixture();
f.publish();
const before = statSync(f[input]);
writeFileSync(f[input] + ".new", "x".repeat(before.size));
utimesSync(f[input] + ".new", before.atime, before.mtime);
renameSync(f[input] + ".new", f[input]);
expect(f.open().restore(f.destination)).toBe(false);
},
);
test("adding a new SDK library search candidate invalidates the previous link", () => {
const f = fixture();
f.publish();
writeFileSync(join(f.sdk, "libSystem.dylib"), "new candidate");
expect(f.open().restore(f.destination)).toBe(false);
});
test("a library search change during tracing prevents publication", () => {
const f = fixture();
vi.mocked(spawnSync).mockImplementation(() => {
if (vi.mocked(spawnSync).mock.calls.length === 2)
writeFileSync(join(f.sdk, "new-library.tbd"), "library");
return { pid: 1, status: 0, signal: null, output: [], stdout: f.library + "\n", stderr: "" };
});
const entry = f.open();
expect(entry.trace([], f.stage)).toBe(false);
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
});
test.skipIf(process.platform === "win32")(
"retargeting an input symlink invalidates the previous link",
() => {
const f = fixture();
const link = join(f.inputs, "current.o");
symlinkSync(f.runtime, link);
const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [link]);
const entry = open();
expect(entry.trace([], f.stage)).toBe(true);
entry.publish(f.output);
expect(open().restore(f.destination)).toBe(true);
const replacement = join(f.inputs, "replacement.o");
writeFileSync(replacement, "runtime");
rmSync(link);
symlinkSync(replacement, link);
expect(open().restore(f.destination)).toBe(false);
},
);
test.each(["executable", "binary", "dsym"])("corrupt %s payloads are misses", (family) => {
const f = fixture(true);
f.publish();
const directory = join(f.cache.root, family);
const key = readdirSync(directory).find((name) => /^[0-9a-f]{64}$/.test(name))!;
writeFileSync(join(directory, key), "corrupt");
expect(f.open().restore(f.destination)).toBe(false);
});
test("invalid cache metadata is rejected even with a matching integrity digest", () => {
const f = fixture();
f.publish();
const bytes = f.cache.read("executable", f.key)!;
const entry = JSON.parse(bytes.toString());
entry.inputs = [];
f.cache.write("executable", f.key, JSON.stringify(entry));
expect(f.open().restore(f.destination)).toBe(false);
});
test("changing an input during emission prevents cache publication", () => {
const f = fixture();
const entry = f.open();
expect(entry.trace([], f.stage)).toBe(true);
writeFileSync(f.runtime, "replacement runtime");
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
});
test("metadata cannot omit a required runtime input", () => {
const f = fixture();
f.publish();
const bytes = f.cache.read("executable", f.key)!;
const entry = JSON.parse(bytes.toString());
entry.inputs = entry.inputs.filter((input: { path: string }) => input.path !== f.runtime);
f.cache.write("executable", f.key, JSON.stringify(entry));
expect(f.open().restore(f.destination)).toBe(false);
});
test("a failed or empty link trace cannot publish a completed executable", () => {
const f = fixture();
const entry = f.open();
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
vi.mocked(spawnSync).mockReturnValue({
pid: 1,
status: 0,
signal: null,
output: [],
stdout: "",
stderr: "",
});
expect(entry.trace([], f.stage)).toBe(false);
entry.publish(f.output);
expect(f.open().restore(f.destination)).toBe(false);
vi.mocked(spawnSync).mockReturnValue({
pid: 1,
status: 1,
signal: null,
output: [],
stdout: "",
stderr: "failed",
});
expect(entry.trace([], f.stage)).toBe(false);
});
test.each(["ld", "crt", "libc"] as const)(
"a Linux key's first link records the linker, startup objects and libc; replacing the %s misses",
(input) => {
const f = fixture();
const lib = join(f.root, "lib");
mkdirSync(lib);
const paths = {
ld: join(f.root, "ld"),
crt: join(lib, "Scrt1.o"),
libc: join(lib, "libc.so.6"),
};
writeFileSync(paths.ld, "linker");
writeFileSync(paths.crt, "startup object");
writeFileSync(paths.libc, "c library");
const nonshared = join(lib, "libc_nonshared.a");
writeFileSync(nonshared, "archive");
const program = join(f.stage, "program.o");
writeFileSync(program, "program");
// With -v, clang prints the linker job it runs, as -### would; GNU ld's
// -t names each file it loads, and an archive member as archive(member).
const job = ` "${paths.ld}" "-pie" "-o" "${f.output}" "${paths.crt}" "-L${lib}" "${program}" "-lc"`;
const loaded = [paths.crt, program, paths.libc, `${nonshared}(atexit.oS)`].join("\n");
vi.mocked(spawnSync).mockImplementation((_command, args) => {
const verbose = (args as string[]).includes("-v");
return {
pid: 1,
status: 0,
signal: null,
output: [],
stdout: verbose ? loaded + "\n" : "",
stderr: verbose ? job + "\n" : "",
};
});
const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true);
// A key's first link traces the driver and the linker in one run, and
// records the output it leaves instead of storing the executable.
const entry = open();
expect(entry.trace(["-o", f.output], f.stage)).toBe(true);
expect(spawnSync).toHaveBeenCalledTimes(1);
expect(vi.mocked(spawnSync).mock.calls[0]![1]).toEqual(["-o", f.output, "-v", "-Wl,-t"]);
entry.publish(f.output);
expect(f.cache.read("executable", f.key)).toBeNull();
entry.recordOutput(f.output);
expect(open().keep(f.output)).toBe(true);
const recorded = (
JSON.parse(f.cache.read("kept", f.key)!.toString()) as { inputs: { path: string }[] }
).inputs.map((saved) => saved.path);
expect(recorded).toEqual(expect.arrayContaining([...Object.values(paths), nonshared, lib]));
expect(recorded).not.toContain(program);
const before = statSync(paths[input]);
writeFileSync(paths[input] + ".new", "x".repeat(before.size));
utimesSync(paths[input] + ".new", before.atime, before.mtime);
renameSync(paths[input] + ".new", paths[input]);
expect(open().keep(f.output)).toBe(false);
},
);
test("a Linux build whose output changed links again, and then stores the executable", () => {
const f = fixture();
const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true);
const first = open();
expect(first.trace(["-o", f.output], f.stage)).toBe(true);
first.publish(f.output);
first.recordOutput(f.output);
// The output is overwritten: it is not kept, and the next link publishes.
writeFileSync(f.output, Buffer.from([1, 2, 3, 4]));
expect(open().keep(f.output)).toBe(false);
const second = open();
expect(second.restore(f.destination)).toBe(false);
expect(second.trace(["-o", f.output], f.stage)).toBe(true);
second.publish(f.output);
second.recordOutput(f.output);
expect(open().keep(f.output)).toBe(true);
// The stored executable restores at another path.
expect(open().restore(f.destination)).toBe(true);
expect(readFileSync(f.destination)).toEqual(readFileSync(f.output));
});