mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
5307 lines
227 KiB
TypeScript
5307 lines
227 KiB
TypeScript
import { driverTraceCandidates, linkTraceCandidate } from "./link-trace.js";
|
|
import { toolchainEnvironmentCachePolicy, toolchainEnvironmentFingerprint } from "./toolchain-environment.js";
|
|
export { toolchainEnvironmentCachePolicy, toolchainEnvironmentFingerprint, type ToolchainEnvironmentCachePolicy } from "./toolchain-environment.js";
|
|
import { IPHONEOS_MIN_VERSION, ANDROID_MIN_API, isIosTarget, isAndroidTarget, isMobileTarget, mobileLibraryTarget, mobileTargetRefusal, configuredTargetPlatform } from "./target-platform.js";
|
|
export { IPHONEOS_MIN_VERSION, ANDROID_MIN_API, isIosTarget, isAndroidTarget, isMobileTarget, mobileLibraryTarget, mobileTargetRefusal, configuredTargetPlatform } from "./target-platform.js";
|
|
import { InternalCompilerError } from "../errors.js";
|
|
import { execFile, spawnSync } from "node:child_process";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { constants as fsConstants, existsSync, readdirSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { access, chmod, copyFile, link, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
import { availableParallelism, homedir, tmpdir } from "node:os";
|
|
import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { localizeElfObject, mergeAndLocalizeCoffObjects } from "./object-localize.js";
|
|
import { executableOptimizationLinkerArgs, executableStripLinkerArgs, windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./targets.js";
|
|
import { createDarwinDebugSymbols, installDarwinDebugSymbols, needsDarwinDebugSymbols, readDarwinDebugSymbols } from "./debug-symbols.js";
|
|
import {
|
|
createVendorArchives,
|
|
MBEDTLS_VERSION,
|
|
QJS_COMMIT,
|
|
ZLIB_VERSION,
|
|
} from "./vendor-archives.js";
|
|
import {
|
|
cacheDigestPath,
|
|
cacheRootDir,
|
|
copyValidCachedFile,
|
|
ensurePrivateCacheRoot,
|
|
fileDigest,
|
|
fileExists,
|
|
installArtifact,
|
|
privateSiblingPath,
|
|
protectCachedArtifact,
|
|
pruneCache,
|
|
publishCachedFile,
|
|
validCachedFile,
|
|
} from "./build-cache.js";
|
|
|
|
export {
|
|
buildCacheRoot,
|
|
prepareBuildCacheRoot,
|
|
pruneBuildCache,
|
|
resolveBuildCacheRoot,
|
|
} from "./build-cache.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const NATIVE_TOOLCHAIN_IMPLEMENTATION_PATH = fileURLToPath(import.meta.url);
|
|
const NATIVE_RECIPE_IMPLEMENTATION_PATHS = [
|
|
NATIVE_TOOLCHAIN_IMPLEMENTATION_PATH,
|
|
join(
|
|
dirname(NATIVE_TOOLCHAIN_IMPLEMENTATION_PATH),
|
|
`build-cache${extname(NATIVE_TOOLCHAIN_IMPLEMENTATION_PATH)}`,
|
|
),
|
|
join(
|
|
dirname(NATIVE_TOOLCHAIN_IMPLEMENTATION_PATH),
|
|
`vendor-archives${extname(NATIVE_TOOLCHAIN_IMPLEMENTATION_PATH)}`,
|
|
),
|
|
];
|
|
|
|
/** Test lanes run against one immutable checkout and one immutable toolchain
|
|
* for the lifetime of each Vitest worker. Production deliberately rediscovers
|
|
* compiler/linker inputs on every invocation, but doing that thousands of
|
|
* times in the differential corpus costs far more than the compile itself.
|
|
* This test-only opt-in lets those workers reuse metadata probes for their
|
|
* session; the cache-correctness suite removes the flag and exercises the
|
|
* strict production path. */
|
|
function stableTestToolchainSession(): boolean {
|
|
return process.env["SCRIPTC_TEST_STABLE_TOOLCHAIN"] === "1";
|
|
}
|
|
|
|
function stableTestMemo<T>(
|
|
cache: Map<string, Promise<T>>,
|
|
key: string,
|
|
probe: () => Promise<T>,
|
|
): Promise<T> {
|
|
if (!stableTestToolchainSession()) return probe();
|
|
const existing = cache.get(key);
|
|
if (existing !== undefined) return existing;
|
|
const pending = probe();
|
|
cache.set(key, pending);
|
|
void pending.catch(() => {
|
|
if (cache.get(key) === pending) cache.delete(key);
|
|
});
|
|
return pending;
|
|
}
|
|
|
|
export const EXECUTABLE_RUNTIME_SOURCES = ["scr_number.c", "scr_bigint.c", "scr_string.c", "scr_grapheme.c", "scr_array.c", "scr_bytes.c", "scr_bytes_io.c", "scr_map.c", "scr_closure.c", "scr_ffi.c", "scr_object.c", "scr_union.c", "scr_exception.c", "scr_error.c", "scr_console.c", "scr_lib.c", "scr_path.c", "scr_url.c", "scr_json.c", "scr_node_builtin.c", "scr_async.c", "scr_crypto_async.c", "scr_child.c", "scr_cycle.c"] as const;
|
|
|
|
/**
|
|
* Per-target section-elimination recipe. This belongs beside the native
|
|
* driver rather than a particular build path: one-shot compilation, cached
|
|
* runtime objects, external object recipes, and runtime packs must all agree
|
|
* on what their final linker is allowed to discard.
|
|
*
|
|
* Executable recipes use both halves. Archive and compile-only recipes never
|
|
* use the link half, but `--lib` uses the compile half so its consumers retain
|
|
* the choice to eliminate unused sections when they perform the final link.
|
|
*/
|
|
export function executableSectionEliminationFlags(platform: string): {
|
|
compile: string[];
|
|
link: string[];
|
|
} {
|
|
switch (platform) {
|
|
case "darwin":
|
|
// ld64's symbol subsections make this sufficient for ordinary C/LLVM
|
|
// objects. Do not attach it only to --dynamic: static programs have the
|
|
// same unreachable runtime sections.
|
|
return { compile: [], link: ["-Wl,-dead_strip"] };
|
|
case "linux":
|
|
return {
|
|
compile: ["-ffunction-sections", "-fdata-sections"],
|
|
link: ["-Wl,--gc-sections"],
|
|
};
|
|
case "win32":
|
|
// clang's MinGW driver forwards this to GNU-flavor ld/lld. A direct
|
|
// local lld-link invocation instead uses /OPT:REF, but scriptc only
|
|
// drives the compiler's GNU-flavor route here.
|
|
return {
|
|
compile: ["-ffunction-sections", "-fdata-sections"],
|
|
link: ["-Wl,--gc-sections"],
|
|
};
|
|
// WASI has a distinct linker/runtime contract. Keep its existing object
|
|
// layout until its linker invocation is validated separately.
|
|
default:
|
|
return { compile: [], link: [] };
|
|
}
|
|
}
|
|
|
|
/** Inputs that can change which native tool/runtime implementation an
|
|
* executable build selects before compileC has a chance to rediscover it.
|
|
* The early whole-program cache keys this exact posture before restoring a
|
|
* final binary; compileC retains its deeper inode/content validation. */
|
|
export async function executableNativeEnvironmentFingerprint(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<string> {
|
|
const configuredCompiler = env["SCRIPTC_CC"] ?? "";
|
|
let compilerIdentity: string;
|
|
try {
|
|
compilerIdentity = await effectiveCompilerEnvironmentIdentity(resolveCc(env), env);
|
|
} catch {
|
|
// A failed trace cannot safely describe a reusable native posture. Keep
|
|
// the build working, but make this invocation miss every persistent early
|
|
// entry so compileC performs its full discovery and validation.
|
|
compilerIdentity = `<unavailable:${configuredCompiler}:${randomUUID()}>`;
|
|
}
|
|
const hash = createHash("sha256")
|
|
.update("executable-native-environment-v2\0")
|
|
.update(toolchainEnvironmentFingerprint(env)).update("\0")
|
|
// PATH text alone is not a resolution proof, and on Darwin /usr/bin/clang
|
|
// is a stable shim whose selected Xcode compiler can change underneath it.
|
|
// Re-resolve and trace the effective driver on every early lookup.
|
|
.update(compilerIdentity).update("\0");
|
|
for (const name of [
|
|
"PATH",
|
|
"SCRIPTC_FETCH_CURL",
|
|
"SCRIPTC_TEST_RUNTIME_SRC_DIR",
|
|
"SCRIPTC_TEST_VENDOR_CACHE_DIR",
|
|
"SCRIPTC_TEST_TRUST_COMPILER_WRAPPER",
|
|
]) {
|
|
const value = env[name];
|
|
hash.update(name).update(value === undefined ? "\0unset\0" : "\0set\0").update(value ?? "").update("\0");
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
export interface CcOptions {
|
|
/** Path of the generated (or hand-written) program TU: a .c file, or the
|
|
* LLVM backend's .ll — clang compiles IR text natively on the same
|
|
* command line, so both ride this one seat. */
|
|
cPath: string;
|
|
/** Path of the native executable to produce. */
|
|
outPath: string;
|
|
/** Additional identity for a translation unit whose complete non-system
|
|
* dependency graph is owned by the caller. Persistent caching is disabled
|
|
* when omitted: arbitrary C can depend on same-path edited headers and on
|
|
* compiler-visible source spelling (`__FILE__`), neither of which the
|
|
* top-level bytes alone can safely represent. scriptc's frontend supplies
|
|
* this for its generated LLVM IR; caller-supplied C deliberately does not. */
|
|
cacheIdentity?: string;
|
|
/** Native optimization posture. Release preserves the historical -O2
|
|
* executable lane; dev selects -O0 and may compile a caller-provided LLVM
|
|
* shard set into independently cached objects before the final link. */
|
|
optimization?: "release" | "dev";
|
|
/** Remove symbol/debug payload from the linked executable. */
|
|
strip?: boolean;
|
|
/** PE executable subsystem; omitted and console use the driver default. */
|
|
windowsSubsystem?: WindowsSubsystem;
|
|
/** Optional equivalent LLVM modules for dev compilation. Unsupported
|
|
* targets or merge failures fall back to the canonical cPath TU. */
|
|
programShards?: readonly { name: string; source: string }[];
|
|
/** Canonical externally visible definitions retained while shard merging
|
|
* demotes generated cross-shard linkage back to local symbols. */
|
|
programPublicSymbols?: readonly string[];
|
|
/** Build with ASan + the runtime RC audit (test/debug lane). */
|
|
sanitize?: boolean;
|
|
/** Additional native archives/objects, appended after the generated
|
|
* program TU so their symbols resolve outbound FFI calls. These inputs can
|
|
* be thin archives or linker scripts with mutable transitive dependencies,
|
|
* so their builds bypass the complete-executable cache while still reusing
|
|
* cached runtime objects. */
|
|
linkInputs?: readonly string[];
|
|
/** Driver-neutral system library names, emitted as `-l<name>` after
|
|
* linkInputs. Because the linker resolves these ambient names to files,
|
|
* their builds bypass the complete-executable cache while still reusing
|
|
* cached runtime objects. */
|
|
systemLibraries?: readonly string[];
|
|
/** Darwin framework names, emitted as distinct driver arguments. */
|
|
frameworks?: readonly string[];
|
|
/** Embed the dynamic-island engine (--dynamic): compiles scr_island.c,
|
|
* defines SCR_DYNAMIC, and links the cached libqjs.a. Off retains the
|
|
* static runtime selection; executable section GC may still remove
|
|
* unreachable static-runtime code. */
|
|
dynamic?: boolean;
|
|
/** The program contains a regex construct (index.ts detects it on the
|
|
* IR): compiles scr_regex.c and links the vendored libregexp — as cached
|
|
* standalone objects in static builds, from the engine archive under
|
|
* --dynamic (one libregexp per binary; its host hooks want the island's
|
|
* JSContext there). Off = regex-free: the command line is exactly the
|
|
* historical runtime selection; executable section GC may remove unrelated
|
|
* unreachable code. */
|
|
regex?: boolean;
|
|
/** The program uses one of the copying/typed-array bridge intrinsics
|
|
* implemented in scr_copying.c (index.ts detects them on the IR).
|
|
* Off keeps that optional TU out of unrelated binaries. */
|
|
copying?: boolean;
|
|
/** The program uses a statically-labelled non-UTF-8 TextDecoder. Its
|
|
* generated mapping tables live behind SCR_TEXT_DECODER_LEGACY so the
|
|
* always-compiled bytes TU stays in the historical size class otherwise. */
|
|
textDecoderLegacy?: boolean;
|
|
/** The program uses fs/promises.open or a FileHandle value
|
|
* (moduleUsesFileHandle on the IR): compiles scr_file_handle.c. Keeping
|
|
* the descriptor object and promise adapters in their own unit preserves
|
|
* the base runtime's size class for programs that never open a handle. */
|
|
fileHandle?: boolean;
|
|
/** The embedded npm graph references fetch (index.ts detects it on the
|
|
* IR): compiles the NATIVE fetch bridge (scr_fetch.c over scr_net +
|
|
* scr_tls + scr_http's client parser + zlib — the socket units join
|
|
* the link implicitly, no libcurl anywhere), which builds for every
|
|
* target the socket units reach: hosts, linux cross, win32 cross.
|
|
* Static user-code fetch compiles the same TU without the engine; the
|
|
* broader web surface still uses its dynamic half. Fetch-free builds
|
|
* keep their exact link line. SCRIPTC_FETCH_CURL=1 selects the retired
|
|
* curl reference instead
|
|
* (scr_fetch_curl.c + system libcurl on hosts / the generated soname
|
|
* stub on linux cross targets — ensureCurlStub), kept compilable for
|
|
* one release as the flip's reference. */
|
|
fetch?: boolean;
|
|
/** The embedded npm graph imports node:http or node:https (index.ts
|
|
* detects it on the IR): compiles the island's http/https client
|
|
* bridge (scr_net_island.c) and implies the socket units into the
|
|
* link, exactly like fetch. The emitted main calls
|
|
* scr_net_island_install on the same predicate (native-fetch builds
|
|
* also register it from scr_fetch_install). */
|
|
netIsland?: boolean;
|
|
/** The program uses zlib (index.ts detects zlib.* libCalls on the IR):
|
|
* compiles scr_zlib.c — the regex/curl gating precedent, so zlib-free
|
|
* binaries keep their exact link line. The default host-clang build links
|
|
* the SYSTEM libz (macOS ships it), byte-identical to the historical line;
|
|
* every Zig build compiles the vendored zlib with the selected driver instead
|
|
* (ensureZlibObjects — zig has no libz in its sysroots). Compressed bytes may
|
|
* differ between the
|
|
* system and vendored libraries, which is why the corpus only ever
|
|
* compares round-trips and fixed-blob inflation, never raw deflate
|
|
* output. */
|
|
zlib?: boolean;
|
|
/** The program uses node:assert (index.ts detects assert.* libCalls on
|
|
* the IR): compiles scr_assert.c — the zlib gating precedent, so
|
|
* assert-free binaries keep their exact size class. scr_regex.c calls
|
|
* the assert throw/inspect helpers (assert.match lives there) and
|
|
* scr_symbol.c calls the equality message assemblers (assert.eqSym
|
|
* lives there), so the regex and symbol switches also pull this
|
|
* file. */
|
|
assert?: boolean;
|
|
/** The program uses util.inspect/format (index.ts detects insp.*
|
|
* libCalls on the IR): compiles scr_inspect.c — the assert gating
|
|
* precedent, so inspect-free binaries keep their exact size class. */
|
|
inspect?: boolean;
|
|
/** The program dispatches prototype methods on dyn receivers (index.ts
|
|
* detects dynInvoke nodes / dyn.defineProps libCalls on the IR):
|
|
* compiles scr_dyn_invoke.c — the assert gating precedent, so
|
|
* dispatch-free binaries keep their exact size class. */
|
|
dynInvoke?: boolean;
|
|
/** The program uses the diagnostics_channel surface (index.ts detects
|
|
* dc.* libCalls on the IR): compiles scr_dc.c — pure data structure
|
|
* over the checked-dynamic tree (no loop hooks, no install), cross-compiles everywhere.
|
|
* Channel-free binaries keep their exact size class. */
|
|
dc?: boolean;
|
|
/** The program uses the checked-dynamic async surfaces
|
|
* (moduleUsesDynAsync on the IR, or the dynInvoke/dc gates — their TUs
|
|
* call into this one): compiles scr_async_dyn.c — dyn-promise
|
|
* reactions, AsyncLocalStorage, the unhandledRejection/warning
|
|
* process events. */
|
|
dynAsync?: boolean;
|
|
/** The program uses the process-events surface (signal/exit listeners,
|
|
* stdin events, for-await over stdin — moduleUsesProcessEvents on the
|
|
* IR): compiles scr_events.c into the binary. Event-free binaries keep
|
|
* their exact link line and size class. */
|
|
events?: boolean;
|
|
/** The program uses the node:events EventEmitter surface
|
|
* (moduleUsesEmitter on the IR): compiles scr_events_emitter.c into the
|
|
* binary — the events gating precedent, but pure data structure (no
|
|
* loop hooks, no install), so it cross-compiles everywhere win32
|
|
* included. Emitter-free binaries keep their exact link line. */
|
|
emitter?: boolean;
|
|
/** The program uses ES Symbol values (moduleUsesSymbol on the IR):
|
|
* compiles scr_symbol.c into the binary — the emitter gating precedent:
|
|
* pure data structure (no loop hooks, no install; the Symbol.for
|
|
* registry initializes lazily), so it cross-compiles everywhere.
|
|
* Symbol-free binaries keep their exact link line. */
|
|
symbol?: boolean;
|
|
bigint?: boolean;
|
|
/** The program uses the URLSearchParams surface (moduleUsesSearchParams
|
|
* on the IR): compiles scr_url_params.c into the binary — the symbol
|
|
* gating precedent: pure data structure (no loop hooks, no install),
|
|
* cross-compiles everywhere. sp-free binaries keep their exact link
|
|
* line (scr_url.c never references the unit). */
|
|
searchParams?: boolean;
|
|
/** The program uses the node:querystring surface (moduleUsesQs on the
|
|
* IR): compiles scr_qs.c into the binary — the searchParams gating
|
|
* precedent: pure data transforms (no loop hooks, no install),
|
|
* cross-compiles everywhere. qs-free binaries keep their exact link
|
|
* line (escape-only programs ride the always-linked component encoder
|
|
* and never flip this). */
|
|
qs?: boolean;
|
|
/** The program uses static util.parseArgs (index.ts detects its libCall):
|
|
* compiles scr_util.c, a pure checked-dynamic data transform. */
|
|
parseArgs?: boolean;
|
|
/** The program uses the node:stream class surface (moduleUsesStream on
|
|
* the IR): compiles scr_stream.c into the binary — always alongside
|
|
* scr_events_emitter.c, which moduleUsesEmitter answers true for
|
|
* whenever this does (the stream classes root at the emitter). Pure
|
|
* data structure plus the loop's deferred-tick hook — no poller, so it
|
|
* cross-compiles everywhere win32 included. */
|
|
stream?: boolean;
|
|
/** The program uses the node:net surface (moduleUsesNet on the IR):
|
|
* compiles scr_net.c into the binary — the events gating precedent, so
|
|
* net-free binaries keep their exact link line. */
|
|
net?: boolean;
|
|
/** The program uses the node:http server surface (moduleUsesHttpServer
|
|
* on the IR): compiles scr_http.c — always alongside scr_net.c, which
|
|
* moduleUsesNet answers true for whenever this does. */
|
|
http?: boolean;
|
|
/** The program uses the REAL node:http2 surface (moduleUsesHttp2 on
|
|
* the IR): compiles scr_http2.c — always alongside scr_net.c, which
|
|
* moduleUsesNet answers true for whenever this does. */
|
|
http2?: boolean;
|
|
/** The program uses the node:dgram or node:dns surface (moduleUsesDgram
|
|
* on the IR): compiles scr_dgram.c into the binary — the net gating
|
|
* precedent, so dgram-free binaries keep their exact link line. */
|
|
dgram?: boolean;
|
|
/** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles
|
|
* scr_watch.c into the binary — the net gating precedent, so watch-free
|
|
* binaries keep their exact link line. */
|
|
watch?: boolean;
|
|
/** The executable manifest has a format-5 foreign callback descriptor:
|
|
* compiles the MPSC queue/self-pipe unit. Other FFI and non-FFI binaries
|
|
* keep their existing runtime size class. */
|
|
foreignFfi?: boolean;
|
|
/** The program uses node:test (moduleUsesNodeTest on the IR): compiles
|
|
* scr_test.c into the binary — the net gating precedent, so test-free
|
|
* binaries keep their exact link line. */
|
|
nodeTest?: boolean;
|
|
/** The program uses the node:tls or node:https surface (moduleUsesTls on
|
|
* the IR): compiles scr_tls.c and links the vendored mbedTLS archive
|
|
* (built lazily like the engine archive, cached per flavor). Always
|
|
* alongside scr_net.c and scr_http.c, which moduleUsesNet /
|
|
* moduleUsesHttpServer answer true for whenever this does. TLS-free
|
|
* binaries keep their exact link line and never compile mbedTLS. */
|
|
tls?: boolean;
|
|
/** The program uses the CA-store introspection surface (moduleUsesTlsCa
|
|
* on the IR — getCACertificates / rootCertificates /
|
|
* setDefaultCACertificates): compiles scr_tls_ca.c, PEM-block bookkeeping
|
|
* plus the platform certificate-store reader on Windows, with NO mbedTLS
|
|
* dependency, so an introspection-only binary never builds the archive.
|
|
* The unit also compiles whenever `tls` does — scr_tls.c consults its
|
|
* default-set override and shared Windows-certificate enumerator. */
|
|
tlsCa?: boolean;
|
|
/** Internal compiler hook: called only after a strict native artifact hit
|
|
* or a successful stable build has installed `outPath`. The executable
|
|
* frontend cache uses it to publish its stamp after native dependencies
|
|
* have validated; arbitrary compileC callers leave it unset. */
|
|
onArtifactReady?: (artifact: ValidatedNativeArtifact) => Promise<void>;
|
|
}
|
|
|
|
/** Native dependency proof attached to an executable frontend-cache entry.
|
|
* compileC produces this only after its strict local/CAS validation succeeds;
|
|
* the early reader replays it before restoring the final executable. */
|
|
export interface ValidatedNativeArtifact {
|
|
dependencies: NativeArtifactDependency[];
|
|
}
|
|
|
|
/** Structured compiler-driver failure. Most callers still let this surface
|
|
* as an internal build error; the TypeScript compiler pipeline recognizes it
|
|
* when an outbound FFI profile is active and turns user-controlled native
|
|
* link failures into SC5004. */
|
|
export class CcCompileError extends Error {
|
|
constructor(
|
|
readonly driver: string,
|
|
readonly stderr: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "CcCompileError";
|
|
}
|
|
}
|
|
|
|
/** Preserve the useful output from a failed compiler/tool invocation. Node's
|
|
* execFile error exposes stderr and stdout independently, but either stream
|
|
* may be present as an empty string. Prefer compiler diagnostics, retain a
|
|
* non-standard stdout diagnostic when that is all the tool emitted, and only
|
|
* then fall back to the process error itself. */
|
|
export function subprocessFailureDetail(err: unknown): string {
|
|
const failure = err as {
|
|
stderr?: string | Buffer;
|
|
stdout?: string | Buffer;
|
|
message?: string;
|
|
};
|
|
const output = (value: string | Buffer | undefined): string => {
|
|
const text = Buffer.isBuffer(value) ? value.toString("utf8") : value ?? "";
|
|
return text.trim().length > 0 ? text.trimEnd() : "";
|
|
};
|
|
const stderr = output(failure.stderr);
|
|
const stdout = output(failure.stdout);
|
|
if (stderr !== "" && stdout !== "") return `${stderr}\n\ncompiler stdout:\n${stdout}`;
|
|
if (stderr !== "") return stderr;
|
|
if (stdout !== "") return `compiler stdout:\n${stdout}`;
|
|
if (typeof failure.message === "string" && failure.message.trim() !== "") {
|
|
return failure.message;
|
|
}
|
|
return String(err);
|
|
}
|
|
|
|
export function runtimeSrcDir(): string {
|
|
const testRoot = process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"];
|
|
if (testRoot !== undefined) return resolve(testRoot);
|
|
const require = createRequire(import.meta.url);
|
|
return join(dirname(require.resolve("@scriptc/runtime/package.json")), "src");
|
|
}
|
|
|
|
/* ── alternate C compiler (SCRIPTC_CC) and cross target (SCRIPTC_TARGET) ──────────
|
|
* SCRIPTC_CC=zigcc swaps the compiler driver to `zig cc` (clang underneath, with
|
|
* zig's bundled sysroots — the door to cross-compiling). Unset or
|
|
* SCRIPTC_CC=clang is the default. Host Linux adds the two glibc requirements
|
|
* that macOS does not need: -D_GNU_SOURCE while compiling, and -lm after all
|
|
* link inputs. Other hosts keep the historical bare-clang command line.
|
|
*
|
|
* SCRIPTC_TARGET=<triple> (zigcc only — plain clang has no cross sysroots here)
|
|
* adds `-target <triple>` to every compile. Linux triples also add
|
|
* -D_GNU_SOURCE: glibc hides POSIX/GNU declarations (kill, realpath, stpcpy,
|
|
* arc4random_buf, posix_spawn_file_actions_addchdir_np, ...) under plain
|
|
* -std=c11, where macOS exposes everything by default. Musl triples additionally
|
|
* carry SCR_MUSL because musl deliberately exposes no libc-identification macro;
|
|
* the runtime uses it only for the narrow libc shim in scr_musl.c. Pin the glibc
|
|
* minor in GNU triples (e.g. aarch64-linux-gnu.2.36) so the binary runs on the
|
|
* differential container's distro (see tests/harness/linux-differential.test.ts).
|
|
*
|
|
* Fetch is NATIVE everywhere (scr_fetch.c over the socket units — no
|
|
* libcurl), so it cross-compiles wherever net/http/tls do, win32
|
|
* included. The retired curl reference (SCRIPTC_FETCH_CURL=1) keeps the
|
|
* historical host -lcurl link and the linux-gnu import-STUB arm (soname
|
|
* libcurl.so.4 — see ensureCurlStub). The event-loop units
|
|
* (net/http/dgram/watch) cross-compile to Linux: the readiness poller is
|
|
* the scr_platform.h contract with kqueue and epoll backends; libregexp,
|
|
* zlib, mbedTLS, and the engine archive (--dynamic) build per target
|
|
* (ensureLreObjects / ensureZlibObjects / ensureTlsArchive /
|
|
* buildEngineArchiveDirect — default host-clang zlib builds still link the
|
|
* system libz, byte-identically; Zig builds use vendored zlib objects).
|
|
* Windows triples (x86_64-windows-gnu, mingw-w64 headers and CRT via zig)
|
|
* have no gates left: events, net/http, fetch, watch, zlib, dgram/dns,
|
|
* tls, and the engine archive (--dynamic) all build per target through
|
|
* their win32 arms (mbedTLS compiles unchanged for the triple — its own _WIN32
|
|
* port covers entropy and timing; the TLS link adds bcrypt for
|
|
* BCryptGenRandom, while the CA-store unit adds crypt32 for the Windows
|
|
* system certificate stores).
|
|
* They additionally compile
|
|
* scr_win.c, the win32 libc shim TU (stpcpy, arc4random_buf — see the
|
|
* _WIN32 block in scr_runtime.h), linking -ladvapi32 for its CSPRNG
|
|
* (RtlGenRandom) and GetUserNameA. Native zigcc builds (no SCRIPTC_TARGET)
|
|
* support everything: same platform, same archives, just a different
|
|
* driver binary.
|
|
* Mobile triples (aarch64-apple-ios, aarch64-apple-ios-simulator,
|
|
* aarch64-linux-android — see the mobile-targets block below) are
|
|
* LIBRARY-MODE targets: compileLibArchive accepts them, compile()/compileC
|
|
* refuse the executable lane with the pointer to --lib. */
|
|
export interface CcDriver {
|
|
/** The compiler argv prefix: ["clang"] (default) or ["zig", "cc"]. */
|
|
argv: string[];
|
|
/** The SCRIPTC_TARGET triple, or null for a host-native build. */
|
|
target: string | null;
|
|
/** The spelling handed to `zig cc -target` (and the relocatable-merge
|
|
* link). Identical to `target` except for the mobile triples, whose
|
|
* canonical LLVM spellings map to zig's own (`aarch64-apple-ios` →
|
|
* `aarch64-ios.15.0`), pinning the minimum OS version in the same
|
|
* breath. Null for a host-native build. */
|
|
zigTarget: string | null;
|
|
/** Extra compile args the produced platform demands. */
|
|
targetArgs: string[];
|
|
/** Platform libraries appended after every object/archive input. */
|
|
linkArgs: string[];
|
|
}
|
|
|
|
/** Whether the selected compiler driver is Zig. This is deliberately based on
|
|
* the executable prefix rather than target presence: targetless Zig is still
|
|
* a Zig build, while target presence is only the platform/target contract. */
|
|
export function isZigDriver(driver: Pick<CcDriver, "argv">): boolean {
|
|
return driver.argv[0] === "zig";
|
|
}
|
|
|
|
/* ── mobile targets (library mode) ─────────────────────────────────────────
|
|
* Three mobile triples are admitted, and only for LIBRARY-MODE archive
|
|
* builds — the consuming pattern is an embedding app linking the archive,
|
|
* never a standalone executable (compile()/compileC refuse the executable
|
|
* lane with the pointer to --lib):
|
|
*
|
|
* aarch64-apple-ios device archives; zig `aarch64-ios.15.0`
|
|
* aarch64-apple-ios-simulator simulator archives; zig
|
|
* `aarch64-ios.15.0-simulator`
|
|
* aarch64-linux-android zig `aarch64-linux-android.26`
|
|
*
|
|
* The minimum-version floors are part of the target contract: iOS archives
|
|
* build for iOS 15.0 (IPHONEOS_MIN_VERSION), Android archives for API level
|
|
* 26 (ANDROID_MIN_API) — LC_BUILD_VERSION minos and the bionic stub level
|
|
* both come from the pinned zig spelling above, so an embedder's deployment
|
|
* target at or above the floor links cleanly.
|
|
*
|
|
* Zig bundles no Apple or bionic libc, so both families compile against an
|
|
* explicit sysroot discovered here and spelled into targetArgs (where every
|
|
* cache tier already keys it):
|
|
*
|
|
* iOS darwin hosts only — `xcrun --show-sdk-path` selects the
|
|
* iPhoneOS/iPhoneSimulator SDK; compiles add `-isysroot <sdk>`
|
|
* plus `-isystem <sdk>/usr/include` (zig's driver manages libc
|
|
* header search itself and would otherwise find no headers).
|
|
* Android any host with an NDK — ANDROID_NDK_ROOT/ANDROID_NDK_HOME, or
|
|
* the newest ndk/<version> under ANDROID_HOME/ANDROID_SDK_ROOT
|
|
* or the platform-default SDK location; compiles add the NDK
|
|
* sysroot's generic and per-triple include directories.
|
|
*
|
|
* Library archives never link, so the sysroot's LIBRARIES are the
|
|
* embedder's side of the contract: Xcode links iOS archives against the
|
|
* selected SDK, and Gradle/NDK builds link Android archives against the
|
|
* API-26+ bionic stubs. */
|
|
/** The Apple SDK root for one mobile platform, discovered through xcrun the
|
|
* way Xcode's own build system selects it. Memoized per SDK name and
|
|
* selection environment: production rediscovers per process, and the two
|
|
* selection variables (DEVELOPER_DIR/SDKROOT) are already mutable-input
|
|
* keys that disable persistent caching. */
|
|
const appleSdkMemos = new Map<string, string>();
|
|
function appleSdkRoot(sdk: "iphoneos" | "iphonesimulator", env: NodeJS.ProcessEnv): string {
|
|
const key = [sdk, env["PATH"] ?? "", env["DEVELOPER_DIR"] ?? "", env["SDKROOT"] ?? ""].join("\0");
|
|
const memo = appleSdkMemos.get(key);
|
|
if (memo !== undefined) return memo;
|
|
const probe = spawnSync("xcrun", ["--sdk", sdk, "--show-sdk-path"], {
|
|
encoding: "utf8",
|
|
env,
|
|
});
|
|
const path = probe.status === 0 ? (probe.stdout ?? "").trim() : "";
|
|
if (path === "" || !existsSync(join(path, "usr", "include"))) {
|
|
throw new Error(
|
|
`the ${sdk} SDK was not found (xcrun --sdk ${sdk} --show-sdk-path failed) — ` +
|
|
`iOS targets need Xcode with the ${sdk === "iphoneos" ? "iPhoneOS" : "iPhoneSimulator"} SDK installed`,
|
|
);
|
|
}
|
|
appleSdkMemos.set(key, path);
|
|
return path;
|
|
}
|
|
|
|
/** Compare dotted-numeric NDK version directory names, newest first. */
|
|
function compareNdkVersionsDesc(a: string, b: string): number {
|
|
const as = a.split(".").map((s) => Number.parseInt(s, 10));
|
|
const bs = b.split(".").map((s) => Number.parseInt(s, 10));
|
|
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
const d = (bs[i] ?? 0) - (as[i] ?? 0);
|
|
if (d !== 0 && Number.isFinite(d)) return d;
|
|
}
|
|
return a < b ? 1 : a > b ? -1 : 0;
|
|
}
|
|
|
|
/** The Android NDK sysroot for the selected environment: an explicit
|
|
* ANDROID_NDK_ROOT/ANDROID_NDK_HOME wins; otherwise the newest ndk/<version>
|
|
* under ANDROID_HOME, ANDROID_SDK_ROOT, or the platform-default SDK
|
|
* location. The prebuilt host directory is discovered rather than guessed —
|
|
* the NDK ships exactly one per host OS. Memoized per selection
|
|
* environment. */
|
|
const ndkSysrootMemos = new Map<string, string>();
|
|
function androidNdkSysroot(env: NodeJS.ProcessEnv): string {
|
|
const key = ["ANDROID_NDK_ROOT", "ANDROID_NDK_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT"]
|
|
.map((name) => env[name] ?? "")
|
|
.join("\0");
|
|
const memo = ndkSysrootMemos.get(key);
|
|
if (memo !== undefined) return memo;
|
|
const ndkRoots: string[] = [];
|
|
const explicit = [env["ANDROID_NDK_ROOT"], env["ANDROID_NDK_HOME"]]
|
|
.find((root): root is string => root !== undefined && root !== "");
|
|
if (explicit !== undefined) {
|
|
ndkRoots.push(explicit);
|
|
} else {
|
|
const sdkRoots = [
|
|
env["ANDROID_HOME"],
|
|
env["ANDROID_SDK_ROOT"],
|
|
process.platform === "darwin"
|
|
? join(homedir(), "Library", "Android", "sdk")
|
|
: process.platform === "win32"
|
|
? join(env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local"), "Android", "Sdk")
|
|
: join(homedir(), "Android", "Sdk"),
|
|
].filter((root): root is string => root !== undefined && root !== "");
|
|
for (const root of sdkRoots) {
|
|
let versions: string[] = [];
|
|
try {
|
|
versions = readdirSync(join(root, "ndk")).filter((name) => /^\d/.test(name));
|
|
} catch {
|
|
continue;
|
|
}
|
|
versions.sort(compareNdkVersionsDesc);
|
|
ndkRoots.push(...versions.map((version) => join(root, "ndk", version)));
|
|
}
|
|
}
|
|
for (const ndk of ndkRoots) {
|
|
const prebuilt = join(ndk, "toolchains", "llvm", "prebuilt");
|
|
let hosts: string[] = [];
|
|
try {
|
|
hosts = readdirSync(prebuilt).sort();
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const host of hosts) {
|
|
const sysroot = join(prebuilt, host, "sysroot");
|
|
if (existsSync(join(sysroot, "usr", "include", "aarch64-linux-android"))) {
|
|
ndkSysrootMemos.set(key, sysroot);
|
|
return sysroot;
|
|
}
|
|
}
|
|
}
|
|
throw new Error(
|
|
"no Android NDK sysroot was found — install an NDK (sdkmanager 'ndk;<version>') and/or set " +
|
|
"ANDROID_NDK_ROOT to it (ANDROID_HOME with an ndk/ directory also works). " +
|
|
"aarch64-linux-android compiles against the NDK's bionic headers.",
|
|
);
|
|
}
|
|
|
|
/** Resolve native platform flags independently of the machine running tests,
|
|
* so the host-Linux contract remains pinned on every development host. */
|
|
function nativePlatformArgs(platform: NodeJS.Platform): Pick<CcDriver, "targetArgs" | "linkArgs"> {
|
|
return platform === "linux"
|
|
? { targetArgs: ["-D_GNU_SOURCE"], linkArgs: ["-lm"] }
|
|
: { targetArgs: [], linkArgs: [] };
|
|
}
|
|
|
|
export function resolveCc(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
hostPlatform: NodeJS.Platform = process.platform,
|
|
): CcDriver {
|
|
const cc = env["SCRIPTC_CC"] ?? "";
|
|
const target = env["SCRIPTC_TARGET"] ?? "";
|
|
const hostArgs = nativePlatformArgs(hostPlatform);
|
|
if (cc === "" || cc === "clang") {
|
|
if (target !== "") {
|
|
throw new Error(
|
|
`SCRIPTC_TARGET=${target} requires SCRIPTC_CC=zigcc — the default clang path has no cross-target sysroots.`,
|
|
);
|
|
}
|
|
return { argv: ["clang"], target: null, zigTarget: null, ...hostArgs };
|
|
}
|
|
if (cc !== "zigcc") {
|
|
throw new Error(`unknown SCRIPTC_CC '${cc}' (supported: clang, zigcc)`);
|
|
}
|
|
if (target === "") return { argv: ["zig", "cc"], target: null, zigTarget: null, ...hostArgs };
|
|
// Validate the target spelling before any SDK/sysroot discovery. Source
|
|
// emission uses the same pure classifier without resolving this driver.
|
|
configuredTargetPlatform(env, hostPlatform);
|
|
const mobileRefusal = mobileTargetRefusal(target, hostPlatform);
|
|
if (mobileRefusal !== null) throw new Error(mobileRefusal);
|
|
if (isIosTarget(target)) {
|
|
// Library-mode-only target (compile()/compileC own the executable-lane
|
|
// refusal). Zig bundles no Apple libc: the compile rides the selected
|
|
// SDK sysroot, with the libc header directory spelled explicitly
|
|
// because zig's driver manages libc search itself and consults no
|
|
// -isysroot for it. The zig spelling pins the iOS 15.0 floor into
|
|
// every object's LC_BUILD_VERSION minos.
|
|
const simulator = target === "aarch64-apple-ios-simulator";
|
|
const sdk = appleSdkRoot(simulator ? "iphonesimulator" : "iphoneos", env);
|
|
const zigTarget = `aarch64-ios.${IPHONEOS_MIN_VERSION}${simulator ? "-simulator" : ""}`;
|
|
return {
|
|
argv: ["zig", "cc"],
|
|
target,
|
|
zigTarget,
|
|
targetArgs: ["-target", zigTarget, "-isysroot", sdk, "-isystem", join(sdk, "usr", "include")],
|
|
linkArgs: [],
|
|
};
|
|
}
|
|
if (isAndroidTarget(target)) {
|
|
// Library-mode-only target. Zig bundles no bionic: compiles ride the
|
|
// NDK sysroot's generic and per-triple include directories. Bionic
|
|
// supports _GNU_SOURCE like glibc (and hides some POSIX declarations
|
|
// without it); the zig spelling pins the API 26 floor, which the NDK's
|
|
// versioned stub libraries enforce at the embedder's link. API 26
|
|
// bionic carries everything the library-lane units call (arc4random_buf
|
|
// included), so no shim TU joins the archive.
|
|
const sysroot = androidNdkSysroot(env);
|
|
const zigTarget = `aarch64-linux-android.${ANDROID_MIN_API}`;
|
|
return {
|
|
argv: ["zig", "cc"],
|
|
target,
|
|
zigTarget,
|
|
targetArgs: [
|
|
"-target",
|
|
zigTarget,
|
|
"-D_GNU_SOURCE",
|
|
"-isystem",
|
|
join(sysroot, "usr", "include"),
|
|
"-isystem",
|
|
join(sysroot, "usr", "include", "aarch64-linux-android"),
|
|
],
|
|
linkArgs: ["-lm"],
|
|
};
|
|
}
|
|
const linux = target.includes("linux");
|
|
const wasi = target.includes("wasi");
|
|
const musl = target.includes("linux-musl");
|
|
return {
|
|
argv: ["zig", "cc"],
|
|
target,
|
|
zigTarget: target,
|
|
targetArgs: [
|
|
"-target",
|
|
target,
|
|
...(linux || wasi ? ["-D_GNU_SOURCE"] : []),
|
|
...(musl ? ["-DSCR_MUSL"] : []),
|
|
...(wasi ? ["-D_WASI_EMULATED_SIGNAL", "-D_WASI_EMULATED_PROCESS_CLOCKS"] : []),
|
|
],
|
|
linkArgs: linux
|
|
? ["-lm"]
|
|
: wasi
|
|
? ["-lwasi-emulated-signal", "-lwasi-emulated-process-clocks"]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
/** Musl intentionally has no predefined libc macro. The explicit Zig target
|
|
* is therefore the source of truth for selecting its small runtime shim. */
|
|
function isMuslTarget(driver: Pick<CcDriver, "target">): boolean {
|
|
return driver.target?.includes("linux-musl") ?? false;
|
|
}
|
|
|
|
/** The OS the produced binary runs on: the triple's OS under SCRIPTC_TARGET,
|
|
* the host's otherwise — so platform-conditional link flags follow the
|
|
* TARGET, not the machine running the compiler. Exported for compile()/
|
|
* analyze(): the FRONTEND consults it too (path.sep / os.EOL literals and
|
|
* the path-module binding follow the target — a win32 triple compiles
|
|
* Node-on-Windows semantics, path.win32 backing the bare module). */
|
|
export function targetPlatform(driver: CcDriver): string {
|
|
if (driver.target === null) return process.platform;
|
|
return configuredTargetPlatform({ SCRIPTC_TARGET: driver.target });
|
|
}
|
|
|
|
/** Architecture identity for host-native cache entries. Explicit cross targets
|
|
* already name their complete target triple; native builds need the process
|
|
* architecture too because one per-user cache can serve both native and
|
|
* emulated processes (arm64 and Rosetta on macOS, for example). */
|
|
export function cacheTargetIdentity(
|
|
driver: Pick<CcDriver, "target">,
|
|
hostPlatform: NodeJS.Platform = process.platform,
|
|
hostArch: string = process.arch,
|
|
): string {
|
|
return driver.target === null ? `native:${hostPlatform}:${hostArch}` : `cross:${driver.target}`;
|
|
}
|
|
|
|
/* ── library mode: the static-archive artifact ────────────────────────────
|
|
* One `scriptc build --lib` invocation produces <name>.lib.a: the program TU
|
|
* object plus exactly the runtime objects the program's IR gates in, every
|
|
* TU compiled with -DSCR_LIB (the per-flavor discipline that keeps library
|
|
* objects apart from executable-lane objects). Persistent builds cache the
|
|
* completed archive by program-TU content and cache runtime objects separately,
|
|
* so an edit recompiles only the changed program object before re-archiving.
|
|
* Large LLVM dev TUs split further into stable symbol-hash shards: compile
|
|
* those in parallel, cache each object independently, then relocatably merge
|
|
* them back into the archive's one canonical program member. A localized edit
|
|
* recompiles only the changed buckets; exact repeats use the merged-object or
|
|
* completed-archive tiers and never repeat the merge.
|
|
* The base set narrows from the executable lane's unconditional sources:
|
|
* scr_async.c (fibers, timers, the loop) and scr_child.c drop — the
|
|
* async_free refusal already guarantees nothing references them — and
|
|
* scr_library.c (sink, arena, reset registry, library funnel) joins. The gated
|
|
* units a library may reach are the pure-data ones (regex + the vendored matcher, assert,
|
|
* inspect, symbol, searchParams, emitter+dyn_handle, zlib); every
|
|
* loop-hooked or ambient unit was refused at SC4005 before emission.
|
|
* External-symbol contract: undefined references only to the target's C/math
|
|
* runtime and system APIs. Windows embedders additionally link advapi32,
|
|
* iphlpapi, and ws2_32; the platform driver supplies its ordinary CRT and
|
|
* kernel imports. Zlib rides the VENDORED per-flavor objects here even on
|
|
* hosts because the executable lane's system `-lz` cannot ride inside an
|
|
* archive. */
|
|
|
|
/** The library base: the executable lane's unconditional sources minus the
|
|
* fiber/loop and child-process units, plus the library-mode TU. */
|
|
const LIB_RUNTIME_SOURCES = [
|
|
...EXECUTABLE_RUNTIME_SOURCES.filter(
|
|
(f) => f !== "scr_async.c" && f !== "scr_crypto_async.c" && f !== "scr_child.c" && f !== "scr_ffi.c",
|
|
),
|
|
"scr_library.c",
|
|
];
|
|
|
|
export interface LibArchiveOptions {
|
|
/** The program TU (.c or .ll — clang compiles either with -c). */
|
|
cPath: string;
|
|
/** Invocation-owned program source to compile under `cPath`'s public
|
|
* spelling. Library assembly uses this for the identity-free projection of
|
|
* a complete caller-visible TU; its bytes drive every native cache key. */
|
|
programSource?: string;
|
|
/** Optional equivalent LLVM modules for native compilation. The public
|
|
* `programSource` remains the canonical TU/cache identity; these stable
|
|
* shards compile independently and are relocatably merged into one program
|
|
* object before archive assembly. */
|
|
programShards?: readonly { name: string; source: string }[];
|
|
/** Canonical externally visible definitions retained while the shard merge
|
|
* demotes generated cross-shard linkage back to local symbols. */
|
|
programPublicSymbols?: readonly string[];
|
|
/** Tiny LLVM module carrying volatile library identity getters.
|
|
* Its bytes join the complete archive key, but the source itself exists
|
|
* only in the invocation-private build directory and the large program-
|
|
* object cache is keyed independently. */
|
|
identityLlvmSource?: string;
|
|
/** The archive to produce (<name>.lib.a). */
|
|
outPath: string;
|
|
/** Caller-owned identity for the generated TU's complete non-system
|
|
* dependency graph. Omission bypasses persistent artifact/object caching,
|
|
* matching compileC's arbitrary-input safety boundary. */
|
|
cacheIdentity?: string;
|
|
sanitize?: boolean;
|
|
/** Native optimization posture: release = -O2, dev = -O0. */
|
|
optimization?: "release" | "dev";
|
|
/** Multi-instance library mode (the profile's abi.localize_runtime): the
|
|
* external symbols to KEEP global — every other scriptc external
|
|
* definition in the archive (the runtime's internals, the program TU's
|
|
* mangled functions and globals, vendor objects) is demoted to a local
|
|
* symbol. Toolchain sanitizer ABI remains external as required,
|
|
* so N archives built under pairwise-distinct prefixes link into one
|
|
* process with no symbol collisions and no shared mutable runtime state.
|
|
* Undefined references (the target C/math runtime and system APIs, plus
|
|
* sanitizer ABI in instrumented builds) keep their global binding. Windows
|
|
* embedders additionally link advapi32, iphlpapi, and ws2_32. Omitted = the
|
|
* classic archive, byte-for-byte. Admitted for darwin/linux/win32 native
|
|
* hosts, linux/android/windows cross triples from any host, and macos/ios
|
|
* cross triples from a darwin host (compileLibrary owns the refusal
|
|
* fence). */
|
|
localizeSymbols?: readonly string[];
|
|
/** Thread-instanced state (the profile's abi.instance_per_thread): every
|
|
* TU of the archive compiles with -DSCR_THREAD_INSTANCES, moving the
|
|
* runtime units' mutable statics into thread-local storage (SCR_TL in
|
|
* scr_runtime.h) to match the program TU's thread-local globals — one
|
|
* complete instance per embedder thread. The define rides cflags, so
|
|
* every cache tier keys it automatically; omitted = the classic
|
|
* archive, byte-for-byte. */
|
|
threadInstances?: boolean;
|
|
/** IR-detected link gates (the compileC precedent, refusal-narrowed). */
|
|
dynInvoke?: boolean;
|
|
regex?: boolean;
|
|
assert?: boolean;
|
|
inspect?: boolean;
|
|
symbol?: boolean;
|
|
bigint?: boolean;
|
|
searchParams?: boolean;
|
|
emitter?: boolean;
|
|
zlib?: boolean;
|
|
copying?: boolean;
|
|
textDecoderLegacy?: boolean;
|
|
}
|
|
|
|
function updateProgramShardCacheIdentity(
|
|
hash: ReturnType<typeof createHash>,
|
|
shards: readonly { name: string; source: string }[] | undefined,
|
|
publicSymbols: readonly string[] | undefined,
|
|
mergeIdentity: string | undefined,
|
|
): void {
|
|
hash.update("\0program-shards\0");
|
|
if (shards === undefined) {
|
|
hash.update("<none>\0");
|
|
} else {
|
|
hash.update("<present>\0");
|
|
for (const shard of shards) {
|
|
hash.update(shard.name).update("\0").update(shard.source).update("\0");
|
|
}
|
|
}
|
|
hash.update("\0program-public-symbols\0");
|
|
if (publicSymbols === undefined) {
|
|
hash.update("<none>\0");
|
|
} else {
|
|
hash.update("<present>\0");
|
|
for (const symbol of publicSymbols) hash.update(symbol).update("\0");
|
|
}
|
|
hash.update("\0program-shard-merge\0");
|
|
hash.update(mergeIdentity === undefined ? "<none>\0" : `<present>\0${mergeIdentity}\0`);
|
|
}
|
|
|
|
/** Identity of the machinery that turns LLVM shard objects back into the
|
|
* canonical program member, or null when the target cannot do so. Sharding is
|
|
* only a build optimization: a missing host tool or unsupported object class
|
|
* retains the ordinary single-TU compile. The identity joins every cache tier
|
|
* that contains merged bytes; raw shard-object keys deliberately omit it. */
|
|
async function resolveProgramShardMergeIdentity(driver: CcDriver): Promise<string | null> {
|
|
const platform = targetPlatform(driver);
|
|
// Mach-O merging uses the host's ld64. A Darwin target produced from a
|
|
// non-Darwin host can still compile as one canonical Zig TU, but the host's
|
|
// ELF/COFF linker cannot combine those objects.
|
|
if (platform === "darwin") {
|
|
if (process.platform !== "darwin") return null;
|
|
const ld = await resolvedTool("ld");
|
|
return ld === null
|
|
? null
|
|
: rememberFingerprintDependencies(
|
|
`program-shard-merge-darwin-v1\0${ld.cacheIdentity}`,
|
|
[ld.canonicalPath],
|
|
);
|
|
}
|
|
if (platform === "linux") {
|
|
if (driver.target === null) {
|
|
const [ld, objcopy] = await Promise.all([
|
|
resolvedTool("ld"),
|
|
resolvedTool("objcopy"),
|
|
]);
|
|
return ld === null || objcopy === null
|
|
? null
|
|
: rememberFingerprintDependencies(
|
|
`program-shard-merge-linux-v1\0${ld.cacheIdentity}\0${objcopy.cacheIdentity}`,
|
|
[ld.canonicalPath, objcopy.canonicalPath],
|
|
);
|
|
}
|
|
const arch = driver.target.split("-", 1)[0];
|
|
if (arch !== "x86_64" && arch !== "aarch64") return null;
|
|
const compiler = await resolvedTool(driver.argv[0] ?? "zig");
|
|
return compiler === null
|
|
? null
|
|
: rememberFingerprintDependencies(
|
|
`program-shard-merge-cross-elf-v1\0${arch}\0${compiler.cacheIdentity}`,
|
|
[compiler.canonicalPath],
|
|
);
|
|
}
|
|
if (platform === "win32") {
|
|
const arch = driver.target?.split("-", 1)[0] ?? process.arch;
|
|
return arch === "x86_64" || arch === "x64"
|
|
? `program-shard-merge-coff-v1\0${arch}`
|
|
: null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function compileLibArchive(opts: LibArchiveOptions): Promise<void> {
|
|
clearCcCaches();
|
|
const rtDir = runtimeSrcDir();
|
|
const driver = resolveCc();
|
|
const shardNames = new Set<string>();
|
|
const programShardsValid = opts.programShards?.every((shard) => {
|
|
if (
|
|
basename(shard.name) !== shard.name || !shard.name.endsWith(".ll") ||
|
|
shardNames.has(shard.name)
|
|
) return false;
|
|
shardNames.add(shard.name);
|
|
return true;
|
|
}) === true;
|
|
const programShardsRequested =
|
|
opts.cPath.endsWith(".ll") &&
|
|
programShardsValid && opts.programShards !== undefined && opts.programShards.length > 1 &&
|
|
opts.programPublicSymbols !== undefined
|
|
? opts.programShards
|
|
: null;
|
|
const programShardMergeIdentity = programShardsRequested === null
|
|
? null
|
|
: await resolveProgramShardMergeIdentity(driver);
|
|
const programShards = programShardMergeIdentity === null ? null : programShardsRequested;
|
|
const programPublicSymbols = programShards === null ? undefined : opts.programPublicSymbols;
|
|
const sanitize = opts.sanitize ?? false;
|
|
const optimization = opts.optimization ?? "release";
|
|
const regex = opts.regex ?? false;
|
|
const sources = [
|
|
...LIB_RUNTIME_SOURCES,
|
|
// win32 targets compile the libc-shim TU into the archive (stpcpy,
|
|
// arc4random_buf — scr_number.c/scr_lib.c/scr_bytes_io.c call them and
|
|
// mingw's CRT has neither), exactly like compileC's unconditional win32
|
|
// arm. The system-DLL imports the shim and scr_lib.c reference
|
|
// (advapi32's CSPRNG/GetUserNameA, iphlpapi's GetAdaptersAddresses,
|
|
// ws2_32's inet_ntop/htonl) stay the EMBEDDER's link line — an archive
|
|
// carries no -l flags. Never present off win32, so host archives
|
|
// cannot change by a byte.
|
|
...(targetPlatform(driver) === "win32" ? ["scr_win.c"] : []),
|
|
// Zig's musl sysroot does not provide arc4random_buf. Keep the fallback
|
|
// inside the archive so library embedders need no extra system library.
|
|
...(isMuslTarget(driver) ? ["scr_musl.c"] : []),
|
|
...(regex ? ["scr_regex.c"] : []),
|
|
...(opts.assert || regex || opts.symbol ? ["scr_assert.c"] : []),
|
|
...(opts.inspect ? ["scr_inspect.c", "scr_console_native.c"] : []),
|
|
...(opts.symbol ? ["scr_symbol.c"] : []),
|
|
...(opts.assert && opts.bigint ? ["scr_bigint_assert.c"] : []),
|
|
...(opts.searchParams ? ["scr_url_params.c"] : []),
|
|
...(opts.emitter ? ["scr_events_emitter.c", "scr_dyn_handle.c"] : []),
|
|
...(opts.dynInvoke ? ["scr_dyn_invoke.c"] : []),
|
|
...(opts.zlib ? ["scr_zlib.c"] : []),
|
|
...(opts.copying ? ["scr_copying.c"] : []),
|
|
];
|
|
const cflags = [
|
|
"-std=c11",
|
|
...driver.targetArgs,
|
|
...(sanitize
|
|
? ["-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"]
|
|
: [optimization === "dev" ? "-O0" : "-O2"]),
|
|
...executableSectionEliminationFlags(targetPlatform(driver)).compile,
|
|
"-fno-math-errno",
|
|
"-fno-strict-aliasing", // the emitted object model type-puns — see compileC's buildArgs
|
|
"-Wno-deprecated-declarations",
|
|
"-DSCR_LIB",
|
|
...(opts.threadInstances ? ["-DSCR_THREAD_INSTANCES"] : []),
|
|
...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []),
|
|
"-I", rtDir,
|
|
...(regex ? ["-I", vendorEngineDir()] : []),
|
|
...(opts.zlib ? ["-I", vendorZlibDir()] : []),
|
|
];
|
|
const programCompilerArgs = opts.cPath.endsWith(".ll")
|
|
? [...cflags, "-Wno-override-module"]
|
|
: cflags;
|
|
const programSourceExtension = opts.cPath.endsWith(".ll") ? ".ll" : ".c";
|
|
const arArgv = isZigDriver(driver) ? [driver.argv[0]!, "ar"] : ["ar"];
|
|
const cachePolicy = toolchainEnvironmentCachePolicy();
|
|
const configuredCacheRoot = cacheRootDir();
|
|
const toolchainEnv = toolchainEnvironmentFingerprint();
|
|
const persistentDriverCache =
|
|
cachePolicy.runtimeObjects &&
|
|
configuredCacheRoot !== null &&
|
|
await compilerDriverSupportsPersistentCache(driver, toolchainEnv);
|
|
// A library archive is compile-only from clang's perspective. Link-only
|
|
// search variables cannot affect it, but any mutable compilation input or
|
|
// opaque compiler wrapper makes every persistent tier unsafe to reuse. An
|
|
// opaque archiver narrows only the completed-archive tier below.
|
|
const cacheIdentity = opts.cacheIdentity;
|
|
let persistentCache: { root: string; identity: string } | null =
|
|
cacheIdentity === undefined || configuredCacheRoot === null || !persistentDriverCache
|
|
? null
|
|
: { root: configuredCacheRoot, identity: cacheIdentity };
|
|
if (persistentCache !== null) {
|
|
try {
|
|
await ensurePrivateCacheRoot(
|
|
persistentCache.root,
|
|
process.env["SCRIPTC_CACHE_DIR"] === undefined,
|
|
);
|
|
} catch {
|
|
persistentCache = null;
|
|
}
|
|
}
|
|
let implicitToolchain: string | null = null;
|
|
let implicitCompileToolchain: string | null = null;
|
|
let runtimeCompilerInvocation: string | null = null;
|
|
let programCompilerInvocation: string | null = null;
|
|
if (persistentDriverCache) {
|
|
try {
|
|
const fingerprints = await implicitToolchainFingerprints(driver, toolchainEnv);
|
|
implicitToolchain = fingerprints.complete;
|
|
implicitCompileToolchain = fingerprints.compile;
|
|
} catch {
|
|
// An identity probe is cache machinery, never a reason a valid native
|
|
// compile should fail. Disable every persistent tier for this invocation.
|
|
persistentCache = null;
|
|
}
|
|
}
|
|
if (persistentCache !== null) {
|
|
try {
|
|
runtimeCompilerInvocation = await effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
cflags,
|
|
);
|
|
programCompilerInvocation = programSourceExtension === ".ll"
|
|
? await effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
programCompilerArgs,
|
|
programSourceExtension,
|
|
)
|
|
: runtimeCompilerInvocation;
|
|
} catch {
|
|
// A wrapper that cannot expose its effective invocation can still build,
|
|
// but its outputs cannot safely participate in a persistent cache.
|
|
persistentCache = null;
|
|
}
|
|
}
|
|
const vendorBuildIdentity = await currentVendorCacheBuildIdentity(
|
|
driver,
|
|
`${toolchainEnv}\0${implicitToolchain ?? "<uncached>"}`,
|
|
);
|
|
// Runtime-localized archives skip the completed-archive tier: their bytes
|
|
// additionally depend on the localization toolchain's identity (host ld/
|
|
// objcopy or the cross driver's lld), which the archive key does not
|
|
// fingerprint. The runtime-object tier still serves them (localization
|
|
// consumes the same per-flavor objects).
|
|
const cacheCompleteArchive =
|
|
opts.localizeSymbols === undefined &&
|
|
persistentCache !== null && await archiverSupportsPersistentCache(arArgv, driver);
|
|
let cachedArchive: string | null = null;
|
|
let compilerVersion = "";
|
|
let archiverVersion = "";
|
|
let runtimeHash = "";
|
|
let programDependencyHash = "";
|
|
let cachedProgramBytes = opts.programSource === undefined
|
|
? null
|
|
: Buffer.from(opts.programSource, "utf8");
|
|
const identityBytes = opts.identityLlvmSource === undefined
|
|
? null
|
|
: Buffer.from(opts.identityLlvmSource, "utf8");
|
|
if (persistentCache !== null) {
|
|
try {
|
|
const [cv, fingerprint, programBytes] = await Promise.all([
|
|
ccVersion(driver.argv, toolchainEnv, true),
|
|
runtimeFingerprint(rtDir),
|
|
cachedProgramBytes === null ? readFile(opts.cPath) : Promise.resolve(cachedProgramBytes),
|
|
]);
|
|
compilerVersion = cv;
|
|
runtimeHash = fingerprint;
|
|
cachedProgramBytes = programBytes;
|
|
programDependencyHash = await translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
programBytes,
|
|
toolchainEnv,
|
|
);
|
|
if (cacheCompleteArchive) {
|
|
const av = await toolVersionOnce(arArgv, toolchainEnv, true);
|
|
archiverVersion = av;
|
|
const key = createHash("sha256")
|
|
// v10 adds the shard-merge implementation/tool identity. The
|
|
// canonical TU still keys source semantics; shard, keep, and merge
|
|
// bytes key the exact merged program object so ABI projections,
|
|
// tool replacements, and single-/multi-TU producers never collide.
|
|
.update("lib-v10\0")
|
|
.update(cacheTargetIdentity(driver)).update("\0")
|
|
.update(toolchainEnv).update("\0")
|
|
.update(implicitToolchain!).update("\0")
|
|
.update(runtimeCompilerInvocation!).update("\0")
|
|
.update(programCompilerInvocation!).update("\0")
|
|
.update(programDependencyHash).update("\0")
|
|
.update(persistentCache.identity).update("\0")
|
|
.update(driver.argv.join("\x1f")).update("\0")
|
|
.update(cv).update("\0")
|
|
.update(fingerprint).update("\0")
|
|
.update(arArgv.join("\x1f")).update("\0")
|
|
.update(av).update("\0")
|
|
.update(cflags.join("\x1f")).update("\0")
|
|
.update(sources.join("\x1f")).update("\0")
|
|
// The compiler-visible spelling and resolved location are both inputs:
|
|
// __FILE__ observes the former, while relative includes follow the
|
|
// latter. Archive members also inherit the TU's basename.
|
|
.update(opts.cPath).update("\0")
|
|
.update(resolve(opts.cPath)).update("\0")
|
|
.update(programBytes);
|
|
updateProgramShardCacheIdentity(
|
|
key,
|
|
programShards ?? undefined,
|
|
programPublicSymbols,
|
|
programShardMergeIdentity ?? undefined,
|
|
);
|
|
const keyHex = key
|
|
.update("\0identity\0")
|
|
.update(identityBytes === null ? "<none>" : "<generated>").update("\0")
|
|
.update(identityBytes ?? Buffer.alloc(0))
|
|
.digest("hex");
|
|
cachedArchive = join(persistentCache.root, "lib", keyHex);
|
|
const tmpOut = privateSiblingPath(opts.outPath, "lib-hit");
|
|
try {
|
|
await mkdir(dirname(opts.outPath), { recursive: true });
|
|
if (!(await copyValidCachedFile(cachedArchive, tmpOut))) {
|
|
throw new Error("invalid cached library archive");
|
|
}
|
|
// Match a fresh `ar` output under the caller's current umask. Cache
|
|
// entries may have been populated by a less restrictive shell.
|
|
await chmod(tmpOut, 0o666 & ~process.umask());
|
|
await rename(tmpOut, opts.outPath);
|
|
return;
|
|
} catch {
|
|
await rm(tmpOut, { force: true }).catch(() => undefined);
|
|
// Miss (or unreadable cache): compile below and publish best-effort.
|
|
}
|
|
}
|
|
} catch {
|
|
// Cache identity trouble is never a build failure. The fresh path below
|
|
// retains the historical compile-everything behavior.
|
|
cachedArchive = null;
|
|
}
|
|
}
|
|
|
|
const transientVendorRoot = persistentCache !== null && implicitToolchain !== null
|
|
? null
|
|
: join(
|
|
tmpdir(),
|
|
`scriptc-lib-vendor-${process.pid}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
const vendorCacheRoot = transientVendorRoot ?? vendorBuildCacheRoot(persistentCache?.root);
|
|
try {
|
|
const buildDir = await mkdtemp(join(tmpdir(), "scriptc-lib-"));
|
|
try {
|
|
const lreObjects = regex
|
|
? await stageVendorInputs(
|
|
() => ensureLreObjects(sanitize, driver, vendorBuildIdentity, vendorCacheRoot),
|
|
join(buildDir, "vendor-lre"),
|
|
)
|
|
: [];
|
|
const zlibObjects = opts.zlib
|
|
? await stageVendorInputs(
|
|
() => ensureZlibObjects(sanitize, driver, vendorBuildIdentity, vendorCacheRoot),
|
|
join(buildDir, "vendor-zlib"),
|
|
)
|
|
: [];
|
|
const compileOne = async (
|
|
src: string,
|
|
objName: string,
|
|
compilerVisibleSource?: string,
|
|
): Promise<string> => {
|
|
const obj = join(buildDir, objName);
|
|
const args = [
|
|
...driver.argv.slice(1),
|
|
...cflags,
|
|
...(compilerVisibleSource !== undefined
|
|
? [
|
|
`-ffile-prefix-map=${src}=${compilerVisibleSource}`,
|
|
"-iquote",
|
|
dirname(resolve(compilerVisibleSource)),
|
|
]
|
|
: []),
|
|
...(src.endsWith(".ll") ? ["-Wno-override-module"] : []),
|
|
"-c", src,
|
|
"-o", obj,
|
|
];
|
|
try {
|
|
await execFileAsync(driver.argv[0] ?? "clang", args);
|
|
} catch (err) {
|
|
const stderr = subprocessFailureDetail(err);
|
|
throw new Error(
|
|
`${driver.argv.join(" ")} failed compiling ${src} for the library archive.\n` +
|
|
`This is a scriptc bug (generated/runtime C should always compile) unless the compiler itself is missing/broken.\n\n${stderr}`,
|
|
);
|
|
}
|
|
return obj;
|
|
};
|
|
const stem = basename(opts.cPath).replace(/\.(c|ll)$/, "");
|
|
const programSource =
|
|
cachedProgramBytes === null
|
|
? opts.cPath
|
|
: join(buildDir, `program${opts.cPath.endsWith(".ll") ? ".ll" : ".c"}`);
|
|
if (cachedProgramBytes !== null && programShards === null) {
|
|
await writeFile(programSource, cachedProgramBytes);
|
|
}
|
|
let cachedProgramObject: string | null = null;
|
|
if (
|
|
persistentCache !== null && cachedProgramBytes !== null && compilerVersion !== "" &&
|
|
implicitToolchain !== null && programCompilerInvocation !== null
|
|
) {
|
|
const programKey = createHash("sha256")
|
|
.update("lib-program-obj-v3\0")
|
|
.update(cacheTargetIdentity(driver)).update("\0")
|
|
.update(toolchainEnv).update("\0")
|
|
.update(implicitToolchain).update("\0")
|
|
.update(programCompilerInvocation).update("\0")
|
|
.update(persistentCache.identity).update("\0")
|
|
.update(driver.argv.join("\x1f")).update("\0")
|
|
.update(compilerVersion).update("\0")
|
|
.update(runtimeHash).update("\0")
|
|
.update(programDependencyHash).update("\0")
|
|
.update(programCompilerArgs.join("\x1f")).update("\0")
|
|
.update(opts.cPath).update("\0")
|
|
.update(resolve(opts.cPath)).update("\0")
|
|
.update(cachedProgramBytes);
|
|
updateProgramShardCacheIdentity(
|
|
programKey,
|
|
programShards ?? undefined,
|
|
programPublicSymbols,
|
|
programShardMergeIdentity ?? undefined,
|
|
);
|
|
const programKeyHex = programKey.digest("hex");
|
|
cachedProgramObject = join(persistentCache.root, "program-obj", programKeyHex);
|
|
}
|
|
const stagedProgramObject = join(buildDir, `${stem}.program.o`);
|
|
let programObject: string;
|
|
let programShardFallback = false;
|
|
if (
|
|
cachedProgramObject !== null &&
|
|
await copyValidCachedFile(cachedProgramObject, stagedProgramObject)
|
|
) {
|
|
programObject = stagedProgramObject;
|
|
} else if (programShards !== null) {
|
|
try {
|
|
const shardEntries = programShards.map((shard, index) => {
|
|
const sourcePath = join(buildDir, shard.name);
|
|
const staged = join(buildDir, `${stem}.program-${index.toString().padStart(3, "0")}.o`);
|
|
let cachePath: string | null = null;
|
|
if (
|
|
persistentCache !== null && compilerVersion !== "" &&
|
|
implicitCompileToolchain !== null && programCompilerInvocation !== null
|
|
) {
|
|
const key = createHash("sha256")
|
|
// v2 removes the broad implicit-toolchain fingerprint: it
|
|
// includes the linker selected by the driver, but raw shard
|
|
// objects are compile-only outputs. The compile-only toolchain
|
|
// identity retains compiler/config/header/assembler inputs;
|
|
// the effective program invocation pins this exact flag lane.
|
|
// Merge-tool identity belongs only to the merged-object and
|
|
// completed-archive tiers.
|
|
.update("lib-program-shard-v2\0")
|
|
.update(cacheTargetIdentity(driver)).update("\0")
|
|
.update(toolchainEnv).update("\0")
|
|
.update(implicitCompileToolchain).update("\0")
|
|
.update(programCompilerInvocation).update("\0")
|
|
.update(persistentCache.identity).update("\0")
|
|
.update(driver.argv.join("\x1f")).update("\0")
|
|
.update(compilerVersion).update("\0")
|
|
.update(runtimeHash).update("\0")
|
|
.update(programDependencyHash).update("\0")
|
|
.update(programCompilerArgs.join("\x1f")).update("\0")
|
|
.update(opts.cPath).update("\0")
|
|
.update(resolve(opts.cPath)).update("\0")
|
|
.update(shard.name).update("\0")
|
|
.update(shard.source)
|
|
.digest("hex");
|
|
cachePath = join(persistentCache.root, "program-shard", key);
|
|
}
|
|
return { ...shard, sourcePath, staged, cachePath, missed: false };
|
|
});
|
|
const shardWidth = Math.min(8, availableParallelism());
|
|
for (let i = 0; i < shardEntries.length; i += shardWidth) {
|
|
await Promise.all(shardEntries.slice(i, i + shardWidth).map(async (entry) => {
|
|
await writeFile(entry.sourcePath, entry.source);
|
|
if (
|
|
entry.cachePath !== null &&
|
|
await copyValidCachedFile(entry.cachePath, entry.staged)
|
|
) return;
|
|
entry.missed = true;
|
|
await compileOne(entry.sourcePath, basename(entry.staged), opts.cPath);
|
|
}));
|
|
}
|
|
const publishable = shardEntries.filter(
|
|
(entry) => entry.missed && entry.cachePath !== null,
|
|
);
|
|
const mergedProgramObject = await localizeLibraryObjects(
|
|
driver,
|
|
arArgv,
|
|
buildDir,
|
|
shardEntries.map((entry) => entry.staged),
|
|
[],
|
|
programPublicSymbols!,
|
|
`${stem}.program`,
|
|
);
|
|
// Keep the canonical archive member spelling. The fact that native
|
|
// compilation used shards is an implementation detail; consumers and
|
|
// deterministic cache tests continue to see `<stem>.program.o`.
|
|
await rename(mergedProgramObject, stagedProgramObject);
|
|
programObject = stagedProgramObject;
|
|
if (cachedProgramObject !== null || publishable.length > 0) {
|
|
try {
|
|
const [currentRuntime, currentInvocation, currentDependencies, currentCompiler] =
|
|
await Promise.all([
|
|
runtimeFingerprint(rtDir),
|
|
effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
programCompilerArgs,
|
|
programSourceExtension,
|
|
),
|
|
translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cachedProgramBytes!,
|
|
toolchainEnv,
|
|
),
|
|
ccVersion(driver.argv, toolchainEnv, true),
|
|
]);
|
|
const shardInputsStillMatch =
|
|
currentRuntime === runtimeHash &&
|
|
currentInvocation === programCompilerInvocation &&
|
|
currentDependencies === programDependencyHash && currentCompiler === compilerVersion;
|
|
const currentFingerprints = shardInputsStillMatch
|
|
? await implicitToolchainFingerprints(driver, toolchainEnv)
|
|
: null;
|
|
const compileInputsStillMatch =
|
|
shardInputsStillMatch &&
|
|
currentFingerprints?.compile === implicitCompileToolchain;
|
|
let mergedInputsStillMatch = false;
|
|
if (compileInputsStillMatch && cachedProgramObject !== null) {
|
|
const currentMerge = await resolveProgramShardMergeIdentity(driver).catch(() => null);
|
|
mergedInputsStillMatch =
|
|
currentFingerprints?.complete === implicitToolchain &&
|
|
currentMerge === programShardMergeIdentity;
|
|
}
|
|
if (compileInputsStillMatch) {
|
|
await Promise.all([
|
|
...(mergedInputsStillMatch
|
|
? [publishCachedFile(programObject, cachedProgramObject!)]
|
|
: []),
|
|
...publishable.map((entry) => publishCachedFile(entry.staged, entry.cachePath!)),
|
|
]);
|
|
}
|
|
} catch {
|
|
// Best-effort: the merged program object is already valid.
|
|
}
|
|
}
|
|
} catch {
|
|
// Sharding is only an optimization. A present but incompatible or
|
|
// failing merge tool (or a shard-only compiler failure) must not
|
|
// turn a valid canonical LLVM TU into a failed library build. Do not
|
|
// publish this retry under shard-derived object/archive cache keys.
|
|
programShardFallback = true;
|
|
if (cachedProgramBytes !== null) await writeFile(programSource, cachedProgramBytes);
|
|
programObject = await compileOne(
|
|
programSource,
|
|
`${stem}.program.o`,
|
|
cachedProgramBytes === null ? undefined : opts.cPath,
|
|
);
|
|
}
|
|
} else {
|
|
programObject = await compileOne(
|
|
programSource,
|
|
`${stem}.program.o`,
|
|
cachedProgramBytes === null ? undefined : opts.cPath,
|
|
);
|
|
if (cachedProgramObject !== null) {
|
|
try {
|
|
const [currentRuntime, currentImplicit, currentInvocation, currentDependencies, currentCompiler] =
|
|
await Promise.all([
|
|
runtimeFingerprint(rtDir),
|
|
implicitToolchainFingerprint(driver, toolchainEnv),
|
|
effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
programCompilerArgs,
|
|
programSourceExtension,
|
|
),
|
|
translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cachedProgramBytes!,
|
|
toolchainEnv,
|
|
),
|
|
ccVersion(driver.argv, toolchainEnv, true),
|
|
]);
|
|
if (
|
|
currentRuntime === runtimeHash &&
|
|
currentImplicit === implicitToolchain &&
|
|
currentInvocation === programCompilerInvocation &&
|
|
currentDependencies === programDependencyHash &&
|
|
currentCompiler === compilerVersion
|
|
) {
|
|
await publishCachedFile(programObject, cachedProgramObject);
|
|
}
|
|
} catch {
|
|
// Best-effort: the archive build already owns a valid object.
|
|
}
|
|
}
|
|
}
|
|
const identityObject = identityBytes === null
|
|
? null
|
|
: await (async () => {
|
|
const source = join(buildDir, "identity.ll");
|
|
await writeFile(source, identityBytes);
|
|
return compileOne(source, `${stem}.identity.o`);
|
|
})();
|
|
let runtimeObjects: string[] | null = null;
|
|
let cacheInputsStable = true;
|
|
let objectImplicitVerification: Promise<boolean> | null = null;
|
|
const objectImplicitToolchainStillMatches = (): Promise<boolean> => {
|
|
objectImplicitVerification ??= Promise.all([
|
|
implicitToolchainFingerprint(driver, toolchainEnv),
|
|
effectiveCompilerInvocationFingerprint(driver, toolchainEnv, cflags),
|
|
]).then(
|
|
([currentImplicit, currentInvocation]) =>
|
|
currentImplicit === implicitToolchain &&
|
|
currentInvocation === runtimeCompilerInvocation,
|
|
() => false,
|
|
);
|
|
return objectImplicitVerification;
|
|
};
|
|
if (persistentCache !== null && compilerVersion !== "" && runtimeHash !== "") {
|
|
try {
|
|
const sourcePaths = sources.map((f) => join(rtDir, f));
|
|
const cached = await ensureRuntimeObjects(
|
|
persistentCache.root,
|
|
driver.argv,
|
|
cflags,
|
|
sourcePaths,
|
|
`lib-obj-v5\0${cacheTargetIdentity(driver)}\0${toolchainEnv}\0${implicitToolchain}\0${runtimeCompilerInvocation}\0${driver.argv.join(" ")}\0${compilerVersion}\0${runtimeHash}\0`,
|
|
async () =>
|
|
(await runtimeFingerprint(rtDir)) === runtimeHash &&
|
|
(await objectImplicitToolchainStillMatches()),
|
|
);
|
|
const staged = await stageRuntimeObjects(cached, join(buildDir, "cached-runtime"));
|
|
runtimeObjects = sourcePaths.map((path) => staged.get(path)!);
|
|
} catch (err) {
|
|
if (err instanceof CacheInputsChangedError) cacheInputsStable = false;
|
|
runtimeObjects = null;
|
|
}
|
|
}
|
|
if (runtimeObjects === null) {
|
|
runtimeObjects = [];
|
|
const width = Math.min(4, availableParallelism());
|
|
for (let i = 0; i < sources.length; i += width) {
|
|
runtimeObjects.push(
|
|
...(await Promise.all(
|
|
sources.slice(i, i + width).map((f) =>
|
|
compileOne(join(rtDir, f), f.replace(/\.c$/, ".o")),
|
|
),
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
const objects = [programObject, ...(identityObject === null ? [] : [identityObject]), ...runtimeObjects, ...lreObjects, ...zlibObjects];
|
|
// Multi-instance library mode: the archive's one member becomes the
|
|
// combined, symbol-localized object (cached vendor/runtime objects
|
|
// are read-only inputs here — the combine step never mutates them).
|
|
const archiveMembers =
|
|
opts.localizeSymbols === undefined
|
|
? objects
|
|
: [
|
|
await localizeLibraryObjects(
|
|
driver,
|
|
arArgv,
|
|
buildDir,
|
|
[programObject, ...(identityObject === null ? [] : [identityObject])],
|
|
[...runtimeObjects, ...lreObjects, ...zlibObjects],
|
|
opts.localizeSymbols,
|
|
stem,
|
|
),
|
|
];
|
|
// A cacheable or runtime-localized build owns a private archive from
|
|
// `ar` through publication. Localized archives deliberately bypass the
|
|
// completed-artifact cache, but still need atomic installation so two
|
|
// invocations sharing a caller-visible output cannot race `rm`/`ar` on
|
|
// that path.
|
|
const archiveOutput =
|
|
cachedArchive === null && opts.localizeSymbols === undefined
|
|
? opts.outPath
|
|
: join(buildDir, "artifact.lib.a");
|
|
await rm(archiveOutput, { force: true }); // `ar r` would append into a stale archive
|
|
await mkdir(dirname(archiveOutput), { recursive: true });
|
|
await execFileAsync(arArgv[0] ?? "ar", [
|
|
...arArgv.slice(1),
|
|
"rcs",
|
|
archiveOutput,
|
|
...archiveMembers,
|
|
]);
|
|
if (archiveOutput !== opts.outPath) await installArtifact(archiveOutput, opts.outPath);
|
|
let runtimeStillMatchesKey = false;
|
|
if (
|
|
cachedArchive !== null &&
|
|
persistentCache !== null &&
|
|
runtimeHash !== "" &&
|
|
programDependencyHash !== "" &&
|
|
compilerVersion !== "" &&
|
|
archiverVersion !== ""
|
|
) {
|
|
const [currentRuntime, currentImplicit, currentRuntimeInvocation, currentProgramInvocation, currentProgramDependencies, currentCompiler, currentArchiver, currentProgramShardMerge] =
|
|
await Promise.all([
|
|
runtimeFingerprint(rtDir).catch(() => null),
|
|
implicitToolchainFingerprint(driver, toolchainEnv).catch(() => null),
|
|
effectiveCompilerInvocationFingerprint(driver, toolchainEnv, cflags).catch(
|
|
() => null,
|
|
),
|
|
effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
programCompilerArgs,
|
|
programSourceExtension,
|
|
).catch(() => null),
|
|
translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cachedProgramBytes!,
|
|
toolchainEnv,
|
|
).catch(() => null),
|
|
ccVersion(driver.argv, toolchainEnv, true).catch(() => null),
|
|
toolVersionOnce(arArgv, toolchainEnv, true).catch(() => null),
|
|
programShards === null
|
|
? Promise.resolve(null)
|
|
: resolveProgramShardMergeIdentity(driver).catch(() => null),
|
|
]);
|
|
runtimeStillMatchesKey =
|
|
cacheInputsStable &&
|
|
!programShardFallback &&
|
|
currentRuntime === runtimeHash &&
|
|
currentImplicit === implicitToolchain &&
|
|
currentRuntimeInvocation === runtimeCompilerInvocation &&
|
|
currentProgramInvocation === programCompilerInvocation &&
|
|
currentProgramDependencies === programDependencyHash &&
|
|
currentCompiler === compilerVersion &&
|
|
currentArchiver === archiverVersion &&
|
|
currentProgramShardMerge === programShardMergeIdentity;
|
|
}
|
|
if (cachedArchive !== null && persistentCache !== null && runtimeStillMatchesKey) {
|
|
try {
|
|
await publishCachedFile(archiveOutput, cachedArchive);
|
|
} catch {
|
|
// Publishing is best-effort; the requested archive is already valid.
|
|
}
|
|
}
|
|
} finally {
|
|
await rm(buildDir, { recursive: true, force: true });
|
|
}
|
|
} finally {
|
|
if (transientVendorRoot !== null) {
|
|
await rm(transientVendorRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
if (persistentCache !== null) {
|
|
await pruneCache(persistentCache.root).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/* Multi-instance library mode's localization step: combine the program
|
|
* object with exactly the runtime/vendor members it reaches into ONE
|
|
* relocatable object, then demote every scriptc external definition except
|
|
* the profile-declared symbols to a local symbol. The internals are not
|
|
* renamed apart — they stop being visible to the embedder's linker at all,
|
|
* so a second archive built under a different prefix brings its own private
|
|
* copy of the whole runtime (allocator, collector, arena, panic sink) into
|
|
* the same process. Undefined references (the target C/math runtime and
|
|
* system APIs, plus sanitizer ABI in instrumented builds) keep their global
|
|
* binding: those platform services and the sanitizer are the embedder's,
|
|
* shared by design. Windows embedders additionally link advapi32, iphlpapi,
|
|
* and ws2_32.
|
|
*
|
|
* The generated program and optional identity objects are mandatory roots;
|
|
* support-member selection still matters. A classic archive's unused members (and their
|
|
* undefined references to units library mode excludes, like the
|
|
* fs-promises unit's fiber symbols) never reach an embedder's link. A
|
|
* blind merge of every object would carry those references into the one
|
|
* combined member. Staging the support objects into an intermediate
|
|
* archive keeps the linker's own member semantics: `ld -r` pulls only the
|
|
* members the program object transitively needs (the COFF arm implements
|
|
* the same member semantics in process).
|
|
*
|
|
* Mach-O — one host-ld64 invocation: -r merges roots + needed members,
|
|
* -exported_symbols_list demotes every unlisted global to
|
|
* private extern, and -r without -keep_private_externs writes
|
|
* private externs out as non-external symbols. Apple ASan's
|
|
* image-registration COMMON remains shared so the final Mach-O
|
|
* image registers its globals once. ld64 reads every Apple
|
|
* platform's objects — macOS architectures and the iOS/
|
|
* iOS-simulator triples alike (LC_BUILD_VERSION platform and
|
|
* minos survive the merge) — so darwin-host macos AND ios
|
|
* targets ride the same arm; compileLibrary refuses those
|
|
* targets elsewhere.
|
|
* ELF, native linux host — ld -r merges with --force-group-allocation
|
|
* (ASan's instrumented globals ride ELF section groups whose
|
|
* signatures repeat across archives sharing runtime objects;
|
|
* resolving the groups into the combined member keeps a later
|
|
* multi-archive link from discarding one archive's copies),
|
|
* then binutils objcopy --keep-global-symbols localizes every
|
|
* other DEFINED global (objcopy leaves undefined symbols
|
|
* global by its own rule). The exact historical recipe.
|
|
* ELF, cross triples from any host — `zig cc -target <triple> -r`
|
|
* merges (zig is already the cross driver's hard requirement),
|
|
* then localizeElfObject demotes in process and resolves
|
|
* section groups the way --force-group-allocation does, with
|
|
* no host binutils/llvm-objcopy dependency.
|
|
* COFF, native win32 hosts and windows cross triples from any host —
|
|
* mergeAndLocalizeCoffObjects performs member selection, the
|
|
* merge, cross-object symbol resolution, and demotion in
|
|
* process: no linker offers a COFF relocatable mode (lld-link
|
|
* mirrors MSVC link.exe; zig's COFF driver refuses multi-object
|
|
* merges) and llvm-objcopy rejects symbol-scope flags for
|
|
* COFF, so no tool pairing exists to shell out to. */
|
|
export async function localizeLibraryObjects(
|
|
driver: CcDriver,
|
|
arArgv: readonly string[],
|
|
buildDir: string,
|
|
rootObjects: readonly string[],
|
|
supportObjects: readonly string[],
|
|
keepSymbols: readonly string[],
|
|
stem: string,
|
|
platform = targetPlatform(driver),
|
|
): Promise<string> {
|
|
const combined = join(buildDir, `${stem}.localized.o`);
|
|
const staging = join(buildDir, `${stem}.localize-staging.a`);
|
|
const keepFile = join(buildDir, "localize-keep.syms");
|
|
const run = async (argv: readonly string[]): Promise<void> => {
|
|
try {
|
|
await execFileAsync(argv[0]!, [...argv.slice(1)]);
|
|
} catch (err) {
|
|
const stderr = subprocessFailureDetail(err);
|
|
throw new Error(
|
|
`${argv[0]} failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n` +
|
|
`Runtime symbol localization needs the ${platform === "darwin" ? "host toolchain's ld" : driver.target === null ? "host toolchain's ld and objcopy" : "cross driver's relocatable link"} beside the C compiler.\n\n${stderr}`,
|
|
);
|
|
}
|
|
};
|
|
if (platform === "win32") {
|
|
// COFF has no relocatable-link tool to stage through; the member
|
|
// selection and combine+demote happen in process over the object bytes.
|
|
const [roots, support] = await Promise.all([
|
|
Promise.all(rootObjects.map((path) => readFile(path))),
|
|
Promise.all(supportObjects.map((path) => readFile(path))),
|
|
]);
|
|
try {
|
|
await writeFile(
|
|
combined,
|
|
mergeAndLocalizeCoffObjects(roots, support, new Set(keepSymbols), {
|
|
roots: rootObjects.map((path) => basename(path)),
|
|
support: supportObjects.map((path) => basename(path)),
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
throw new Error(
|
|
`COFF symbol localization failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n\n${(err as Error).message}`,
|
|
);
|
|
}
|
|
return combined;
|
|
}
|
|
const supportArgs = supportObjects.length === 0 ? [] : [staging];
|
|
if (supportObjects.length > 0) {
|
|
await run([arArgv[0] ?? "ar", ...arArgv.slice(1), "rcs", staging, ...supportObjects]);
|
|
}
|
|
if (platform === "darwin") {
|
|
await writeFile(keepFile, keepSymbols.map((s) => `_${s}\n`).join(""));
|
|
await run(["ld", "-r", ...rootObjects, ...supportArgs, "-o", combined, "-exported_symbols_list", keepFile]);
|
|
} else if (platform === "linux" && driver.target === null) {
|
|
await writeFile(keepFile, keepSymbols.map((s) => `${s}\n`).join(""));
|
|
await run(["ld", "-r", "--force-group-allocation", ...rootObjects, ...supportArgs, "-o", combined]);
|
|
await run(["objcopy", `--keep-global-symbols=${keepFile}`, combined]);
|
|
} else if (platform === "linux") {
|
|
// Cross ELF: the cross driver's own lld performs the relocatable merge
|
|
// (with the staging archive's member semantics); demotion and section-
|
|
// group resolution happen in process. -nostdlib keeps zig from feeding
|
|
// libc/compiler-rt inputs into the merge. Android rides this arm
|
|
// unchanged: bionic archives are ordinary aarch64 ELF64, and the merge
|
|
// uses the driver's zig spelling (which pins the API floor).
|
|
await run([
|
|
driver.argv[0] ?? "zig",
|
|
...driver.argv.slice(1),
|
|
"-target", driver.zigTarget ?? driver.target!,
|
|
"-nostdlib",
|
|
"-r", ...rootObjects, ...supportArgs,
|
|
"-o", combined,
|
|
]);
|
|
try {
|
|
await writeFile(combined, localizeElfObject(await readFile(combined), new Set(keepSymbols)));
|
|
} catch (err) {
|
|
throw new Error(
|
|
`ELF symbol localization failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n\n${(err as Error).message}`,
|
|
);
|
|
}
|
|
} else {
|
|
throw new InternalCompilerError(
|
|
`runtime symbol localization (abi.localize_runtime) has no ${platform} arm; compileLibrary admits darwin, linux, and win32 builds only`,
|
|
);
|
|
}
|
|
return combined;
|
|
}
|
|
|
|
/* -------------------------- persistent build cache ---------------------------
|
|
* Content-addressed caches that let repeat builds of unchanged programs skip
|
|
* payload code generation/linking — the test lanes' dominant cost. Executable
|
|
* lookups still run lightweight dependency and link-input metadata probes.
|
|
* Principal keyspaces under the cache root:
|
|
*
|
|
* bin/<key> — whole program binaries. key = sha256(resolved clang
|
|
* identity/version, target + compiler/linker environment,
|
|
* effective driver invocations for the real build flags,
|
|
* implicit system-header and resolved linker-input bytes,
|
|
* linker/assembler identities, runtime fingerprint
|
|
* (every .c/.h in the runtime src dir plus the vendor pin
|
|
* QJS_COMMIT), the caller's dependency identity, the
|
|
* compiler-visible TU path, Darwin output basename, the
|
|
* FULL normalized command line, and the emitted LLVM bytes).
|
|
* Emitted LLVM is
|
|
* deterministic, so unchanged programs
|
|
* hit; any flag difference — e.g. the sanitized lane's
|
|
* -O1/-fsanitize=address/-DSCR_RC_AUDIT — lands in a
|
|
* naturally distinct key. On a hit the cached binary is
|
|
* copied and atomically renamed onto outPath (never an
|
|
* in-place overwrite — see the hit path) after its digest
|
|
* is verified; the binary is still EXECUTED live by whoever
|
|
* asked for it, so no comparison or sanitizer coverage
|
|
* is ever skipped.
|
|
*
|
|
* lib/<key> — whole library archives. The identity covers the resolved
|
|
* compiler and archiver identities/versions, compiler/linker
|
|
* environment, effective build-flavor driver invocations,
|
|
* implicit toolchain inputs, runtime fingerprint,
|
|
* target/flags, gated source set, caller dependency
|
|
* identity, TU path, and program-TU bytes.
|
|
* Checksum-verified hits skip native code generation and ar.
|
|
*
|
|
* program-obj/<key> — the canonical library program object, after any LLVM
|
|
* shard merge. Exact TU repeats reuse it directly.
|
|
*
|
|
* program-shard/<key> — stable LLVM dev-library buckets keyed by their IR
|
|
* text and complete toolchain/TU identity. Local edits
|
|
* retain every bucket whose definitions/declarations did
|
|
* not change.
|
|
*
|
|
* obj/<set>/<f>.o — per-flavor runtime objects for cache-miss builds. The
|
|
* historical single invocation recompiles every runtime
|
|
* TU per program (~1.3s at -O2); with cached objects a
|
|
* miss compiles only the program's LLVM and links (~0.15s).
|
|
* Library-mode -DSCR_LIB objects use a distinct flavor.
|
|
* The clang driver hands every input the same option set,
|
|
* so per-TU `-c` compiles with those options plus a final
|
|
* link reproduce the single invocation exactly; the
|
|
* driver's effective invocation under that flavor also
|
|
* joins the object-set identity. Every
|
|
* cached object carries a verified digest. Object
|
|
* compiles go through ccache when it is installed (silent
|
|
* fallback when not).
|
|
*
|
|
* Frontend-generated production builds supply a dependency identity and use a
|
|
* per-user platform cache by default. Arbitrary low-level TUs omit the identity
|
|
* and bypass this cache because their include graph is caller-owned.
|
|
* Compiler wrappers also bypass every persistent tier: a wrapper can branch on
|
|
* the real source/object topology and inject inputs no synthetic probe sees.
|
|
* Direct Clang/Zig drivers and Apple's immutable /usr/bin/clang handoff retain
|
|
* caching; wrapper-driven builds use private, invocation-local vendor outputs.
|
|
* Opaque archiver wrappers bypass completed library archives for the same
|
|
* reason while retaining safely keyed runtime objects.
|
|
* Builds with caller-supplied native link inputs (archive/object paths or
|
|
* `-l<name>` libraries) also bypass the whole binary cache: a thin archive,
|
|
* linker script, or ambient resolution can change transitively without
|
|
* changing the named input's bytes or spelling. Their runtime objects remain
|
|
* cached, and every invocation performs the final link against current inputs.
|
|
* SCRIPTC_CACHE_DIR overrides the root (the test lanes use this to stay
|
|
* repo-local); an explicitly empty value or SCRIPTC_NO_CACHE=1 disables reads
|
|
* and writes. With caching disabled compileC issues the exact historical
|
|
* command line.
|
|
*
|
|
* Eviction: size-capped LRU over the whole cache root (SCRIPTC_CACHE_MAX_MB,
|
|
* default 4096). Explicit caps are checked after every successful write; the
|
|
* large default is swept on the first and every 64th write so corpus/watch
|
|
* loops do not repeatedly walk a growing tree. Reads bump mtimes. The harness's
|
|
* oracle cache lives under the same root and is swept by the same pass. Cache
|
|
* trouble is never a build failure — every cache error falls back to a real
|
|
* compile. */
|
|
|
|
|
|
const ccVersionMemos = new Map<string, Promise<string>>();
|
|
|
|
interface ResolvedTool {
|
|
canonicalPath: string;
|
|
cacheIdentity: string;
|
|
fileIdentity: string;
|
|
}
|
|
|
|
/** Identity of the compiler implementation and configuration selected by a
|
|
* fresh driver invocation. In particular, Darwin's stable /usr/bin/clang shim
|
|
* exposes the currently selected Xcode clang only in its `-###` trace. The
|
|
* normalized trace also covers output-affecting default driver configuration
|
|
* that can change while argv[0] and PATH retain the same spelling. */
|
|
async function effectiveCompilerEnvironmentIdentity(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs">,
|
|
env: NodeJS.ProcessEnv,
|
|
): Promise<string> {
|
|
const compiler = driver.argv[0] ?? "clang";
|
|
const resolvedDriver = await resolvedTool(compiler, env);
|
|
if (resolvedDriver === null) return `<unresolved:${compiler}>`;
|
|
const probeDir = await mkdtemp(join(tmpdir(), "scriptc-early-cc-probe-"));
|
|
try {
|
|
const source = join(probeDir, "empty.c");
|
|
const object = join(probeDir, "empty.o");
|
|
await writeFile(source, "int scriptc_early_driver_probe;\n");
|
|
const trace = await execFileAsync(
|
|
compiler,
|
|
[
|
|
...driver.argv.slice(1),
|
|
...driver.targetArgs,
|
|
"-###",
|
|
"-std=c11",
|
|
"-c",
|
|
source,
|
|
"-o",
|
|
object,
|
|
],
|
|
{ cwd: probeDir, env, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const effectiveSpellings: string[] = [];
|
|
for (const line of `${trace.stdout}\n${trace.stderr}`.split(/\r?\n/)) {
|
|
const tokens = driverTraceCandidates(line);
|
|
const cc1 = tokens.indexOf("-cc1");
|
|
if (cc1 > 0) effectiveSpellings.push(tokens[cc1 - 1]!);
|
|
}
|
|
const effective = effectiveSpellings.length === 1
|
|
? await resolvedTool(effectiveSpellings[0]!, env)
|
|
: null;
|
|
return createHash("sha256")
|
|
.update("effective-compiler-environment-v1\0")
|
|
.update(resolvedDriver.cacheIdentity).update("\0")
|
|
.update(normalizedProbeInvocation(trace, probeDir)).update("\0")
|
|
.update(effective?.cacheIdentity ?? `<unresolved-effective:${effectiveSpellings.join("\x1f")}>`)
|
|
.digest("hex");
|
|
} finally {
|
|
await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/** Resolve the executable the OS will select for an argv[0] spelling. The
|
|
* path and inode metadata join the version output below: two PATH postures
|
|
* must not share cache entries merely because both drivers call themselves
|
|
* `clang` or print the same upstream version. ctime catches an in-place tool
|
|
* replacement even when a package manager preserves its size and mtime. */
|
|
async function resolvedTool(
|
|
command: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<ResolvedTool | null> {
|
|
const hasSeparator = command.includes("/") || command.includes("\\");
|
|
const configuredPath = (env["PATH"] ?? (process.platform === "win32" ? "" : "/usr/bin:/bin"))
|
|
.split(delimiter);
|
|
const pathEntries = hasSeparator
|
|
? [""]
|
|
: process.platform === "win32"
|
|
? ["", dirname(process.execPath), ...configuredPath]
|
|
: configuredPath;
|
|
const windowsExtensions =
|
|
process.platform === "win32" && extname(command) === ""
|
|
? (env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => extension !== "")
|
|
: [""];
|
|
for (const entry of pathEntries) {
|
|
const directory = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
|
|
const base = hasSeparator
|
|
? isAbsolute(command) ? command : resolve(command)
|
|
: join(directory === "" ? process.cwd() : directory, command);
|
|
for (const extension of windowsExtensions) {
|
|
const candidate = `${base}${extension}`;
|
|
try {
|
|
await access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
|
const [canonical, info] = await Promise.all([realpath(candidate), stat(candidate)]);
|
|
if (!info.isFile()) continue;
|
|
const fileIdentity = [
|
|
canonical,
|
|
info.dev,
|
|
info.ino,
|
|
info.size,
|
|
info.mtimeMs,
|
|
info.ctimeMs,
|
|
].join("\0");
|
|
return {
|
|
canonicalPath: canonical,
|
|
fileIdentity,
|
|
cacheIdentity: [resolve(candidate), fileIdentity].join("\0"),
|
|
};
|
|
} catch {
|
|
// Keep searching PATH/PATHEXT exactly as process spawning would.
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resolvedToolIdentity(
|
|
command: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<string | null> {
|
|
return (await resolvedTool(command, env))?.cacheIdentity ?? null;
|
|
}
|
|
|
|
const directCompilerDriverMemos = new Map<string, boolean>();
|
|
const directCompilerSelections = new Map<string, ResolvedTool>();
|
|
|
|
function compilerDriverProbeKey(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
|
|
environmentFingerprint: string,
|
|
): string {
|
|
return [
|
|
environmentFingerprint,
|
|
driver.argv.join("\x1f"),
|
|
driver.target ?? "<native>",
|
|
driver.targetArgs.join("\x1f"),
|
|
].join("\0");
|
|
}
|
|
|
|
/** `/usr/bin/clang` on Darwin is Apple's immutable driver shim: its `-###`
|
|
* trace names the selected Xcode/CommandLineTools clang rather than the shim
|
|
* itself. The ordinary implicit-toolchain fingerprint below captures that
|
|
* selected executable and its dependencies, so this trusted system handoff is
|
|
* the one intentional exception to the same-executable rule. */
|
|
function isAppleSystemClangHandoff(
|
|
driver: ResolvedTool,
|
|
effectiveCompiler: ResolvedTool,
|
|
): boolean {
|
|
return process.platform === "darwin" &&
|
|
driver.canonicalPath === "/usr/bin/clang" &&
|
|
basename(effectiveCompiler.canonicalPath) === "clang";
|
|
}
|
|
|
|
/** Persistent caches require an inspectable compiler driver. A general
|
|
* wrapper can branch on the real source/object paths or argument topology and
|
|
* inject inputs only into the final invocation; no synthetic metadata probe
|
|
* can safely represent that behavior. Accept direct Clang/Zig drivers (plus
|
|
* Apple's system shim) and conservatively keep wrapper-driven builds on the
|
|
* uncached path. */
|
|
export async function compilerDriverSupportsPersistentCache(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
|
|
environmentFingerprint: string,
|
|
): Promise<boolean> {
|
|
// Cache-race tests exercise publication below an intentionally instrumented
|
|
// wrapper. This is deliberately undocumented and test-scoped.
|
|
if (process.env["SCRIPTC_TEST_TRUST_COMPILER_WRAPPER"] === "1") return true;
|
|
|
|
const driverKey = compilerDriverProbeKey(driver, environmentFingerprint);
|
|
const compiler = driver.argv[0] ?? "clang";
|
|
const resolvedDriver = await resolvedTool(compiler);
|
|
// A prior dependency list cannot prove that name resolution is unchanged:
|
|
// a new header in an earlier search directory leaves every previously
|
|
// resolved file untouched. Require the driver to be present so each cache
|
|
// invocation can rediscover its complete dependency graph.
|
|
if (resolvedDriver === null) return false;
|
|
const probeKey = `${driverKey}\0${resolvedDriver.fileIdentity}`;
|
|
const memoized = directCompilerDriverMemos.get(probeKey);
|
|
if (memoized !== undefined) return memoized;
|
|
|
|
const probeDir = await mkdtemp(join(tmpdir(), "scriptc-driver-probe-"));
|
|
let direct = false;
|
|
try {
|
|
const source = join(probeDir, "empty.c");
|
|
const object = join(probeDir, "empty.o");
|
|
await writeFile(source, "int scriptc_driver_probe;\n");
|
|
const trace = await execFileAsync(
|
|
compiler,
|
|
[
|
|
...driver.argv.slice(1),
|
|
...driver.targetArgs,
|
|
"-###",
|
|
"-std=c11",
|
|
"-c",
|
|
source,
|
|
"-o",
|
|
object,
|
|
],
|
|
{ cwd: probeDir, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const effectiveSpellings: string[] = [];
|
|
for (const line of `${trace.stdout}\n${trace.stderr}`.split(/\r?\n/)) {
|
|
const tokens = driverTraceCandidates(line);
|
|
const cc1 = tokens.indexOf("-cc1");
|
|
if (cc1 > 0) effectiveSpellings.push(tokens[cc1 - 1]!);
|
|
}
|
|
if (effectiveSpellings.length === 1) {
|
|
const effectiveCompiler = await resolvedTool(effectiveSpellings[0]!);
|
|
direct = effectiveCompiler !== null &&
|
|
(effectiveCompiler.fileIdentity === resolvedDriver.fileIdentity ||
|
|
isAppleSystemClangHandoff(resolvedDriver, effectiveCompiler));
|
|
if (direct) directCompilerSelections.set(driverKey, effectiveCompiler!);
|
|
}
|
|
} catch {
|
|
direct = false;
|
|
} finally {
|
|
await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
directCompilerDriverMemos.set(probeKey, direct);
|
|
if (!direct) directCompilerSelections.delete(driverKey);
|
|
return direct;
|
|
}
|
|
|
|
/** Complete library archives may skip `ar`, so the selected archiver must be
|
|
* as inspectable as the compiler. `zig ar` is the already-validated Zig
|
|
* executable. For the host spelling, accept only the immutable platform
|
|
* tool locations; a PATH wrapper can branch on the real member topology or
|
|
* inject mutable inputs that `ar --version` cannot expose. Other archivers
|
|
* retain runtime-object reuse but rebuild the program member and archive. */
|
|
async function archiverSupportsPersistentCache(
|
|
arArgv: readonly string[],
|
|
driver: Pick<CcDriver, "argv">,
|
|
): Promise<boolean> {
|
|
if (
|
|
arArgv.length === 2 &&
|
|
arArgv[0] === driver.argv[0] &&
|
|
arArgv[1] === "ar" &&
|
|
driver.argv[0] === "zig"
|
|
) {
|
|
return true;
|
|
}
|
|
if (arArgv.length !== 1) return false;
|
|
const archiver = await resolvedTool(arArgv[0] ?? "ar");
|
|
if (archiver === null || !/(?:^|-)ar$/.test(basename(archiver.canonicalPath))) {
|
|
return false;
|
|
}
|
|
if (archiver.canonicalPath.startsWith("/usr/bin/") || archiver.canonicalPath.startsWith("/bin/")) {
|
|
return true;
|
|
}
|
|
return process.platform === "darwin" &&
|
|
/^\/Applications\/Xcode[^/]*\.app\/Contents\/Developer\/Toolchains\/[^/]+\.xctoolchain\/usr\/bin\/[^/]*ar$/.test(
|
|
archiver.canonicalPath,
|
|
);
|
|
}
|
|
|
|
export async function ccVersion(
|
|
argv: string[],
|
|
environmentFingerprint: string = toolchainEnvironmentFingerprint(),
|
|
fresh: boolean = false,
|
|
): Promise<string> {
|
|
const spellingKey = `${environmentFingerprint}\0${argv.join("\x1f")}`;
|
|
const executableIdentity = await resolvedToolIdentity(argv[0] ?? "clang");
|
|
const key = `${spellingKey}\0${executableIdentity ?? "<unresolved>"}`;
|
|
const probe = (): Promise<string> =>
|
|
// `zig cc --version` (zig 0.16) drops an empty a.o in its cwd. A private
|
|
// probe directory avoids both caller pollution and the cross-process /
|
|
// cross-user collision a fixed tmpdir()/a.o would create on Linux.
|
|
(async () => {
|
|
const probeDir = await mkdtemp(join(tmpdir(), "scriptc-cc-version-"));
|
|
try {
|
|
const r = await execFileAsync(
|
|
argv[0] ?? "clang",
|
|
[...argv.slice(1), "--version"],
|
|
{ cwd: probeDir },
|
|
);
|
|
return `${executableIdentity ?? "<unresolved>"}\0${`${r.stdout}\n${r.stderr}`.trim()}`;
|
|
} finally {
|
|
await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
})();
|
|
const requireFreshProbe = fresh && !stableTestToolchainSession();
|
|
let memo = requireFreshProbe && executableIdentity !== null ? probe() : ccVersionMemos.get(key);
|
|
if (memo === undefined) {
|
|
memo = probe();
|
|
ccVersionMemos.set(key, memo);
|
|
} else if (requireFreshProbe) {
|
|
ccVersionMemos.set(key, memo);
|
|
}
|
|
return memo;
|
|
}
|
|
|
|
const toolVersionMemos = new Map<string, Promise<string>>();
|
|
const toolVersionFallbacks = new Map<string, Promise<string>>();
|
|
async function toolVersionOnce(
|
|
argv: string[],
|
|
environmentFingerprint: string = toolchainEnvironmentFingerprint(),
|
|
fresh: boolean = false,
|
|
): Promise<string> {
|
|
const spellingKey = `${environmentFingerprint}\0${argv.join("\x1f")}`;
|
|
const executableIdentity = await resolvedToolIdentity(argv[0] ?? "ar");
|
|
if (executableIdentity === null) {
|
|
const fallback = toolVersionFallbacks.get(spellingKey);
|
|
if (fallback !== undefined) return fallback;
|
|
}
|
|
const key = `${spellingKey}\0${executableIdentity ?? "<unresolved>"}`;
|
|
const probe = (): Promise<string> =>
|
|
execFileAsync(argv[0] ?? "ar", [...argv.slice(1), "--version"]).then(
|
|
(r) => `${executableIdentity ?? "<unresolved>"}\0${`${r.stdout}\n${r.stderr}`.trim()}`,
|
|
(err: { stdout?: string; stderr?: string; message?: string }) =>
|
|
`${executableIdentity ?? "<unresolved>"}\0${`${err.stdout ?? ""}\n${err.stderr ?? ""}`.trim() || err.message || key}`,
|
|
);
|
|
let memo = fresh && executableIdentity !== null ? probe() : toolVersionMemos.get(key);
|
|
if (memo === undefined) {
|
|
memo = probe();
|
|
toolVersionMemos.set(key, memo);
|
|
} else if (fresh) {
|
|
toolVersionMemos.set(key, memo);
|
|
}
|
|
if (executableIdentity !== null) toolVersionFallbacks.set(spellingKey, memo);
|
|
return memo;
|
|
}
|
|
|
|
interface ImplicitToolchainProbe {
|
|
compilerIdentity: string;
|
|
compilerInvocation: string;
|
|
dependencies: string[];
|
|
dependencyFingerprint: string;
|
|
invocationPaths: string[];
|
|
tools: { spelling: string; identity: string | null; path: string | null }[];
|
|
compileToolSpellings: string[];
|
|
}
|
|
|
|
interface ImplicitToolchainFingerprints {
|
|
/** Compiler inputs plus assembler identity: safe for compile-only objects. */
|
|
compile: string;
|
|
/** The compile identity plus the driver's selected linker identity. */
|
|
complete: string;
|
|
}
|
|
|
|
interface ImplicitLinkerProbe {
|
|
compilerIdentity: string;
|
|
linkerInvocation: string;
|
|
dependencies: string[];
|
|
dependencyFingerprint: string;
|
|
invocationPaths: string[];
|
|
linker: { spelling: string; identity: string | null; path: string | null };
|
|
}
|
|
|
|
interface FingerprintDependencies {
|
|
paths: string[];
|
|
/** Content identity computed while the enclosing fingerprint was minted.
|
|
* Null means the fingerprint has no file-content component. */
|
|
contentPaths: string[];
|
|
contentFingerprint: string | null;
|
|
}
|
|
|
|
/** Dependency paths discovered while computing one strict fingerprint. The
|
|
* output-local cache stamp snapshots these exact files/directories after a
|
|
* validated build, then can prove a later same-output no-op without spawning
|
|
* clang again. The content identity closes the gap between hashing and that
|
|
* metadata snapshot: a file changed in the gap cannot make new bytes ride an
|
|
* old key. Keep the map bounded for long-lived corpus/test processes. */
|
|
const fingerprintDependencies = new Map<string, FingerprintDependencies>();
|
|
function rememberFingerprintDependencies(
|
|
fingerprint: string,
|
|
paths: readonly string[],
|
|
contentPaths: readonly string[] = [],
|
|
contentFingerprint: string | null = null,
|
|
): string {
|
|
fingerprintDependencies.set(fingerprint, {
|
|
paths: [...new Set(paths)].sort(),
|
|
contentPaths: [...new Set(contentPaths)].sort(),
|
|
contentFingerprint,
|
|
});
|
|
if (fingerprintDependencies.size > 256) {
|
|
const oldest = fingerprintDependencies.keys().next().value as string | undefined;
|
|
if (oldest !== undefined) fingerprintDependencies.delete(oldest);
|
|
}
|
|
return fingerprint;
|
|
}
|
|
|
|
function fingerprintDependencyPaths(fingerprint: string): string[] {
|
|
return fingerprintDependencies.get(fingerprint)?.paths ?? [];
|
|
}
|
|
|
|
function parseMakeDependencies(output: string, cwd: string = process.cwd()): string[] {
|
|
const flattened = output.replace(/\\\r?\n/g, " ");
|
|
const separator = flattened.indexOf(": ");
|
|
if (separator < 0) throw new Error("compiler dependency probe returned no make rule");
|
|
const input = flattened.slice(separator + 2);
|
|
const paths: string[] = [];
|
|
let current = "";
|
|
let escaped = false;
|
|
for (const char of input) {
|
|
if (escaped) {
|
|
current += char;
|
|
escaped = false;
|
|
} else if (char === "\\") {
|
|
escaped = true;
|
|
} else if (/\s/.test(char)) {
|
|
if (current !== "") {
|
|
paths.push(current.replace(/\$\$/g, "$"));
|
|
current = "";
|
|
}
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
if (escaped) current += "\\";
|
|
if (current !== "") paths.push(current.replace(/\$\$/g, "$"));
|
|
return [...new Set(paths.map((path) => resolve(cwd, path)))].sort();
|
|
}
|
|
|
|
function normalizedProbeInvocation(
|
|
result: { stdout: string; stderr: string },
|
|
probeDir: string,
|
|
): string {
|
|
return `${result.stdout}\n${result.stderr}`
|
|
// Probe-local paths vary on every invocation and carry no toolchain
|
|
// identity. Both ordinary and shell-escaped Windows spellings can appear
|
|
// in a driver's quoted trace.
|
|
.split(probeDir).join("<probe>")
|
|
.split(probeDir.replace(/\\/g, "\\\\")).join("<probe>")
|
|
.trim();
|
|
}
|
|
|
|
interface EffectiveCompilerInvocationProbe {
|
|
compilerIdentity: string;
|
|
invocation: string;
|
|
dependencies: string[];
|
|
dependencyFingerprint: string;
|
|
invocationPaths: string[];
|
|
}
|
|
|
|
/** The effective cc1 invocation and injected dependencies for the flags used
|
|
* by real runtime/program compiles. The broad implicit-toolchain probe below
|
|
* intentionally uses a target-wide synthetic TU so it can discover every
|
|
* owned system header, but that generic command is not sufficient identity for
|
|
* a wrapper that injects flags or preincluded files only for a particular build
|
|
* flavor (for example, only when it sees -O2 or -DSCR_DYNAMIC). */
|
|
async function effectiveCompilerInvocationFingerprintFresh(
|
|
driver: Pick<CcDriver, "argv">,
|
|
environmentFingerprint: string,
|
|
compileArgs: readonly string[],
|
|
sourceExtension: ".c" | ".ll" = ".c",
|
|
): Promise<string> {
|
|
const compiler = driver.argv[0] ?? "clang";
|
|
const compilerIdentity = await resolvedToolIdentity(compiler);
|
|
if (compilerIdentity === null) {
|
|
throw new Error("compiler unavailable before effective invocation identity was established");
|
|
}
|
|
let probe: EffectiveCompilerInvocationProbe;
|
|
{
|
|
const probeDir = await mkdtemp(join(tmpdir(), "scriptc-effective-cc-probe-"));
|
|
try {
|
|
const source = join(probeDir, `program${sourceExtension}`);
|
|
const output = join(probeDir, "program.o");
|
|
await writeFile(
|
|
source,
|
|
sourceExtension === ".ll"
|
|
? "define i32 @scriptc_effective_probe() { ret i32 0 }\n"
|
|
: "int scriptc_effective_probe(void) { return 0; }\n",
|
|
);
|
|
const prefix = [...driver.argv.slice(1), ...compileArgs];
|
|
const [invocation, dependencyResult] = await Promise.all([
|
|
execFileAsync(
|
|
compiler,
|
|
[...prefix, "-###", "-c", source, "-o", output],
|
|
{ cwd: probeDir, maxBuffer: 16 * 1024 * 1024 },
|
|
),
|
|
sourceExtension === ".c"
|
|
? execFileAsync(compiler, [...prefix, "-M", source], {
|
|
cwd: probeDir,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
})
|
|
: Promise.resolve(null),
|
|
]);
|
|
const dependencies =
|
|
dependencyResult === null
|
|
? []
|
|
: parseMakeDependencies(dependencyResult.stdout, probeDir).filter(
|
|
(path) => path !== resolve(source),
|
|
);
|
|
probe = {
|
|
compilerIdentity,
|
|
invocation: normalizedProbeInvocation(invocation, probeDir),
|
|
dependencies,
|
|
dependencyFingerprint: await fingerprintDependencyFiles(dependencies),
|
|
invocationPaths: await existingDriverTracePaths(
|
|
`${invocation.stdout}\n${invocation.stderr}`,
|
|
probeDir,
|
|
probeDir,
|
|
),
|
|
};
|
|
} finally {
|
|
await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const fingerprint = createHash("sha256")
|
|
.update("effective-compiler-invocation-v2\0")
|
|
.update(environmentFingerprint)
|
|
.update("\0")
|
|
.update(probe.compilerIdentity)
|
|
.update("\0")
|
|
.update(driver.argv.join("\x1f"))
|
|
.update("\0")
|
|
.update(compileArgs.join("\x1f"))
|
|
.update("\0")
|
|
.update(sourceExtension)
|
|
.update("\0")
|
|
.update(probe.invocation)
|
|
.update("\0")
|
|
.update(probe.dependencies.join("\x1f"))
|
|
.update("\0")
|
|
.update(probe.dependencyFingerprint)
|
|
.digest("hex");
|
|
return rememberFingerprintDependencies(
|
|
fingerprint,
|
|
[...probe.dependencies, ...probe.invocationPaths],
|
|
probe.dependencies,
|
|
probe.dependencyFingerprint,
|
|
);
|
|
}
|
|
|
|
const stableEffectiveCompilerInvocationMemos = new Map<string, Promise<string>>();
|
|
function effectiveCompilerInvocationFingerprint(
|
|
driver: Pick<CcDriver, "argv">,
|
|
environmentFingerprint: string,
|
|
compileArgs: readonly string[],
|
|
sourceExtension: ".c" | ".ll" = ".c",
|
|
): Promise<string> {
|
|
const key = [
|
|
environmentFingerprint,
|
|
driver.argv.join("\x1f"),
|
|
compileArgs.join("\x1f"),
|
|
sourceExtension,
|
|
].join("\0");
|
|
return stableTestMemo(stableEffectiveCompilerInvocationMemos, key, () =>
|
|
effectiveCompilerInvocationFingerprintFresh(
|
|
driver,
|
|
environmentFingerprint,
|
|
compileArgs,
|
|
sourceExtension,
|
|
),
|
|
);
|
|
}
|
|
|
|
async function nativeSourceFiles(
|
|
directory: string,
|
|
recursive: boolean,
|
|
include: (name: string) => boolean = (name) => name.endsWith(".c") || name.endsWith(".h"),
|
|
): Promise<string[]> {
|
|
const files: string[] = [];
|
|
const walk = async (current: string, ancestors: ReadonlySet<string>): Promise<void> => {
|
|
const canonical = await realpath(current).catch(() => resolve(current));
|
|
if (ancestors.has(canonical)) return;
|
|
const nestedAncestors = new Set(ancestors).add(canonical);
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
const path = join(current, entry.name);
|
|
if (entry.isDirectory() && recursive) {
|
|
await walk(path, nestedAncestors);
|
|
} else if (entry.isSymbolicLink()) {
|
|
const target = await stat(path).catch(() => null);
|
|
if (target?.isDirectory() && recursive) await walk(path, nestedAncestors);
|
|
else if (target?.isFile() && include(entry.name)) files.push(path);
|
|
} else if (entry.isFile() && include(entry.name)) {
|
|
files.push(path);
|
|
}
|
|
}
|
|
};
|
|
await walk(directory, new Set());
|
|
return files.sort();
|
|
}
|
|
|
|
/** Every system-header spelling consumed by a source tree scriptc owns. The
|
|
* dependency probe preprocesses these spellings with the selected driver and
|
|
* hashes the resolved files. Runtime-local and vendored source/header bytes
|
|
* have their own content/version identities; scanning every owned file for
|
|
* angle includes closes the system-header gap in separately built QuickJS,
|
|
* libregexp, mbedTLS, zlib, curl, and Ryū translation units. */
|
|
export async function implicitDependencyProbeIncludes(rtDir: string): Promise<string[]> {
|
|
const vendor = join(rtDir, "..", "vendor");
|
|
const quickjsSources = new Set<string>([...QJS_ENGINE_SOURCES, ...LRE_SOURCES]);
|
|
const zlibSources = new Set<string>(ZLIB_SOURCES);
|
|
const fileGroups = await Promise.all([
|
|
nativeSourceFiles(rtDir, false),
|
|
nativeSourceFiles(join(vendor, "ryu"), false),
|
|
nativeSourceFiles(
|
|
join(vendor, "quickjs-ng"),
|
|
false,
|
|
(name) => name.endsWith(".h") || quickjsSources.has(name),
|
|
),
|
|
nativeSourceFiles(join(vendor, "mbedtls", "include"), true),
|
|
nativeSourceFiles(join(vendor, "mbedtls", "library"), true),
|
|
nativeSourceFiles(
|
|
join(vendor, "zlib"),
|
|
false,
|
|
(name) => name.endsWith(".h") || zlibSources.has(name),
|
|
),
|
|
nativeSourceFiles(join(vendor, "curl", "include"), true, (name) => name.endsWith(".h")),
|
|
]);
|
|
const includes = new Set<string>();
|
|
for (const path of fileGroups.flat()) {
|
|
const source = await readFile(path, "utf8");
|
|
for (const match of source.matchAll(/^\s*#\s*include\s*<([^>]+)>/gm)) {
|
|
includes.add(`<${match[1]!}>`);
|
|
}
|
|
}
|
|
return [...includes].sort();
|
|
}
|
|
|
|
function implicitDependencyIncludeDirective(include: string): string {
|
|
const targetCondition =
|
|
include === "<cpuid.h>" || include === "<immintrin.h>"
|
|
? " && (defined(__i386__) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64))"
|
|
: include === "<intrin.h>"
|
|
? " && defined(_WIN32)"
|
|
: include === "<arm64_neon.h>" || include === "<arm_acle.h>" || include === "<arm_neon.h>"
|
|
? " && (defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64))"
|
|
: "";
|
|
return `#if __has_include(${include})${targetCondition}\n#include ${include}\n#endif\n`;
|
|
}
|
|
|
|
async function fingerprintDependencyFiles(paths: readonly string[]): Promise<string> {
|
|
const hash = createHash("sha256").update("implicit-dependencies-v1\0");
|
|
for (let i = 0; i < paths.length; i += 128) {
|
|
const batch = paths.slice(i, i + 128);
|
|
const contents = await Promise.all(batch.map((path) => readFile(path)));
|
|
for (let j = 0; j < batch.length; j++) {
|
|
hash.update(batch[j]!).update("\0").update(contents[j]!).update("\0");
|
|
}
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
async function fingerprintDependenciesStillMatch(fingerprints: readonly string[]): Promise<boolean> {
|
|
const distinct = [...new Set(fingerprints)];
|
|
const identities = distinct
|
|
.map((fingerprint) => fingerprintDependencies.get(fingerprint))
|
|
// A restored native-metadata stamp carries and revalidates the dependency
|
|
// snapshot from the process that minted this fingerprint. Only identities
|
|
// computed in this process have an additional content hash to recheck here.
|
|
.filter((identity): identity is FingerprintDependencies => identity !== undefined);
|
|
return (await Promise.all(
|
|
identities.map(async (identity) =>
|
|
identity.contentFingerprint === null ||
|
|
await fingerprintDependencyFiles(identity.contentPaths).catch(() => null) ===
|
|
identity.contentFingerprint
|
|
),
|
|
)).every(Boolean);
|
|
}
|
|
|
|
interface TranslationUnitDependencyProbe {
|
|
compilerIdentity: string;
|
|
dependencies: string[];
|
|
dependencyFingerprint: string;
|
|
}
|
|
|
|
/** Exact headers selected while preprocessing the caller's translation unit.
|
|
* The shared toolchain probe covers the runtime/vendor trees, but compileC is
|
|
* also a public API: an opted-in caller can include a system or header-only SDK
|
|
* surface that no runtime source names. Probe an invocation-private snapshot
|
|
* of the keyed bytes with the real compile flags, preserving the original
|
|
* quote-include directory and compiler-visible source spelling. */
|
|
async function translationUnitDependencyFingerprintFresh(
|
|
driver: Pick<CcDriver, "argv">,
|
|
cflags: readonly string[],
|
|
sourcePath: string,
|
|
sourceBytes: Buffer,
|
|
environmentFingerprint: string,
|
|
): Promise<string> {
|
|
if (sourcePath.endsWith(".ll")) {
|
|
return rememberFingerprintDependencies(
|
|
createHash("sha256").update("translation-unit-dependencies-v1\0llvm-ir").digest("hex"),
|
|
[],
|
|
);
|
|
}
|
|
|
|
const compiler = driver.argv[0] ?? "clang";
|
|
const compilerIdentity = await resolvedToolIdentity(compiler);
|
|
if (compilerIdentity === null) {
|
|
throw new Error("compiler unavailable before translation-unit dependencies were established");
|
|
}
|
|
let probe: TranslationUnitDependencyProbe;
|
|
{
|
|
const probeDir = await mkdtemp(join(tmpdir(), "scriptc-tu-probe-"));
|
|
try {
|
|
const snapshot = join(probeDir, "program.c");
|
|
await writeFile(snapshot, sourceBytes);
|
|
const result = await execFileAsync(
|
|
compiler,
|
|
[
|
|
...driver.argv.slice(1),
|
|
// The real source directory is searched before every caller-supplied
|
|
// -iquote/-I directory. Put its surrogate first to preserve that
|
|
// precedence after moving the keyed bytes into the probe directory.
|
|
"-iquote",
|
|
dirname(resolve(sourcePath)),
|
|
...cflags,
|
|
`-ffile-prefix-map=${snapshot}=${sourcePath}`,
|
|
"-M",
|
|
snapshot,
|
|
],
|
|
{ cwd: probeDir, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const ownPaths = new Set([resolve(snapshot), resolve(sourcePath)]);
|
|
const dependencies = parseMakeDependencies(result.stdout, probeDir).filter(
|
|
(path) => !ownPaths.has(path),
|
|
);
|
|
probe = {
|
|
compilerIdentity,
|
|
dependencies,
|
|
dependencyFingerprint: await fingerprintDependencyFiles(dependencies),
|
|
};
|
|
} finally {
|
|
await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const fingerprint = createHash("sha256")
|
|
.update("translation-unit-dependencies-v1\0")
|
|
.update(environmentFingerprint)
|
|
.update("\0")
|
|
.update(probe.compilerIdentity)
|
|
.update("\0")
|
|
.update(probe.dependencies.join("\x1f"))
|
|
.update("\0")
|
|
.update(probe.dependencyFingerprint)
|
|
.digest("hex");
|
|
return rememberFingerprintDependencies(
|
|
fingerprint,
|
|
probe.dependencies,
|
|
probe.dependencies,
|
|
probe.dependencyFingerprint,
|
|
);
|
|
}
|
|
|
|
const stableTranslationUnitDependencyMemos = new Map<string, Promise<string>>();
|
|
function translationUnitDependencyFingerprint(
|
|
driver: Pick<CcDriver, "argv">,
|
|
cflags: readonly string[],
|
|
sourcePath: string,
|
|
sourceBytes: Buffer,
|
|
environmentFingerprint: string,
|
|
): Promise<string> {
|
|
const key = createHash("sha256")
|
|
.update(environmentFingerprint)
|
|
.update("\0")
|
|
.update(driver.argv.join("\x1f"))
|
|
.update("\0")
|
|
.update(cflags.join("\x1f"))
|
|
.update("\0")
|
|
.update(sourcePath)
|
|
.update("\0")
|
|
.update(resolve(sourcePath))
|
|
.update("\0")
|
|
.update(sourceBytes)
|
|
.digest("hex");
|
|
return stableTestMemo(stableTranslationUnitDependencyMemos, key, () =>
|
|
translationUnitDependencyFingerprintFresh(
|
|
driver,
|
|
cflags,
|
|
sourcePath,
|
|
sourceBytes,
|
|
environmentFingerprint,
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Identity of implicit compiler inputs that do not appear in buildArgs:
|
|
* default SDK/system headers and the assembler/linker selected by the driver.
|
|
* Small preprocessor dependency probes include every header spelling used by
|
|
* the runtime when it is available for the selected target (and therefore the
|
|
* vendored headers those TUs consume), then hash the exact dependency bytes.
|
|
* Vendored source snapshots remain keyed by their version pins. When the
|
|
* compiler is available, dependency discovery runs afresh so an SDK/config
|
|
* change that redirects includes cannot hide behind an unchanged old path
|
|
* list. Dependency discovery must succeed on every cache-enabled invocation;
|
|
* a prior path list cannot reveal a new higher-priority header. */
|
|
async function implicitToolchainFingerprintsFresh(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
|
|
environmentFingerprint: string,
|
|
): Promise<ImplicitToolchainFingerprints> {
|
|
let probe: ImplicitToolchainProbe;
|
|
const compiler = driver.argv[0] ?? "clang";
|
|
const compilerIdentity = await resolvedToolIdentity(compiler);
|
|
if (compilerIdentity === null) {
|
|
throw new Error("compiler unavailable before implicit toolchain identity was established");
|
|
}
|
|
{
|
|
const probeDir = await mkdtemp(join(tmpdir(), "scriptc-toolchain-probe-"));
|
|
try {
|
|
const dependencyIncludes = await implicitDependencyProbeIncludes(runtimeSrcDir());
|
|
// Clang's GNU cpuid.h and Windows intrin.h both define `__cpuid` with
|
|
// incompatible signatures. Real TUs select only one context; preserve
|
|
// that isolation while still discovering both targets' dependency sets.
|
|
const sourceGroups = [
|
|
dependencyIncludes.filter((include) => include !== "<intrin.h>"),
|
|
dependencyIncludes.filter((include) => include === "<intrin.h>"),
|
|
].filter((group) => group.length > 0);
|
|
const sources = sourceGroups.map((_, index) => join(probeDir, `empty-${index}.c`));
|
|
const driverSource = join(probeDir, "driver-empty.c");
|
|
const driverOutput = join(probeDir, "driver-output.o");
|
|
await Promise.all([
|
|
...sources.map((source, index) =>
|
|
writeFile(source, sourceGroups[index]!.map(implicitDependencyIncludeDirective).join("")),
|
|
),
|
|
writeFile(driverSource, "int scriptc_driver_probe;\n"),
|
|
]);
|
|
const prefix = [...driver.argv.slice(1), ...driver.targetArgs];
|
|
const probeArgs = [
|
|
...prefix,
|
|
"-std=c11",
|
|
"-D_GNU_SOURCE",
|
|
"-D_XOPEN_SOURCE=700",
|
|
"-I", runtimeSrcDir(),
|
|
"-I", vendorEngineDir(),
|
|
"-I", join(vendorTlsDir(), "include"),
|
|
"-I", join(vendorTlsDir(), "library"),
|
|
"-I", vendorZlibDir(),
|
|
"-I", join(vendorCurlDir(), "include"),
|
|
"-M",
|
|
];
|
|
const [dependencyResults, linker, assembler, compilerInvocation] = await Promise.all([
|
|
Promise.all(
|
|
sources.map((source) =>
|
|
execFileAsync(compiler, [...probeArgs, source], {
|
|
cwd: probeDir,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
}),
|
|
),
|
|
),
|
|
execFileAsync(compiler, [...prefix, "-print-prog-name=ld"], { cwd: probeDir }),
|
|
execFileAsync(compiler, [...prefix, "-print-prog-name=as"], { cwd: probeDir }),
|
|
// `-###` exposes the effective cc1 invocation after compiler-driver
|
|
// config and ordinary wrappers have injected their implicit flags. A
|
|
// wrapper can read environment variables unknown to scriptc; hashing
|
|
// this trace keeps those flags from hiding behind an unchanged wrapper
|
|
// executable/version and dependency set.
|
|
execFileAsync(
|
|
compiler,
|
|
[...prefix, "-std=c11", "-###", "-c", driverSource, "-o", driverOutput],
|
|
{ cwd: probeDir, maxBuffer: 16 * 1024 * 1024 },
|
|
),
|
|
]);
|
|
const assemblerSpelling = assembler.stdout.trim();
|
|
const toolSpellings = [linker.stdout.trim(), assemblerSpelling].filter(
|
|
(value, index, all) => value !== "" && all.indexOf(value) === index,
|
|
);
|
|
const sourceSet = new Set(sources);
|
|
const dependencyPaths = [
|
|
...new Set(
|
|
dependencyResults
|
|
.flatMap((dependencies) => parseMakeDependencies(dependencies.stdout))
|
|
.filter((path) => !sourceSet.has(path)),
|
|
),
|
|
].sort();
|
|
probe = {
|
|
compilerIdentity: compilerIdentity ?? `<unresolved>\0${compiler}`,
|
|
compilerInvocation: normalizedProbeInvocation(compilerInvocation, probeDir),
|
|
dependencies: dependencyPaths,
|
|
dependencyFingerprint: await fingerprintDependencyFiles(dependencyPaths),
|
|
invocationPaths: await existingDriverTracePaths(
|
|
`${compilerInvocation.stdout}\n${compilerInvocation.stderr}`,
|
|
probeDir,
|
|
probeDir,
|
|
),
|
|
tools: await Promise.all(
|
|
toolSpellings.map(async (spelling) => {
|
|
const resolved = await resolvedTool(spelling);
|
|
return {
|
|
spelling,
|
|
identity: resolved?.cacheIdentity ?? null,
|
|
path: resolved?.canonicalPath ?? null,
|
|
};
|
|
}),
|
|
),
|
|
compileToolSpellings: assemblerSpelling === "" ? [] : [assemblerSpelling],
|
|
};
|
|
} finally {
|
|
await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const tools = await Promise.all(probe.tools.map(async (tool) => ({
|
|
...tool,
|
|
currentIdentity: await resolvedToolIdentity(tool.spelling),
|
|
})));
|
|
const fingerprint = (
|
|
domain: string,
|
|
selectedTools: readonly (typeof tools)[number][],
|
|
): string => {
|
|
const hash = createHash("sha256")
|
|
.update(domain)
|
|
.update(environmentFingerprint)
|
|
.update("\0")
|
|
.update(probe.compilerIdentity)
|
|
.update("\0")
|
|
.update(probe.compilerInvocation)
|
|
.update("\0")
|
|
.update(driver.argv.join("\x1f"))
|
|
.update("\0")
|
|
.update(driver.targetArgs.join("\x1f"))
|
|
.update("\0")
|
|
.update(probe.dependencies.join("\x1f"))
|
|
.update("\0")
|
|
.update(probe.dependencyFingerprint)
|
|
.update("\0");
|
|
for (const tool of selectedTools) {
|
|
hash
|
|
.update(tool.spelling)
|
|
.update("\0")
|
|
.update(tool.currentIdentity ?? tool.identity ?? "<unresolved>")
|
|
.update("\0");
|
|
}
|
|
return rememberFingerprintDependencies(
|
|
hash.digest("hex"),
|
|
[
|
|
...probe.dependencies,
|
|
...probe.invocationPaths,
|
|
...selectedTools.flatMap((tool) => tool.path === null ? [] : [tool.path]),
|
|
],
|
|
probe.dependencies,
|
|
probe.dependencyFingerprint,
|
|
);
|
|
};
|
|
const compileSpellingSet = new Set(probe.compileToolSpellings);
|
|
return {
|
|
// Preserve the established complete fingerprint domain and byte stream.
|
|
complete: fingerprint("implicit-toolchain-v2\0", tools),
|
|
compile: fingerprint(
|
|
"implicit-compile-toolchain-v1\0",
|
|
tools.filter((tool) => compileSpellingSet.has(tool.spelling)),
|
|
),
|
|
};
|
|
}
|
|
|
|
const stableImplicitToolchainMemos = new Map<string, Promise<ImplicitToolchainFingerprints>>();
|
|
function implicitToolchainFingerprints(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
|
|
environmentFingerprint: string,
|
|
): Promise<ImplicitToolchainFingerprints> {
|
|
const key = [
|
|
environmentFingerprint,
|
|
driver.argv.join("\x1f"),
|
|
driver.target ?? "<native>",
|
|
driver.targetArgs.join("\x1f"),
|
|
runtimeSrcDir(),
|
|
].join("\0");
|
|
return stableTestMemo(stableImplicitToolchainMemos, key, () =>
|
|
implicitToolchainFingerprintsFresh(driver, environmentFingerprint),
|
|
);
|
|
}
|
|
|
|
function implicitToolchainFingerprint(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
|
|
environmentFingerprint: string,
|
|
): Promise<string> {
|
|
return implicitToolchainFingerprints(driver, environmentFingerprint).then(
|
|
(fingerprints) => fingerprints.complete,
|
|
);
|
|
}
|
|
|
|
async function existingDriverTracePaths(
|
|
output: string,
|
|
cwd: string,
|
|
excludedRoot: string,
|
|
): Promise<string[]> {
|
|
const candidates = new Set<string>();
|
|
for (const line of output.split(/\r?\n/)) {
|
|
const tokens = driverTraceCandidates(line);
|
|
for (let index = 0; index < tokens.length; index++) {
|
|
const token = tokens[index]!;
|
|
const joinedPathOption = ["-I", "-L", "-F"].find(
|
|
(option) => token.startsWith(option) && token.length > option.length,
|
|
);
|
|
const equalsPathOption = ["--sysroot=", "-resource-dir="].find((option) =>
|
|
token.startsWith(option)
|
|
);
|
|
const separatePathOptions = [
|
|
"-I",
|
|
"-L",
|
|
"-F",
|
|
"--sysroot",
|
|
"-isysroot",
|
|
"-resource-dir",
|
|
"-isystem",
|
|
"-iquote",
|
|
"-internal-isystem",
|
|
"-internal-externc-isystem",
|
|
"-internal-iframework",
|
|
];
|
|
const optionPath = joinedPathOption !== undefined
|
|
? token.slice(joinedPathOption.length)
|
|
: equalsPathOption !== undefined
|
|
? token.slice(equalsPathOption.length)
|
|
: separatePathOptions.includes(token)
|
|
? tokens[index + 1] ?? ""
|
|
: token;
|
|
if (!isAbsolute(optionPath)) continue;
|
|
const path = resolve(cwd, optionPath);
|
|
if (
|
|
path === excludedRoot ||
|
|
path.startsWith(`${excludedRoot}/`) ||
|
|
path.startsWith(`${excludedRoot}\\`)
|
|
) {
|
|
continue;
|
|
}
|
|
candidates.add(path);
|
|
}
|
|
}
|
|
const existing = await Promise.all(
|
|
[...candidates].map(async (path) => [path, await lstat(path).catch(() => null)] as const),
|
|
);
|
|
return existing
|
|
.filter((entry): entry is readonly [string, NonNullable<(typeof entry)[1]>] => entry[1] !== null)
|
|
.map(([path]) => path)
|
|
.sort();
|
|
}
|
|
|
|
export async function parseLinkTraceFiles(
|
|
output: string,
|
|
cwd: string,
|
|
excludedRoot: string,
|
|
driverDryRun: boolean = false,
|
|
): Promise<string[]> {
|
|
const files = new Set<string>();
|
|
for (const line of output.split(/\r?\n/)) {
|
|
const candidates = driverDryRun ? driverTraceCandidates(line) : linkTraceCandidate(line);
|
|
for (const candidate of candidates) {
|
|
const path = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
|
|
if (path === excludedRoot || path.startsWith(`${excludedRoot}/`) || path.startsWith(`${excludedRoot}\\`)) {
|
|
continue;
|
|
}
|
|
const info = await stat(path).catch(() => null);
|
|
if (info?.isFile()) {
|
|
files.add(path);
|
|
// Traditional `-Wl,-t` output has one dependency per line, while
|
|
// Zig's COFF `-###` fallback prints the entire lld-link argv on one
|
|
// line. Keep scanning every dry-run token so CRT/import/runtime
|
|
// libraries after the first path also join the fingerprint.
|
|
if (!driverDryRun) break;
|
|
}
|
|
}
|
|
}
|
|
return [...files].sort();
|
|
}
|
|
|
|
/** Exact files selected by the compiler driver's implicit link. A tiny
|
|
* target object plus the linker's trace mode resolves CRT objects, compiler
|
|
* runtimes, linker scripts, SDK stubs/import libraries, and every ambient
|
|
* default library without guessing platform search layouts. The caller adds
|
|
* build-flavor flags such as ASan and scriptc's own fixed `-l` arguments.
|
|
* Every cache-enabled invocation performs a fresh trace; a prior resolved path
|
|
* list cannot reveal a newly selected higher-priority linker input. */
|
|
async function implicitLinkerFingerprintFresh(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
|
|
environmentFingerprint: string,
|
|
linkArgs: readonly string[],
|
|
effectiveInvocationArgs?: readonly string[],
|
|
traceInvocationArgs?: readonly string[],
|
|
): Promise<string> {
|
|
const invocationArgs =
|
|
effectiveInvocationArgs ?? [...driver.targetArgs, ...linkArgs];
|
|
const traceArgs = traceInvocationArgs ?? [...driver.targetArgs, ...linkArgs];
|
|
const compiler = driver.argv[0] ?? "clang";
|
|
const compilerIdentity = await resolvedToolIdentity(compiler);
|
|
if (compilerIdentity === null) {
|
|
throw new Error("compiler unavailable before implicit linker identity was established");
|
|
}
|
|
let probe: ImplicitLinkerProbe;
|
|
{
|
|
const probeDir = await mkdtemp(join(tmpdir(), "scriptc-linker-probe-"));
|
|
try {
|
|
const source = join(probeDir, "empty.c");
|
|
const object = join(probeDir, "empty.o");
|
|
const output = join(probeDir, process.platform === "win32" ? "empty.exe" : "empty");
|
|
await writeFile(source, "int main(void) { return 0; }\n");
|
|
const prefix = [...driver.argv.slice(1), ...driver.targetArgs];
|
|
const [, linker] = await Promise.all([
|
|
execFileAsync(
|
|
compiler,
|
|
[...prefix, "-std=c11", "-c", source, "-o", object],
|
|
{ cwd: probeDir },
|
|
),
|
|
execFileAsync(
|
|
compiler,
|
|
[...prefix, "-print-prog-name=ld"],
|
|
{ cwd: probeDir },
|
|
),
|
|
]);
|
|
// Unlike the compile-only toolchain trace, this exposes options a
|
|
// compiler wrapper/config injects only for link invocations (rpaths,
|
|
// subsystem/stack settings, --defsym, and peers). Resolved input files
|
|
// alone cannot represent those output-affecting flags.
|
|
const driverInvocation = await execFileAsync(
|
|
compiler,
|
|
[...driver.argv.slice(1), ...invocationArgs, object, "-###", "-o", output],
|
|
{ cwd: probeDir, maxBuffer: 32 * 1024 * 1024 },
|
|
);
|
|
// The dry run exposes absolute files a wrapper injects for this exact
|
|
// build flavor. Its unresolved -l spellings are supplemented by the
|
|
// real trace below, which runs with the same flavor flags but omits
|
|
// not-yet-materialized scriptc-owned vendor prerequisites.
|
|
const driverDependencies = await parseLinkTraceFiles(
|
|
`${driverInvocation.stdout}\n${driverInvocation.stderr}`,
|
|
probeDir,
|
|
probeDir,
|
|
true,
|
|
);
|
|
let tracedDependencies: string[] = [];
|
|
try {
|
|
const trace = await execFileAsync(
|
|
compiler,
|
|
[...driver.argv.slice(1), ...traceArgs, object, "-Wl,-t", "-o", output],
|
|
{ cwd: probeDir, maxBuffer: 32 * 1024 * 1024 },
|
|
);
|
|
tracedDependencies = await parseLinkTraceFiles(
|
|
`${trace.stdout}\n${trace.stderr}`,
|
|
probeDir,
|
|
probeDir,
|
|
);
|
|
} catch (error) {
|
|
// Zig's COFF linker deliberately rejects GNU ld's `-t`, but `zig cc
|
|
// -###` prints its fully resolved lld-link input list (CRT and every
|
|
// import/compiler-runtime library as absolute cache paths). Other
|
|
// drivers' dry runs commonly leave `-lc`/`-lSystem` unresolved, so
|
|
// they must not use this fallback as a complete artifact identity.
|
|
if (driver.argv[0] !== "zig" || driver.argv[1] !== "cc") throw error;
|
|
}
|
|
const dependencies = [...new Set([...driverDependencies, ...tracedDependencies])].sort();
|
|
if (dependencies.length === 0) {
|
|
throw new Error("linker trace reported no resolved input files");
|
|
}
|
|
const linkerSpelling = linker.stdout.trim();
|
|
probe = {
|
|
compilerIdentity,
|
|
linkerInvocation: normalizedProbeInvocation(driverInvocation, probeDir),
|
|
dependencies,
|
|
dependencyFingerprint: await fingerprintDependencyFiles(dependencies),
|
|
invocationPaths: await existingDriverTracePaths(
|
|
`${driverInvocation.stdout}\n${driverInvocation.stderr}`,
|
|
probeDir,
|
|
probeDir,
|
|
),
|
|
linker: {
|
|
spelling: linkerSpelling,
|
|
identity:
|
|
linkerSpelling === ""
|
|
? null
|
|
: (await resolvedTool(linkerSpelling))?.cacheIdentity ?? null,
|
|
path:
|
|
linkerSpelling === ""
|
|
? null
|
|
: (await resolvedTool(linkerSpelling))?.canonicalPath ?? null,
|
|
},
|
|
};
|
|
} finally {
|
|
await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const linkerIdentity =
|
|
probe.linker.spelling === ""
|
|
? null
|
|
: await resolvedToolIdentity(probe.linker.spelling);
|
|
const fingerprint = createHash("sha256")
|
|
.update("implicit-linker-v3\0")
|
|
.update(environmentFingerprint)
|
|
.update("\0")
|
|
.update(probe.compilerIdentity)
|
|
.update("\0")
|
|
.update(probe.linkerInvocation)
|
|
.update("\0")
|
|
.update(driver.argv.join("\x1f"))
|
|
.update("\0")
|
|
.update(driver.targetArgs.join("\x1f"))
|
|
.update("\0")
|
|
.update(linkArgs.join("\x1f"))
|
|
.update("\0")
|
|
.update(invocationArgs.join("\x1f"))
|
|
.update("\0")
|
|
.update(traceArgs.join("\x1f"))
|
|
.update("\0")
|
|
.update(probe.dependencies.join("\x1f"))
|
|
.update("\0")
|
|
.update(probe.dependencyFingerprint)
|
|
.update("\0")
|
|
.update(probe.linker.spelling)
|
|
.update("\0")
|
|
.update(linkerIdentity ?? probe.linker.identity ?? "<unresolved>")
|
|
.digest("hex");
|
|
return rememberFingerprintDependencies(
|
|
fingerprint,
|
|
[
|
|
...probe.dependencies,
|
|
...probe.invocationPaths,
|
|
...(probe.linker.path === null ? [] : [probe.linker.path]),
|
|
],
|
|
probe.dependencies,
|
|
probe.dependencyFingerprint,
|
|
);
|
|
}
|
|
|
|
const stableImplicitLinkerMemos = new Map<string, Promise<string>>();
|
|
function implicitLinkerFingerprint(
|
|
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
|
|
environmentFingerprint: string,
|
|
linkArgs: readonly string[],
|
|
effectiveInvocationArgs?: readonly string[],
|
|
traceInvocationArgs?: readonly string[],
|
|
): Promise<string> {
|
|
const invocationArgs = effectiveInvocationArgs ?? [...driver.targetArgs, ...linkArgs];
|
|
const traceArgs = traceInvocationArgs ?? [...driver.targetArgs, ...linkArgs];
|
|
const key = [
|
|
environmentFingerprint,
|
|
driver.argv.join("\x1f"),
|
|
driver.target ?? "<native>",
|
|
driver.targetArgs.join("\x1f"),
|
|
linkArgs.join("\x1f"),
|
|
invocationArgs.join("\x1f"),
|
|
traceArgs.join("\x1f"),
|
|
].join("\0");
|
|
return stableTestMemo(stableImplicitLinkerMemos, key, () =>
|
|
implicitLinkerFingerprintFresh(
|
|
driver,
|
|
environmentFingerprint,
|
|
linkArgs,
|
|
effectiveInvocationArgs,
|
|
traceInvocationArgs,
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Resolve the exact files consumed by one compiler-driver link invocation.
|
|
* Runtime packs reuse the native toolchain's strict dry-run plus real linker
|
|
* trace so a PATH-selected Clang carries its own linker, SDK, compiler
|
|
* runtime, and injected inputs into the executable cache proof. */
|
|
export async function nativeLinkerDependencyPaths(
|
|
linker: string,
|
|
linkArgs: readonly string[],
|
|
): Promise<string[]> {
|
|
const fingerprint = await implicitLinkerFingerprint(
|
|
{ argv: [linker], targetArgs: [], target: null },
|
|
toolchainEnvironmentFingerprint(),
|
|
linkArgs,
|
|
);
|
|
return fingerprintDependencyPaths(fingerprint);
|
|
}
|
|
|
|
let ccacheMemo: Promise<boolean> | null = null;
|
|
|
|
/** Reset process observations whose validity is bounded to one public native
|
|
* build. A long-lived caller may change PATH or install/remove ccache between
|
|
* invocations; the next build must probe that environment again. */
|
|
export function clearCcCaches(): void {
|
|
ccacheMemo = null;
|
|
}
|
|
|
|
function ccacheAvailable(): Promise<boolean> {
|
|
ccacheMemo ??= execFileAsync("ccache", ["--version"]).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
return ccacheMemo;
|
|
}
|
|
|
|
/** Content hash of every owned native source/header plus this backend's build
|
|
* recipe implementation. It keys complete artifacts, runtime objects, and the
|
|
* separately built vendor prerequisites, so two installed scriptc versions or
|
|
* worktrees can share a user cache without exchanging outputs produced from
|
|
* different vendored bytes or compile/archive recipes. Recursive enumeration
|
|
* also catches a newly added nested header that begins shadowing a system
|
|
* include, while content hashing catches same-size timestamp-preserving edits. */
|
|
async function runtimeFingerprintFresh(rtDir: string): Promise<string> {
|
|
const groups = await runtimeFingerprintInputGroups(rtDir);
|
|
const h = createHash("sha256")
|
|
.update("native-owned-inputs-v2\0")
|
|
.update(QJS_COMMIT).update(MBEDTLS_VERSION).update(ZLIB_VERSION)
|
|
.update("\0backend-recipe\0");
|
|
for (const path of NATIVE_RECIPE_IMPLEMENTATION_PATHS) {
|
|
h.update(basename(path)).update("\0").update(await readFile(path)).update("\0");
|
|
}
|
|
for (const group of groups) {
|
|
for (const n of group.names) {
|
|
h.update(group.label).update("/").update(n).update("\0").update(await readFile(join(group.dir, n))).update("\0");
|
|
}
|
|
}
|
|
return h.digest("hex");
|
|
}
|
|
|
|
async function runtimeFingerprintInputGroups(
|
|
rtDir: string,
|
|
): Promise<{ label: string; dir: string; names: string[] }[]> {
|
|
return Promise.all(
|
|
[
|
|
{ label: "runtime", dir: rtDir },
|
|
{ label: "ryu", dir: join(rtDir, "..", "vendor", "ryu") },
|
|
{ label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") },
|
|
{ label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") },
|
|
{ label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") },
|
|
{ label: "curl", dir: join(rtDir, "..", "vendor", "curl") },
|
|
].map(async (group) => {
|
|
const names = (await nativeSourceFiles(group.dir, true))
|
|
.map((path) => relative(group.dir, path))
|
|
.sort();
|
|
return { ...group, names };
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function runtimeFingerprintInputPaths(rtDir: string): Promise<string[]> {
|
|
return [
|
|
...(await runtimeFingerprintInputGroups(rtDir)).flatMap((group) =>
|
|
group.names.map((name) => join(group.dir, name))
|
|
),
|
|
...NATIVE_RECIPE_IMPLEMENTATION_PATHS,
|
|
];
|
|
}
|
|
|
|
const stableRuntimeFingerprintMemos = new Map<string, Promise<string>>();
|
|
export function runtimeFingerprint(rtDir: string): Promise<string> {
|
|
const key = resolve(rtDir);
|
|
return stableTestMemo(stableRuntimeFingerprintMemos, key, () => runtimeFingerprintFresh(rtDir));
|
|
}
|
|
|
|
const {
|
|
vendorEngineDir,
|
|
vendorTlsDir,
|
|
vendorCurlDir,
|
|
vendorBuildCacheRoot,
|
|
vendorCacheTargetFlavor,
|
|
vendorCacheBuildIdentity,
|
|
currentVendorCacheBuildIdentity,
|
|
engineArchivePath,
|
|
stageVendorInputs,
|
|
ensureEngineArchive,
|
|
lreObjectPaths,
|
|
ensureLreObjects,
|
|
vendorZlibDir,
|
|
zlibObjectPaths,
|
|
ensureZlibObjects,
|
|
curlStubDirPath,
|
|
ensureCurlStub,
|
|
tlsArchivePath,
|
|
ensureTlsArchive,
|
|
QJS_ENGINE_SOURCES,
|
|
LRE_SOURCES,
|
|
ZLIB_SOURCES,
|
|
} = createVendorArchives({
|
|
runtimeSrcDir,
|
|
targetPlatform,
|
|
isZigDriver,
|
|
resolvedToolIdentity,
|
|
runtimeFingerprint,
|
|
});
|
|
|
|
export { vendorCacheBuildIdentity, vendorCacheTargetFlavor };
|
|
|
|
class CacheInputsChangedError extends Error {
|
|
constructor() {
|
|
super("runtime inputs changed while populating the native object cache");
|
|
this.name = "CacheInputsChangedError";
|
|
}
|
|
}
|
|
|
|
|
|
interface LocalArtifactStamp {
|
|
version: 2;
|
|
key: string;
|
|
digest: string;
|
|
debugSymbolsDigest?: string;
|
|
dependencies: NativeArtifactDependency[];
|
|
integrity: string;
|
|
}
|
|
|
|
export interface NativeArtifactDependency {
|
|
path: string;
|
|
kind: "file" | "directory" | "symlink";
|
|
dev: number;
|
|
ino: number;
|
|
size: number;
|
|
mtimeMs: number;
|
|
ctimeMs: number;
|
|
/** Symlinks are identity-bearing paths whose target bytes also matter. */
|
|
targetPath?: string;
|
|
targetKind?: "file" | "directory";
|
|
targetDev?: number;
|
|
targetIno?: number;
|
|
targetSize?: number;
|
|
targetMtimeMs?: number;
|
|
targetCtimeMs?: number;
|
|
/** A directory's recursive namespace. This detects a new nested candidate
|
|
* that can begin shadowing an existing system/header dependency. */
|
|
treeDigest?: string;
|
|
treeExclusions?: string[];
|
|
}
|
|
|
|
async function directoryTreeDigest(
|
|
root: string,
|
|
excludedPaths: readonly string[] = [],
|
|
): Promise<string> {
|
|
const hash = createHash("sha256").update("native-dependency-tree-v1\0");
|
|
const visited = new Set<string>();
|
|
const excluded = excludedPaths.map((path) => resolve(path));
|
|
const walk = async (directory: string, relative: string): Promise<void> => {
|
|
const canonical = await realpath(directory);
|
|
if (visited.has(canonical)) {
|
|
hash.update(relative).update("\0cycle\0").update(canonical).update("\0");
|
|
return;
|
|
}
|
|
visited.add(canonical);
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const entry of entries) {
|
|
const path = join(directory, entry.name);
|
|
const absolute = resolve(path);
|
|
if (excluded.some((candidate) =>
|
|
absolute === candidate ||
|
|
absolute.startsWith(`${candidate}/`) ||
|
|
absolute.startsWith(`${candidate}\\`)
|
|
)) continue;
|
|
const child = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
const info = await lstat(path);
|
|
const kind = info.isDirectory()
|
|
? "directory"
|
|
: info.isFile()
|
|
? "file"
|
|
: info.isSymbolicLink()
|
|
? "symlink"
|
|
: "other";
|
|
hash.update(child).update("\0").update(kind).update("\0");
|
|
if (kind === "symlink") {
|
|
const target = await realpath(path).catch(() => "<missing>");
|
|
hash.update(target).update("\0");
|
|
const targetInfo = await stat(path).catch(() => null);
|
|
if (targetInfo?.isDirectory()) await walk(path, child);
|
|
}
|
|
if (kind === "directory") await walk(path, child);
|
|
}
|
|
visited.delete(canonical);
|
|
};
|
|
await walk(root, "");
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
function localDependencyKind(
|
|
info: Awaited<ReturnType<typeof lstat>>,
|
|
): NativeArtifactDependency["kind"] | null {
|
|
return info.isFile()
|
|
? "file"
|
|
: info.isDirectory()
|
|
? "directory"
|
|
: info.isSymbolicLink()
|
|
? "symlink"
|
|
: null;
|
|
}
|
|
|
|
async function snapshotLocalArtifactDependency(
|
|
path: string,
|
|
treeExclusions: readonly string[] | null = null,
|
|
): Promise<NativeArtifactDependency> {
|
|
const info = await lstat(path);
|
|
const kind = localDependencyKind(info);
|
|
if (kind === null) throw new Error(`unsupported local artifact dependency: ${path}`);
|
|
const dependency: NativeArtifactDependency = {
|
|
path,
|
|
kind,
|
|
dev: info.dev,
|
|
ino: info.ino,
|
|
size: info.size,
|
|
mtimeMs: info.mtimeMs,
|
|
ctimeMs: info.ctimeMs,
|
|
};
|
|
if (kind === "directory" && treeExclusions !== null) {
|
|
dependency.treeExclusions = [...new Set(treeExclusions.map((entry) => resolve(entry)))].sort();
|
|
dependency.treeDigest = await directoryTreeDigest(path, dependency.treeExclusions);
|
|
}
|
|
if (kind === "symlink") {
|
|
const targetPath = await realpath(path);
|
|
const target = await stat(path);
|
|
const targetKind = target.isFile() ? "file" : target.isDirectory() ? "directory" : null;
|
|
if (targetKind === null) throw new Error(`unsupported symlink target dependency: ${path}`);
|
|
dependency.targetPath = targetPath;
|
|
dependency.targetKind = targetKind;
|
|
dependency.targetDev = target.dev;
|
|
dependency.targetIno = target.ino;
|
|
dependency.targetSize = target.size;
|
|
dependency.targetMtimeMs = target.mtimeMs;
|
|
dependency.targetCtimeMs = target.ctimeMs;
|
|
if (targetKind === "directory" && treeExclusions !== null) {
|
|
dependency.treeExclusions = [...new Set(treeExclusions.map((entry) => resolve(entry)))].sort();
|
|
dependency.treeDigest = await directoryTreeDigest(path, dependency.treeExclusions);
|
|
}
|
|
}
|
|
return dependency;
|
|
}
|
|
|
|
async function snapshotLocalArtifactDependencies(
|
|
dependencyPaths: readonly string[],
|
|
recursiveDirectories: readonly string[] = [],
|
|
recursiveExclusions: readonly string[] = [],
|
|
): Promise<NativeArtifactDependency[]> {
|
|
const recursive = new Set(recursiveDirectories.map((path) => resolve(path)));
|
|
return Promise.all(
|
|
[...new Set(dependencyPaths)].sort().map((path) =>
|
|
snapshotLocalArtifactDependency(
|
|
path,
|
|
recursive.has(resolve(path)) ? recursiveExclusions : null,
|
|
)
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Capture exact filesystem identities for inputs produced outside the C
|
|
* toolchain but consumed by its cache proofs. Callers carry this snapshot
|
|
* forward so later stages can prove the same inputs remained installed. */
|
|
export async function snapshotNativeArtifactDependencies(
|
|
dependencyPaths: readonly string[],
|
|
): Promise<NativeArtifactDependency[]> {
|
|
return snapshotLocalArtifactDependencies(dependencyPaths);
|
|
}
|
|
|
|
export async function nativeArtifactDependenciesStillMatch(
|
|
dependencies: readonly NativeArtifactDependency[],
|
|
): Promise<boolean> {
|
|
if (!dependencies.every((dependency) =>
|
|
dependency !== null && typeof dependency === "object" &&
|
|
typeof dependency.path === "string" &&
|
|
(dependency.kind === "file" || dependency.kind === "directory" || dependency.kind === "symlink") &&
|
|
typeof dependency.dev === "number" && typeof dependency.ino === "number" &&
|
|
typeof dependency.size === "number" && typeof dependency.mtimeMs === "number" &&
|
|
typeof dependency.ctimeMs === "number" &&
|
|
(dependency.treeDigest === undefined || typeof dependency.treeDigest === "string") &&
|
|
(dependency.treeExclusions === undefined || (
|
|
Array.isArray(dependency.treeExclusions) &&
|
|
dependency.treeExclusions.every((path) => typeof path === "string")
|
|
)) &&
|
|
(dependency.kind !== "symlink" || (
|
|
typeof dependency.targetPath === "string" &&
|
|
(dependency.targetKind === "file" || dependency.targetKind === "directory") &&
|
|
typeof dependency.targetDev === "number" && typeof dependency.targetIno === "number" &&
|
|
typeof dependency.targetSize === "number" && typeof dependency.targetMtimeMs === "number" &&
|
|
typeof dependency.targetCtimeMs === "number"
|
|
))
|
|
)) return false;
|
|
return (await Promise.all(
|
|
dependencies.map(async (dependency) => {
|
|
const current = await snapshotLocalArtifactDependency(
|
|
dependency.path,
|
|
dependency.treeDigest === undefined ? null : dependency.treeExclusions ?? [],
|
|
).catch(() => null);
|
|
return current !== null && JSON.stringify(current) === JSON.stringify(dependency);
|
|
}),
|
|
)).every(Boolean);
|
|
}
|
|
|
|
interface NativeMetadataStamp {
|
|
version: 2;
|
|
key: string;
|
|
values: Record<string, string>;
|
|
dependencies: NativeArtifactDependency[];
|
|
integrity: string;
|
|
}
|
|
|
|
function nativeMetadataStampPath(root: string, key: string): string {
|
|
return join(root, "meta", createHash("sha256").update(key).digest("hex"));
|
|
}
|
|
|
|
function nativeMetadataStampIntegrity(
|
|
stamp: Pick<NativeMetadataStamp, "version" | "key" | "values" | "dependencies">,
|
|
): string {
|
|
return createHash("sha256")
|
|
.update("native-metadata-stamp-v2\0")
|
|
.update(JSON.stringify(stamp))
|
|
.digest("hex");
|
|
}
|
|
|
|
async function readNativeMetadataStamp(
|
|
root: string,
|
|
key: string,
|
|
): Promise<NativeMetadataStamp | null> {
|
|
try {
|
|
const stamp = JSON.parse(
|
|
await readFile(nativeMetadataStampPath(root, key), "utf8"),
|
|
) as NativeMetadataStamp;
|
|
if (
|
|
stamp.version !== 2 ||
|
|
stamp.key !== key ||
|
|
stamp.values === null ||
|
|
typeof stamp.values !== "object" ||
|
|
!Array.isArray(stamp.dependencies) ||
|
|
!/^[0-9a-f]{64}$/.test(stamp.integrity) ||
|
|
nativeMetadataStampIntegrity({
|
|
version: stamp.version,
|
|
key: stamp.key,
|
|
values: stamp.values,
|
|
dependencies: stamp.dependencies,
|
|
}) !== stamp.integrity ||
|
|
!(await nativeArtifactDependenciesStillMatch(stamp.dependencies))
|
|
) {
|
|
return null;
|
|
}
|
|
return stamp;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function publishNativeMetadataStamp(
|
|
root: string,
|
|
key: string,
|
|
values: Record<string, string>,
|
|
dependencyPaths: readonly string[],
|
|
fingerprints: readonly string[] = [],
|
|
): Promise<NativeMetadataStamp> {
|
|
const destination = nativeMetadataStampPath(root, key);
|
|
await mkdir(dirname(destination), { recursive: true });
|
|
const dependencies = await snapshotLocalArtifactDependencies(dependencyPaths);
|
|
if (!(await fingerprintDependenciesStillMatch(fingerprints))) {
|
|
throw new CacheInputsChangedError();
|
|
}
|
|
const unsigned = { version: 2, key, values, dependencies } as const;
|
|
const stamp: NativeMetadataStamp = {
|
|
...unsigned,
|
|
integrity: nativeMetadataStampIntegrity(unsigned),
|
|
};
|
|
const tmp = `${destination}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
try {
|
|
await writeFile(tmp, `${JSON.stringify(stamp)}\n`, { mode: 0o600 });
|
|
await rename(tmp, destination);
|
|
} finally {
|
|
await rm(tmp, { force: true }).catch(() => undefined);
|
|
}
|
|
return stamp;
|
|
}
|
|
|
|
function nativeMetadataKey(
|
|
kind: string,
|
|
parts: readonly (string | readonly string[])[],
|
|
): string {
|
|
const hash = createHash("sha256").update(`native-metadata-${kind}-v2\0`);
|
|
for (const part of parts) {
|
|
hash.update(typeof part === "string" ? part : part.join("\x1f")).update("\0");
|
|
}
|
|
return `${kind}-${hash.digest("hex")}`;
|
|
}
|
|
|
|
/** The caller-visible output is itself the cheapest safe cache tier. Once a
|
|
* generated TU has produced this exact binary, an unchanged rebuild need not
|
|
* rediscover every SDK header and linker input merely to copy equivalent bytes
|
|
* back onto the same path. This tier is deliberately narrower than the CAS:
|
|
* only frontend-generated programs with no caller-owned native inputs opt in.
|
|
* The generated TU bytes, every scriptc runtime source, the selected direct
|
|
* compiler inode, target/options/environment, and the output path all join the
|
|
* key. A digest rejects a modified/truncated output before the no-op hit. */
|
|
function localArtifactIdentity(
|
|
opts: CcOptions,
|
|
driver: CcDriver,
|
|
environmentFingerprint: string,
|
|
compilerIdentity: string,
|
|
runtimeHash: string,
|
|
programBytes: Buffer,
|
|
programShardMerge: string | null,
|
|
): string {
|
|
const normalizedOptions = Object.fromEntries(
|
|
Object.entries(opts)
|
|
// Shard source can be tens of megabytes. Hash it incrementally below
|
|
// instead of materializing a second giant JSON string solely for this
|
|
// output-local fast-path identity.
|
|
.filter(([key, value]) =>
|
|
value !== undefined && key !== "programShards" && key !== "programPublicSymbols"
|
|
)
|
|
.sort(([a], [b]) => a.localeCompare(b)),
|
|
);
|
|
const executableSectionFlags = executableSectionEliminationFlags(targetPlatform(driver));
|
|
const executableOptimizationFlags = executableOptimizationLinkerArgs(
|
|
targetPlatform(driver),
|
|
opts.optimization ?? "release",
|
|
);
|
|
const hash = createHash("sha256")
|
|
.update("local-artifact-v2\0")
|
|
.update(cacheTargetIdentity(driver)).update("\0")
|
|
.update(environmentFingerprint).update("\0")
|
|
.update(compilerIdentity).update("\0")
|
|
.update(runtimeHash).update("\0")
|
|
.update(driver.argv.join("\x1f")).update("\0")
|
|
.update(driver.targetArgs.join("\x1f")).update("\0")
|
|
.update(driver.linkArgs.join("\x1f")).update("\0")
|
|
.update(executableSectionFlags.compile.join("\x1f")).update("\0")
|
|
.update(executableSectionFlags.link.join("\x1f")).update("\0");
|
|
// Only release WASI currently adds an optimization-specific link flag.
|
|
// Salt that identity so a pre-fix DWARF-bearing output cannot hit, without
|
|
// invalidating unchanged native and dev artifacts on upgrade.
|
|
if (executableOptimizationFlags.length > 0) {
|
|
hash.update("optimization-linker-flags\0")
|
|
.update(executableOptimizationFlags.join("\x1f")).update("\0");
|
|
}
|
|
if (programShardMerge !== null) {
|
|
updateProgramShardCacheIdentity(
|
|
hash,
|
|
opts.programShards,
|
|
opts.programPublicSymbols,
|
|
programShardMerge,
|
|
);
|
|
}
|
|
return hash
|
|
.update(process.env["SCRIPTC_FETCH_CURL"] === "1" ? "fetch-curl" : "fetch-native").update("\0")
|
|
.update(JSON.stringify(normalizedOptions)).update("\0")
|
|
.update(resolve(opts.cPath)).update("\0")
|
|
.update(resolve(opts.outPath)).update("\0")
|
|
.update(programBytes)
|
|
.digest("hex");
|
|
}
|
|
|
|
function localArtifactStampPath(root: string, outPath: string): string {
|
|
const outputKey = createHash("sha256").update(resolve(outPath)).digest("hex");
|
|
return join(root, "local", outputKey);
|
|
}
|
|
|
|
function localArtifactStampIntegrity(
|
|
stamp: Pick<LocalArtifactStamp, "version" | "key" | "digest" | "dependencies" | "debugSymbolsDigest">,
|
|
): string {
|
|
return createHash("sha256")
|
|
.update("local-artifact-stamp-v2\0")
|
|
.update(JSON.stringify(stamp))
|
|
.digest("hex");
|
|
}
|
|
|
|
async function localArtifactHit(
|
|
stampPath: string,
|
|
outPath: string,
|
|
key: string,
|
|
darwinDebugSymbols = false,
|
|
): Promise<LocalArtifactStamp | null> {
|
|
try {
|
|
const stamp = JSON.parse(await readFile(stampPath, "utf8")) as Partial<LocalArtifactStamp>;
|
|
const output = await lstat(outPath);
|
|
const expectedMode = 0o777 & ~process.umask();
|
|
if (
|
|
stamp.version !== 2 ||
|
|
stamp.key !== key ||
|
|
!/^[0-9a-f]{64}$/.test(stamp.digest ?? "") ||
|
|
(darwinDebugSymbols !== (stamp.debugSymbolsDigest !== undefined)) ||
|
|
(stamp.debugSymbolsDigest !== undefined && !/^[0-9a-f]{64}$/.test(stamp.debugSymbolsDigest)) ||
|
|
!Array.isArray(stamp.dependencies) ||
|
|
!/^[0-9a-f]{64}$/.test(stamp.integrity ?? "") ||
|
|
localArtifactStampIntegrity({
|
|
version: stamp.version,
|
|
key: stamp.key,
|
|
digest: stamp.digest!,
|
|
dependencies: stamp.dependencies,
|
|
...(stamp.debugSymbolsDigest === undefined ? {} : { debugSymbolsDigest: stamp.debugSymbolsDigest }),
|
|
}) !== stamp.integrity ||
|
|
!output.isFile() ||
|
|
(output.mode & 0o777) !== expectedMode ||
|
|
stamp.dependencies.some((dependency) =>
|
|
dependency === null ||
|
|
typeof dependency !== "object" ||
|
|
typeof dependency.path !== "string" ||
|
|
dependency.kind !== "file" &&
|
|
dependency.kind !== "directory" &&
|
|
dependency.kind !== "symlink" ||
|
|
typeof dependency.dev !== "number" ||
|
|
typeof dependency.ino !== "number" ||
|
|
typeof dependency.size !== "number" ||
|
|
typeof dependency.mtimeMs !== "number" ||
|
|
typeof dependency.ctimeMs !== "number" ||
|
|
dependency.treeDigest !== undefined && typeof dependency.treeDigest !== "string" ||
|
|
dependency.kind === "symlink" && (
|
|
typeof dependency.targetPath !== "string" ||
|
|
dependency.targetKind !== "file" && dependency.targetKind !== "directory" ||
|
|
typeof dependency.targetDev !== "number" ||
|
|
typeof dependency.targetIno !== "number" ||
|
|
typeof dependency.targetSize !== "number" ||
|
|
typeof dependency.targetMtimeMs !== "number" ||
|
|
typeof dependency.targetCtimeMs !== "number"
|
|
)
|
|
) ||
|
|
!(await nativeArtifactDependenciesStillMatch(stamp.dependencies)) ||
|
|
await fileDigest(outPath) !== stamp.digest ||
|
|
darwinDebugSymbols && createHash("sha256").update(await readDarwinDebugSymbols(outPath)).digest("hex") !== stamp.debugSymbolsDigest
|
|
) {
|
|
return null;
|
|
}
|
|
const now = new Date();
|
|
await utimes(stampPath, now, now).catch(() => undefined);
|
|
return stamp as LocalArtifactStamp;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function publishLocalArtifactStamp(
|
|
stampPath: string,
|
|
outPath: string,
|
|
key: string,
|
|
dependencyPaths: readonly string[],
|
|
recursiveDirectories: readonly string[] = [],
|
|
recursiveExclusions: readonly string[] = [],
|
|
darwinDebugSymbols = false,
|
|
): Promise<LocalArtifactStamp> {
|
|
await mkdir(dirname(stampPath), { recursive: true });
|
|
const tmp = `${stampPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
try {
|
|
const dependencies = await snapshotLocalArtifactDependencies(
|
|
dependencyPaths,
|
|
recursiveDirectories,
|
|
recursiveExclusions,
|
|
);
|
|
const unsigned = {
|
|
version: 2,
|
|
key,
|
|
digest: await fileDigest(outPath),
|
|
dependencies,
|
|
...(darwinDebugSymbols
|
|
? { debugSymbolsDigest: createHash("sha256").update(await readDarwinDebugSymbols(outPath)).digest("hex") }
|
|
: {}),
|
|
} as const;
|
|
const stamp: LocalArtifactStamp = {
|
|
...unsigned,
|
|
integrity: localArtifactStampIntegrity(unsigned),
|
|
};
|
|
await writeFile(tmp, `${JSON.stringify(stamp)}\n`, { mode: 0o600 });
|
|
await rename(tmp, stampPath);
|
|
return stamp;
|
|
} finally {
|
|
await rm(tmp, { force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/** The cached .o set for one flag flavor, compiled on first need. Concurrent
|
|
* first builds (parallel test workers on a cold cache) may duplicate work;
|
|
* per-file atomic renames make every winner equivalent. Publication is held
|
|
* until verifyInputs confirms that the source/header fingerprint used by the
|
|
* key still describes the bytes clang just read. */
|
|
async function ensureRuntimeObjects(
|
|
root: string,
|
|
ccArgv: string[],
|
|
cflags: string[],
|
|
sources: string[],
|
|
keyPrefix: string,
|
|
verifyInputs: () => Promise<boolean>,
|
|
protectedPaths?: Set<string>,
|
|
): Promise<Map<string, string>> {
|
|
const setKey = createHash("sha256")
|
|
.update(keyPrefix)
|
|
.update(cflags.join("\x1f"))
|
|
.digest("hex")
|
|
.slice(0, 24);
|
|
const objDir = join(root, "obj", setKey);
|
|
const objOf = (src: string): string => join(objDir, `${basename(src, ".c")}.o`);
|
|
if (protectedPaths !== undefined) {
|
|
for (const source of sources) {
|
|
const object = objOf(source);
|
|
protectedPaths.add(object);
|
|
protectedPaths.add(cacheDigestPath(object));
|
|
}
|
|
}
|
|
const present = await Promise.all(sources.map((s) => validCachedFile(objOf(s))));
|
|
const missing = sources.filter((_, i) => !present[i]);
|
|
if (missing.length > 0) {
|
|
// ccache wraps only the default clang driver — multi-word drivers
|
|
// (`zig cc`) run bare; their object sets are keyed apart anyway.
|
|
const useCcache =
|
|
process.env["SCRIPTC_TEST_DISABLE_CCACHE"] !== "1" &&
|
|
ccArgv.length === 1 &&
|
|
ccArgv[0] === "clang" &&
|
|
(await ccacheAvailable());
|
|
await mkdir(objDir, { recursive: true });
|
|
const tmpDir = await mkdtemp(join(tmpdir(), "scriptc-cache-obj-"));
|
|
try {
|
|
const compiled = new Map<string, string>();
|
|
// Modest parallelism: a flavor's objects build once, but several cold
|
|
// workers can race here — keep each build's CPU footprint small.
|
|
const width = 4;
|
|
for (let i = 0; i < missing.length; i += width) {
|
|
await Promise.all(
|
|
missing.slice(i, i + width).map(async (src) => {
|
|
const tmpObj = join(tmpDir, `${basename(src, ".c")}.o`);
|
|
const argv = [...(useCcache ? ["ccache"] : []), ...ccArgv, ...cflags, "-c", src, "-o", tmpObj];
|
|
await execFileAsync(argv[0] ?? "clang", argv.slice(1), useCcache
|
|
? {
|
|
// ccache direct mode remembers only the headers selected by
|
|
// its previous manifest and can miss a newly created,
|
|
// higher-priority header. The scriptc object-set key already
|
|
// includes the recursive runtime namespace fingerprint, so
|
|
// carry it into ccache's own keyspace as well.
|
|
env: {
|
|
...process.env,
|
|
CCACHE_NAMESPACE: [
|
|
process.env["CCACHE_NAMESPACE"],
|
|
`scriptc-${setKey}`,
|
|
].filter((value) => value !== undefined && value !== "").join(":"),
|
|
},
|
|
}
|
|
: undefined);
|
|
compiled.set(src, tmpObj);
|
|
}),
|
|
);
|
|
}
|
|
// The fingerprint was computed before these subprocesses started. Do
|
|
// not place their outputs under that key if a checkout/package update
|
|
// changed any runtime source or included header while clang was reading.
|
|
if (!(await verifyInputs())) throw new CacheInputsChangedError();
|
|
for (const [src, built] of compiled) {
|
|
const destination = objOf(src);
|
|
await publishCachedFile(built, destination);
|
|
}
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
if (!(await verifyInputs())) throw new CacheInputsChangedError();
|
|
const objects = new Map(sources.map((s) => [s, objOf(s)]));
|
|
if (!(await Promise.all([...objects.values()].map(validCachedFile))).every(Boolean)) {
|
|
throw new Error("native object cache integrity check failed");
|
|
}
|
|
return objects;
|
|
}
|
|
|
|
/** Give one active link/archive operation private names for its cached
|
|
* runtime objects. A hard link keeps the inode alive if another process's LRU
|
|
* sweep unlinks the cache entry; filesystems that cannot hard-link across the
|
|
* cache/tmp boundary fall back to a copy. If eviction wins before staging,
|
|
* the caller catches the read failure and performs a fully fresh compile. */
|
|
export async function stageRuntimeObjects(
|
|
objects: ReadonlyMap<string, string>,
|
|
stageDir: string,
|
|
): Promise<Map<string, string>> {
|
|
await mkdir(stageDir, { recursive: true });
|
|
const now = new Date();
|
|
const staged = await Promise.all(
|
|
[...objects].map(async ([source, object]) => {
|
|
const destination = join(stageDir, basename(object));
|
|
try {
|
|
await link(object, destination);
|
|
} catch {
|
|
await copyFile(object, destination);
|
|
}
|
|
// Object files participate in the same mtime-based LRU as complete
|
|
// artifacts. A successful stage is a cache read, so promote the source
|
|
// name best-effort (it may have raced an eviction after the hard link).
|
|
await utimes(object, now, now).catch(() => undefined);
|
|
return [source, destination] as const;
|
|
}),
|
|
);
|
|
return new Map(staged);
|
|
}
|
|
|
|
|
|
/** Compiles one C program together with the runtime sources.
|
|
* With caching disabled, the runtime (a dozen small files) is recompiled on
|
|
* every build in one clang invocation — no cached-archive
|
|
* staleness bugs. --dynamic additionally compiles
|
|
* scr_island.c under SCR_DYNAMIC and links the cached engine archive (built
|
|
* lazily, see above); regex-using programs additionally compile scr_regex.c
|
|
* and link libregexp (the cached objects, or the archive's own copy under
|
|
* --dynamic). Executable links use the target's section-elimination recipe
|
|
* independently of those feature gates.
|
|
*
|
|
* With a caller-supplied dependency identity and an enabled cache root,
|
|
* unchanged programs skip payload code generation/linking via the binary
|
|
* cache after lightweight metadata probes, and misses link the program's own
|
|
* TU against cached per-flavor runtime objects. */
|
|
async function compileCInternal(
|
|
opts: CcOptions,
|
|
cacheWarmOnly: boolean,
|
|
cacheWarmPaths?: Set<string>,
|
|
): Promise<void> {
|
|
const rtDir = runtimeSrcDir();
|
|
const sanitize = opts.sanitize ?? false;
|
|
const optimization = opts.optimization ?? "release";
|
|
const dynamic = opts.dynamic ?? false;
|
|
const regex = opts.regex ?? false;
|
|
// fetch's implementation switch: the default is the NATIVE bridge
|
|
// (scr_fetch.c over scr_net + scr_tls + scr_http's client parser +
|
|
// zlib — no libcurl anywhere), which implies the socket units into the
|
|
// link. SCRIPTC_FETCH_CURL=1 keeps the retired curl reference
|
|
// (scr_fetch_curl.c + system libcurl / the linux soname stub)
|
|
// compilable for one release as the flip's reference.
|
|
const fetchOn = opts.fetch ?? false;
|
|
// The retired curl bridge has only a SCR_DYNAMIC implementation.
|
|
// Static fetch always keeps the native runtime even when a developer
|
|
// has the comparison switch exported in their shell.
|
|
const curlFetch =
|
|
dynamic && fetchOn && process.env["SCRIPTC_FETCH_CURL"] === "1";
|
|
const nativeFetch = fetchOn && !curlFetch;
|
|
// The island's node:http/https client bridge: embedded graphs that
|
|
// import those builtins get working clients over the same socket units
|
|
// (native-fetch builds always carry it — scr_fetch_install registers it).
|
|
const netIsland = dynamic && ((opts.netIsland ?? false) || nativeFetch);
|
|
const net = (opts.net ?? false) || nativeFetch || netIsland;
|
|
const http = (opts.http ?? false) || nativeFetch || netIsland;
|
|
const tls = (opts.tls ?? false) || nativeFetch || netIsland;
|
|
const tlsCa = (opts.tlsCa ?? false) || tls;
|
|
const driver = resolveCc();
|
|
const darwinDebugSymbols = needsDarwinDebugSymbols(targetPlatform(driver), optimization, opts.strip);
|
|
if (opts.frameworks?.length && targetPlatform(driver) !== "darwin") throw new Error("FFI frameworks require a Darwin target");
|
|
if (opts.frameworks?.some(name => !/^[A-Za-z][A-Za-z0-9_]*$/.test(name))) throw new Error("Invalid FFI framework name");
|
|
const debugFlags = optimization === "dev" && !opts.strip
|
|
? ["-gline-tables-only", ...(opts.cPath.endsWith(".ll") ? [] : ["-gno-column-info"])]
|
|
: [];
|
|
const shardNames = new Set<string>();
|
|
const programShardsValid = opts.programShards?.every((shard) => {
|
|
if (
|
|
basename(shard.name) !== shard.name || !shard.name.endsWith(".ll") ||
|
|
shardNames.has(shard.name)
|
|
) return false;
|
|
shardNames.add(shard.name);
|
|
return true;
|
|
}) === true;
|
|
const programShardsRequested =
|
|
optimization === "dev" && !sanitize && opts.cPath.endsWith(".ll") &&
|
|
programShardsValid && opts.programShards !== undefined && opts.programShards.length > 1 &&
|
|
opts.programPublicSymbols !== undefined
|
|
? opts.programShards
|
|
: null;
|
|
const programShardMergeIdentity = programShardsRequested === null
|
|
? null
|
|
: await resolveProgramShardMergeIdentity(driver);
|
|
const programShards = programShardMergeIdentity === null ? null : programShardsRequested;
|
|
const programPublicSymbols = programShards === null ? undefined : opts.programPublicSymbols;
|
|
// Mobile triples produce library archives, never standalone executables:
|
|
// the executable-lane runtime (event loop, sockets, child processes) is
|
|
// not verified on those device classes. compile() reports the SC3002
|
|
// diagnostic first; this is the backstop for direct compileC callers.
|
|
if (isMobileTarget(driver.target)) {
|
|
throw new Error(
|
|
`SCRIPTC_TARGET=${driver.target} builds library-mode static archives only — ` +
|
|
`compile with a library profile (SCRIPTC_CC=zigcc scriptc build --lib --profile <profile.json>) and link the archive from the app project.`,
|
|
);
|
|
}
|
|
const runtimeSources = targetPlatform(driver) === "wasi"
|
|
? EXECUTABLE_RUNTIME_SOURCES.filter((source) => source !== "scr_child.c")
|
|
: EXECUTABLE_RUNTIME_SOURCES;
|
|
const executableSectionFlags = executableSectionEliminationFlags(targetPlatform(driver));
|
|
const windowsSubsystemArgs = windowsSubsystemLinkerArgs(targetPlatform(driver), opts.windowsSubsystem);
|
|
const executableLinkFlags = [
|
|
...executableSectionFlags.link,
|
|
...executableOptimizationLinkerArgs(
|
|
targetPlatform(driver),
|
|
optimization,
|
|
),
|
|
...executableStripLinkerArgs(targetPlatform(driver), opts.strip ?? false),
|
|
...windowsSubsystemArgs,
|
|
];
|
|
// scr_async.c submits callback-style filesystem work to a native worker.
|
|
// POSIX drivers need the thread compile/link mode; win32 uses CreateThread.
|
|
const threadArgs = targetPlatform(driver) === "win32" || targetPlatform(driver) === "wasi"
|
|
? []
|
|
: ["-pthread"];
|
|
if (driver.target !== null) {
|
|
// See the resolveCc block: these inputs are built on and for the HOST
|
|
// (vendored archives, system libs). Regex, zlib, and the engine archive
|
|
// are NOT here: their vendored sources are plain C that ensureLreObjects
|
|
// / ensureZlibObjects / buildEngineArchiveDirect compile per target with
|
|
// the driver itself — win32 included (the Windows lane runs the
|
|
// @dynamic corpus and the zlib program against the box's Node). The
|
|
// NATIVE fetch rides the socket units and cross-compiles with them —
|
|
// no gate; only the retired curl REFERENCE (SCRIPTC_FETCH_CURL=1)
|
|
// keeps a linux-only arm (the vendored curl headers + the generated
|
|
// soname stub, ensureCurlStub — win32 has no system libcurl contract
|
|
// to bind at load time). The event-loop units, tls, and dgram cross-compile to Linux
|
|
// AND Windows (scr_platform.h poller + per-target mbedTLS; the
|
|
// loop's win32 arm is WSAPoll, scr_loop_wsapoll.c, and the socket
|
|
// units' win32 arms respell winsock behind POSIX-errno wrappers —
|
|
// scr_net.c/scr_dgram.c/scr_tls.c). The events unit cross-compiles
|
|
// everywhere: its win32 arm is CRT signal() +
|
|
// PeekNamedPipe/WaitForSingleObject probes (scr_events.c), served by
|
|
// the loop's capped win32 idle sleep (scr_async.c).
|
|
const unsupported = (
|
|
[
|
|
// The NATIVE fetch cross-compiles wherever the socket units do
|
|
// (linux and win32 both); only the retired curl reference keeps
|
|
// its linux-only soname-stub arm.
|
|
["fetch (SCRIPTC_FETCH_CURL)", curlFetch && targetPlatform(driver) !== "linux"],
|
|
] as const
|
|
)
|
|
.filter(([, on]) => on)
|
|
.map(([name]) => name);
|
|
if (unsupported.length > 0) {
|
|
throw new Error(
|
|
`SCRIPTC_TARGET=${driver.target}: ${unsupported.join(", ")} not supported under a cross target yet ` +
|
|
`(host-built vendor archives / system libs — see docs/linux-port.md).`,
|
|
);
|
|
}
|
|
}
|
|
const cachePolicy = toolchainEnvironmentCachePolicy();
|
|
const configuredCacheRoot = cacheRootDir();
|
|
const toolchainEnv = toolchainEnvironmentFingerprint();
|
|
const persistentDriverCache =
|
|
cachePolicy.runtimeObjects &&
|
|
configuredCacheRoot !== null &&
|
|
await compilerDriverSupportsPersistentCache(driver, toolchainEnv);
|
|
// Only compiler-generated TUs opt in. Arbitrary `compileC` inputs
|
|
// inputs may include caller-owned headers whose contents are not otherwise
|
|
// represented in this key, so they retain the fully uncached historical
|
|
// path unless the caller supplies its own complete dependency identity.
|
|
const cacheIdentity = opts.cacheIdentity;
|
|
let persistentCache: { root: string; identity: string } | null =
|
|
cacheIdentity === undefined || configuredCacheRoot === null || !persistentDriverCache
|
|
? null
|
|
: { root: configuredCacheRoot, identity: cacheIdentity };
|
|
if (cacheWarmOnly && persistentCache === null) {
|
|
throw new Error(
|
|
"native cache warming requires a persistently cacheable compiler environment",
|
|
);
|
|
}
|
|
if (persistentCache !== null) {
|
|
try {
|
|
await ensurePrivateCacheRoot(
|
|
persistentCache.root,
|
|
process.env["SCRIPTC_CACHE_DIR"] === undefined,
|
|
);
|
|
} catch (error) {
|
|
if (cacheWarmOnly) {
|
|
throw new Error("native cache warming could not prepare the persistent cache root", {
|
|
cause: error,
|
|
});
|
|
}
|
|
persistentCache = null;
|
|
}
|
|
}
|
|
let localArtifact: {
|
|
stampPath: string;
|
|
key: string;
|
|
runtimeHash: string;
|
|
programBytes: Buffer;
|
|
compilerPath: string;
|
|
} | null = null;
|
|
// Generated executable TUs are closed over scriptc's own runtime tree. A
|
|
// same-output rebuild can therefore check those bytes directly before the
|
|
// broader cross-output CAS performs its compiler/SDK/linker rediscovery.
|
|
// FFI/native-input builds and the public arbitrary-C cache API stay on the
|
|
// strict path because their dependency graphs are caller-owned.
|
|
if (
|
|
!cacheWarmOnly &&
|
|
persistentCache !== null &&
|
|
cachePolicy.completeArtifacts &&
|
|
persistentCache.identity === "scriptc-generated-v1" &&
|
|
(opts.linkInputs?.length ?? 0) === 0 &&
|
|
(opts.frameworks?.length ?? 0) === 0 &&
|
|
(opts.systemLibraries?.length ?? 0) === 0 &&
|
|
process.env["SCRIPTC_TEST_TRUST_COMPILER_WRAPPER"] !== "1"
|
|
) {
|
|
try {
|
|
const [compiler, runtimeHash, programBytes] = await Promise.all([
|
|
resolvedTool(driver.argv[0] ?? "clang"),
|
|
runtimeFingerprint(rtDir),
|
|
readFile(opts.cPath),
|
|
]);
|
|
if (compiler !== null) {
|
|
const effectiveCompiler = directCompilerSelections.get(
|
|
compilerDriverProbeKey(driver, toolchainEnv),
|
|
) ?? compiler;
|
|
const key = localArtifactIdentity(
|
|
opts,
|
|
driver,
|
|
toolchainEnv,
|
|
`${compiler.cacheIdentity}\0${effectiveCompiler.cacheIdentity}`,
|
|
runtimeHash,
|
|
programBytes,
|
|
programShardMergeIdentity,
|
|
);
|
|
const stampPath = localArtifactStampPath(persistentCache.root, opts.outPath);
|
|
localArtifact = {
|
|
stampPath,
|
|
key,
|
|
runtimeHash,
|
|
programBytes,
|
|
compilerPath: effectiveCompiler.canonicalPath,
|
|
};
|
|
const hit = await localArtifactHit(stampPath, opts.outPath, key, darwinDebugSymbols);
|
|
if (hit !== null) {
|
|
await opts.onArtifactReady?.({ dependencies: hit.dependencies }).catch(() => undefined);
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// The output-local tier is only an optimization; the fully validated
|
|
// CAS below remains the source of truth on any metadata trouble.
|
|
localArtifact = null;
|
|
}
|
|
}
|
|
let implicitToolchain: string | null = null;
|
|
let implicitCompileToolchain: string | null = null;
|
|
let toolchainMetadataStamp: NativeMetadataStamp | null = null;
|
|
const metadataCompiler = persistentCache === null
|
|
? null
|
|
: await resolvedTool(driver.argv[0] ?? "clang");
|
|
const metadataEffectiveCompiler = directCompilerSelections.get(
|
|
compilerDriverProbeKey(driver, toolchainEnv),
|
|
) ?? metadataCompiler;
|
|
const toolchainMetadataKey =
|
|
persistentCache === null ||
|
|
metadataCompiler === null ||
|
|
metadataEffectiveCompiler === null ||
|
|
process.env["SCRIPTC_TEST_TRUST_COMPILER_WRAPPER"] === "1"
|
|
? null
|
|
: nativeMetadataKey("toolchain", [
|
|
cacheTargetIdentity(driver),
|
|
toolchainEnv,
|
|
driver.argv,
|
|
driver.targetArgs,
|
|
metadataCompiler.cacheIdentity,
|
|
metadataEffectiveCompiler.cacheIdentity,
|
|
rtDir,
|
|
]);
|
|
if (persistentDriverCache) {
|
|
try {
|
|
toolchainMetadataStamp =
|
|
persistentCache === null || toolchainMetadataKey === null
|
|
? null
|
|
: await readNativeMetadataStamp(persistentCache.root, toolchainMetadataKey);
|
|
implicitToolchain = toolchainMetadataStamp?.values["implicitToolchain"] ?? null;
|
|
implicitCompileToolchain =
|
|
toolchainMetadataStamp?.values["implicitCompileToolchain"] ?? null;
|
|
if (
|
|
implicitToolchain === null ||
|
|
(programShards !== null && implicitCompileToolchain === null)
|
|
) {
|
|
const fingerprints = await implicitToolchainFingerprints(driver, toolchainEnv);
|
|
implicitToolchain = fingerprints.complete;
|
|
implicitCompileToolchain = fingerprints.compile;
|
|
toolchainMetadataStamp = null;
|
|
}
|
|
let compilerVersion = toolchainMetadataStamp?.values["compilerVersion"];
|
|
if (compilerVersion === undefined) {
|
|
compilerVersion = await ccVersion(driver.argv, toolchainEnv, true);
|
|
}
|
|
if (
|
|
toolchainMetadataStamp === null &&
|
|
persistentCache !== null &&
|
|
toolchainMetadataKey !== null
|
|
) {
|
|
if (metadataCompiler !== null && metadataEffectiveCompiler !== null) {
|
|
toolchainMetadataStamp = await publishNativeMetadataStamp(
|
|
persistentCache.root,
|
|
toolchainMetadataKey,
|
|
{
|
|
implicitToolchain,
|
|
...(implicitCompileToolchain === null ? {} : { implicitCompileToolchain }),
|
|
compilerVersion,
|
|
},
|
|
[
|
|
metadataCompiler.canonicalPath,
|
|
metadataEffectiveCompiler.canonicalPath,
|
|
...fingerprintDependencyPaths(implicitToolchain),
|
|
...(implicitCompileToolchain === null
|
|
? []
|
|
: fingerprintDependencyPaths(implicitCompileToolchain)),
|
|
],
|
|
[
|
|
implicitToolchain,
|
|
...(implicitCompileToolchain === null ? [] : [implicitCompileToolchain]),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Cache discovery is best-effort. In particular, a compiler wrapper can
|
|
// compile successfully without implementing the metadata probes.
|
|
if (cacheWarmOnly) {
|
|
throw new Error("native cache warming could not validate the compiler toolchain", {
|
|
cause: error,
|
|
});
|
|
}
|
|
persistentCache = null;
|
|
}
|
|
}
|
|
const vendorBuildIdentity = await currentVendorCacheBuildIdentity(
|
|
driver,
|
|
`${toolchainEnv}\0${implicitToolchain ?? "<uncached>"}`,
|
|
);
|
|
// Mutable include/SDK/config inputs can change behind a stable environment
|
|
// spelling. The complete/runtime cache is disabled below; vendor objects
|
|
// must follow the same rule instead of silently surviving in the user cache.
|
|
// A private root gives this invocation the usual vendor build recipe
|
|
// without publishing or reusing those prerequisites.
|
|
const transientVendorRoot = persistentCache !== null && implicitToolchain !== null
|
|
? null
|
|
: join(
|
|
tmpdir(),
|
|
`scriptc-vendor-${process.pid}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
const vendorCacheRoot = transientVendorRoot ?? vendorBuildCacheRoot(persistentCache?.root);
|
|
// Every vendor output path is deterministic from pins, flags, driver, and
|
|
// target. Build the command/key from those paths now, but do not materialize
|
|
// them until a complete-binary lookup has missed.
|
|
let engineArchive = dynamic
|
|
? engineArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
|
|
: null;
|
|
let tlsArchive = tls
|
|
? tlsArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
|
|
: null;
|
|
// --dynamic + regex shares the archive's libregexp (its host hooks and
|
|
// ours would collide; see scr_regex.c) — the standalone objects are for
|
|
// static builds only.
|
|
let lreObjects = regex && !dynamic
|
|
? lreObjectPaths(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
|
|
: [];
|
|
// Vendored zlib is the Zig story — default host-clang builds keep the exact
|
|
// historical `-lz` system link (see CcOptions.zlib). The native fetch's
|
|
// gzip decoder rides the same objects/link.
|
|
let zlibObjects =
|
|
((opts.zlib ?? false) || nativeFetch) && isZigDriver(driver)
|
|
? zlibObjectPaths(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
|
|
: [];
|
|
// The libcurl import stub is likewise CROSS-only — host builds keep the
|
|
// exact historical system `-lcurl` link (see CcOptions.fetch). Curl
|
|
// reference builds only.
|
|
let curlStubDir =
|
|
curlFetch && driver.target !== null
|
|
? curlStubDirPath(driver, vendorBuildIdentity, vendorCacheRoot)
|
|
: null;
|
|
const materializeVendorPrerequisites = async (
|
|
stageRoot?: string,
|
|
materializeCacheRoot: string = vendorCacheRoot,
|
|
): Promise<void> => {
|
|
// Preserve source order to avoid multiplying first-build resource
|
|
// pressure when several large vendor sets are cold simultaneously.
|
|
if (dynamic) {
|
|
const cachedArchive = engineArchivePath(
|
|
sanitize,
|
|
driver,
|
|
vendorBuildIdentity,
|
|
materializeCacheRoot,
|
|
);
|
|
protectCachedArtifact(cacheWarmPaths, cachedArchive);
|
|
const materialize = async (): Promise<string[]> => [
|
|
await ensureEngineArchive(sanitize, driver, vendorBuildIdentity, materializeCacheRoot),
|
|
];
|
|
const paths = stageRoot === undefined
|
|
? await materialize()
|
|
: await stageVendorInputs(materialize, join(stageRoot, "engine"));
|
|
engineArchive = paths[0]!;
|
|
}
|
|
if (tls) {
|
|
const cachedArchive = tlsArchivePath(
|
|
sanitize,
|
|
driver,
|
|
vendorBuildIdentity,
|
|
materializeCacheRoot,
|
|
);
|
|
protectCachedArtifact(cacheWarmPaths, cachedArchive);
|
|
const materialize = async (): Promise<string[]> => [
|
|
await ensureTlsArchive(sanitize, driver, vendorBuildIdentity, materializeCacheRoot),
|
|
];
|
|
const paths = stageRoot === undefined
|
|
? await materialize()
|
|
: await stageVendorInputs(materialize, join(stageRoot, "tls"));
|
|
tlsArchive = paths[0]!;
|
|
}
|
|
if (regex && !dynamic) {
|
|
for (const object of lreObjectPaths(
|
|
sanitize,
|
|
driver,
|
|
vendorBuildIdentity,
|
|
materializeCacheRoot,
|
|
)) protectCachedArtifact(cacheWarmPaths, object);
|
|
const materialize = async (): Promise<string[]> =>
|
|
await ensureLreObjects(sanitize, driver, vendorBuildIdentity, materializeCacheRoot);
|
|
lreObjects = stageRoot === undefined
|
|
? await materialize()
|
|
: await stageVendorInputs(materialize, join(stageRoot, "lre"));
|
|
}
|
|
if (zlibObjects.length > 0) {
|
|
for (const object of zlibObjectPaths(
|
|
sanitize,
|
|
driver,
|
|
vendorBuildIdentity,
|
|
materializeCacheRoot,
|
|
)) protectCachedArtifact(cacheWarmPaths, object);
|
|
const materialize = async (): Promise<string[]> =>
|
|
await ensureZlibObjects(sanitize, driver, vendorBuildIdentity, materializeCacheRoot);
|
|
zlibObjects = stageRoot === undefined
|
|
? await materialize()
|
|
: await stageVendorInputs(materialize, join(stageRoot, "zlib"));
|
|
}
|
|
if (curlStubDir !== null) {
|
|
protectCachedArtifact(cacheWarmPaths, join(
|
|
curlStubDirPath(driver, vendorBuildIdentity, materializeCacheRoot),
|
|
"libcurl.so",
|
|
));
|
|
const materialize = async (): Promise<string[]> => [
|
|
join(await ensureCurlStub(driver, vendorBuildIdentity, materializeCacheRoot), "libcurl.so"),
|
|
];
|
|
const paths = stageRoot === undefined
|
|
? await materialize()
|
|
: await stageVendorInputs(materialize, join(stageRoot, "curl"));
|
|
curlStubDir = dirname(paths[0]!);
|
|
}
|
|
};
|
|
// rt() maps each runtime source's path on the command line: identity for
|
|
// the historical single invocation, cached-.o substitution on cache misses.
|
|
const buildArgs = (
|
|
rt: (path: string) => string,
|
|
build: {
|
|
programPath?: string;
|
|
outPath?: string;
|
|
compilerVisibleSource?: string;
|
|
} = {},
|
|
): string[] => [
|
|
"-std=c11",
|
|
...debugFlags,
|
|
...driver.targetArgs,
|
|
...threadArgs,
|
|
...(sanitize
|
|
? [optimization === "dev" ? "-O0" : "-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"]
|
|
: [optimization === "dev" ? "-O0" : "-O2"]),
|
|
...executableSectionFlags.compile,
|
|
...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []),
|
|
"-fno-math-errno",
|
|
// The runtime object model uses type-punned C: a hierarchy
|
|
// upcast is a raw pointer cast, so one object's header (rc, vt) and
|
|
// fields are read and written through BOTH the base and derived struct
|
|
// types (sc_retain_Derived vs sc_release_Base on the same object).
|
|
// C's effective-type rule calls that UB, and clang's TBAA at -O2
|
|
// reorders/elides the rc updates once everything inlines — an upcast
|
|
// identity compare frees the object while a global still owns it.
|
|
// The LLVM backend emits no TBAA metadata; this flag preserves
|
|
// matching memory semantics in the runtime. Mirrored in the
|
|
// cache-miss cflags below and compileLibArchive — the three option
|
|
// sets must stay in lockstep.
|
|
"-fno-strict-aliasing",
|
|
"-Wno-deprecated-declarations", // ucontext fibers (scr_async.c)
|
|
"-I", rtDir,
|
|
...runtimeSources.map((f) => rt(join(rtDir, f))),
|
|
...(opts.copying ? [rt(join(rtDir, "scr_copying.c"))] : []),
|
|
...(opts.fileHandle ? [rt(join(rtDir, "scr_file_handle.c"))] : []),
|
|
// win32 targets compile the libc-shim TU (stpcpy, arc4random_buf,
|
|
// gmtime_r, strcasestr — the _WIN32 block in scr_runtime.h declares
|
|
// them) and link advapi32 (the CSPRNG RtlGenRandom/SystemFunction036,
|
|
// GetUserNameA), iphlpapi (GetAdaptersAddresses behind
|
|
// os.networkInterfaces), and ws2_32 (inet_ntop there; the socket
|
|
// units ride the same import). Never present on the default path, so
|
|
// the historical line cannot change.
|
|
...(targetPlatform(driver) === "win32"
|
|
? [rt(join(rtDir, "scr_win.c")), "-ladvapi32", "-liphlpapi", "-lws2_32"]
|
|
: []),
|
|
// musl deliberately has no libc-identification predefine; resolveCc's
|
|
// SCR_MUSL flag and this target-selected TU travel together.
|
|
...(isMuslTarget(driver) ? [rt(join(rtDir, "scr_musl.c"))] : []),
|
|
...(regex
|
|
? ["-I", vendorEngineDir(), rt(join(rtDir, "scr_regex.c")), ...lreObjects]
|
|
: []),
|
|
...(opts.assert || regex || opts.symbol ? [rt(join(rtDir, "scr_assert.c"))] : []),
|
|
...(opts.inspect ? [rt(join(rtDir, "scr_inspect.c")), rt(join(rtDir, "scr_console_native.c"))] : []),
|
|
...((opts.dynInvoke || nativeFetch) ? [rt(join(rtDir, "scr_dyn_invoke.c"))] : []),
|
|
...(opts.dc ? [rt(join(rtDir, "scr_dc.c"))] : []),
|
|
...(opts.dynAsync || opts.dynInvoke || opts.dc || opts.fileHandle || nativeFetch ? [rt(join(rtDir, "scr_async_dyn.c"))] : []),
|
|
// The zlib UNIT (scr_zlib.c) gates on zlib.* IR use; the LINK (system
|
|
// libz on the default host-clang build, vendored objects on Zig builds)
|
|
// also serves the native fetch's gzip decoder — spread exactly once.
|
|
...(opts.zlib
|
|
? isZigDriver(driver)
|
|
? ["-I", vendorZlibDir(), rt(join(rtDir, "scr_zlib.c")), ...zlibObjects]
|
|
: [rt(join(rtDir, "scr_zlib.c"))]
|
|
: nativeFetch
|
|
? isZigDriver(driver)
|
|
? ["-I", vendorZlibDir(), ...zlibObjects]
|
|
: []
|
|
: []),
|
|
// The zlib ↔ island bridge: only when BOTH halves are in the build
|
|
// (the scr_inspect_island.c pattern) — the emitted main calls its
|
|
// installer exactly then.
|
|
...(opts.zlib && opts.dynamic ? [rt(join(rtDir, "scr_zlib_island.c"))] : []),
|
|
...(opts.events ? [rt(join(rtDir, "scr_events.c")), rt(join(rtDir, "scr_readline.c"))] : []),
|
|
...(opts.emitter ? [rt(join(rtDir, "scr_events_emitter.c"))] : []),
|
|
// The checked-dynamic HANDLE support unit (listener gate + runtime
|
|
// adapter closures): every referencing unit is one of the emitter or
|
|
// net families (http implies net), so handle-free binaries keep
|
|
// their exact size class.
|
|
...(opts.emitter || net ? [rt(join(rtDir, "scr_dyn_handle.c"))] : []),
|
|
...(opts.symbol ? [rt(join(rtDir, "scr_symbol.c"))] : []),
|
|
...(opts.assert && opts.bigint ? [rt(join(rtDir, "scr_bigint_assert.c"))] : []),
|
|
...(opts.searchParams ? [rt(join(rtDir, "scr_url_params.c"))] : []),
|
|
...(opts.qs ? [rt(join(rtDir, "scr_qs.c"))] : []),
|
|
...(opts.parseArgs ? [rt(join(rtDir, "scr_util.c"))] : []),
|
|
...(opts.stream ? [rt(join(rtDir, "scr_stream.c"))] : []),
|
|
// The readiness-poller backends (scr_platform.h): kqueue on macOS/BSD,
|
|
// epoll on Linux, WSAPoll on Windows — each TU is empty off its
|
|
// platform, so all three link whenever a poller-using unit does and
|
|
// the others cost nothing (ws2_32 rides the unconditional win32 libs
|
|
// above).
|
|
...(net || opts.dgram
|
|
? [
|
|
rt(join(rtDir, "scr_loop_kqueue.c")),
|
|
rt(join(rtDir, "scr_loop_epoll.c")),
|
|
rt(join(rtDir, "scr_loop_wsapoll.c")),
|
|
]
|
|
: []),
|
|
...(net ? [rt(join(rtDir, "scr_net.c"))] : []),
|
|
...(http ? [rt(join(rtDir, "scr_http.c"))] : []),
|
|
...(opts.http2 ?? false ? [rt(join(rtDir, "scr_http2.c"))] : []),
|
|
...(opts.dgram ? [rt(join(rtDir, "scr_dgram.c"))] : []),
|
|
...(opts.watch ? [rt(join(rtDir, "scr_watch.c"))] : []),
|
|
...(opts.foreignFfi ? [rt(join(rtDir, "scr_ffi_queue.c"))] : []),
|
|
...(opts.nodeTest ? [rt(join(rtDir, "scr_test.c"))] : []),
|
|
// The CA-store unit rides its own gate OR the tls one: scr_tls.c
|
|
// references its default-set override unconditionally.
|
|
...(tlsCa ? [rt(join(rtDir, "scr_tls_ca.c"))] : []),
|
|
...(tlsArchive
|
|
? [
|
|
"-I", join(vendorTlsDir(), "include"),
|
|
rt(join(rtDir, "scr_tls.c")),
|
|
tlsArchive,
|
|
// mbedTLS's win32 entropy poll is BCryptGenRandom (bcrypt.h).
|
|
// The unconditional win32 libs above do not carry it.
|
|
// Never present on the default path, so the historical TLS
|
|
// link line cannot change.
|
|
...(targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
|
|
]
|
|
: []),
|
|
// scr_tls_ca.c enumerates and PEM-encodes Windows system-store entries.
|
|
// This is independent of the mbedTLS archive: getCACertificates-only
|
|
// programs need crypt32 too, while TLS programs imply the CA unit.
|
|
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
|
|
// Static fetch is the engine-free half of scr_fetch.c. Dynamic builds
|
|
// compile the same source beside scr_island.c below, where its
|
|
// SCR_DYNAMIC half installs the full web surface.
|
|
...(nativeFetch && !dynamic ? [rt(join(rtDir, "scr_fetch.c"))] : []),
|
|
...(engineArchive
|
|
? [
|
|
"-DSCR_DYNAMIC",
|
|
"-I", vendorEngineDir(),
|
|
rt(join(rtDir, "scr_island.c")),
|
|
rt(join(rtDir, "scr_web.c")),
|
|
// The one unit referencing BOTH the island and the inspect
|
|
// engine (insp.jsval): linked exactly when both halves are.
|
|
...(opts.inspect ? [rt(join(rtDir, "scr_inspect_island.c"))] : []),
|
|
// fetch: the NATIVE bridge by default (its socket/tls/zlib
|
|
// dependencies joined the link above); the curl REFERENCE
|
|
// (SCRIPTC_FETCH_CURL=1) keeps the historical system -lcurl /
|
|
// linux soname-stub arms for one release.
|
|
...(nativeFetch ? [rt(join(rtDir, "scr_fetch.c"))] : []),
|
|
// The island's node:http/https CLIENT bridge (scr_net_island.c):
|
|
// the one TU referencing both the socket units and the engine —
|
|
// compiled beside the native fetch (scr_fetch_install registers
|
|
// it) and whenever the embedded graph imports node:http/https
|
|
// (the emitted main calls scr_net_island_install exactly then).
|
|
...(netIsland ? [rt(join(rtDir, "scr_net_island.c"))] : []),
|
|
...(curlFetch
|
|
? curlStubDir !== null
|
|
? ["-I", join(vendorCurlDir(), "include"), rt(join(rtDir, "scr_fetch_curl.c")), `-L${curlStubDir}`, "-lcurl"]
|
|
: [rt(join(rtDir, "scr_fetch_curl.c")), "-lcurl"]
|
|
: []),
|
|
engineArchive,
|
|
// Linux's libm is appended after every input below for GNU ld's
|
|
// left-to-right archive resolution. Other dynamic targets keep
|
|
// the historical engine-adjacent spelling.
|
|
...(driver.linkArgs.includes("-lm") ? [] : ["-lm"]),
|
|
// The PE stack reserve, pinned to the 8MB POSIX main-stack
|
|
// geometry ISL_MAIN_STACK_BUDGET is sized against (4MB engine
|
|
// budget + 4MB excursion margin) — quickjs-ng's own CMake makes
|
|
// the same 8MB choice on Windows. Not left to the driver:
|
|
// classic mingw ld defaults to 2MB (which the budget would blow
|
|
// straight past); zig's lld happens to default to 16MB today,
|
|
// but that is nobody's contract.
|
|
...(targetPlatform(driver) === "win32" ? ["-Wl,--stack,8388608"] : []),
|
|
]
|
|
: []),
|
|
// A .ll program TU (the LLVM backend) deliberately carries no target
|
|
// triple (byte-stable output; clang supplies the host/SCRIPTC_TARGET
|
|
// triple exactly as it does for .c) — silence the -Woverride-module
|
|
// note about that. Never present for .c inputs, so the historical C
|
|
// command line cannot change by a byte.
|
|
...(opts.cPath.endsWith(".ll") ? ["-Wno-override-module"] : []),
|
|
...(build.compilerVisibleSource !== undefined && build.programPath !== undefined
|
|
? [
|
|
`-ffile-prefix-map=${build.programPath}=${build.compilerVisibleSource}`,
|
|
"-iquote",
|
|
dirname(resolve(build.compilerVisibleSource)),
|
|
]
|
|
: []),
|
|
build.programPath ?? opts.cPath,
|
|
...(opts.linkInputs ?? []),
|
|
...(opts.systemLibraries ?? []).map((name) => `-l${name}`),
|
|
...(opts.frameworks ?? []).flatMap(name => ["-framework", name]),
|
|
// GNU ld resolves libraries from left to right and commonly enables
|
|
// --as-needed: host-clang libz must follow scr_zlib.c/scr_fetch.c and every
|
|
// generated/native input that references inflate symbols. Cross
|
|
// and targetless Zig builds use vendored zlib objects in the input section above.
|
|
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver)
|
|
? ["-lz"]
|
|
: []),
|
|
// glibc keeps libm separate from libc. This must trail the generated
|
|
// program and every native FFI input because GNU ld resolves archives
|
|
// from left to right.
|
|
...driver.linkArgs,
|
|
...executableLinkFlags,
|
|
"-o", build.outPath ?? opts.outPath,
|
|
];
|
|
// Compile-only flags shared by runtime-object population and the caller-TU
|
|
// dependency probe. They reproduce the option set every TU sees in the
|
|
// historical single clang invocation.
|
|
const cflags = [
|
|
"-std=c11",
|
|
...debugFlags,
|
|
...driver.targetArgs,
|
|
...threadArgs,
|
|
...(sanitize
|
|
? [optimization === "dev" ? "-O0" : "-O1", "-fsanitize=address", "-DSCR_RC_AUDIT"]
|
|
: [optimization === "dev" ? "-O0" : "-O2"]),
|
|
...executableSectionFlags.compile,
|
|
...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []),
|
|
"-fno-math-errno",
|
|
"-fno-strict-aliasing", // the emitted object model type-puns — see buildArgs
|
|
"-Wno-deprecated-declarations",
|
|
"-I", rtDir,
|
|
...(regex || dynamic ? ["-I", vendorEngineDir()] : []),
|
|
...(zlibObjects.length > 0 ? ["-I", vendorZlibDir()] : []),
|
|
...(curlStubDir !== null ? ["-I", join(vendorCurlDir(), "include")] : []),
|
|
...(tlsArchive !== null ? ["-I", join(vendorTlsDir(), "include")] : []),
|
|
...(dynamic ? ["-DSCR_DYNAMIC"] : []),
|
|
];
|
|
const programCompilerArgs = opts.cPath.endsWith(".ll")
|
|
? [...cflags, "-Wno-override-module"]
|
|
: cflags;
|
|
const programSourceExtension = opts.cPath.endsWith(".ll") ? ".ll" : ".c";
|
|
const ccName = driver.argv.join(" ");
|
|
const runClang = async (args: string[]): Promise<void> => {
|
|
try {
|
|
await execFileAsync(driver.argv[0] ?? "clang", [...driver.argv.slice(1), ...args]);
|
|
} catch (err) {
|
|
const stderr = subprocessFailureDetail(err);
|
|
const guidance =
|
|
(opts.linkInputs?.length ?? 0) > 0 ||
|
|
(opts.frameworks?.length ?? 0) > 0 ||
|
|
(opts.systemLibraries?.length ?? 0) > 0
|
|
? "This build includes native FFI link inputs. Check that every symbol and system library exists, " +
|
|
"that archive/object ordering is correct, and that each input matches the selected target."
|
|
: `Check the supplied native source and the selected ${ccName} toolchain.`;
|
|
throw new CcCompileError(
|
|
ccName,
|
|
stderr,
|
|
`${ccName} failed compiling ${opts.cPath}.\n` +
|
|
`${guidance}\n\n${stderr}`,
|
|
);
|
|
}
|
|
};
|
|
const runUncachedBuild = async (): Promise<void> => {
|
|
const privateVendorRoot = transientVendorRoot ?? join(
|
|
tmpdir(),
|
|
`scriptc-vendor-fallback-${process.pid}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
try {
|
|
await materializeVendorPrerequisites(undefined, privateVendorRoot);
|
|
await runClang(buildArgs((p) => p));
|
|
} finally {
|
|
await rm(privateVendorRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
let runtimeCompilerInvocation: string | null = null;
|
|
let programCompilerInvocation: string | null = null;
|
|
let payloadMetadata: Promise<[string, string, Buffer]> | null = null;
|
|
let compileMetadataStamp: NativeMetadataStamp | null = null;
|
|
const compileMetadataKey =
|
|
persistentCache === null || process.env["SCRIPTC_TEST_TRUST_COMPILER_WRAPPER"] === "1"
|
|
? null
|
|
: nativeMetadataKey("compile", [
|
|
cacheTargetIdentity(driver),
|
|
toolchainEnv,
|
|
implicitToolchain ?? "<uncached>",
|
|
driver.argv,
|
|
cflags,
|
|
programCompilerArgs,
|
|
programSourceExtension,
|
|
]);
|
|
if (persistentCache !== null) {
|
|
try {
|
|
// These probes inspect disjoint inputs. Start the payload reads here as
|
|
// well so runtime hashing and clang's dry-run traces overlap instead of
|
|
// forming a serial prelude before every cache lookup.
|
|
payloadMetadata = Promise.all([
|
|
Promise.resolve(
|
|
toolchainMetadataStamp?.values["compilerVersion"] ??
|
|
ccVersion(driver.argv, toolchainEnv, true),
|
|
),
|
|
localArtifact === null
|
|
? runtimeFingerprint(rtDir)
|
|
: Promise.resolve(localArtifact.runtimeHash),
|
|
localArtifact === null
|
|
? readFile(opts.cPath)
|
|
: Promise.resolve(localArtifact.programBytes),
|
|
]);
|
|
compileMetadataStamp = compileMetadataKey === null
|
|
? null
|
|
: await readNativeMetadataStamp(persistentCache.root, compileMetadataKey);
|
|
if (compileMetadataStamp !== null) {
|
|
runtimeCompilerInvocation = compileMetadataStamp.values["runtimeInvocation"] ?? null;
|
|
programCompilerInvocation = compileMetadataStamp.values["programInvocation"] ?? null;
|
|
if (runtimeCompilerInvocation === null || programCompilerInvocation === null) {
|
|
compileMetadataStamp = null;
|
|
}
|
|
}
|
|
if (compileMetadataStamp === null) {
|
|
const [runtimeInvocation, programInvocation] = await Promise.all([
|
|
effectiveCompilerInvocationFingerprint(driver, toolchainEnv, cflags),
|
|
programSourceExtension === ".ll"
|
|
? effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
programCompilerArgs,
|
|
programSourceExtension,
|
|
)
|
|
: Promise.resolve(null),
|
|
]);
|
|
runtimeCompilerInvocation = runtimeInvocation;
|
|
programCompilerInvocation = programInvocation ?? runtimeInvocation;
|
|
if (compileMetadataKey !== null) {
|
|
compileMetadataStamp = await publishNativeMetadataStamp(
|
|
persistentCache.root,
|
|
compileMetadataKey,
|
|
{
|
|
runtimeInvocation: runtimeCompilerInvocation,
|
|
programInvocation: programCompilerInvocation,
|
|
},
|
|
[
|
|
...fingerprintDependencyPaths(runtimeCompilerInvocation),
|
|
...fingerprintDependencyPaths(programCompilerInvocation),
|
|
],
|
|
[runtimeCompilerInvocation, programCompilerInvocation],
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Preserve the uncached build for wrappers that compile successfully but
|
|
// cannot provide a dry-run trace for the real build flavor.
|
|
if (cacheWarmOnly) {
|
|
throw new Error("native cache warming could not validate the compiler invocation", {
|
|
cause: error,
|
|
});
|
|
}
|
|
persistentCache = null;
|
|
}
|
|
}
|
|
|
|
if (persistentCache === null) {
|
|
// The direct uncached command is the source-of-truth executable recipe.
|
|
await runUncachedBuild();
|
|
return;
|
|
}
|
|
|
|
let cv: string;
|
|
let fingerprint: string;
|
|
let cBytes: Buffer;
|
|
try {
|
|
[cv, fingerprint, cBytes] = await (payloadMetadata ?? Promise.all([
|
|
ccVersion(driver.argv, toolchainEnv, true),
|
|
runtimeFingerprint(rtDir),
|
|
readFile(opts.cPath),
|
|
]));
|
|
} catch (error) {
|
|
// A version/fingerprint probe is an optimization boundary. If the compiler
|
|
// itself can still compile, preserve the pre-cache behavior instead of
|
|
// surfacing a metadata command's failure as the build result.
|
|
if (cacheWarmOnly) {
|
|
throw new Error("native cache warming could not validate cache inputs", {
|
|
cause: error,
|
|
});
|
|
}
|
|
await runUncachedBuild();
|
|
return;
|
|
}
|
|
// Caller-supplied native inputs can all hide mutable dependencies: `-l`
|
|
// resolves through ambient search paths, while a thin archive or linker
|
|
// script can retain identical top-level bytes as its referenced files are
|
|
// rebuilt. Keep the safe runtime-object cache, but force a fresh final link
|
|
// whenever the caller supplies either form.
|
|
let cacheCompleteArtifact =
|
|
!cacheWarmOnly &&
|
|
cachePolicy.completeArtifacts &&
|
|
(opts.linkInputs?.length ?? 0) === 0 &&
|
|
(opts.frameworks?.length ?? 0) === 0 &&
|
|
(opts.systemLibraries?.length ?? 0) === 0;
|
|
let programDependencies: string | null = null;
|
|
const linkProbeArgs = [
|
|
...(sanitize ? ["-fsanitize=address"] : []),
|
|
...threadArgs,
|
|
...(targetPlatform(driver) === "win32"
|
|
? ["-ladvapi32", "-liphlpapi", "-lws2_32"]
|
|
: []),
|
|
...(tls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
|
|
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
|
|
...(curlFetch && driver.target === null ? ["-lcurl"] : []),
|
|
...(dynamic && !driver.linkArgs.includes("-lm") ? ["-lm"] : []),
|
|
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
|
|
...driver.linkArgs,
|
|
...executableLinkFlags,
|
|
];
|
|
// Both the wrapper dry run and dependency trace need the real build's
|
|
// compile/link flag shape: wrappers commonly inject flags or native inputs
|
|
// conditionally on optimization, sanitizer, dynamic, or platform switches.
|
|
const effectiveLinkInvocationArgs = [
|
|
...programCompilerArgs,
|
|
...(targetPlatform(driver) === "win32"
|
|
? ["-ladvapi32", "-liphlpapi", "-lws2_32"]
|
|
: []),
|
|
...(tls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
|
|
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
|
|
...(curlStubDir !== null ? [`-L${curlStubDir}`] : []),
|
|
...(curlFetch && driver.target === null ? ["-lcurl"] : []),
|
|
...(dynamic && !driver.linkArgs.includes("-lm") ? ["-lm"] : []),
|
|
...(dynamic && targetPlatform(driver) === "win32"
|
|
? ["-Wl,--stack,8388608"]
|
|
: []),
|
|
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
|
|
...driver.linkArgs,
|
|
...executableLinkFlags,
|
|
];
|
|
// A complete hit is checked before cross-target curl's generated import stub
|
|
// is materialized. Its -L spelling still joins the dry-run identity, while
|
|
// the real trace omits only that not-yet-existing scriptc-owned directory.
|
|
const linkTraceInvocationArgs =
|
|
curlStubDir === null
|
|
? effectiveLinkInvocationArgs
|
|
: effectiveLinkInvocationArgs.filter((arg) => arg !== `-L${curlStubDir}`);
|
|
let implicitLinker: string | null = null;
|
|
let preBuildDependencies: NativeArtifactDependency[] | null = null;
|
|
let localArtifactDependencyPaths: string[] | null = null;
|
|
let linkMetadataStamp: NativeMetadataStamp | null = null;
|
|
const linkMetadataKey =
|
|
process.env["SCRIPTC_TEST_TRUST_COMPILER_WRAPPER"] === "1"
|
|
? null
|
|
: nativeMetadataKey("link", [
|
|
cacheTargetIdentity(driver),
|
|
toolchainEnv,
|
|
implicitToolchain ?? "<uncached>",
|
|
runtimeCompilerInvocation ?? "<uncached>",
|
|
programCompilerInvocation ?? "<uncached>",
|
|
driver.argv,
|
|
linkProbeArgs,
|
|
effectiveLinkInvocationArgs,
|
|
linkTraceInvocationArgs,
|
|
...(programShardMergeIdentity === null ? [] : [programShardMergeIdentity]),
|
|
]);
|
|
if (cacheCompleteArtifact) {
|
|
try {
|
|
// Header discovery and linker tracing are independent subprocess trees.
|
|
// Running them together removes one complete probe round-trip from both
|
|
// cache hits and ordinary edit/build misses without changing either key.
|
|
linkMetadataStamp = linkMetadataKey === null
|
|
? null
|
|
: await readNativeMetadataStamp(persistentCache.root, linkMetadataKey);
|
|
if (linkMetadataStamp !== null) {
|
|
implicitLinker = linkMetadataStamp.values["implicitLinker"] ?? null;
|
|
if (implicitLinker === null) linkMetadataStamp = null;
|
|
}
|
|
[programDependencies, implicitLinker] = await Promise.all([
|
|
translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cBytes,
|
|
toolchainEnv,
|
|
),
|
|
linkMetadataStamp === null
|
|
? implicitLinkerFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
linkProbeArgs,
|
|
effectiveLinkInvocationArgs,
|
|
linkTraceInvocationArgs,
|
|
)
|
|
: Promise.resolve(implicitLinker!),
|
|
]);
|
|
if (linkMetadataStamp === null && linkMetadataKey !== null) {
|
|
linkMetadataStamp = await publishNativeMetadataStamp(
|
|
persistentCache.root,
|
|
linkMetadataKey,
|
|
{ implicitLinker },
|
|
fingerprintDependencyPaths(implicitLinker),
|
|
[implicitLinker],
|
|
);
|
|
}
|
|
preBuildDependencies = await snapshotLocalArtifactDependencies([
|
|
...(await runtimeFingerprintInputPaths(rtDir)),
|
|
...(toolchainMetadataStamp?.dependencies.map((dependency) => dependency.path) ??
|
|
fingerprintDependencyPaths(implicitToolchain!)),
|
|
...(compileMetadataStamp?.dependencies.map((dependency) => dependency.path) ?? []),
|
|
...(linkMetadataStamp?.dependencies.map((dependency) => dependency.path) ??
|
|
fingerprintDependencyPaths(implicitLinker!)),
|
|
...fingerprintDependencyPaths(programDependencies),
|
|
...(programShardMergeIdentity === null
|
|
? []
|
|
: fingerprintDependencyPaths(programShardMergeIdentity)),
|
|
]);
|
|
// Every content-bearing fingerprint was computed before this metadata
|
|
// snapshot. Re-read those exact files now so the snapshot cannot certify
|
|
// bytes that changed after hashing but before the final compile starts.
|
|
if (!(await fingerprintDependenciesStillMatch([
|
|
implicitToolchain!,
|
|
runtimeCompilerInvocation!,
|
|
programCompilerInvocation!,
|
|
implicitLinker!,
|
|
programDependencies,
|
|
...(programShardMergeIdentity === null ? [] : [programShardMergeIdentity]),
|
|
])) ||
|
|
await runtimeFingerprint(rtDir).catch(() => null) !== fingerprint ||
|
|
!(await Promise.all(
|
|
[toolchainMetadataStamp, compileMetadataStamp, linkMetadataStamp]
|
|
.filter((stamp): stamp is NativeMetadataStamp => stamp !== null)
|
|
.map((stamp) => nativeArtifactDependenciesStillMatch(stamp.dependencies)),
|
|
)).every(Boolean)) {
|
|
throw new CacheInputsChangedError();
|
|
}
|
|
if (localArtifact !== null) {
|
|
// Native metadata stamps persist their dependency paths across CLI
|
|
// processes; fingerprintDependencyPaths deliberately does not. Build
|
|
// the output-local stamp from this complete validated snapshot so a
|
|
// source edit in a fresh process cannot replace it with only the two
|
|
// process-local fallback paths.
|
|
localArtifactDependencyPaths = [
|
|
localArtifact.compilerPath,
|
|
dirname(resolve(opts.cPath)),
|
|
...preBuildDependencies.map((dependency) => dependency.path),
|
|
];
|
|
}
|
|
} catch {
|
|
// Program-header discovery and linker tracing are both required for a
|
|
// complete hit. Runtime objects remain safely cacheable if either probe
|
|
// is unavailable.
|
|
cacheCompleteArtifact = false;
|
|
preBuildDependencies = null;
|
|
localArtifactDependencyPaths = null;
|
|
}
|
|
}
|
|
// Warm-only builds deliberately do not need linker/header identity for a
|
|
// complete executable, but they still need a stable runtime-object key.
|
|
// The ordinary complete-artifact path computes these values above.
|
|
if (cacheWarmOnly && preBuildDependencies === null) {
|
|
try {
|
|
const [dependencies, invocation] = await Promise.all([
|
|
translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cBytes,
|
|
toolchainEnv,
|
|
),
|
|
effectiveCompilerInvocationFingerprint(driver, toolchainEnv, cflags),
|
|
]);
|
|
programDependencies = dependencies;
|
|
if (runtimeCompilerInvocation === null) runtimeCompilerInvocation = invocation;
|
|
} catch (error) {
|
|
throw new Error("native cache warming could not validate runtime-object inputs", {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
const binDir = join(persistentCache.root, "bin");
|
|
let keyHex: string | null = null;
|
|
let cachedBin: string | null = null;
|
|
if (cacheCompleteArtifact) {
|
|
// The key sees the full command line with the two program-specific paths
|
|
// normalized out. The C bytes are hashed separately; Darwin additionally
|
|
// keys the output basename because ld embeds it in the ad-hoc signature.
|
|
// Runtime and vendor paths stay verbatim — their contents are covered by
|
|
// the fingerprint and the pin.
|
|
const identityArgs = buildArgs((p) => p).map((a) =>
|
|
a === opts.cPath ? "<program.c>" : a === opts.outPath ? "<out>" : a,
|
|
);
|
|
const key = createHash("sha256")
|
|
// Sharded dev builds need the v10 identity below. Canonical single-TU
|
|
// builds retain v9 so adding the opt-in mode does not evict release
|
|
// binaries compiled by an earlier scriptc.
|
|
.update(programShards === null ? "bin-v9\0" : "bin-v10\0")
|
|
.update(cacheTargetIdentity(driver)).update("\0")
|
|
.update(toolchainEnv).update("\0")
|
|
.update(implicitToolchain!).update("\0")
|
|
.update(runtimeCompilerInvocation!).update("\0")
|
|
.update(programCompilerInvocation!).update("\0")
|
|
.update(implicitLinker!).update("\0")
|
|
.update(programDependencies!).update("\0")
|
|
.update(persistentCache.identity).update("\0")
|
|
// Preserve both the spelling clang sees (__FILE__) and the location used
|
|
// to resolve relative includes. The top-level bytes are not sufficient.
|
|
.update(opts.cPath).update("\0")
|
|
.update(resolve(opts.cPath)).update("\0")
|
|
.update(targetPlatform(driver) === "darwin" ? basename(opts.outPath) : "<out>").update("\0")
|
|
// The driver spelling joins the version string: `zig cc --version`
|
|
// reports the clang underneath and could otherwise collide with a
|
|
// same-version host clang.
|
|
.update(ccName).update("\0")
|
|
.update(cv).update("\0")
|
|
.update(fingerprint).update("\0")
|
|
.update(identityArgs.join("\x1f")).update("\0")
|
|
.update(cBytes);
|
|
if (programShards !== null) {
|
|
updateProgramShardCacheIdentity(
|
|
key,
|
|
programShards,
|
|
programPublicSymbols,
|
|
programShardMergeIdentity ?? undefined,
|
|
);
|
|
}
|
|
keyHex = key.digest("hex");
|
|
cachedBin = join(binDir, keyHex);
|
|
const tmpOut = privateSiblingPath(opts.outPath, "bin-hit");
|
|
try {
|
|
// NEVER copy over outPath in place: overwriting an already-executed
|
|
// signed binary invalidates the kernel's per-vnode code-signature cache
|
|
// on macOS and the next exec dies with SIGKILL. Copy to a fresh inode
|
|
// and rename it into place instead.
|
|
if (!(await copyValidCachedFile(cachedBin, tmpOut))) {
|
|
throw new Error("invalid cached executable");
|
|
}
|
|
if (darwinDebugSymbols) {
|
|
const cachedSymbols = `${cachedBin}.dsym`;
|
|
const symbolsCopy = privateSiblingPath(opts.outPath, "dsym-hit");
|
|
try {
|
|
if (!(await copyValidCachedFile(cachedSymbols, symbolsCopy))) throw new Error("invalid cached dSYM");
|
|
const bytes = await readFile(symbolsCopy);
|
|
// A concurrent publisher may replace either payload. The sidecar
|
|
// also names its binary digest so a mixed pair always misses.
|
|
if (bytes.subarray(0, 32).toString("hex") !== await fileDigest(tmpOut)) throw new Error("mismatched cached dSYM");
|
|
await installDarwinDebugSymbols(bytes.subarray(32), opts.outPath);
|
|
} finally {
|
|
await rm(symbolsCopy, { force: true });
|
|
}
|
|
}
|
|
// Match a fresh linker output under the caller's current umask. Reusing a
|
|
// cache entry populated by a less restrictive shell must not widen access.
|
|
await chmod(tmpOut, 0o777 & ~process.umask());
|
|
await rename(tmpOut, opts.outPath);
|
|
if (localArtifact !== null && localArtifactDependencyPaths !== null) {
|
|
const stamp = await publishLocalArtifactStamp(
|
|
localArtifact.stampPath,
|
|
opts.outPath,
|
|
localArtifact.key,
|
|
localArtifactDependencyPaths,
|
|
[dirname(resolve(opts.cPath))],
|
|
[persistentCache.root],
|
|
darwinDebugSymbols,
|
|
).catch(() => null);
|
|
if (stamp !== null) {
|
|
await opts.onArtifactReady?.({ dependencies: stamp.dependencies }).catch(() => undefined);
|
|
}
|
|
}
|
|
return; // hit: the program/runtime payload compile and link were skipped
|
|
} catch {
|
|
await rm(tmpOut, { force: true }).catch(() => undefined);
|
|
/* miss — build below, then publish */
|
|
}
|
|
}
|
|
|
|
// Miss: link the program's own TU against cached per-flavor runtime objects.
|
|
// Collect the runtime sources this build actually compiles (the same
|
|
// conditionals as the command line, by construction).
|
|
const rtInputs: string[] = [];
|
|
buildArgs((p) => {
|
|
rtInputs.push(p);
|
|
return p;
|
|
});
|
|
const buildDir = await mkdtemp(join(tmpdir(), "scriptc-cache-build-"));
|
|
try {
|
|
await materializeVendorPrerequisites(join(buildDir, "vendor-inputs"));
|
|
// Freeze the exact bytes used for the key. Generated TUs live at stable
|
|
// paths under .scriptc/, so two builds can otherwise overwrite that path
|
|
// between hashing and clang and publish one invocation's code under the
|
|
// other's key. The prefix map preserves the original __FILE__/debug-file
|
|
// spelling while clang reads this invocation-private snapshot.
|
|
const programPath = join(buildDir, `program${programSourceExtension}`);
|
|
// Preserve the caller-visible basename while keeping Darwin builds on a
|
|
// private inode: ld uses this spelling as the embedded ad-hoc signing
|
|
// identifier. Other targets retain the basename-independent cache key.
|
|
const privateOut = join(
|
|
buildDir,
|
|
targetPlatform(driver) === "darwin"
|
|
? basename(opts.outPath)
|
|
: process.platform === "win32"
|
|
? "artifact.exe"
|
|
: "artifact",
|
|
);
|
|
await writeFile(programPath, cBytes);
|
|
|
|
let objects: Map<string, string> | null = null;
|
|
let cacheInputsStable = true;
|
|
let strictObjectVerification: Promise<boolean> | null = null;
|
|
const objectImplicitToolchainStillMatches = (): Promise<boolean> => {
|
|
if (preBuildDependencies !== null) {
|
|
return nativeArtifactDependenciesStillMatch(preBuildDependencies);
|
|
}
|
|
// Complete-artifact caching can be disabled by caller-owned native
|
|
// inputs while the safe runtime-object tier remains active. Preserve its
|
|
// strict discovery fallback in that posture.
|
|
strictObjectVerification ??= Promise.all([
|
|
runtimeFingerprint(rtDir),
|
|
implicitToolchainFingerprint(driver, toolchainEnv),
|
|
effectiveCompilerInvocationFingerprint(driver, toolchainEnv, cflags),
|
|
]).then(
|
|
([currentRuntime, currentImplicit, currentInvocation]) =>
|
|
currentRuntime === fingerprint &&
|
|
currentImplicit === implicitToolchain &&
|
|
currentInvocation === runtimeCompilerInvocation,
|
|
() => false,
|
|
);
|
|
return strictObjectVerification;
|
|
};
|
|
try {
|
|
const cached = await ensureRuntimeObjects(
|
|
persistentCache.root,
|
|
driver.argv,
|
|
cflags,
|
|
rtInputs,
|
|
`obj-v5\0${cacheTargetIdentity(driver)}\0${toolchainEnv}\0${implicitToolchain}\0${runtimeCompilerInvocation}\0${ccName}\0${cv}\0${fingerprint}\0`,
|
|
async () =>
|
|
await objectImplicitToolchainStillMatches(),
|
|
cacheWarmPaths,
|
|
);
|
|
objects = await stageRuntimeObjects(cached, join(buildDir, "runtime-objects"));
|
|
} catch (err) {
|
|
if (err instanceof CacheInputsChangedError) cacheInputsStable = false;
|
|
if (cacheWarmOnly) {
|
|
throw new Error("native cache warming could not persist runtime objects", {
|
|
cause: err,
|
|
});
|
|
}
|
|
objects = null; // cache trouble is never a build failure
|
|
}
|
|
|
|
const compileShardedProgram = async (): Promise<string | null> => {
|
|
if (
|
|
programShards === null || programPublicSymbols === undefined ||
|
|
programCompilerInvocation === null || implicitCompileToolchain === null
|
|
) return null;
|
|
try {
|
|
const programDependencyHash = programDependencies ??
|
|
await translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cBytes,
|
|
toolchainEnv,
|
|
);
|
|
const stem = basename(opts.cPath, ".ll");
|
|
const entries = programShards.map((shard, index) => {
|
|
const sourcePath = join(buildDir, shard.name);
|
|
const staged = join(
|
|
buildDir,
|
|
`${stem}.program-${index.toString().padStart(3, "0")}.o`,
|
|
);
|
|
const key = createHash("sha256")
|
|
.update("exe-program-shard-v1\0")
|
|
.update(cacheTargetIdentity(driver)).update("\0")
|
|
.update(toolchainEnv).update("\0")
|
|
.update(implicitCompileToolchain).update("\0")
|
|
.update(programCompilerInvocation).update("\0")
|
|
.update(persistentCache.identity).update("\0")
|
|
.update(driver.argv.join("\x1f")).update("\0")
|
|
.update(cv).update("\0")
|
|
.update(fingerprint).update("\0")
|
|
.update(programDependencyHash).update("\0")
|
|
.update(programCompilerArgs.join("\x1f")).update("\0")
|
|
.update(opts.cPath).update("\0")
|
|
.update(resolve(opts.cPath)).update("\0")
|
|
.update(shard.name).update("\0")
|
|
.update(shard.source)
|
|
.digest("hex");
|
|
return {
|
|
...shard,
|
|
sourcePath,
|
|
staged,
|
|
cachePath: join(persistentCache.root, "program-shard", key),
|
|
missed: false,
|
|
};
|
|
});
|
|
const shardWidth = Math.min(8, availableParallelism());
|
|
for (let i = 0; i < entries.length; i += shardWidth) {
|
|
await Promise.all(entries.slice(i, i + shardWidth).map(async (entry) => {
|
|
await writeFile(entry.sourcePath, entry.source);
|
|
if (await copyValidCachedFile(entry.cachePath, entry.staged)) return;
|
|
entry.missed = true;
|
|
await runClang([
|
|
...programCompilerArgs,
|
|
`-ffile-prefix-map=${entry.sourcePath}=${opts.cPath}`,
|
|
"-c",
|
|
entry.sourcePath,
|
|
"-o",
|
|
entry.staged,
|
|
]);
|
|
}));
|
|
}
|
|
const arArgv = isZigDriver(driver) ? [driver.argv[0]!, "ar"] : ["ar"];
|
|
const merged = await localizeLibraryObjects(
|
|
driver,
|
|
arArgv,
|
|
buildDir,
|
|
entries.map((entry) => entry.staged),
|
|
[],
|
|
programPublicSymbols,
|
|
`${stem}.program`,
|
|
);
|
|
const publishable = entries.filter((entry) => entry.missed);
|
|
if (publishable.length > 0) {
|
|
try {
|
|
const [currentRuntime, currentFingerprints, currentInvocation, currentDependencies, currentCompiler, currentMerge] =
|
|
await Promise.all([
|
|
runtimeFingerprint(rtDir),
|
|
implicitToolchainFingerprints(driver, toolchainEnv),
|
|
effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
programCompilerArgs,
|
|
".ll",
|
|
),
|
|
translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cBytes,
|
|
toolchainEnv,
|
|
),
|
|
ccVersion(driver.argv, toolchainEnv, true),
|
|
resolveProgramShardMergeIdentity(driver),
|
|
]);
|
|
if (
|
|
currentRuntime === fingerprint &&
|
|
currentFingerprints.compile === implicitCompileToolchain &&
|
|
currentInvocation === programCompilerInvocation &&
|
|
currentDependencies === programDependencyHash &&
|
|
currentCompiler === cv &&
|
|
currentMerge === programShardMergeIdentity
|
|
) {
|
|
await Promise.all(
|
|
publishable.map((entry) => publishCachedFile(entry.staged, entry.cachePath)),
|
|
);
|
|
}
|
|
} catch {
|
|
// The merged object is valid for this invocation; publication is
|
|
// only an optimization for later edits.
|
|
}
|
|
}
|
|
return merged;
|
|
} catch {
|
|
// Sharding is an optimization. Compile/link the canonical TU below if
|
|
// a target tool, one shard, or relocatable merge is unavailable.
|
|
return null;
|
|
}
|
|
};
|
|
const shardedProgramObject = await compileShardedProgram();
|
|
if (programShards !== null && shardedProgramObject === null) {
|
|
// The canonical TU fallback is valid output, but it must not populate a
|
|
// cache key describing a successful shard projection/merge.
|
|
cacheInputsStable = false;
|
|
}
|
|
await runClang(
|
|
buildArgs(
|
|
(p) => objects?.get(p) ?? p,
|
|
shardedProgramObject === null
|
|
? { programPath, outPath: privateOut, compilerVisibleSource: opts.cPath }
|
|
: { programPath: shardedProgramObject, outPath: privateOut },
|
|
),
|
|
);
|
|
if (darwinDebugSymbols) {
|
|
// clang invokes dsymutil when compiling a source TU while linking.
|
|
// A sharded program is already an object, so run it explicitly while
|
|
// the merged object and staged runtime inputs still exist.
|
|
if (shardedProgramObject !== null) await createDarwinDebugSymbols(privateOut);
|
|
await installDarwinDebugSymbols(await readDarwinDebugSymbols(privateOut), opts.outPath);
|
|
}
|
|
await installArtifact(privateOut, opts.outPath);
|
|
|
|
if (cachedBin !== null && keyHex !== null) {
|
|
// Metadata comparison catches ordinary changes cheaply, but cannot by
|
|
// itself prove the snapshot was taken from the same bytes hashed into
|
|
// the key. Recompute every content-bearing identity after the final link
|
|
// so a header/SDK/compiler change in either pre-build gap cannot publish
|
|
// new output under an old key.
|
|
const [currentRuntime, currentImplicit, currentRuntimeInvocation, currentProgramInvocation, currentProgramDependencies, currentLinker, currentCompiler, currentProgramShardMerge] =
|
|
await Promise.all([
|
|
runtimeFingerprint(rtDir).catch(() => null),
|
|
implicitToolchainFingerprint(driver, toolchainEnv).catch(() => null),
|
|
effectiveCompilerInvocationFingerprint(driver, toolchainEnv, cflags).catch(
|
|
() => null,
|
|
),
|
|
effectiveCompilerInvocationFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
programCompilerArgs,
|
|
programSourceExtension,
|
|
).catch(() => null),
|
|
translationUnitDependencyFingerprint(
|
|
driver,
|
|
cflags,
|
|
opts.cPath,
|
|
cBytes,
|
|
toolchainEnv,
|
|
).catch(() => null),
|
|
implicitLinkerFingerprint(
|
|
driver,
|
|
toolchainEnv,
|
|
linkProbeArgs,
|
|
effectiveLinkInvocationArgs,
|
|
linkTraceInvocationArgs,
|
|
).catch(() => null),
|
|
ccVersion(driver.argv, toolchainEnv, true).catch(() => null),
|
|
programShards === null
|
|
? Promise.resolve(null)
|
|
: resolveProgramShardMergeIdentity(driver).catch(() => null),
|
|
]);
|
|
cacheInputsStable =
|
|
cacheInputsStable &&
|
|
preBuildDependencies !== null &&
|
|
await nativeArtifactDependenciesStillMatch(preBuildDependencies) &&
|
|
currentRuntime === fingerprint &&
|
|
currentImplicit === implicitToolchain &&
|
|
currentRuntimeInvocation === runtimeCompilerInvocation &&
|
|
currentProgramInvocation === programCompilerInvocation &&
|
|
currentProgramDependencies === programDependencies &&
|
|
currentLinker === implicitLinker &&
|
|
currentCompiler === cv &&
|
|
currentProgramShardMerge === programShardMergeIdentity;
|
|
}
|
|
if (cachedBin !== null && keyHex !== null && cacheInputsStable) {
|
|
try {
|
|
// Cache artifacts are data, never execution targets. Publication keeps
|
|
// generated code and embedded literals private; the hit path reapplies
|
|
// the caller's current executable mode to its destination copy.
|
|
await publishCachedFile(privateOut, cachedBin);
|
|
if (darwinDebugSymbols) {
|
|
const symbols = privateSiblingPath(privateOut, "debug-symbols");
|
|
await writeFile(symbols, Buffer.concat([
|
|
Buffer.from(await fileDigest(privateOut), "hex"),
|
|
await readDarwinDebugSymbols(privateOut),
|
|
]));
|
|
await publishCachedFile(symbols, `${cachedBin}.dsym`);
|
|
}
|
|
} catch {
|
|
/* publishing is best-effort */
|
|
}
|
|
}
|
|
if (
|
|
localArtifact !== null &&
|
|
localArtifactDependencyPaths !== null &&
|
|
cacheCompleteArtifact &&
|
|
cacheInputsStable
|
|
) {
|
|
const stamp = await publishLocalArtifactStamp(
|
|
localArtifact.stampPath,
|
|
opts.outPath,
|
|
localArtifact.key,
|
|
localArtifactDependencyPaths,
|
|
[dirname(resolve(opts.cPath))],
|
|
[persistentCache.root],
|
|
darwinDebugSymbols,
|
|
).catch(() => null);
|
|
if (stamp !== null) {
|
|
await opts.onArtifactReady?.({ dependencies: stamp.dependencies }).catch(() => undefined);
|
|
}
|
|
}
|
|
} finally {
|
|
await rm(buildDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
// Runtime-object population is itself a cache write, including on builds
|
|
// whose native link inputs disable complete-artifact publication.
|
|
await pruneCache(persistentCache.root, cacheWarmPaths).catch(() => undefined);
|
|
}
|
|
|
|
export async function compileC(opts: CcOptions): Promise<void> {
|
|
clearCcCaches();
|
|
await compileCInternal(opts, false);
|
|
if (targetPlatform(resolveCc()) === "darwin" && !needsDarwinDebugSymbols("darwin", opts.optimization, opts.strip)) {
|
|
await rm(`${opts.outPath}.dSYM`, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export type NativeCacheWarmProfile = "runtime" | "tls" | "dynamic";
|
|
|
|
export interface WarmNativeCachesOptions {
|
|
/** Native object posture to seed. Defaults to the shipped release/-O2 lane. */
|
|
optimization?: "release" | "dev";
|
|
/** Seed ASan + RC-audit objects instead of the ordinary lane. */
|
|
sanitize?: boolean;
|
|
/** Feature families to seed. Defaults to every expensive native family. */
|
|
profiles?: readonly NativeCacheWarmProfile[];
|
|
}
|
|
|
|
export interface WarmNativeCachesResult {
|
|
cacheRoot: string;
|
|
profiles: { profile: NativeCacheWarmProfile; elapsedMs: number }[];
|
|
}
|
|
|
|
export function supportedNativeCacheWarmProfiles(
|
|
driver: CcDriver,
|
|
): readonly NativeCacheWarmProfile[] {
|
|
if (isMobileTarget(driver.target) || targetPlatform(driver) === "wasi") return [];
|
|
return ["runtime", "tls", "dynamic"];
|
|
}
|
|
|
|
/** Populate expensive native prerequisites against the current compiler,
|
|
* target, SDK, and environment. The resulting entries use the exact same
|
|
* identities and validators as ordinary builds; this merely pays their cost
|
|
* before a developer's first program asks for them. Synthetic link products
|
|
* are discarded and never enter the complete-executable cache. */
|
|
export async function warmNativeCaches(
|
|
options: WarmNativeCachesOptions = {},
|
|
): Promise<WarmNativeCachesResult> {
|
|
clearCcCaches();
|
|
const cacheRoot = cacheRootDir();
|
|
if (cacheRoot === null) {
|
|
throw new Error(
|
|
"the native build cache is disabled (unset SCRIPTC_NO_CACHE and use a non-empty SCRIPTC_CACHE_DIR)",
|
|
);
|
|
}
|
|
await ensurePrivateCacheRoot(
|
|
cacheRoot,
|
|
process.env["SCRIPTC_CACHE_DIR"] === undefined,
|
|
);
|
|
const known = new Set<NativeCacheWarmProfile>(["runtime", "tls", "dynamic"]);
|
|
for (const profile of options.profiles ?? []) {
|
|
if (!known.has(profile)) throw new Error(`unknown native cache warm profile '${profile}'`);
|
|
}
|
|
if (options.profiles?.length === 0) return { cacheRoot, profiles: [] };
|
|
const mobileTarget = mobileLibraryTarget();
|
|
if (mobileTarget !== null) {
|
|
throw new Error(
|
|
`native cache warming targets executable builds and is unsupported for SCRIPTC_TARGET=${mobileTarget}`,
|
|
);
|
|
}
|
|
const driver = resolveCc();
|
|
const supported = supportedNativeCacheWarmProfiles(driver);
|
|
if (supported.length === 0) {
|
|
throw new Error(
|
|
`native cache warming targets persistently cached native executables and is unsupported for SCRIPTC_TARGET=${driver.target}`,
|
|
);
|
|
}
|
|
const profiles = [...new Set(options.profiles ?? supported)];
|
|
for (const profile of profiles) {
|
|
if (!supported.includes(profile)) {
|
|
throw new Error(
|
|
`native cache warm profile '${profile}' is unsupported for SCRIPTC_TARGET=${driver.target}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const workDir = await mkdtemp(join(tmpdir(), "scriptc-cache-warm-"));
|
|
const cPath = join(workDir, "warm.c");
|
|
await writeFile(cPath, "int main(void) { return 0; }\n");
|
|
const protectedPaths = new Set<string>();
|
|
try {
|
|
const results = await Promise.all(profiles.map(async (profile) => {
|
|
const started = performance.now();
|
|
await compileCInternal({
|
|
cPath,
|
|
outPath: join(workDir, process.platform === "win32" ? `${profile}.exe` : profile),
|
|
cacheIdentity: "scriptc-native-cache-warm-v1",
|
|
optimization: options.optimization ?? "release",
|
|
sanitize: options.sanitize ?? false,
|
|
...(profile === "tls" ? { fetch: true } : {}),
|
|
...(profile === "dynamic" ? { dynamic: true } : {}),
|
|
}, true, protectedPaths);
|
|
return {
|
|
profile,
|
|
elapsedMs: Math.round((performance.now() - started) * 10) / 10,
|
|
};
|
|
}));
|
|
await pruneCache(cacheRoot).catch(() => undefined);
|
|
if (!(await Promise.all([...protectedPaths].map(fileExists))).every(Boolean)) {
|
|
throw new Error(
|
|
`SCRIPTC_CACHE_MAX_MB is too small to retain the requested native cache warm profiles (${profiles.join(", ")})`,
|
|
);
|
|
}
|
|
return { cacheRoot, profiles: results };
|
|
} finally {
|
|
await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|