Fix linker cache invalidation and warnings

This commit is contained in:
Chris Tate
2026-08-31 19:52:41 -05:00
parent b777c059b6
commit 402c84f26b
9 changed files with 171 additions and 24 deletions
+10 -1
View File
@@ -8,6 +8,7 @@ import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { hostSupportsRuntimePack } from "../scripts/runtime-pack-host.mjs";
import { LEGACY_C_EXECUTABLE_WARNING, shouldWarnLegacyCExecutable } from "./legacy-c-warning.js";
import { CLI_OPTIONS, USAGE } from "./usage.js";
// Node 24 can persist V8's compiled module bytecode. scriptc's CLI imports
@@ -109,7 +110,7 @@ async function tryFastPath(): Promise<number | null> {
let nativeEnvironment: string | null;
try {
nativeEnvironment = helperObjectRoute
? startup.executableLinkerEnvironmentFingerprint()
? await startup.executableLinkerEnvironmentFingerprint()
: await startup.executableNativeEnvironmentFingerprint();
} catch {
nativeEnvironment = null;
@@ -134,6 +135,14 @@ async function tryFastPath(): Promise<number | null> {
nodeVersion: process.version,
});
if (hit === null) return null;
if (shouldWarnLegacyCExecutable({
executable: true,
fromC: false,
backend,
sanitize: values.sanitize,
})) {
process.stderr.write(LEGACY_C_EXECUTABLE_WARNING);
}
if (hit.native.llvmRefusal !== undefined) {
process.stderr.write(`scriptc: backend c (llvm refused: ${hit.native.llvmRefusal})\n`);
}
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { shouldWarnLegacyCExecutable } from "./legacy-c-warning.js";
const executable = {
executable: true,
fromC: false,
backend: undefined,
sanitize: false,
};
test("warns only for generated legacy C executables on runtime-pack hosts", () => {
expect(shouldWarnLegacyCExecutable(executable, { SCRIPTC_CC: "clang" }, true)).toBe(true);
expect(shouldWarnLegacyCExecutable({ ...executable, fromC: true }, { SCRIPTC_CC: "clang" }, true)).toBe(false);
expect(shouldWarnLegacyCExecutable({ ...executable, backend: "c" }, { SCRIPTC_CC: "clang" }, true)).toBe(false);
expect(shouldWarnLegacyCExecutable({ ...executable, sanitize: true }, { SCRIPTC_CC: "clang" }, true)).toBe(false);
expect(shouldWarnLegacyCExecutable({ ...executable, executable: false }, { SCRIPTC_CC: "clang" }, true)).toBe(false);
expect(shouldWarnLegacyCExecutable(executable, { SCRIPTC_CC: "clang", SCRIPTC_TARGET: "wasm32-wasi" }, true)).toBe(false);
expect(shouldWarnLegacyCExecutable(executable, { SCRIPTC_CC: "" }, true)).toBe(false);
expect(shouldWarnLegacyCExecutable(executable, { SCRIPTC_CC: "clang" }, false)).toBe(false);
});
+18
View File
@@ -0,0 +1,18 @@
import { hostSupportsRuntimePack } from "../scripts/runtime-pack-host.mjs";
export const LEGACY_C_EXECUTABLE_WARNING =
"scriptc: warning: SCRIPTC_CC selects the deprecated legacy C executable path on macOS arm64; " +
"unset it to use the bundled LLVM helper/runtime pack, or use SCRIPTC_LINKER to select the platform linker driver\n";
export function shouldWarnLegacyCExecutable(options: {
executable: boolean;
fromC: boolean;
backend: "c" | "llvm" | undefined;
sanitize: boolean;
}, env: NodeJS.ProcessEnv = process.env, runtimePackHost = hostSupportsRuntimePack()): boolean {
return options.executable && !options.fromC &&
options.backend !== "c" && !options.sanitize &&
(env["SCRIPTC_TARGET"] ?? "") === "" &&
runtimePackHost &&
env["SCRIPTC_CC"] !== undefined && env["SCRIPTC_CC"] !== "";
}
+8 -10
View File
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { analyze, buildTargetPlatform, compile, compileExternalC, compileLibrary, isExactExternalTypeSpecifier, renderDiagnostics, renderCoverage, resolveProvenanceSources, setProvenanceSources, warmNativeCaches, type NativeCacheWarmProfile } from "@scriptc/compiler";
import { hostSupportsRuntimePack } from "../scripts/runtime-pack-host.mjs";
import { LEGACY_C_EXECUTABLE_WARNING, shouldWarnLegacyCExecutable } from "./legacy-c-warning.js";
import { resolveOutputOptions } from "./output-options.js";
import { selectOutputPaths } from "./paths.js";
import { CLI_OPTIONS, USAGE } from "./usage.js";
@@ -263,15 +263,13 @@ async function main(): Promise<number> {
// SCRIPTC_CC remains a migration escape hatch for explicit C, sanitizer,
// and comparison builds. The normal LLVM executable route is controlled by
// SCRIPTC_LINKER, which receives objects and archives only.
if (
output.outputKind === "exe" && values.backend !== "c" && !values.sanitize &&
(process.env["SCRIPTC_TARGET"] ?? "") === "" && hostSupportsRuntimePack() &&
process.env["SCRIPTC_CC"] !== undefined && process.env["SCRIPTC_CC"] !== ""
) {
process.stderr.write(
"scriptc: warning: SCRIPTC_CC selects the deprecated legacy C executable path on macOS arm64; " +
"unset it to use the bundled LLVM helper/runtime pack, or use SCRIPTC_LINKER to select the platform linker driver\n",
);
if (shouldWarnLegacyCExecutable({
executable: output.outputKind === "exe",
fromC: values["from-c"],
backend: values.backend,
sanitize: values.sanitize,
})) {
process.stderr.write(LEGACY_C_EXECUTABLE_WARNING);
}
let nativeLinkInfo: object | undefined;
+38 -1
View File
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { release as osRelease, tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
@@ -8,6 +8,8 @@ import { expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../../..");
const bootstrap = join(repoRoot, "packages/cli/dist/bootstrap.js");
const runtimePackHost = process.platform === "darwin" && process.arch === "arm64" &&
Number.parseInt(osRelease().split(".", 1)[0] ?? "", 10) >= 24;
test("bootstrap serves version and help without loading the compiler graph", async () => {
const preloadDir = await mkdtemp(join(tmpdir(), "scriptc-bootstrap-preload-"));
@@ -92,3 +94,38 @@ test("bootstrap exact builds use the routed cache and source edits fall through"
await rm(dir, { recursive: true, force: true });
}
}, 120_000);
test.skipIf(!runtimePackHost)(
"bootstrap cache hits retain the legacy C executable warning",
async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-bootstrap-legacy-warning-"));
const cacheRoot = join(dir, "cache");
const entry = join(dir, "main.ts");
const outPath = join(dir, "program");
const env = {
...process.env,
SCRIPTC_CACHE_DIR: cacheRoot,
SCRIPTC_CC: "clang",
SCRIPTC_TIMING: "1",
};
delete env.SCRIPTC_NO_CACHE;
const build = () => execFileAsync(
process.execPath,
[bootstrap, "build", entry, "-o", outPath],
{ env, maxBuffer: 4 * 1024 * 1024 },
);
try {
await writeFile(entry, 'console.log("legacy warning");\n');
const first = await build();
expect(first.stderr).toContain("deprecated legacy C executable path");
expect(first.stderr).toContain("scriptc lowering");
const cached = await build();
expect(cached.stderr).toContain("deprecated legacy C executable path");
expect(cached.stderr).not.toContain("scriptc lowering");
} finally {
await rm(dir, { recursive: true, force: true });
}
},
120_000,
);
+6 -2
View File
@@ -102,7 +102,7 @@ test("--from-c forwards the dev optimization posture", async () => {
"}",
"",
].join("\n"));
await execFileAsync(
const result = await execFileAsync(
process.execPath,
[
"--import",
@@ -116,8 +116,12 @@ test("--from-c forwards the dev optimization posture", async () => {
"-o",
outPath,
],
{ maxBuffer: 4 * 1024 * 1024 },
{
env: { ...process.env, SCRIPTC_CC: "clang" },
maxBuffer: 4 * 1024 * 1024,
},
);
expect(result.stderr).not.toContain("deprecated legacy C executable path");
await expect(execFileAsync(outPath)).resolves.toMatchObject({ stdout: "" });
} finally {
await rm(dir, { recursive: true, force: true });
+34 -8
View File
@@ -5,7 +5,7 @@
* runtime C as part of linking.
*/
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, realpathSync, statSync } from "node:fs";
import { mkdtemp, rename, rm, stat } from "node:fs/promises";
import { delimiter } from "node:path";
@@ -16,6 +16,7 @@ import {
nativeArtifactDependenciesStillMatch,
parseLinkTraceFiles,
subprocessFailureDetail,
toolchainEnvironmentCachePolicy,
toolchainEnvironmentFingerprint,
type NativeArtifactDependency,
} from "./native-toolchain.js";
@@ -35,8 +36,10 @@ export function resolvePlatformLinker(env: NodeJS.ProcessEnv = process.env): str
* must still invalidate an early executable when a new `clang` appears ahead
* of an unchanged PATH entry; relying on the string "clang" would restore a
* binary past a wrapper that injects link inputs. */
function platformLinkerIdentity(env: NodeJS.ProcessEnv): string {
const linker = resolvePlatformLinker(env);
function platformLinkerIdentity(
env: NodeJS.ProcessEnv,
linker: string = resolvePlatformLinker(env),
): string {
const hasSeparator = linker.includes("/") || linker.includes("\\");
const pathEntries = hasSeparator
? [""]
@@ -69,13 +72,34 @@ function platformLinkerIdentity(env: NodeJS.ProcessEnv): string {
* linker's resolved SDK/CRT inputs before it restores an executable. This
* deliberately performs no synthetic C compilation: the linker receives
* only the helper-produced program object and runtime-pack artifacts. */
export function executableLinkerEnvironmentFingerprint(
export async function executableLinkerEnvironmentFingerprint(
env: NodeJS.ProcessEnv = process.env,
): string {
): Promise<string> {
const linker = resolvePlatformLinker(env);
const linkerIdentity = platformLinkerIdentity(env);
let effectiveDriverIdentity: string;
try {
const selected = await execFileAsync(linker, ["-print-prog-name=clang"], {
env,
maxBuffer: 16 * 1024 * 1024,
});
const selectedDriver = selected.stdout.trim();
if (selectedDriver === "") throw new Error("linker driver reported no effective clang");
effectiveDriverIdentity = platformLinkerIdentity(env, selectedDriver);
} catch {
// A driver that cannot expose its effective clang may still link correctly,
// but the trusted default cannot safely address an existing early
// executable entry. Explicit drivers never publish complete executables,
// so their own file identity remains a sufficient stable partial-cache key.
effectiveDriverIdentity = env["SCRIPTC_LINKER"] === undefined
? `<unavailable:${linker}:${randomUUID()}>`
: `<unavailable:${linker}>`;
}
const hash = createHash("sha256")
.update("executable-linker-environment-v1\0")
.update("executable-linker-environment-v2\0")
.update(toolchainEnvironmentFingerprint(env)).update("\0")
.update(platformLinkerIdentity(env)).update("\0");
.update(linkerIdentity).update("\0")
.update(effectiveDriverIdentity).update("\0");
for (const name of ["PATH", "SCRIPTC_FETCH_CURL", "SCRIPTC_TEST_RUNTIME_SRC_DIR"] as const) {
const value = env[name];
hash.update(name).update(value === undefined ? "\0unset\0" : "\0set\0").update(value ?? "").update("\0");
@@ -92,7 +116,9 @@ export function platformLinkerSupportsPersistentCache(
// The Apple system shim is a stable front door to the active SDK/linker;
// linkNativeExecutable snapshots the selected transitive inputs before it
// publishes the final cache entry. A PATH wrapper is intentionally opaque.
return env["SCRIPTC_LINKER"] === undefined && platformLinkerIdentity(env).startsWith("/usr/bin/clang\0");
return env["SCRIPTC_LINKER"] === undefined &&
toolchainEnvironmentCachePolicy(env).completeArtifacts &&
platformLinkerIdentity(env).startsWith("/usr/bin/clang\0");
}
async function snapshotDependencies(paths: readonly string[]): Promise<NativeArtifactDependency[]> {
@@ -15,7 +15,12 @@ import {
type RuntimePackManifest,
} from "./runtime-pack.js";
import { createNativeLinkPlan } from "./link-plan.js";
import { linkNativeExecutable, platformLinkerSupportsPersistentCache, resolvePlatformLinker } from "./linker.js";
import {
executableLinkerEnvironmentFingerprint,
linkNativeExecutable,
platformLinkerSupportsPersistentCache,
resolvePlatformLinker,
} from "./linker.js";
import { MACOS_ARM64_TARGET } from "./targets.js";
const VERSION = compilerReleaseVersion();
@@ -127,6 +132,36 @@ describe("runtime pack manifests", () => {
expect(resolvePlatformLinker({})).toBe("clang");
expect(resolvePlatformLinker({ SCRIPTC_LINKER: "ld-driver" })).toBe("ld-driver");
expect(platformLinkerSupportsPersistentCache({ SCRIPTC_LINKER: "wrapper" })).toBe(false);
expect(platformLinkerSupportsPersistentCache({ LIBRARY_PATH: "/mutable" })).toBe(false);
expect(platformLinkerSupportsPersistentCache({ SDKROOT: "/mutable" })).toBe(false);
});
test("the linker environment follows the effective clang behind a stable driver", async () => {
const root = await mkdtemp(join(tmpdir(), "scriptc-linker-environment-"));
const linker = join(root, "linker.mjs");
const selectedOne = join(root, "selected-one");
const selectedTwo = join(root, "selected-two");
await Promise.all([
writeFile(selectedOne, "one"),
writeFile(selectedTwo, "two"),
writeFile(linker, [
"#!/bin/sh",
'test "$1" = "-print-prog-name=clang" || exit 2',
'printf "%s\\n" "$SCRIPTC_TEST_SELECTED_CLANG"',
"",
].join("\n")),
]);
await chmod(linker, 0o755);
const first = await executableLinkerEnvironmentFingerprint({
SCRIPTC_LINKER: linker,
SCRIPTC_TEST_SELECTED_CLANG: selectedOne,
});
const second = await executableLinkerEnvironmentFingerprint({
SCRIPTC_LINKER: linker,
SCRIPTC_TEST_SELECTED_CLANG: selectedTwo,
});
expect(second).not.toBe(first);
});
test("feature implications and predicates are deterministic", () => {
+1 -1
View File
@@ -1359,7 +1359,7 @@ async function compileTracked(
: (process.env["SCRIPTC_CC"] ?? "clang"),
],
nativeEnvironment: helperObjectRoute
? executableLinkerEnvironmentFingerprint()
? await executableLinkerEnvironmentFingerprint()
: await executableNativeEnvironmentFingerprint(),
nodeVersion: process.version,
implementation: implementation.digest,