diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 8870fbe1b..3904d65ef 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -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 { 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 { 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`); } diff --git a/packages/cli/src/legacy-c-warning.test.ts b/packages/cli/src/legacy-c-warning.test.ts new file mode 100644 index 000000000..cf858c486 --- /dev/null +++ b/packages/cli/src/legacy-c-warning.test.ts @@ -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); +}); diff --git a/packages/cli/src/legacy-c-warning.ts b/packages/cli/src/legacy-c-warning.ts new file mode 100644 index 000000000..930f8be19 --- /dev/null +++ b/packages/cli/src/legacy-c-warning.ts @@ -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"] !== ""; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index dfb098b76..bc9af9258 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -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 { // 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; diff --git a/packages/cli/test/bootstrap.test.ts b/packages/cli/test/bootstrap.test.ts index 4781daefd..420f4761c 100644 --- a/packages/cli/test/bootstrap.test.ts +++ b/packages/cli/test/bootstrap.test.ts @@ -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, +); diff --git a/packages/cli/test/executable-cache.test.ts b/packages/cli/test/executable-cache.test.ts index 77398473d..453589471 100644 --- a/packages/cli/test/executable-cache.test.ts +++ b/packages/cli/test/executable-cache.test.ts @@ -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 }); diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index db7234aa7..9d86e43a1 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -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 { + 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 + ? `` + : ``; + } 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 { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 0e7b1657e..68b8f1ec1 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -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", () => { diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c87eb8376..5b2a58927 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -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,