Files
scriptc/tests/harness/runtime-tree-shaking.test.ts
Chris Tateandmmamedel 837f6bc62b Tree-shake unreachable executable runtime sections (#271)
* feat: tree-shake executable runtime sections

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* test: run runtime tree-shaking checks in sandbox

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* fix(runtime): release lazy process caches

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

* test: stabilize runtime tree-shaking size guard

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>

---------

Co-authored-by: mmamedel <23098414+mmamedel@users.noreply.github.com>
2026-08-31 09:01:19 -05:00

206 lines
8.2 KiB
TypeScript

/* Executable runtime reachability. The source-runtime recipe intentionally
* still includes the complete historical base for direct compileC callers;
* executable section GC is what makes its unused functions/data disappear.
* These fixtures therefore pin both halves of that contract: hello has no
* reachable members from the formerly-unavoidable families, while every
* feature fixture retains an anchor and behaves exactly like Node. */
import { execFile } from "node:child_process";
import { createServer } from "node:http";
import { mkdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests/runtime-tree-shaking");
// These source-toolchain contracts are safe in the Linux Sandboxes used by
// `test:sandbox`: they deliberately use the C backend and `nm`, both of
// which are part of that image. Keep the Windows host out of this POSIX
// fixture (its child program uses /bin/echo), but do not use
// SCRIPTC_PORTABLE_ONLY here: that marker means "run in a Linux Sandbox",
// not "skip native executable assertions".
const sourceToolchainTest = process.platform === "win32" ? test.skip : test;
interface Fixture {
name: string;
source: string;
anchor: string;
}
const FIXTURES: Fixture[] = [
{
name: "child",
source: `import { spawnSync } from "node:child_process";
const r = spawnSync("/bin/echo", ["child"], { encoding: "utf8" });
console.log(r.stdout.trim(), r.status);
`,
anchor: "scr_spawn_sync",
},
{
name: "path-posix",
source: `import { join } from "node:path/posix";
console.log(join("a", "..", "b"));
`,
anchor: "scr_path_join",
},
{
name: "path-win32",
source: `import { join } from "node:path/win32";
console.log(join("C:\\\\a", "..", "b"));
`,
anchor: "scr_path_win32_join",
},
{
name: "url",
source: `import { fileURLToPath, pathToFileURL } from "node:url";
console.log(fileURLToPath(pathToFileURL("/tmp/a b")));
`,
// This composition is optimized through the URL bridge, so its direct
// anchors are the file-path conversion helpers rather than URL parsing.
anchor: "scr_url_from_path",
},
{
name: "json-parse",
source: `console.log(JSON.parse('{"ok":true}').ok);
`,
anchor: "scr_json_parse",
},
{
name: "date",
source: `console.log(new Date(0).toISOString());
`,
anchor: "scr_date_to_iso",
},
{
name: "stream-consumers-json",
source: `import { Readable } from "node:stream";
import { json } from "node:stream/consumers";
const stream = new Readable({ read() {} });
stream.push('{"value":7}');
stream.push(null);
console.log((await json(stream)).value);
`,
anchor: "scr_json_parse",
},
];
async function build(name: string, source: string) {
const outDir = join(cacheDir, name);
const sourcePath = join(outDir, "main.mjs");
mkdirSync(outDir, { recursive: true });
writeFileSync(sourcePath, source);
const result = await compile(sourcePath, {
outPath: join(outDir, "program"),
outDir,
// The C lane proves source-toolchain linking. macOS additionally runs
// the default lane below, which selects the helper/runtime-pack path.
backend: "c",
});
if (!result.ok) {
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
}
return { sourcePath, binaryPath: result.binaryPath };
}
async function output(command: string, args: string[]): Promise<string> {
return (await execFileAsync(command, args, { encoding: "utf8" })).stdout;
}
async function symbols(binaryPath: string): Promise<string> {
// `nm` is supplied by Xcode/binutils on the supported native lanes. Inspect
// local symbols too: a static process cache, for example, is still retained
// payload even though it is not part of the executable's external ABI.
return output("nm", [binaryPath]);
}
async function expectNodeParity(sourcePath: string, binaryPath: string, args: string[] = []): Promise<void> {
const [node, native] = await Promise.all([
output(process.execPath, [sourcePath, ...args]),
output(binaryPath, args),
]);
expect(native).toBe(node);
}
sourceToolchainTest("static hello strips unreachable runtime families while feature programs retain them", async () => {
const helloSource = `console.log("hello", "world");\n`;
const hello = await build("hello", helloSource);
await expectNodeParity(hello.sourcePath, hello.binaryPath);
const helloSymbols = await symbols(hello.binaryPath);
for (const family of [
"scr_path_win32_",
"scr_exec_",
"scr_url_",
"scr_json_parse",
"scr_date_",
]) {
expect(helloSymbols, `hello retains ${family}`).not.toContain(family);
}
for (const fixture of FIXTURES) {
// /bin/echo is the portable POSIX child fixture. The Windows child
// surface remains covered by its cross-target corpus contracts.
if (fixture.name === "child" && process.platform === "win32") continue;
const result = await build(fixture.name, fixture.source);
await expectNodeParity(result.sourcePath, result.binaryPath);
const nativeSymbols = await symbols(result.binaryPath);
expect(nativeSymbols, `${fixture.name} lost ${fixture.anchor}`).toContain(fixture.anchor);
}
// Symbol absence is the primary reachability contract. Keep a deliberately
// roomy, platform-specific hello-world ceiling too: it catches losing
// section GC without pinning an exact linker/SDK byte count. The canonical
// Linux C build is about 41KB and current Mach-O builds are about 70KB;
// these limits leave several native pages of linker-version slack while
// remaining far below the former roughly-400KB always-linked runtime.
const helloSizeLimit = process.platform === "linux" ? 64 * 1024 : 96 * 1024;
expect(statSync(hello.binaryPath).size).toBeLessThan(helloSizeLimit);
});
sourceToolchainTest("fetch response JSON retains the URL and parser runtime", async () => {
const server = createServer((_request, response) => {
response.setHeader("content-type", "application/json");
response.end('{"ok":true}');
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
if (address === null || typeof address === "string") throw new Error("test server has no TCP address");
const fixture: Fixture = {
name: "fetch-response-json",
source: `const response = await fetch(process.argv[2]);
console.log((await response.json()).ok);
`,
anchor: "scr_json_parse",
};
const result = await build(fixture.name, fixture.source);
await expectNodeParity(result.sourcePath, result.binaryPath, [`http://127.0.0.1:${address.port}`]);
const nativeSymbols = await symbols(result.binaryPath);
expect(nativeSymbols).toContain("scr_json_parse");
expect(nativeSymbols).toContain("scr_url_release");
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
// The helper emits a native program object only on supported macOS arm64.
// Its normal backend path links the precompiled runtime pack; run the same
// reachability assertion there so the source-only C lane cannot regress it.
test.skipIf(process.platform !== "darwin" || process.arch !== "arm64")(
"macOS helper/runtime-pack links dead-strip static hello too",
async () => {
const outDir = join(cacheDir, "hello-runtime-pack");
const sourcePath = join(outDir, "main.mjs");
mkdirSync(outDir, { recursive: true });
writeFileSync(sourcePath, `console.log("hello", "world");\n`);
const result = await compile(sourcePath, { outPath: join(outDir, "program"), outDir });
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
await expectNodeParity(sourcePath, result.binaryPath);
const nativeSymbols = await symbols(result.binaryPath);
for (const family of ["scr_path_win32_", "scr_exec_", "scr_url_", "scr_json_parse", "scr_date_"]) {
expect(nativeSymbols, `runtime pack retained ${family}`).not.toContain(family);
}
},
);