Files
scriptc/packages/compiler/src/native/cache.ts
T
Malte Ubl 5b8661bf8c Keep native compiler IR and frontend cache entries out of single strings
Scriptc-compiled strings stop at 2^31 - 1 UTF-8 bytes and exceeding the
cap traps ("scriptc: out of memory") instead of throwing. In the native
compiler the self-hosted IR was one 515 MB string, and the frontend
cache entry JSON.stringify'd LLVM and IR together into one ~920 MB
string, about 2.3x below the trap.

- NativeExecutableInput.ir is compact IR as ~16M-char parts; every IR
  output is written with writeTextPartsSync.
- Frontend cache entries move to scriptc.native-frontend.v3: the input
  metadata carries placeholders and the LLVM and IR follow as separate
  members, so the entry is written as parts with an incremental digest
  (NativeCache.writeParts). Entries a native reader could not decode as
  one string are neither published nor read (NativeCache.read maxBytes),
  turning the old trap into a cache miss.
- SCRIPTC_BOOTSTRAP_IR_INPUT decodes the seed IR per function with
  deserializeModuleBytes.

The native LLVM text remains one string (344 MB for the compiler,
~6x below the native cap).
2026-10-10 20:39:35 -07:00

197 lines
6.7 KiB
TypeScript

import { createHash } from "node:crypto";
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { resolveBuildCacheRoot } from "../backend/cache-root.js";
import { writeTextPartsSync } from "./text-file.js";
/** UTF-8 bytes one native compiler string can hold (the runtime's
* SCR_STR_MAX_CAP). Exceeding it traps rather than throwing, so payloads
* decoded as one string are bounded by it up front. */
export const NATIVE_STRING_BYTES = 0x7fffffff;
export function contentDigest(value: string | Uint8Array): string {
const hash = createHash("sha256");
if (typeof value === "string") hash.update(value);
else hash.update(value);
return hash.digest("hex");
}
/** A compiler cache contains private source and executable data. The host
* checks ownership and permissions before this class accepts a root. */
export class NativeCache {
readonly root: string;
constructor(root: string) {
this.root = join(root, "native-v1");
mkdirSync(this.root, { recursive: true, mode: 0o700 });
}
/** Entries larger than `maxBytes` are misses. */
read(family: string, key: string, maxBytes = Infinity): Buffer | null {
try {
const path = join(this.root, family, key);
if (maxBytes < Infinity && statSync(path).size > maxBytes) return null;
const bytes = readFileSync(path);
if (contentDigest(bytes) !== readFileSync(path + ".sha256", "utf8").trim()) return null;
return bytes;
} catch {
return null;
}
}
/** Directory where the LLVM helper reuses partition artifacts for one
* helper build. The helper names and verifies the entries itself. */
partitionDirectory(helperIdentity: string): string | null {
const directory = join(this.root, "partitions", contentDigest(helperIdentity).slice(0, 32));
try {
mkdirSync(directory, { recursive: true, mode: 0o700 });
return directory;
} catch {
/* An unavailable cache only prevents reuse. */
return null;
}
}
write(family: string, key: string, bytes: string | Uint8Array): 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");
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.
renameSync(join(stage, "payload"), path);
renameSync(join(stage, "digest"), path + ".sha256");
} catch {
/* Cache failures never change a build's result. */
} finally {
try {
if (stage !== null) rmSync(stage, { recursive: true, force: true });
} catch {
/* Cleanup is best effort too. */
}
}
}
/** Publish text parts without joining them into one string. */
writeParts(family: string, key: string, parts: readonly string[]): void {
let stage: string | null = null;
try {
const directory = join(this.root, family);
mkdirSync(directory, { recursive: true, mode: 0o700 });
stage = mkdtempSync(join(directory, ".write-"));
writeTextPartsSync(join(stage, "payload"), parts);
const hash = createHash("sha256");
for (const part of parts) hash.update(part);
writeFileSync(join(stage, "digest"), hash.digest("hex") + "\n");
const path = join(directory, key);
renameSync(join(stage, "payload"), path);
renameSync(join(stage, "digest"), path + ".sha256");
} catch {
/* Cache failures never change a build's result. */
} finally {
try {
if (stage !== null) rmSync(stage, { recursive: true, force: true });
} catch {
/* Cleanup is best effort too. */
}
}
}
prune(): void {
try {
const configured = Number(process.env["SCRIPTC_CACHE_MAX_MB"] ?? "4096");
const limit =
(Number.isFinite(configured) && configured >= 0 ? configured : 4096) * 1024 * 1024;
const files: { path: string; size: number; time: number }[] = [];
let total = 0;
for (const family of [
"frontend",
"object",
"sanitizer",
"executable",
"binary",
"dsym",
"coverage",
]) {
const directory = join(this.root, family);
let names: string[];
try {
names = readdirSync(directory);
} catch {
continue;
}
for (const name of names) {
if (!/^[0-9a-f]{64}$/.test(name)) continue;
const path = join(directory, name);
const info = statSync(path);
if (!info.isFile()) continue;
files.push({ path, size: info.size, time: info.mtimeMs });
total += info.size;
}
}
// Helper partition entries: partitions/<helper>/<xx>/<key>.<kind>.
const partitions = join(this.root, "partitions");
const list = (directory: string) => {
try {
return readdirSync(directory, { withFileTypes: true });
} catch {
return [];
}
};
for (const helper of list(partitions)) {
if (!helper.isDirectory()) continue;
for (const bucket of list(join(partitions, helper.name))) {
if (!bucket.isDirectory()) continue;
const directory = join(partitions, helper.name, bucket.name);
for (const entry of list(directory)) {
if (!entry.isFile() || !/^[0-9a-f]{64}\.(?:o|bc)$/.test(entry.name)) continue;
const path = join(directory, entry.name);
const info = statSync(path);
files.push({ path, size: info.size, time: info.mtimeMs });
total += info.size;
}
}
}
if (total <= limit) return;
files.sort((a, b) => a.time - b.time);
for (const file of files) {
if (total <= limit) break;
rmSync(file.path, { force: true });
rmSync(file.path + ".sha256", { force: true });
total -= file.size;
}
} catch {
/* Eviction is best effort. */
}
}
}
export function openNativeCache(
isPrivate: (path: string, harden: boolean) => boolean,
): NativeCache | null {
const root = resolveBuildCacheRoot();
if (root === null) return null;
try {
mkdirSync(root, { recursive: true, mode: 0o700 });
if (!isPrivate(root, process.env["SCRIPTC_CACHE_DIR"] === undefined)) return null;
const cache = new NativeCache(root);
if (!isPrivate(cache.root, false)) return null;
return cache;
} catch {
return null;
}
}