Files
scriptc/packages/compiler/src/native/executable-cache.ts
T
Chris Tate a4ae5f9e27 Run the linker job directly for host Linux links with lld
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.
2026-10-25 15:17:20 -05:00

487 lines
18 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
import type { CompileRequestOptions } from "../compile-types.js";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { driverTraceCandidates, linkTraceCandidate } from "../backend/link-trace.js";
import {
toolchainEnvironmentCachePolicy,
toolchainEnvironmentFingerprint,
} from "../backend/toolchain-environment.js";
import { runNativeTool } from "../backend/native-tools.js";
import type { NativeLinkFeatures } from "../backend/native-link-info.js";
import type { NativeOptimization } from "../backend/optimization.js";
import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js";
import type { NativeToolchain } from "./toolchain.js";
import { contentDigest, NativeCache } from "./cache.js";
import type { LinkerJobLink } from "./linker-job.js";
export interface InputIdentity {
path: string;
canonical: string;
kind: "file" | "directory";
dev: number;
ino: number;
size: number;
mtime: number;
ctime: number;
}
interface ExecutableEntry {
schema: "scriptc.native-executable.v1";
inputs: InputIdentity[];
binary: string;
symbols: string | null;
}
export function identity(path: string): InputIdentity {
path = resolve(path);
const info = statSync(path);
if (!info.isFile() && !info.isDirectory())
throw new Error(`unsupported native cache input: ${path}`);
return {
path,
canonical: realpathSync(path),
kind: info.isFile() ? "file" : "directory",
dev: info.dev,
ino: info.ino,
size: info.size,
mtime: info.mtimeMs,
ctime: info.ctimeMs,
};
}
function sameIdentity(a: InputIdentity, b: InputIdentity): boolean {
return (
a.path === b.path &&
a.canonical === b.canonical &&
a.kind === b.kind &&
a.dev === b.dev &&
a.ino === b.ino &&
a.size === b.size &&
a.mtime === b.mtime &&
a.ctime === b.ctime
);
}
export function stillMatches(inputs: InputIdentity[]): boolean {
try {
return inputs.every((input) => sameIdentity(identity(input.path), input));
} catch {
return false;
}
}
export function commandPath(command: string): string {
if (command.includes("/") || command.includes("\\")) return resolve(command);
for (const directory of (process.env["PATH"] ?? "/usr/bin:/bin").split(delimiter)) {
const candidate = join(directory || process.cwd(), command);
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
/* Try the next PATH entry. */
}
}
throw new Error(`native command is unavailable: ${command}`);
}
function toolOutput(executable: string, args: string[]): string {
const result = spawnSync(executable, args, { encoding: "utf8", stdio: "pipe" });
if (result.error) throw result.error;
if (result.status !== 0 || result.signal !== null)
throw new Error("could not trace native link inputs");
return result.stdout + "\n" + result.stderr;
}
export function tracePaths(output: string, driver: boolean, roots: string[]): string[] {
const paths = new Set<string>();
for (const line of output.split(/\r?\n/)) {
for (const candidate of driver ? driverTraceCandidates(line) : linkTraceCandidate(line)) {
const option = driver
? ["-L", "-F", "--sysroot="].find((prefix) => candidate.startsWith(prefix))
: undefined;
const spelling = option === undefined ? candidate : candidate.slice(option.length);
if (!isAbsolute(spelling)) continue;
const path = resolve(spelling);
if (roots.some((root) => path === root || path.startsWith(root + "/"))) continue;
try {
const input = identity(path);
paths.add(path);
paths.add(dirname(path));
if (input.kind === "directory" && path.endsWith(".sdk")) {
for (const name of ["SDKSettings.json", "SDKSettings.plist"]) {
const settings = join(path, name);
try {
identity(settings);
paths.add(settings);
} catch {
/* The SDK directory observes new settings files. */
}
}
}
} catch {
/* Non-path trace tokens are not dependencies. */
}
}
}
return [...paths].sort();
}
/** Linux: the output a build left, which an unchanged rebuild keeps while
* the output and every input keep their identities. */
interface KeptEntry {
schema: "scriptc.native-kept.v1";
output: InputIdentity;
inputs: InputIdentity[];
}
export function validIdentity(input: InputIdentity): boolean {
return (
input !== null &&
typeof input === "object" &&
typeof input.path === "string" &&
isAbsolute(input.path) &&
typeof input.canonical === "string" &&
(input.kind === "file" || input.kind === "directory") &&
[input.dev, input.ino, input.size, input.mtime, input.ctime].every(
(value) => typeof value === "number" && Number.isFinite(value),
)
);
}
function validKept(value: unknown): value is KeptEntry {
if (value === null || typeof value !== "object") return false;
const entry = value as Partial<KeptEntry>;
return (
entry.schema === "scriptc.native-kept.v1" &&
entry.output !== undefined &&
validIdentity(entry.output) &&
Array.isArray(entry.inputs) &&
entry.inputs.length > 0 &&
entry.inputs.every(validIdentity)
);
}
function validEntry(value: unknown): value is ExecutableEntry {
if (value === null || typeof value !== "object") return false;
const entry = value as Partial<ExecutableEntry>;
return (
entry.schema === "scriptc.native-executable.v1" &&
typeof entry.binary === "string" &&
/^[0-9a-f]{64}$/.test(entry.binary) &&
(entry.symbols === null ||
(typeof entry.symbols === "string" && /^[0-9a-f]{64}$/.test(entry.symbols))) &&
Array.isArray(entry.inputs) &&
entry.inputs.length > 0 &&
entry.inputs.every(validIdentity)
);
}
function readSymbols(binary: string): Buffer {
const contents = join(binary + ".dSYM", "Contents");
const plist = readFileSync(join(contents, "Info.plist"));
const dwarf = readFileSync(join(contents, "Resources", "DWARF", basename(binary)));
const header = Buffer.alloc(12);
header.write("SCDSYM01");
header.writeUInt32LE(plist.length, 8);
return Buffer.concat([header, plist, dwarf]);
}
function stageSymbols(bytes: Buffer, binary: string): void {
if (bytes.length < 12 || bytes.subarray(0, 8).toString() !== "SCDSYM01")
throw new Error("invalid cached dSYM");
const end = 12 + bytes.readUInt32LE(8);
if (end <= 12 || end >= bytes.length) throw new Error("invalid cached dSYM sizes");
const contents = join(binary + ".dSYM", "Contents");
const dwarf = join(contents, "Resources", "DWARF");
mkdirSync(dwarf, { recursive: true });
writeFileSync(join(contents, "Info.plist"), bytes.subarray(12, end));
writeFileSync(join(dwarf, basename(binary)), bytes.subarray(end));
}
/** A completed executable is reusable only after the frontend has validated
* its source observations and emitted the same LLVM. Native inputs bracket
* helper verification, runtime staging and the actual link. */
export class NativeExecutableCache {
private inputs: InputIdentity[];
private traced = false;
/** The inputs of the entry restore() used. */
private restoredInputs: InputIdentity[] | null = null;
private deferredAdded = false;
constructor(
private readonly cache: NativeCache,
private readonly key: string,
private readonly linker: string,
private readonly debug: boolean,
paths: string[],
/** Linux: trace with one verbose link, keep an unchanged output, and
* store the executable from a key's second link on. */
private readonly linux = false,
/** Inputs that the key determines, such as the runtime pack's selected
* artifacts. keep() does not read them, because the entry it checks
* lists them; restore() and trace() add their identities first. */
private readonly deferredPaths: () => string[] = () => [],
) {
this.inputs = [...new Set(paths)].sort().map(identity);
}
private addDeferredInputs(): void {
if (this.deferredAdded) return;
this.deferredAdded = true;
const listed = new Set(this.inputs.map((input) => input.path));
for (const path of this.deferredPaths())
if (!listed.has(path)) {
listed.add(path);
this.inputs.push(identity(path));
}
}
/** Whether `recorded`, the inputs of an entry, still match and cover this
* build's inputs. */
private covers(recorded: InputIdentity[]): boolean {
if (!stillMatches(recorded) || !stillMatches(this.inputs)) return false;
const saved = new Map(recorded.map((input) => [input.path, input]));
return this.inputs.every((input) => {
const found = saved.get(input.path);
return found !== undefined && sameIdentity(found, input);
});
}
/** Linux: true when `output` is the executable the last build of this key
* left there, unchanged, and its inputs are unchanged; the caller keeps it. */
keep(output: string): boolean {
if (!this.linux) return false;
try {
const metadata = this.cache.read("kept", this.key);
if (metadata === null) return false;
const entry: unknown = JSON.parse(metadata.toString("utf8"));
if (!validKept(entry)) return false;
const current = identity(output);
return sameIdentity(current, entry.output) && this.covers(entry.inputs);
} catch {
return false;
}
}
restore(output: string): boolean {
try {
const metadata = this.cache.read("executable", this.key);
if (metadata === null) return false;
this.addDeferredInputs();
const entry: unknown = JSON.parse(metadata.toString("utf8"));
if (!validEntry(entry) || !this.covers(entry.inputs)) return false;
if (this.debug !== (entry.symbols !== null)) return false;
const binary = this.cache.read("binary", entry.binary);
const symbols = entry.symbols === null ? null : this.cache.read("dsym", entry.symbols);
if (binary === null || binary.length === 0 || (this.debug && symbols === null)) return false;
if (!stillMatches(entry.inputs)) return false;
if (symbols !== null) stageSymbols(symbols, output);
writeFileSync(output, binary);
chmodSync(output, 0o755);
this.restoredInputs = entry.inputs;
return true;
} catch {
return false;
}
}
/** Trace the real object-only link, including the SDK's transitive stubs.
* Directories from the driver's search line detect a newly added candidate.
* Private build inputs already belong to the LLVM/runtime cache identity.
* A true result means the requested output was linked successfully and its
* inputs remained stable. The caller can publish that output directly. On
* false, the caller links the output itself. On Linux, `job` runs the
* traced link as its recorded linker job when it can, and otherwise records
* the job the driver ran (see LinkerJobs). */
trace(args: string[], stage: string, job?: LinkerJobLink): boolean {
this.traced = false;
try {
this.addDeferredInputs();
const roots = [resolve(stage), realpathSync(stage)];
let driverInputs = this.linux
? []
: tracePaths(toolOutput(this.linker, [...args, "-###"]), true, roots).map(identity);
// On Linux, -v prints the jobs that -### would, while the link runs.
const traced = [...args, ...(this.linux ? ["-v"] : []), "-Wl,-t"];
const direct = this.linux ? (job?.run(traced, false) ?? null) : null;
const linked = direct?.output ?? toolOutput(this.linker, traced);
if (this.linux) {
driverInputs = direct?.driverInputs ?? tracePaths(linked, true, roots).map(identity);
if (direct === null) job?.record(traced, linked, driverInputs);
}
const paths = tracePaths(linked, false, roots);
if (paths.length === 0 || !stillMatches(this.inputs) || !stillMatches(driverInputs))
return false;
// The driver's job line and the linker's trace name many of the same
// files; each is checked once.
const listed = new Set(this.inputs.map((input) => input.path));
for (const input of [...driverInputs, ...paths.map(identity)])
if (!listed.has(input.path)) {
listed.add(input.path);
this.inputs.push(input);
}
this.traced = true;
return true;
} catch {
return false;
}
}
publish(output: string): void {
try {
// On Linux the first build of a key only records its output (see
// recordOutput); the executable is stored once a later build relinks.
if (!this.traced || (this.linux && !this.cache.has("kept", this.key))) return;
if (!stillMatches(this.inputs)) return;
const binary = readFileSync(output);
const symbols = this.debug ? readSymbols(output) : null;
const binaryKey = this.cache.writeContent("binary", binary);
const symbolsKey = symbols === null ? null : this.cache.writeContent("dsym", symbols);
if (!stillMatches(this.inputs)) return;
const entry: ExecutableEntry = {
schema: "scriptc.native-executable.v1",
inputs: this.inputs,
binary: binaryKey,
symbols: symbolsKey,
};
this.cache.write("executable", this.key, JSON.stringify(entry));
} catch {
/* Build artifacts remain valid when caching is unavailable. */
}
}
/** Linux: records `output`, the executable this build left in place, for
* keep(). */
recordOutput(output: string): void {
if (!this.linux) return;
try {
const inputs = this.traced ? this.inputs : this.restoredInputs;
if (inputs === null || !stillMatches(inputs)) return;
const entry: KeptEntry = {
schema: "scriptc.native-kept.v1",
output: identity(output),
inputs,
};
this.cache.write("kept", this.key, JSON.stringify(entry));
} catch {
/* Build artifacts remain valid when caching is unavailable. */
}
}
}
/** The runtime pack an executable links: its directory, and the link features
* and flavor that select its artifacts. `select` reads and validates the
* manifest; the cache calls it only when it needs the artifacts' identities,
* so an unchanged rebuild that keeps its output never reads the manifest. */
export interface ExecutableRuntimePack {
root: string;
features: NativeLinkFeatures;
flavor: NativeOptimization;
select: () => NativeRuntimeSelection;
}
export function openNativeExecutableCache(
cache: NativeCache | null,
toolchain: NativeToolchain,
llvmDigest: () => string,
options: CompileRequestOptions,
ffi: FfiProfile | null,
pack: ExecutableRuntimePack,
): NativeExecutableCache | null {
// Match the established complete-cache contract: the default Apple driver
// or a Linux clang driver, known runtime inputs, and no wrapper or
// caller-supplied library searches.
const platform = toolchain.target.platform;
if (
cache === null ||
(platform !== "darwin" && platform !== "linux") ||
options.sanitize ||
// The key names a profile by path, not by its bytes.
options.profileGenerate !== undefined ||
options.profileUse !== undefined ||
process.env["SCRIPTC_LINKER"] !== undefined ||
process.env["SCRIPTC_LLVM_HELPER"] !== undefined ||
toolchain.linkerArgs.length !== 0 ||
ffi !== null ||
!toolchainEnvironmentCachePolicy().completeArtifacts
)
return null;
try {
const linker = commandPath(toolchain.linker);
// Apple's driver at /usr/bin/clang, or a clang driver on Linux. Both
// name every input of a link in their -### and -Wl,-t traces.
if (
platform === "darwin"
? realpathSync(linker) !== "/usr/bin/clang"
: !/^clang(-\d+)?$/.test(basename(realpathSync(linker)))
)
return null;
// Apple's /usr/bin/clang is a shim for the active developer directory's
// driver; a Linux clang driver is itself the driver.
const effective =
platform === "darwin"
? commandPath(runNativeTool(linker, ["-print-prog-name=clang"]).trim())
: linker;
// Linux keeps debug information in the executable; darwin keeps it in a
// separate dSYM, which the cache stores beside the executable.
const debug = platform === "darwin" && options.optimization === "dev" && !options.strip;
const paths = [
linker,
effective,
toolchain.helperExecutable,
join(toolchain.helperPackageRoot, "package.json"),
join(toolchain.runtimePackRoot, "package.json"),
join(toolchain.runtimePackRoot, "runtime-pack.json"),
];
const manifest = join(pack.root, "runtime-pack.json");
if (platform === "linux") paths.push(manifest);
// The manifest's identity, the features and the flavor in the key fix
// which artifacts the selection names.
const artifacts = (): string[] => {
const selection = pack.select();
return [
...selection.selected.runtime,
...selection.selected.archives,
...selection.licenses,
].map((artifact) => join(selection.root, artifact.path));
};
if (debug) {
const dsymutil = commandPath(toolchain.dsymutil);
if (realpathSync(dsymutil) !== "/usr/bin/dsymutil") return null;
paths.push(dsymutil);
}
const key = contentDigest(
JSON.stringify({
schema: 1,
llvm: llvmDigest(),
options,
toolchain,
runtimeIdentity: readFileSync(join(pack.root, "package.json"), "utf8"),
// On Linux the manifest's identity names it: restore checks that
// identity again, as it does every other input.
runtimeManifest:
platform === "darwin" ? readFileSync(manifest, "utf8") : identity(manifest),
runtimeFeatures: pack.features,
runtimeFlavor: pack.flavor,
linker: identity(linker),
effective: identity(effective),
environment: toolchainEnvironmentFingerprint(),
cwd: process.cwd(),
path: process.env["PATH"] ?? null,
}),
);
return new NativeExecutableCache(
cache,
key,
linker,
debug,
paths,
platform === "linux",
artifacts,
);
} catch {
return null;
}
}