mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
perf: preseed native build caches (#192)
* perf: preseed native build caches - Warm runtime, TLS, and dynamic-engine artifacts during npm installation - Add an explicit toolchain-aware cache warm command for images and CI - Move vendor artifacts into the user cache and build QuickJS directly * fix: harden native cache warming * fix: promote staged vendor cache entries * fix: isolate shared native cache entries * fix: validate shared vendor cache artifacts
This commit is contained in:
+1
-1
@@ -14,6 +14,6 @@ To prepare a release:
|
||||
|
||||
CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds the workspace, verifies all three package versions match (a mismatch fails with a hint to run `scripts/sync-versions.mjs`), and publishes to npm in dependency order — `@scriptc/runtime`, then `@scriptc/compiler`, then `scriptc` — so each package's dependencies are resolvable the moment it lands. After the publish succeeds, a separate job creates the git tag `v<version>` and the GitHub release with the marked changelog entry as its body, and attaches `surface-manifest.json` — the machine-readable listing of the surface the static tier compiles at that version (stable per-entry ids, so two releases diff mechanically; see `packages/compiler/src/coverage/surface-manifest.ts` for the schema). The job regenerates the manifest from the tree and fails on any byte difference from the committed file before attaching, so the asset is always the manifest of the code being released. The same file ships inside the `@scriptc/compiler` package as `@scriptc/compiler/surface-manifest.json`.
|
||||
|
||||
Two deliberate differences from repositories that ship prebuilt binaries: there are no platform binary assets to build or stage — scriptc compiles programs on the user's machine with the local clang — so the GitHub release is a tag, release notes, and the manifest asset only, and the npm publish never waits on the GitHub release (the release job runs after the publish, not before it).
|
||||
Two deliberate differences from repositories that ship prebuilt binaries: there are no platform binary assets to build or stage — scriptc compiles programs on the user's machine with the local clang. The npm package's best-effort postinstall warms runtime, TLS, and engine caches against that exact local toolchain; it does not ship foreign objects. The GitHub release is therefore a tag, release notes, and the manifest asset only, and the npm publish never waits on the GitHub release (the release job runs after the publish, not before it).
|
||||
|
||||
Publishing uses npm trusted publishing (OIDC) — there is no npm token secret. The one-time setup is already done: each of the three packages is configured on npmjs.com with a GitHub Actions trusted publisher pointing at repository `vercel-labs/scriptc`, workflow `release.yml`, environment `Release`. A package missing that configuration fails with an OIDC authentication error before anything is uploaded. Re-runs are safe: any package already on the registry at the target version is skipped, so a partially published release can be resumed by re-running the workflow.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLI Reference
|
||||
|
||||
The CLI has three commands. `scriptc --help` prints this same surface.
|
||||
The CLI has four commands. `scriptc --help` prints this same surface.
|
||||
|
||||
```console
|
||||
$ scriptc --help
|
||||
@@ -13,6 +13,8 @@ Usage:
|
||||
scriptc coverage <file.ts|.js> --dynamic what a --dynamic build compiles, and what still blocks it
|
||||
scriptc coverage <file.ts|.js> --external-types <specifier=file.d.ts>
|
||||
type-resolve an embedder-provided module for analysis
|
||||
scriptc cache warm [runtime|tls|dynamic…] prebuild expensive native cache families
|
||||
for the current compiler/SDK/target
|
||||
```
|
||||
|
||||
## scriptc build
|
||||
@@ -50,6 +52,10 @@ Note: `run` does not forward extra command-line arguments to the program. To pas
|
||||
|
||||
Analyzes the program without producing a binary and reports, statement by statement, what compiles statically, what needs the embedded dynamic engine, and what blocks the rest. With `--dynamic` it answers a different question: what would a `--dynamic` build compile, and what still blocks it. Both forms are covered in depth in [Coverage Reports](/coverage).
|
||||
|
||||
## scriptc cache warm
|
||||
|
||||
Prebuilds the release runtime objects and native TLS/dynamic-engine archives against the currently selected compiler, SDK, and target. npm installations run this best-effort automatically; use the explicit command when preparing a container image, CI runner, or installation whose lifecycle scripts were disabled. Pass one or more of `runtime`, `tls`, and `dynamic` to seed only those families. Warming applies to persistently cached native executable targets; WASI and mobile library targets report a target-level error. It also reports an error when mutable toolchain inputs have disabled persistent caching, rather than doing disposable work. The entries use the ordinary strict cache identities, and later builds still revalidate their compiler and dependency inputs.
|
||||
|
||||
## Options
|
||||
|
||||
<dl>
|
||||
|
||||
@@ -14,13 +14,15 @@
|
||||
"scriptc": "dist/bootstrap.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"scripts"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
|
||||
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"postinstall": "node scripts/warm-cache.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scriptc/compiler": "workspace:*"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Workspace installs should stay cheap and deterministic: repository test
|
||||
// images already manage cache warming explicitly. Published npm packages do
|
||||
// not contain src/, so only installed consumers take this best-effort path.
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
if (!existsSync(join(packageRoot, "src")) && process.env["SCRIPTC_NO_CACHE"] !== "1") {
|
||||
try {
|
||||
const { warmNativeCaches } = await import("@scriptc/compiler");
|
||||
await warmNativeCaches();
|
||||
} catch (error) {
|
||||
// Installation must never depend on a native toolchain. A machine without
|
||||
// clang/ar (or with lifecycle scripts disabled) retains ordinary lazy
|
||||
// first-build behavior; explicit `scriptc cache warm` reports errors.
|
||||
if (process.env["SCRIPTC_CACHE_WARM_DEBUG"] === "1") {
|
||||
process.stderr.write(`scriptc: cache warm skipped: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { analyze, buildTargetPlatform, compile, compileC, compileLibrary, isExactExternalTypeSpecifier, renderAll, renderCoverage, resolveProvenanceSources, setProvenanceSources } from "@scriptc/compiler";
|
||||
import { analyze, buildTargetPlatform, compile, compileC, compileLibrary, isExactExternalTypeSpecifier, renderAll, renderCoverage, resolveProvenanceSources, setProvenanceSources, warmNativeCaches, type NativeCacheWarmProfile } from "@scriptc/compiler";
|
||||
import { defaultExecutableName } from "./paths.js";
|
||||
import { CLI_OPTIONS, USAGE } from "./usage.js";
|
||||
|
||||
@@ -67,6 +67,40 @@ async function main(): Promise<number> {
|
||||
}
|
||||
|
||||
const [command, inputArg] = positionals;
|
||||
if (command === "cache") {
|
||||
if (inputArg !== "warm") fail(`unknown cache command "${inputArg ?? ""}" (supported: warm)\n\n${USAGE}`);
|
||||
if (values.lib || values.dynamic || values.backend !== undefined || values["from-c"] || values.ffi !== undefined || values.profile !== undefined || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || !values["keep-c"]) {
|
||||
fail(`scriptc cache warm takes only native optimization/sanitizer options and profile names\n\n${USAGE}`);
|
||||
}
|
||||
const optimization = values.optimization;
|
||||
if (optimization !== undefined && optimization !== "release" && optimization !== "dev") {
|
||||
fail(`unknown optimization "${optimization}" (supported: release, dev)\n\n${USAGE}`);
|
||||
}
|
||||
const profileArgs = positionals.slice(2);
|
||||
const knownProfiles = new Set<NativeCacheWarmProfile>(["runtime", "tls", "dynamic"]);
|
||||
for (const profile of profileArgs) {
|
||||
if (!knownProfiles.has(profile as NativeCacheWarmProfile)) {
|
||||
fail(`unknown cache warm profile "${profile}" (supported: runtime, tls, dynamic)`);
|
||||
}
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await warmNativeCaches({
|
||||
...(optimization === undefined ? {} : { optimization }),
|
||||
sanitize: values.sanitize,
|
||||
...(profileArgs.length === 0
|
||||
? {}
|
||||
: { profiles: profileArgs as NativeCacheWarmProfile[] }),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(`scriptc: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
process.stdout.write(`${result.cacheRoot}\n`);
|
||||
for (const profile of result.profiles) {
|
||||
process.stdout.write(`${profile.profile}\t${Math.round(profile.elapsedMs)}ms\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (command !== "build" && command !== "run" && command !== "coverage") {
|
||||
fail(`unknown command "${command}"\n\n${USAGE}`);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ Usage:
|
||||
profile-declared C symbols; a profile
|
||||
with a sidecar section also gets the
|
||||
contract sidecar JSON beside the archive
|
||||
scriptc cache warm [runtime|tls|dynamic…] prebuild expensive native cache families
|
||||
for the current compiler/SDK/target
|
||||
|
||||
Options:
|
||||
-o, --out <path> output path (default: .scriptc/<name>[.exe|.wasm])
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
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 runtimePackageRoot = join(repoRoot, "packages/runtime");
|
||||
|
||||
test("cache warm accepts focused profiles and rejects unknown ones", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-cli-cache-warm-"));
|
||||
const cacheRoot = join(dir, "cache");
|
||||
try {
|
||||
const env = { ...process.env, SCRIPTC_CACHE_DIR: cacheRoot };
|
||||
const warmed = await execFileAsync(
|
||||
process.execPath,
|
||||
[bootstrap, "cache", "warm", "runtime"],
|
||||
{ env, maxBuffer: 4 * 1024 * 1024 },
|
||||
);
|
||||
expect(warmed.stdout).toContain(`${cacheRoot}\n`);
|
||||
expect(warmed.stdout).toMatch(/runtime\t\d+ms/);
|
||||
expect(warmed.stdout).not.toContain("tls\t");
|
||||
expect((await readdir(join(cacheRoot, "obj"))).length).toBeGreaterThan(0);
|
||||
|
||||
await expect(
|
||||
execFileAsync(process.execPath, [bootstrap, "cache", "warm", "unknown"], { env }),
|
||||
).rejects.toMatchObject({ stderr: expect.stringContaining("unknown cache warm profile") });
|
||||
|
||||
await expect(
|
||||
execFileAsync(process.execPath, [bootstrap, "cache", "warm", "runtime"], {
|
||||
env: { ...env, CPATH: dir },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining("requires a persistently cacheable compiler environment"),
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("the runtime npm tarball excludes legacy package-local vendor caches", async () => {
|
||||
const fixture = join(runtimePackageRoot, "vendor", ".cache", "package-test-fixture");
|
||||
try {
|
||||
await mkdir(fixture, { recursive: true });
|
||||
await writeFile(join(fixture, "foreign-native-object.o"), "must not ship\n");
|
||||
const packed = await execFileAsync(
|
||||
"pnpm",
|
||||
["pack", "--dry-run", "--json"],
|
||||
{ cwd: runtimePackageRoot, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const manifest = JSON.parse(packed.stdout) as { files: { path: string }[] };
|
||||
expect(manifest.files.some(({ path }) => path.startsWith("vendor/.cache/"))).toBe(false);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -17,10 +17,12 @@ import {
|
||||
runtimeFingerprint,
|
||||
runtimeSrcDir,
|
||||
stageRuntimeObjects,
|
||||
supportedNativeCacheWarmProfiles,
|
||||
toolchainEnvironmentCachePolicy,
|
||||
toolchainEnvironmentFingerprint,
|
||||
vendorCacheBuildIdentity,
|
||||
vendorCacheTargetFlavor,
|
||||
warmNativeCaches,
|
||||
} from "./cc.js";
|
||||
import { splitLlvmProgram } from "./llvm/split.js";
|
||||
|
||||
@@ -246,9 +248,11 @@ test("the toolchain environment joins cache identities", () => {
|
||||
completeArtifacts: false,
|
||||
runtimeObjects: false,
|
||||
});
|
||||
// scriptc invokes its compiler and archiver directly; conventional build-
|
||||
// system variables do not alter those commands.
|
||||
expect(toolchainEnvironmentCachePolicy({ CFLAGS: "-I/headers" })).toEqual({
|
||||
completeArtifacts: false,
|
||||
runtimeObjects: false,
|
||||
completeArtifacts: true,
|
||||
runtimeObjects: true,
|
||||
});
|
||||
expect(toolchainEnvironmentCachePolicy({ ZIG_LIB_DIR: "/zig/lib" })).toEqual({
|
||||
completeArtifacts: false,
|
||||
@@ -259,15 +263,19 @@ test("the toolchain environment joins cache identities", () => {
|
||||
runtimeObjects: false,
|
||||
});
|
||||
|
||||
expect(vendorCacheBuildIdentity(base, "compiler-one")).not.toBe(
|
||||
vendorCacheBuildIdentity(base, "compiler-two"),
|
||||
expect(vendorCacheBuildIdentity(base, "compiler-one", "sources-one")).not.toBe(
|
||||
vendorCacheBuildIdentity(base, "compiler-two", "sources-one"),
|
||||
);
|
||||
expect(vendorCacheBuildIdentity(base, "compiler-one")).not.toBe(
|
||||
expect(vendorCacheBuildIdentity(base, "compiler-one", "sources-one")).not.toBe(
|
||||
vendorCacheBuildIdentity(
|
||||
toolchainEnvironmentFingerprint({ MACOSX_DEPLOYMENT_TARGET: "11.0" }),
|
||||
"compiler-one",
|
||||
"sources-one",
|
||||
),
|
||||
);
|
||||
expect(vendorCacheBuildIdentity(base, "compiler-one", "sources-one")).not.toBe(
|
||||
vendorCacheBuildIdentity(base, "compiler-one", "sources-two"),
|
||||
);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
@@ -1830,6 +1838,337 @@ test("the hard disable bypasses vendor prerequisite caches", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"shared vendor prerequisites re-key when vendored source bytes change",
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-vendor-source-identity-"));
|
||||
scratch.push(dir);
|
||||
const cacheRoot = join(dir, "cache");
|
||||
const fakeRuntimeRoot = join(dir, "runtime");
|
||||
const fakeRuntime = join(fakeRuntimeRoot, "src");
|
||||
const originalRuntime = runtimeSrcDir();
|
||||
const cPath = join(dir, "program.c");
|
||||
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
||||
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
||||
const oldRuntimeDir = process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"];
|
||||
const oldVendorCacheDir = process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
cp(originalRuntime, fakeRuntime, { recursive: true }),
|
||||
mkdir(join(fakeRuntimeRoot, "vendor"), { recursive: true }).then(() =>
|
||||
Promise.all(["quickjs-ng", "ryu"].map((name) =>
|
||||
cp(
|
||||
join(originalRuntime, "..", "vendor", name),
|
||||
join(fakeRuntimeRoot, "vendor", name),
|
||||
{ recursive: true },
|
||||
)
|
||||
))
|
||||
),
|
||||
]);
|
||||
process.env["SCRIPTC_CACHE_DIR"] = cacheRoot;
|
||||
process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"] = fakeRuntime;
|
||||
delete process.env["SCRIPTC_NO_CACHE"];
|
||||
delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
await writeFile(cPath, "int main(void) { return 0; }\n");
|
||||
|
||||
await compileC({
|
||||
cPath,
|
||||
outPath: join(dir, "first"),
|
||||
cacheIdentity: TEST_CACHE_IDENTITY,
|
||||
regex: true,
|
||||
});
|
||||
const vendorRoot = join(cacheRoot, "vendor");
|
||||
expect((await readdir(vendorRoot)).filter((name) => name.includes("-lre-"))).toHaveLength(1);
|
||||
|
||||
const libregexp = join(fakeRuntimeRoot, "vendor", "quickjs-ng", "libregexp.c");
|
||||
await writeFile(libregexp, `${await readFile(libregexp, "utf8")}\n/* cache identity probe */\n`);
|
||||
await writeFile(cPath, "int main(void) { return 0; } /* second */\n");
|
||||
await compileC({
|
||||
cPath,
|
||||
outPath: join(dir, "second"),
|
||||
cacheIdentity: TEST_CACHE_IDENTITY,
|
||||
regex: true,
|
||||
});
|
||||
expect((await readdir(vendorRoot)).filter((name) => name.includes("-lre-"))).toHaveLength(2);
|
||||
} finally {
|
||||
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
||||
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
||||
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
||||
if (oldRuntimeDir === undefined) delete process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"];
|
||||
else process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"] = oldRuntimeDir;
|
||||
if (oldVendorCacheDir === undefined) delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"] = oldVendorCacheDir;
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"uncached arbitrary C keeps vendor prerequisites outside a read-only runtime package",
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-readonly-runtime-"));
|
||||
scratch.push(dir);
|
||||
const fakeRuntimeRoot = join(dir, "runtime");
|
||||
const fakeRuntime = join(fakeRuntimeRoot, "src");
|
||||
const fakeVendor = join(fakeRuntimeRoot, "vendor");
|
||||
const originalRuntime = runtimeSrcDir();
|
||||
const cPath = join(dir, "program.c");
|
||||
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
||||
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
||||
const oldRuntimeDir = process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"];
|
||||
const oldVendorCacheDir = process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
cp(originalRuntime, fakeRuntime, { recursive: true }),
|
||||
mkdir(fakeVendor, { recursive: true }).then(() =>
|
||||
Promise.all(["quickjs-ng", "ryu"].map((name) =>
|
||||
cp(join(originalRuntime, "..", "vendor", name), join(fakeVendor, name), {
|
||||
recursive: true,
|
||||
})
|
||||
))
|
||||
),
|
||||
]);
|
||||
process.env["SCRIPTC_CACHE_DIR"] = join(dir, "cache");
|
||||
process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"] = fakeRuntime;
|
||||
delete process.env["SCRIPTC_NO_CACHE"];
|
||||
delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
await writeFile(cPath, "int main(void) { return 0; }\n");
|
||||
await chmod(fakeVendor, 0o555);
|
||||
|
||||
const outPath = join(dir, "program");
|
||||
await compileC({ cPath, outPath, regex: true });
|
||||
expect((await stat(outPath)).isFile()).toBe(true);
|
||||
await expect(stat(join(fakeVendor, ".cache"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await chmod(fakeVendor, 0o755).catch(() => undefined);
|
||||
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
||||
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
||||
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
||||
if (oldRuntimeDir === undefined) delete process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"];
|
||||
else process.env["SCRIPTC_TEST_RUNTIME_SRC_DIR"] = oldRuntimeDir;
|
||||
if (oldVendorCacheDir === undefined) delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"] = oldVendorCacheDir;
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
test("native cache warming seeds exact runtime and vendor families without complete binaries", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-cache-warm-"));
|
||||
scratch.push(dir);
|
||||
const cacheRoot = join(dir, "cache");
|
||||
const vendorCacheRoot = join(cacheRoot, "vendor");
|
||||
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
||||
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
||||
const oldVendorCacheDir = process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
try {
|
||||
process.env["SCRIPTC_CACHE_DIR"] = cacheRoot;
|
||||
delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
delete process.env["SCRIPTC_NO_CACHE"];
|
||||
const warmed = await warmNativeCaches({ profiles: ["runtime", "tls", "dynamic"] });
|
||||
expect(warmed.cacheRoot).toBe(cacheRoot);
|
||||
expect(warmed.profiles.map(({ profile }) => profile)).toEqual(["runtime", "tls", "dynamic"]);
|
||||
expect(warmed.profiles.every(({ elapsedMs }) => elapsedMs >= 0)).toBe(true);
|
||||
expect((await readdir(join(cacheRoot, "obj"), { withFileTypes: true })).filter((e) => e.isDirectory())).toHaveLength(3);
|
||||
expect(await readdir(join(cacheRoot, "bin")).catch(() => [])).toEqual([]);
|
||||
const vendorEntries = await readdir(vendorCacheRoot);
|
||||
const tlsEntry = vendorEntries.find((name) => name.startsWith("mbedtls-"));
|
||||
const engineEntry = vendorEntries.find((name) => /^3c8f3d689539-plain-/.test(name));
|
||||
expect(tlsEntry).toBeDefined();
|
||||
expect(engineEntry).toBeDefined();
|
||||
expect((await stat(join(vendorCacheRoot, tlsEntry!, "libmbedtls.a.sha256"))).isFile()).toBe(true);
|
||||
expect((await stat(join(vendorCacheRoot, engineEntry!, "libqjs.a.sha256"))).isFile()).toBe(true);
|
||||
} finally {
|
||||
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
||||
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
||||
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
||||
if (oldVendorCacheDir === undefined) delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"] = oldVendorCacheDir;
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("damaged shared vendor archives are rejected and rebuilt before linking", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-vendor-integrity-"));
|
||||
scratch.push(dir);
|
||||
const cacheRoot = join(dir, "cache");
|
||||
const cPath = join(dir, "program.c");
|
||||
const outPath = join(dir, "program");
|
||||
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
||||
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
||||
const oldVendorCacheDir = process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
const corruption = Buffer.from("scriptc-corrupt-vendor-archive\n");
|
||||
try {
|
||||
process.env["SCRIPTC_CACHE_DIR"] = cacheRoot;
|
||||
delete process.env["SCRIPTC_NO_CACHE"];
|
||||
delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
await writeFile(cPath, "int main(void) { return 0; }\n");
|
||||
await warmNativeCaches({ profiles: ["dynamic"] });
|
||||
|
||||
const vendorRoot = join(cacheRoot, "vendor");
|
||||
const engineDir = (await readdir(vendorRoot)).find((name) =>
|
||||
/^3c8f3d689539-plain-/.test(name)
|
||||
);
|
||||
expect(engineDir).toBeDefined();
|
||||
const engineArchive = join(vendorRoot, engineDir!, "libqjs.a");
|
||||
expect((await stat(`${engineArchive}.sha256`)).isFile()).toBe(true);
|
||||
await writeFile(engineArchive, corruption);
|
||||
|
||||
await compileC({
|
||||
cPath,
|
||||
outPath,
|
||||
cacheIdentity: TEST_CACHE_IDENTITY,
|
||||
dynamic: true,
|
||||
});
|
||||
|
||||
expect((await stat(outPath)).isFile()).toBe(true);
|
||||
const repaired = await readFile(engineArchive);
|
||||
expect(repaired).not.toEqual(corruption);
|
||||
expect((await readFile(`${engineArchive}.sha256`, "utf8")).trim()).toBe(
|
||||
createHash("sha256").update(repaired).digest("hex"),
|
||||
);
|
||||
} finally {
|
||||
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
||||
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
||||
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
||||
if (oldVendorCacheDir === undefined) delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"] = oldVendorCacheDir;
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("dynamic builds promote staged vendor archives before bounded LRU eviction", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-vendor-lru-"));
|
||||
scratch.push(dir);
|
||||
const cacheRoot = join(dir, "cache");
|
||||
const cPath = join(dir, "program.c");
|
||||
const outPath = join(dir, "program");
|
||||
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
||||
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
||||
const oldMax = process.env["SCRIPTC_CACHE_MAX_MB"];
|
||||
const oldVendorCacheDir = process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
try {
|
||||
process.env["SCRIPTC_CACHE_DIR"] = cacheRoot;
|
||||
delete process.env["SCRIPTC_NO_CACHE"];
|
||||
delete process.env["SCRIPTC_CACHE_MAX_MB"];
|
||||
delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
await writeFile(cPath, "int main(void) { return 0; }\n");
|
||||
await warmNativeCaches({ profiles: ["dynamic"] });
|
||||
|
||||
const vendorRoot = join(cacheRoot, "vendor");
|
||||
const engineDir = (await readdir(vendorRoot)).find((name) =>
|
||||
/^3c8f3d689539-plain-/.test(name)
|
||||
);
|
||||
expect(engineDir).toBeDefined();
|
||||
const engineArchive = join(vendorRoot, engineDir!, "libqjs.a");
|
||||
const initialBytes = await cacheTreeBytes(cacheRoot);
|
||||
const capMb = Math.max(4, Math.ceil(initialBytes * 2 / (1024 * 1024)));
|
||||
const filler = join(cacheRoot, "filler.bin");
|
||||
const staleArchiveTime = new Date("2000-01-01T00:00:00.000Z");
|
||||
const fillerTime = new Date("2001-01-01T00:00:00.000Z");
|
||||
await writeFile(filler, Buffer.alloc(capMb * 1024 * 1024));
|
||||
await utimes(engineArchive, staleArchiveTime, staleArchiveTime);
|
||||
await utimes(filler, fillerTime, fillerTime);
|
||||
process.env["SCRIPTC_CACHE_MAX_MB"] = String(capMb);
|
||||
|
||||
await compileC({
|
||||
cPath,
|
||||
outPath,
|
||||
cacheIdentity: TEST_CACHE_IDENTITY,
|
||||
dynamic: true,
|
||||
});
|
||||
|
||||
expect((await stat(outPath)).isFile()).toBe(true);
|
||||
expect((await stat(engineArchive)).mtimeMs).toBeGreaterThan(fillerTime.getTime());
|
||||
expect(await cacheTreeBytes(cacheRoot)).toBeLessThanOrEqual(capMb * 1024 * 1024);
|
||||
} finally {
|
||||
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
||||
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
||||
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
||||
if (oldMax === undefined) delete process.env["SCRIPTC_CACHE_MAX_MB"];
|
||||
else process.env["SCRIPTC_CACHE_MAX_MB"] = oldMax;
|
||||
if (oldVendorCacheDir === undefined) delete process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_TEST_VENDOR_CACHE_DIR"] = oldVendorCacheDir;
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("native cache warming follows the hard cache disable", async () => {
|
||||
const oldNoCache = process.env["SCRIPTC_NO_CACHE"];
|
||||
try {
|
||||
process.env["SCRIPTC_NO_CACHE"] = "1";
|
||||
await expect(warmNativeCaches({ profiles: ["runtime"] })).rejects.toThrow(
|
||||
"native build cache is disabled",
|
||||
);
|
||||
} finally {
|
||||
if (oldNoCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
|
||||
else process.env["SCRIPTC_NO_CACHE"] = oldNoCache;
|
||||
}
|
||||
});
|
||||
|
||||
test("native cache warming refuses environments that disable persistent objects", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-cache-warm-disabled-"));
|
||||
scratch.push(dir);
|
||||
const cacheRoot = join(dir, "cache");
|
||||
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
||||
const oldCpath = process.env["CPATH"];
|
||||
try {
|
||||
process.env["SCRIPTC_CACHE_DIR"] = cacheRoot;
|
||||
process.env["CPATH"] = dir;
|
||||
await expect(warmNativeCaches({ profiles: ["runtime"] })).rejects.toThrow(
|
||||
"requires a persistently cacheable compiler environment",
|
||||
);
|
||||
expect(await readdir(cacheRoot)).toEqual([]);
|
||||
} finally {
|
||||
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
||||
if (oldCpath === undefined) delete process.env["CPATH"];
|
||||
else process.env["CPATH"] = oldCpath;
|
||||
}
|
||||
});
|
||||
|
||||
test("native cache warm profiles follow target capabilities", () => {
|
||||
expect(supportedNativeCacheWarmProfiles({
|
||||
argv: ["zig", "cc"],
|
||||
target: "wasm32-wasi",
|
||||
zigTarget: "wasm32-wasi",
|
||||
targetArgs: [],
|
||||
linkArgs: [],
|
||||
})).toEqual([]);
|
||||
expect(supportedNativeCacheWarmProfiles({
|
||||
argv: ["zig", "cc"],
|
||||
target: "aarch64-apple-ios",
|
||||
zigTarget: "aarch64-ios.15.0",
|
||||
targetArgs: [],
|
||||
linkArgs: [],
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
test("parallel native cache warming fails cleanly when the bounded cache cannot retain its profiles", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-cache-warm-cap-"));
|
||||
scratch.push(dir);
|
||||
const cacheRoot = join(dir, "cache");
|
||||
const oldCacheDir = process.env["SCRIPTC_CACHE_DIR"];
|
||||
const oldMax = process.env["SCRIPTC_CACHE_MAX_MB"];
|
||||
try {
|
||||
process.env["SCRIPTC_CACHE_DIR"] = cacheRoot;
|
||||
process.env["SCRIPTC_CACHE_MAX_MB"] = "2.5";
|
||||
await expect(warmNativeCaches()).rejects.toThrow(
|
||||
"SCRIPTC_CACHE_MAX_MB is too small to retain the requested native cache warm profiles",
|
||||
);
|
||||
expect(await readdir(cacheRoot)).not.toEqual([]);
|
||||
} finally {
|
||||
if (oldCacheDir === undefined) delete process.env["SCRIPTC_CACHE_DIR"];
|
||||
else process.env["SCRIPTC_CACHE_DIR"] = oldCacheDir;
|
||||
if (oldMax === undefined) delete process.env["SCRIPTC_CACHE_MAX_MB"];
|
||||
else process.env["SCRIPTC_CACHE_MAX_MB"] = oldMax;
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test.skipIf(process.platform !== "darwin")(
|
||||
"vendor object caches separate deployment-target environments",
|
||||
async () => {
|
||||
|
||||
+468
-198
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,15 @@ import { compilerImplementationIdentity } from "./library/implementation-identit
|
||||
|
||||
export const VERSION = "0.0.1";
|
||||
|
||||
export { compileC, runtimeSrcDir, type CcOptions } from "./backend/cc.js";
|
||||
export {
|
||||
compileC,
|
||||
runtimeSrcDir,
|
||||
warmNativeCaches,
|
||||
type CcOptions,
|
||||
type NativeCacheWarmProfile,
|
||||
type WarmNativeCachesOptions,
|
||||
type WarmNativeCachesResult,
|
||||
} from "./backend/cc.js";
|
||||
export { ANDROID_MIN_API, IPHONEOS_MIN_VERSION, isAndroidTarget, isIosTarget, isMobileTarget, mobileLibraryTarget, mobileTargetRefusal } from "./backend/cc.js";
|
||||
export { emitModule, type CEmitOptions } from "./backend/emission/emitter.js";
|
||||
export type { ScrDiagnostic } from "./diagnostics/diagnostic.js";
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
.cache
|
||||
Vendored
+4
-4
@@ -27,7 +27,7 @@ The QuickJS-ng JavaScript engine, embedded by the opt-in `--dynamic` build mode
|
||||
|
||||
The tree is a plain snapshot of the upstream commit with directories the library build does not need removed (tests/, docs/, examples/, test262 fixtures, CI config, generator scripts). No vendored file is modified; to update, re-clone upstream at the new commit, delete its .git directory, apply the same trim, and update this file.
|
||||
|
||||
The engine archive (libqjs.a) is built lazily on the first `--dynamic` compile into `.cache/<commit>-<flavor>-<target>-<toolchain>/` next to this file — one flavor per lane (plain, asan), native platform/architecture or explicit cross target, and compiler environment/identity. The cache directory is gitignored and safe to delete.
|
||||
The engine archive (libqjs.a) is prebuilt best-effort during npm installation (or explicitly by `scriptc cache warm`) and otherwise built lazily on the first `--dynamic` compile. It lives under the per-user build cache's `vendor/<commit>-<flavor>-<target>-<toolchain>/` directory — one flavor per lane (plain, asan), native platform/architecture or explicit cross target, and compiler environment/identity.
|
||||
|
||||
## zlib/
|
||||
|
||||
@@ -37,7 +37,7 @@ The engine archive (libqjs.a) is built lazily on the first `--dynamic` compile i
|
||||
|
||||
The CROSS-target arm of node:zlib (see packages/runtime/src/scr_zlib.c): host builds keep the historical system `-lz` link (macOS ships libz), but zig's cross sysroots have no libz, so SCRIPTC_TARGET builds compile this vendored copy per target instead. Only the flat `*.c`/`*.h` at the distribution root are vendored (LICENSE beside them) — contrib, tests, build machinery, and docs are not. No vendored file is modified; the gz* file-I/O TUs are vendored for faithfulness but never compiled (nothing references the gzFile API — see ZLIB_SOURCES in packages/compiler/src/backend/cc.ts). To update, re-fetch the release tarball, copy the same flat set, and bump `ZLIB_VERSION` in cc.ts.
|
||||
|
||||
The objects build lazily on the first zlib-using cross compile into `.cache/zlib-<version>-<flavor>-<target>-<toolchain>/` next to this file — one flavor per driver/target and compiler environment/identity (the lre-objects pattern). Zlib-free binaries never compile any of this; compressed OUTPUT bytes are zlib-version-dependent, which is why the corpus only compares round-trips and fixed-blob inflation.
|
||||
The objects build lazily on the first zlib-using cross compile into the per-user build cache's `vendor/zlib-<version>-<flavor>-<target>-<toolchain>/` directory — one flavor per driver/target and compiler environment/identity (the lre-objects pattern). Zlib-free binaries never compile any of this; compressed OUTPUT bytes are zlib-version-dependent, which is why the corpus only compares round-trips and fixed-blob inflation.
|
||||
|
||||
## curl/
|
||||
|
||||
@@ -47,7 +47,7 @@ The objects build lazily on the first zlib-using cross compile into `.cache/zlib
|
||||
|
||||
HEADERS ONLY — no curl C source is vendored and none is ever compiled. These headers now serve ONLY the RETIRED curl reference implementation of fetch (packages/runtime/src/scr_fetch_curl.c, selected by `SCRIPTC_FETCH_CURL=1`; kept compilable for one release as the native flip's reference — see scr_fetch.c, the default, which rides scr_net/scr_tls/scr_http/zlib and touches nothing here). Under the flag: host builds link the system `-lcurl` (macOS ships libcurl), and linux-gnu CROSS builds compile scr_fetch_curl.c against these headers, then link against a generated STUB `libcurl.so` (soname `libcurl.so.4`, empty definitions of exactly the symbols scr_fetch_curl.c calls — see CURL_STUB_SYMBOLS in packages/compiler/src/backend/cc.ts) so the produced binary records a plain `DT_NEEDED libcurl.so.4` that the TARGET system's real libcurl satisfies at load time, the standard cross-link import-stub technique. The 7.88.1 pin is a floor, not a lock: scr_fetch_curl.c's newest requirement is CURLOPT_PROTOCOLS_STR (7.85.0) and the unversioned symbol references bind to any libcurl.so.4. This whole directory leaves with scr_fetch_curl.c when the reference retires for good.
|
||||
|
||||
The stub builds lazily on the first flag-selected fetch cross compile into `.cache/curl-stub-<target>-<toolchain>/` next to this file (the zlib-objects pattern). Default builds never see any of this, and no build ever compiles curl itself.
|
||||
The stub builds lazily on the first flag-selected fetch cross compile into the per-user build cache's `vendor/curl-stub-<target>-<toolchain>/` directory (the zlib-objects pattern). Default builds never see any of this, and no build ever compiles curl itself.
|
||||
|
||||
## mbedtls/
|
||||
|
||||
@@ -57,4 +57,4 @@ The stub builds lazily on the first flag-selected fetch cross compile into `.cac
|
||||
|
||||
The TLS provider behind node:tls/node:https (see the design note atop packages/runtime/src/scr_tls.c for why mbedTLS over SecureTransport/BoringSSL/libtls). Only `include/` and `library/` are vendored (LICENSE beside them) — tests, docs, programs, scripts, and CMake machinery are not. No vendored file is modified and no custom config is applied (the stock `mbedtls_config.h` builds every `library/*.c` standalone with clang); to update, re-fetch the release tarball, copy the same two directories, and bump `MBEDTLS_VERSION` in packages/compiler/src/backend/cc.ts.
|
||||
|
||||
The archive (libmbedtls.a) is built lazily on the first TLS-using compile into `.cache/mbedtls-<version>-<flavor>-<target>-<toolchain>/` next to this file — one flavor per lane (plain, asan), native platform/architecture or explicit cross target, and compiler environment/identity, the libqjs.a pattern. TLS-free binaries never compile or link any of this.
|
||||
The archive (libmbedtls.a) is prebuilt best-effort during npm installation (or explicitly by `scriptc cache warm`) and otherwise built lazily on the first TLS-using compile. It lives under the per-user build cache's `vendor/mbedtls-<version>-<flavor>-<target>-<toolchain>/` directory — one flavor per lane (plain, asan), native platform/architecture or explicit cross target, and compiler environment/identity, the libqjs.a pattern. TLS-free binaries never compile or link any of this.
|
||||
|
||||
Reference in New Issue
Block a user