mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-21 05:50:55 +08:00
The tree-shaking test failed on Linux x64 with "expected 68344 to be less
than 65536". Measured with the release helper/runtime-pack pipeline in a
Linux x64 sandbox (hello = console.log("hello", "world")):
PR file .text R+E segment
#743 64256 15794 0x3f11
#744 68416 16050 0x4011
#764 68520 16546 0x4221
The allocator adds 256 bytes of .text to hello: the inline scr_mem_free
fast path in the release functions hello already links (scr_str_release,
scr_arr_destroy, scr_arr_gc_free, scr_cyc_free, scr_dyn_dispose). Its
slow path and address-space reservation (scr_sa_slow, scr_sa_reserve)
are already stripped from hello. ELF segments are page aligned, and #743
left only 239 bytes before the executable segment's 16 KiB page, so 17
bytes over the boundary move every later segment, and the file, by 4 KiB.
Shrinking hello back under the old ceiling is not a reasonable trade.
At the top of the stack the executable segment is 545 bytes past the
page boundary. The extra bytes come from hot-path code the later PRs add
on purpose (number-to-string fast paths in scr_f64_to_str, inline-alloc
release paths, the direct fd write), all reached through console.log's
generic argument rendering. Outlining them to save one page would undo
the optimizations those PRs add.
The ceiling's documented intent is "several native pages of
linker-version slack" above a hello that the comment put at about 41KB.
That no longer held before this stack (64256 bytes on #743, 1.2 KiB of
slack). 80 KiB restores about three pages of slack over the stack's
largest hello and still catches lost section GC (the old always-linked
runtime was about 400KB). Symbol absence remains the primary
reachability contract. The Mach-O ceiling is unchanged.
Verified in the Linux x64 sandbox: pnpm test
tests/harness/runtime-tree-shaking.test.ts fails on #744 before this
change (68416 vs 65536) and passes after it, on #744 and on #764.
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
/* Executable runtime reachability. Section GC removes unused code from the
|
|
* precompiled runtime pack. Hello must discard unreachable families, while
|
|
* each feature fixture retains its runtime anchor and matches 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");
|
|
// The executable and symbol contracts use POSIX tools and /bin/echo.
|
|
const nativeToolchainTest = process.platform === "win32" ? test.skip : test;
|
|
|
|
interface Fixture {
|
|
name: string;
|
|
source: string;
|
|
anchor: string;
|
|
}
|
|
|
|
const FIXTURES: Fixture[] = [
|
|
{
|
|
name: "regex",
|
|
source: `console.log(/world/.test("hello world"));\n`,
|
|
anchor: "scr_regex_test",
|
|
},
|
|
{
|
|
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,
|
|
backend: "llvm",
|
|
});
|
|
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 expectNoReleaseDebugInfo(binaryPath: string): Promise<void> {
|
|
if (process.platform !== "linux") return;
|
|
const sections = await output("readelf", ["--section-headers", "--wide", binaryPath]);
|
|
expect(sections).not.toMatch(/\.(?:z?debug_|stab)/);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
nativeToolchainTest(
|
|
"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);
|
|
await expectNoReleaseDebugInfo(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);
|
|
await expectNoReleaseDebugInfo(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 build is about 64-68KB and current Mach-O builds are about 70KB.
|
|
// ELF segments are page aligned, so the Linux size moves in 4 KiB steps
|
|
// whenever .text crosses a page boundary. 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" ? 80 * 1024 : 96 * 1024;
|
|
expect(statSync(hello.binaryPath).size).toBeLessThan(helloSizeLimit);
|
|
},
|
|
);
|
|
|
|
nativeToolchainTest("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}`,
|
|
]);
|
|
await expectNoReleaseDebugInfo(result.binaryPath);
|
|
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())),
|
|
);
|
|
}
|
|
});
|
|
|
|
// Also exercise default backend selection on the Darwin shipping host.
|
|
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);
|
|
}
|
|
},
|
|
);
|