diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 3c9bdf411..937b4b109 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { closeSync, + existsSync, mkdirSync, mkdtempSync, openSync, @@ -88,14 +89,19 @@ export class NativeCache { } } - write(family: string, key: string, bytes: string | Uint8Array): void { + write( + family: string, + key: string, + bytes: string | Uint8Array, + digest = contentDigest(bytes), + ): void { let stage: string | null = null; try { const directory = join(this.root, family); mkdirSync(directory, { recursive: true, mode: 0o700 }); stage = mkdtempSync(join(directory, ".write-")); writeFileSync(join(stage, "payload"), bytes); - writeFileSync(join(stage, "digest"), contentDigest(bytes) + "\n"); + writeFileSync(join(stage, "digest"), digest + "\n"); const path = join(directory, key); // Concurrent writers publish identical content under the same key; // a reader between the two renames simply treats it as a miss. @@ -112,6 +118,29 @@ export class NativeCache { } } + /** Whether a marker or entry exists, without reading or checking it. */ + has(family: string, key: string): boolean { + return existsSync(join(this.root, family, key)); + } + + /** Records an empty marker that has() finds. */ + mark(family: string, key: string): void { + try { + const directory = join(this.root, family); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync(join(directory, key), ""); + } catch { + /* Cache failures never change a build's result. */ + } + } + + /** Writes `bytes` under their content digest, and returns the digest. */ + writeContent(family: string, bytes: Uint8Array): string { + const digest = contentDigest(bytes); + this.write(family, digest, bytes, digest); + return digest; + } + /** Publish text parts without joining them into one string. */ writeParts(family: string, key: string, parts: Generator): void { let stage: string | null = null; @@ -149,6 +178,7 @@ export class NativeCache { "object", "sanitizer", "executable", + "linked", "binary", "dsym", "coverage", diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index a37d76f80..63769fba6 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -56,7 +56,7 @@ import { clearFenceEvalCaches } from "../library/fence-eval.js"; import { archiveNativeLibrary, localizeNativeLibrary, linkNativeWasmLibrary } from "./library.js"; import type { NativeToolchain } from "./toolchain.js"; import { evaluateNativeComptime } from "./comptime.js"; -import { contentDigest, fileDigest, type NativeCache } from "./cache.js"; +import { contentDigest, fileDigest, partsDigest, type NativeCache } from "./cache.js"; import { nativeProfileKey, nativeProfileRefusal, @@ -168,6 +168,7 @@ export class NativeCompiler { outputKind: "obj" | "asm", partitions = 1, profile?: NativeProfileOptions, + llvmDigest: () => string = () => fileDigest(input), ): string[] { const toolchain = this.toolchain; const outputs = nativePartitionPaths(output, partitions); @@ -186,7 +187,7 @@ export class NativeCompiler { key = contentDigest( JSON.stringify({ schema: 1, - llvm: fileDigest(input), + llvm: llvmDigest(), outputKind, optimization, source, @@ -253,6 +254,7 @@ export class NativeCompiler { stage: string, library: boolean, profile?: NativeProfileOptions, + llvmDigest?: () => string, ): string[] { // Library archives merge dev shards relocatably; executables link the // helper's program partitions directly. @@ -263,7 +265,16 @@ export class NativeCompiler { const split = llvm === null ? null : splitLlvmLibraryProgram(llvm); if (split === null) { const partitions = library ? 1 : nativeProgramPartitions(statSync(input).size); - return this.emitObject(input, output, source, optimization, "obj", partitions, profile); + return this.emitObject( + input, + output, + source, + optimization, + "obj", + partitions, + profile, + llvmDigest, + ); } const objects: string[] = []; for (const [index, shard] of split.shards.entries()) { @@ -358,6 +369,9 @@ export class NativeCompiler { return { ok: true, artifact: { kind: "ir", path: output } }; } const { llvm, features } = prepared; + // The executable and object caches key the same LLVM text. + let llvmDigest: string | undefined; + const digestLlvm = (): string => (llvmDigest ??= partsDigest(llvm)); const inputDirectory = join(stage, "input"); mkdirSync(inputDirectory); const llvmInput = outputKind === "llvm" ? stagedOutput : join(inputDirectory, "program.ll"); @@ -385,7 +399,14 @@ export class NativeCompiler { const executableCache = executablePack === null ? null - : openNativeExecutableCache(this.cache, toolchain, llvm, options, ffi, executablePack); + : openNativeExecutableCache( + this.cache, + toolchain, + digestLlvm, + options, + ffi, + executablePack, + ); const restored = executableCache?.restore(stagedOutput) ?? false; timing(restored ? "executable-cache-hit" : "executable-cache-miss"); if (!restored) { @@ -408,6 +429,7 @@ export class NativeCompiler { stage, false, profile, + digestLlvm, ); else this.emitObject( diff --git a/packages/compiler/src/native/executable-cache-open.test.ts b/packages/compiler/src/native/executable-cache-open.test.ts new file mode 100644 index 000000000..05e57148d --- /dev/null +++ b/packages/compiler/src/native/executable-cache-open.test.ts @@ -0,0 +1,86 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; +import { NativeCache } from "./cache.js"; +import { openNativeExecutableCache } from "./executable-cache.js"; +import type { NativeToolchain } from "./toolchain.js"; + +const directories: string[] = []; +const saved = new Map(); +beforeEach(() => { + for (const name of ["SCRIPTC_LINKER", "SCRIPTC_LLVM_HELPER", "CC", "LDFLAGS", "LIBRARY_PATH"]) { + saved.set(name, process.env[name]); + delete process.env[name]; + } +}); +afterEach(() => { + for (const [name, value] of saved) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + saved.clear(); + for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +/** A toolchain whose linker is a driver script named `driver`; it answers + * `-print-prog-name=clang` with its own path. */ +function fixture(platform: string, driver: string) { + const root = mkdtempSync(join(tmpdir(), "scriptc-executable-cache-open-")); + directories.push(root); + const bin = join(root, "bin"); + const helper = join(root, "helper"); + const runtime = join(root, "runtime"); + for (const directory of [bin, helper, runtime]) mkdirSync(directory); + const linker = join(bin, driver); + writeFileSync(linker, '#!/bin/sh\necho "$0"\n'); + chmodSync(linker, 0o755); + writeFileSync(join(helper, "codegen"), "helper"); + writeFileSync(join(helper, "package.json"), "{}"); + writeFileSync(join(runtime, "package.json"), "{}"); + writeFileSync(join(runtime, "runtime-pack.json"), "{}"); + const toolchain = { + compilerVersion: "test", + target: { platform }, + helperExecutable: join(helper, "codegen"), + helperPackageRoot: helper, + runtimePackRoot: runtime, + linker, + linkerArgs: [], + dsymutil: "dsymutil", + } as unknown as NativeToolchain; + const pack = { + root: runtime, + manifest: { licenses: [] }, + packageText: "{}", + manifestText: "{}", + selected: { runtime: [], archives: [] }, + flavor: "release", + } as unknown as NativeRuntimeSelection; + const open = () => + openNativeExecutableCache( + new NativeCache(join(root, "cache")), + toolchain, + () => "llvm digest", + { outPath: join(root, "program"), outDir: root, optimization: "release" }, + null, + pack, + ); + return { open }; +} + +test.skipIf(process.platform === "win32")( + "Linux builds that link with a clang driver use the executable cache", + () => { + expect(fixture("linux", "clang").open()).not.toBeNull(); + expect(fixture("linux", "clang-22").open()).not.toBeNull(); + expect(fixture("linux", "gcc").open()).toBeNull(); + expect(fixture("windows", "clang").open()).toBeNull(); + expect(fixture("wasi", "clang").open()).toBeNull(); + // On darwin only Apple's driver at /usr/bin/clang qualifies. + expect(fixture("darwin", "clang").open()).toBeNull(); + process.env["SCRIPTC_LLVM_HELPER"] = "/elsewhere/codegen"; + expect(fixture("linux", "clang").open()).toBeNull(); + }, +); diff --git a/packages/compiler/src/native/executable-cache.test.ts b/packages/compiler/src/native/executable-cache.test.ts index 91762f046..83a493721 100644 --- a/packages/compiler/src/native/executable-cache.test.ts +++ b/packages/compiler/src/native/executable-cache.test.ts @@ -222,3 +222,61 @@ test("a failed or empty link trace cannot publish a completed executable", () => }); expect(entry.trace([], f.stage)).toBe(false); }); + +test.each(["ld", "crt", "libc"] as const)( + "a Linux key's second 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 is not traced; the caller links the output itself. + expect(open().trace(["-o", f.output], f.stage)).toBe(false); + expect(spawnSync).not.toHaveBeenCalled(); + expect(open().restore(f.destination)).toBe(false); + // Its next link traces both the driver and the linker in one run. + 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); + const recorded = ( + JSON.parse(f.cache.read("executable", 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); + expect(open().restore(f.destination)).toBe(true); + 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().restore(f.destination)).toBe(false); + }, +); diff --git a/packages/compiler/src/native/executable-cache.ts b/packages/compiler/src/native/executable-cache.ts index c0ac755b9..1dfbf0b00 100644 --- a/packages/compiler/src/native/executable-cache.ts +++ b/packages/compiler/src/native/executable-cache.ts @@ -11,7 +11,7 @@ import { import { runNativeTool } from "../backend/native-tools.js"; import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; import type { NativeToolchain } from "./toolchain.js"; -import { contentDigest, NativeCache, partsDigest } from "./cache.js"; +import { contentDigest, NativeCache } from "./cache.js"; interface InputIdentity { path: string; @@ -48,9 +48,22 @@ function identity(path: string): InputIdentity { }; } +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 + ); +} + function stillMatches(inputs: InputIdentity[]): boolean { try { - return inputs.every((input) => JSON.stringify(identity(input.path)) === JSON.stringify(input)); + return inputs.every((input) => sameIdentity(identity(input.path), input)); } catch { return false; } @@ -165,6 +178,8 @@ function stageSymbols(bytes: Buffer, binary: string): void { export class NativeExecutableCache { private inputs: InputIdentity[]; private traced = false; + /** Linux: no entry exists and the key was never linked before. */ + private readonly firstLink: boolean; constructor( private readonly cache: NativeCache, @@ -172,23 +187,28 @@ export class NativeExecutableCache { private readonly linker: string, private readonly debug: boolean, paths: string[], + /** Linux: trace with one verbose link, from a key's second link on. */ + private readonly linux = false, ) { - this.inputs = [...new Set(paths)].sort().map(identity); + this.firstLink = linux && !cache.has("executable", key) && !cache.has("linked", key); + // A first link neither restores nor traces, so it needs no identities. + this.inputs = this.firstLink ? [] : [...new Set(paths)].sort().map(identity); } restore(output: string): boolean { + if (this.firstLink) return false; try { const metadata = this.cache.read("executable", this.key); if (metadata === null) return false; const entry: unknown = JSON.parse(metadata.toString("utf8")); if (!validEntry(entry) || !stillMatches(entry.inputs) || !stillMatches(this.inputs)) return false; + const saved = new Map(entry.inputs.map((input) => [input.path, input])); if ( - !this.inputs.every((input) => - entry.inputs.some( - (saved) => saved.path === input.path && JSON.stringify(saved) === JSON.stringify(input), - ), - ) + !this.inputs.every((input) => { + const recorded = saved.get(input.path); + return recorded !== undefined && sameIdentity(recorded, input); + }) ) return false; if (this.debug !== (entry.symbols !== null)) return false; @@ -209,14 +229,24 @@ export class NativeExecutableCache { * 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. */ + * inputs remained stable. The caller can publish that output directly. On + * false, the caller links the output itself. */ trace(args: string[], stage: string): boolean { this.traced = false; try { + // On Linux a key's first link only records that it was linked; a later + // link of the same key traces, so a build that runs once traces nothing. + if (this.firstLink) { + this.cache.mark("linked", this.key); + return false; + } const roots = [resolve(stage), realpathSync(stage)]; - const dry = toolOutput(this.linker, [...args, "-###"]); - const driverInputs = tracePaths(dry, true, roots).map(identity); - const linked = toolOutput(this.linker, [...args, "-Wl,-t"]); + 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 linked = toolOutput(this.linker, [...args, ...(this.linux ? ["-v"] : []), "-Wl,-t"]); + if (this.linux) driverInputs = tracePaths(linked, true, roots).map(identity); const paths = tracePaths(linked, false, roots); if (paths.length === 0 || !stillMatches(this.inputs) || !stillMatches(driverInputs)) return false; @@ -233,10 +263,8 @@ export class NativeExecutableCache { if (!this.traced || !stillMatches(this.inputs)) return; const binary = readFileSync(output); const symbols = this.debug ? readSymbols(output) : null; - const binaryKey = contentDigest(binary); - const symbolsKey = symbols === null ? null : contentDigest(symbols); - this.cache.write("binary", binaryKey, binary); - if (symbols !== null && symbolsKey !== null) this.cache.write("dsym", symbolsKey, symbols); + 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", @@ -254,16 +282,18 @@ export class NativeExecutableCache { export function openNativeExecutableCache( cache: NativeCache | null, toolchain: NativeToolchain, - llvm: readonly string[], + llvmDigest: () => string, options: CompileRequestOptions, ffi: FfiProfile | null, pack: NativeRuntimeSelection, ): NativeExecutableCache | null { - // Match the established complete-cache contract: default Apple driver, - // known runtime inputs, and no wrapper or caller-supplied library searches. + // 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 || - toolchain.target.platform !== "darwin" || + (platform !== "darwin" && platform !== "linux") || options.sanitize || // The key names a profile by path, not by its bytes. options.profileGenerate !== undefined || @@ -277,9 +307,23 @@ export function openNativeExecutableCache( return null; try { const linker = commandPath(toolchain.linker); - if (realpathSync(linker) !== "/usr/bin/clang") return null; - const effective = commandPath(runNativeTool(linker, ["-print-prog-name=clang"]).trim()); - const debug = options.optimization === "dev" && !options.strip; + // 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, @@ -291,6 +335,7 @@ export function openNativeExecutableCache( (artifact) => join(pack.root, artifact.path), ), ]; + if (platform === "linux") paths.push(join(pack.root, "runtime-pack.json")); if (debug) { const dsymutil = commandPath(toolchain.dsymutil); if (realpathSync(dsymutil) !== "/usr/bin/dsymutil") return null; @@ -299,11 +344,16 @@ export function openNativeExecutableCache( const key = contentDigest( JSON.stringify({ schema: 1, - llvm: partsDigest(llvm), + llvm: llvmDigest(), options, toolchain, runtimeIdentity: pack.packageText, - runtimeManifest: pack.manifestText, + // On Linux the manifest's identity names it: restore checks that + // identity again, as it does every other input. + runtimeManifest: + platform === "darwin" + ? pack.manifestText + : identity(join(pack.root, "runtime-pack.json")), linker: identity(linker), effective: identity(effective), environment: toolchainEnvironmentFingerprint(), @@ -311,7 +361,7 @@ export function openNativeExecutableCache( path: process.env["PATH"] ?? null, }), ); - return new NativeExecutableCache(cache, key, linker, debug, paths); + return new NativeExecutableCache(cache, key, linker, debug, paths, platform === "linux"); } catch { return null; }