Files
scriptc/tests/harness/node-island.test.ts
T
Malte UblandClaude Opus 5.5 d646fa70d0 Collect garbage cycles across the scriptc and V8 heaps (NI-8)
Node islands stack, part 11 of 11 (scriptc-perf/results/islands-prs/PLAN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:49:56 -07:00

209 lines
8.0 KiB
TypeScript

/* Node islands loaded in-process (scope-B milestone NI-1), where it cannot
* be a differential corpus program: a run that never calls into Node never
* maps libnode, the beside packaging carries its own pack, a missing or
* mismatched pack fails the first call with a message, and the executable
* links no Node and exports nothing libnode could bind to. The corpus
* (6800-6803) pins the call semantics against Node. Needs a libnode pack
* in SCRIPTC_LIBNODE; required in the Linux x64 CI lanes (libnode.ts). */
import { execFile } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { beforeAll, describe, expect, test } from "vitest";
import { compile, deserializeModule } from "@scriptc/compiler";
import { definedFunctionNames, nodeIslandSymbols } from "../../scripts/zero-cost-compare.mjs";
import { libnodePack } from "./libnode.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const fixture = join(repoRoot, "tests/fixtures/node-island/lifecycle.ts");
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
// Required in the Linux x64 CI lanes, where a missing pack fails here.
const pack = libnodePack();
const skip = pack === null;
interface Run {
stdout: string;
stderr: string;
code: number | null;
}
let binary = "";
// The fixture reads whether libnode is mapped from /proc/self/maps; other
// platforms print "unknown".
const before = `mapped before ${process.platform === "linux" ? "false" : "unknown"}`;
const after = `mapped after ${process.platform === "linux" ? "true" : "unknown"}`;
async function run(
path: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): Promise<Run> {
try {
const { stdout, stderr } = await execFileAsync(path, args, { env, encoding: "utf8" });
return { stdout, stderr: comparable(stderr), code: 0 };
} catch (err) {
const e = err as { stdout?: string; stderr?: string; code?: unknown };
if (typeof e.code !== "number") throw err;
return { stdout: e.stdout ?? "", stderr: comparable(e.stderr ?? ""), code: e.code };
}
}
/** Linux ASan's once-per-process warning at the first context switch (the
* switch to Node's stack), dropped as differential.test.ts drops it. */
function comparable(stderr: string): string {
if (!sanitize) return stderr;
return stderr
.split("\n")
.filter((l) => !/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext/.test(l))
.join("\n");
}
async function build(name: string, libnode?: string): Promise<string> {
const dir = join(outDir, name);
mkdirSync(dir, { recursive: true });
const result = await compile(fixture, {
outPath: join(dir, "lifecycle"),
outDir: dir,
sanitize,
...(libnode === undefined ? {} : { libnode }),
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
return result.binaryPath;
}
/** A bridge library that answers the ABI handshake and nothing else. */
async function fakePack(name: string, abi: string, version: string): Promise<string> {
const dir = join(outDir, name);
mkdirSync(dir, { recursive: true });
const source = join(dir, "fake-bridge.c");
writeFileSync(
source,
`#include "scr_node_bridge.h"
static const ScrNodeBridge bridge = {${abi}, "${version}", 127};
__attribute__((visibility("default"))) const ScrNodeBridge *scr_node_bridge_v1(void) { return &bridge; }
`,
);
const library =
process.platform === "darwin" ? "libscr_node_bridge.dylib" : "libscr_node_bridge.so";
await execFileAsync("clang", [
"-shared",
"-fPIC",
"-I",
join(repoRoot, "packages/runtime/src"),
source,
"-o",
join(dir, library),
]);
return dir;
}
describe.skipIf(skip)("Node islands in-process", () => {
beforeAll(async () => {
binary = await build("recorded");
}, 300_000);
test("loads Node on the first call, and only then", async () => {
expect(await run(binary, [])).toEqual({
stdout: `${before}\n42\n${after}\ndone\n`,
stderr: "",
code: 0,
});
expect(await run(binary, ["never"])).toEqual({
stdout: `${before}\ndone\n`,
stderr: "",
code: 0,
});
});
test("process.exit after loading Node keeps its code", async () => {
const result = await run(binary, ["exit"]);
expect(result.code).toBe(5);
expect(result.stdout).toBe(`${before}\n42\n${after}\n`);
});
test("beside packaging carries its own pack", async () => {
const beside = await build("beside", "beside");
expect(existsSync(join(outDir, "beside/lifecycle.libnode/manifest.json"))).toBe(true);
const env = { ...process.env };
delete env["SCRIPTC_LIBNODE"];
const result = await run(beside, [], env);
expect(result.stdout).toBe(`${before}\n42\n${after}\ndone\n`);
expect(result.code).toBe(0);
}, 300_000);
test("a missing pack fails the first call", async () => {
const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: "/nonexistent/pack" });
expect(result.code).toBe(1);
expect(result.stdout).toBe(`${before}\n`);
expect(result.stderr).toMatch(
/^scriptc: cannot load Node\.js for this program's Node islands: .*\/nonexistent\/pack\/libscr_node_bridge\.(so|dylib).*\n$/,
);
});
test("a pack of another Node version fails the first call", async () => {
const dir = await fakePack("old-node", "SCR_NODE_BRIDGE_ABI", "22.11.0");
const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: dir });
expect(result.code).toBe(1);
expect(result.stderr).toMatch(
new RegExp(
`^scriptc: this program's Node islands need libnode \\d+\\.\\d+\\.\\d+; found 22\\.11\\.0 \\(NODE_MODULE_VERSION 127\\) at ${dir}\\n$`,
),
);
});
test("a bridge of another ABI fails the first call", async () => {
const dir = await fakePack("other-abi", "999u", "24.22.0");
const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: dir });
expect(result.code).toBe(1);
expect(result.stderr).toBe(
`scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 10)\n`,
);
});
test("links no Node and exports nothing libnode binds to", async () => {
if (process.platform !== "linux" || sanitize) return;
const { stdout } = await execFileAsync("nm", [binary], {
encoding: "utf8",
maxBuffer: 1 << 28,
});
const defined = definedFunctionNames(stdout);
expect(nodeIslandSymbols(defined)).toContain("scr_node_island_call_sync");
expect(defined.filter((name) => /^(napi_|_ZN2v8|_ZN4node|uv_)/.test(name))).toEqual([]);
// libnode joins the global scope (RTLD_GLOBAL): the executable must not
// export a name it would bind to instead of its own.
const dynamic = await execFileAsync("nm", ["-D", "--defined-only", binary], {
encoding: "utf8",
});
const conflicting = dynamic.stdout
.split("\n")
.map((line) => line.trim().split(/\s+/).at(-1) ?? "")
.filter((name) =>
/^(napi_|node_|uv_|_ZN2v8|_ZN4node|SSL|EVP_|ERR_|deflate|inflate|crc32|adler32|mbedtls_|ares_|nghttp2_)/.test(
name,
),
);
expect(conflicting).toEqual([]);
});
});
// Lowering checks every argument and result type for JSON data; IR output
// needs no libnode pack.
test("recursive JSON types cross into Node", async () => {
const dir = join(outDir, "recursive-json");
mkdirSync(dir, { recursive: true });
const outPath = join(dir, "main.ir.json");
const result = await compile(join(repoRoot, "tests/fixtures/node-island/recursive-json.ts"), {
outPath,
outDir: dir,
outputKind: "ir",
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8")));
expect(ir).toContain("nodeIsland.callSync");
}, 300_000);