mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
A Linux executable link runs `clang -fuse-ld=lld ...`, and the driver only starts ld.lld with a job it derives from its arguments, its environment and the installed toolchain. Starting the driver costs about 10 ms of a small program's 29 ms link. LinkerJobs (native/linker-job.ts) records that job in the native cache, with the link's own inputs and output as positions. A traced link (the Linux executable cache's `-v -Wl,-t` link) records it from the job line the driver prints with -v; a link the executable cache does not trace lists it with -v -### first. The entry also keeps the identities of the files and directories the job and the driver's -v lines name (ld.lld, startup objects, library directories, the GCC installation, the driver's directory): the set the Linux executable cache records from its traced link. It is keyed by the driver's identity, the link's arguments, the toolchain environment, PATH and the working directory. A later link with the same key whose recorded paths still have those identities runs ld.lld with the job directly, and the executable cache traces it with the job's -t output and the recorded identities. Every other link runs through the driver as before: cross-target links, links without lld, a driver that is not clang, SCRIPTC_LINKER, sanitizer links, a toolchain environment that names mutable inputs, a listing without exactly one ld.lld job naming each of the link's paths once, a recorded path whose identity changed, and a job that fails (the driver then reports the error). The linker gets the same arguments, so the executables are identical.
374 lines
14 KiB
TypeScript
374 lines
14 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 { identity, 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));
|
|
});
|
|
|
|
test("a Linux build that keeps its output does not read the deferred inputs", () => {
|
|
const f = fixture();
|
|
const artifact = join(f.root, "inputs", "artifact.o");
|
|
writeFileSync(artifact, "runtime pack artifact");
|
|
const deferred = vi.fn(() => [artifact]);
|
|
const open = () =>
|
|
new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true, deferred);
|
|
const first = open();
|
|
expect(first.trace(["-o", f.output], f.stage)).toBe(true);
|
|
first.recordOutput(f.output);
|
|
expect(deferred).toHaveBeenCalledTimes(1);
|
|
const recorded = (
|
|
JSON.parse(f.cache.read("kept", f.key)!.toString()) as { inputs: { path: string }[] }
|
|
).inputs.map((saved) => saved.path);
|
|
expect(recorded).toContain(artifact);
|
|
// The unchanged rebuild checks the recorded identities, the artifact's
|
|
// among them, without asking for the deferred inputs.
|
|
expect(open().keep(f.output)).toBe(true);
|
|
expect(deferred).toHaveBeenCalledTimes(1);
|
|
// A replaced artifact misses.
|
|
writeFileSync(artifact, "another runtime pack artifact");
|
|
expect(open().keep(f.output)).toBe(false);
|
|
expect(deferred).toHaveBeenCalledTimes(1);
|
|
// restore() adds the deferred inputs before it checks an entry.
|
|
const second = open();
|
|
expect(second.trace(["-o", f.output], f.stage)).toBe(true);
|
|
second.publish(f.output);
|
|
expect(open().restore(f.destination)).toBe(true);
|
|
expect(deferred).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test("a Linux link that runs as its linker job is traced from the job's run, and otherwise records it", () => {
|
|
const f = fixture();
|
|
const lib = join(f.root, "lib");
|
|
mkdirSync(lib);
|
|
const crt = join(lib, "Scrt1.o");
|
|
writeFileSync(crt, "startup object");
|
|
const program = join(f.stage, "program.o");
|
|
writeFileSync(program, "program");
|
|
const job = {
|
|
run: vi.fn((_args: string[], _list: boolean) => ({
|
|
output: [crt, program].join("\n") + "\n",
|
|
driverInputs: [identity(crt), identity(lib)],
|
|
})),
|
|
record: vi.fn(),
|
|
};
|
|
const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true);
|
|
const entry = open();
|
|
expect(entry.trace(["-o", f.output], f.stage, job)).toBe(true);
|
|
// The job runs the traced link, and the driver does not.
|
|
expect(job.run.mock.calls[0]).toEqual([["-o", f.output, "-v", "-Wl,-t"], false]);
|
|
expect(spawnSync).not.toHaveBeenCalled();
|
|
expect(job.record).not.toHaveBeenCalled();
|
|
entry.recordOutput(f.output);
|
|
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([crt, lib]));
|
|
expect(recorded).not.toContain(program);
|
|
expect(open().keep(f.output)).toBe(true);
|
|
// A link without a job runs through the driver, and records the job from
|
|
// the driver's output.
|
|
const record = vi.fn();
|
|
expect(open().trace(["-o", f.output], f.stage, { run: () => null, record })).toBe(true);
|
|
expect(spawnSync).toHaveBeenCalledTimes(1);
|
|
expect(record).toHaveBeenCalledWith(["-o", f.output, "-v", "-Wl,-t"], f.library + "\n\n", [
|
|
identity(f.sdk),
|
|
identity(f.library),
|
|
]);
|
|
});
|