Files
Chris Tate 3f66c8e20b Make LLVM the sole production backend
- Remove C emission, debugging options, and automatic backend fallback.
- Complete LLVM lowering and native bootstrap coverage across supported targets.
- Link executables and libraries with precompiled C runtime packs, including iOS and Android.
2026-09-29 20:54:09 -05:00

635 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Island engine behavior that is scriptc-only by nature — deliberately NOT
* in the differential corpus:
*
* - Exception TEXT crossing the boundary: the bridge renders an island
* exception as String(e) ("TypeError: boom") and throws it as a catchable
* scriptc string. Node's engine (V8) words some messages differently, so
* message content is asserted here against OUR bridge, never against Node
* (island VALUE results stay differential — tests/corpus/1100..).
* - Stack-overflow containment on fibers: async bodies run on ucontext
* fibers (256KB; 1MB under ASan — frames inflate); the island re-anchors
* the engine's stack check on every entry. Unbounded recursion in an
* island eval must surface as a catchable RangeError — a crash here
* means the re-anchor regressed.
* - The static/dynamic fence: --dynamic must not change emitted LLVM, and a
* static binary must stay in its size class (the ~620KB engine must never
* leak into default builds).
*
* SCRIPTC_SAN=1 builds the programs with ASan + the RC audit; the island's
* counting allocator asserts zero live engine allocations at teardown.
*/
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { describe, 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");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const platformTest = process.env["SCRIPTC_PORTABLE_ONLY"] === "1" ? test.skip : test;
interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface BuildResult {
binaryPath: string;
llvmPath: string;
}
/** Compiles an inline program (island tests default to --dynamic). */
async function build(
name: string,
source: string,
opts: { dynamic?: boolean; sanitize?: boolean } = {},
): Promise<BuildResult> {
const dynamic = opts.dynamic ?? true;
const san = opts.sanitize ?? sanitize;
const key = createHash("sha256")
.update(source)
.update(san ? "san" : "plain")
.update(dynamic ? "dyn" : "")
.digest("hex")
.slice(0, 16);
const outDir = join(cacheDir, `island-${key}`);
mkdirSync(outDir, { recursive: true });
const file = join(outDir, `${name}.ts`);
writeFileSync(file, source);
const result = await compile(file, {
outPath: join(outDir, name),
outDir,
sanitize: san,
dynamic,
backend: "llvm",
});
if (!result.ok) {
throw new Error(
"island program failed to compile:\n" +
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
return { binaryPath: result.binaryPath, llvmPath: result.llvmPath };
}
/** Linux ASan prints a once-per-process warning to stderr the first time a
* fiber swapcontext()s (the interceptor has no off switch; Apple's ASan
* never intercepts ucontext, so the macOS lanes never see it). Sanitizer
* diagnostic noise, never program output — dropped before any stderr
* expectation, the RC-audit-skip-notice pattern from differential.test.ts. */
const stripAsanFiberWarning = (s: string): string =>
s.replace(/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext.*\n/gm, "");
/** build() + run, tolerating nonzero exit. */
async function compileAndRun(
name: string,
source: string,
opts: { dynamic?: boolean } = {},
): Promise<RunResult> {
const { binaryPath } = await build(name, source, opts);
try {
const { stdout, stderr } = await execFileAsync(binaryPath, [], { encoding: "utf8" });
return { stdout, stderr: stripAsanFiberWarning(stderr), exitCode: 0 };
} catch (err) {
const e = err as { code?: unknown; stdout?: string; stderr?: string };
if (typeof e.code !== "number") throw err;
return { stdout: e.stdout ?? "", stderr: stripAsanFiberWarning(e.stderr ?? ""), exitCode: e.code };
}
}
describe(`island engine (scriptc-only${sanitize ? ", sanitized" : ""})`, () => {
test("island throw is caught by static try/catch and execution recovers", async () => {
const r = await compileAndRun(
"catchable",
`function attempt(code: string): string {
try {
return __island_eval(code);
} catch {
return "recovered";
}
}
console.log(attempt("1 + 1"), attempt("throw new TypeError('boom')"), attempt("'still ok'"));
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("2 recovered still ok\n");
expect(r.stderr).toBe("");
});
test("uncaught island exception prints the bridge's String(e) message", async () => {
const r = await compileAndRun(
"uncaught",
`console.log("before");
const x = __island_eval("(function () { throw new TypeError('boom') })()");
console.log("unreachable", x);
`,
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("before\n");
expect(r.stderr).toContain("Uncaught TypeError: boom");
});
test("thrown non-Error island values stringify through the bridge", async () => {
const r = await compileAndRun(
"throw-value",
`const x = __island_eval("throw 'plain string reason'");
console.log("unreachable", x);
`,
);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("Uncaught plain string reason");
});
test("island Error instances cross out as real error objects", async () => {
// Engine Errors arrive as ScrError instances: the builtin vtable is
// picked by NAME (instanceof TypeError narrows engine TypeErrors),
// custom names ride an Error-rooted instance, and non-Error throws
// keep the string payload — never wrongly an Error.
const r = await compileAndRun(
"error-crossing-out",
`try {
__island_eval("throw new TypeError('island boom')");
} catch (e) {
if (e instanceof TypeError) {
console.log("typed", e.name, e.message);
}
}
try {
__island_eval("class Weird extends Error { constructor(m) { super(m); this.name = 'Weird'; } } throw new Weird('custom')");
} catch (e) {
if (e instanceof Error && !(e instanceof TypeError)) {
console.log("custom", e.name, e.message, e.toString());
}
}
try {
__island_eval("throw 'plain reason'");
} catch (e) {
if (typeof e === "string") console.log("string payload", e);
if (e instanceof Error) console.log("wrongly an Error");
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(
"typed TypeError island boom\ncustom Weird custom Weird: custom\nstring payload plain reason\n",
);
expect(r.stderr).toBe("");
});
test("island syntax errors arrive as catchable errors, not crashes", async () => {
const r = await compileAndRun(
"syntax",
`let ok = "no";
try {
const x = __island_eval("this is ( not JS");
console.log("unreachable", x);
} catch {
ok = "caught";
}
console.log(ok);
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("caught\n");
});
test("deep island recursion on a fiber is a catchable RangeError, not a crash", async () => {
// Async bodies run on fixed-size ucontext fibers; without the
// per-entry JS_UpdateStackTop re-anchor this SIGBUSes instead of
// throwing.
const r = await compileAndRun(
"fiber-overflow",
`async function deep(): Promise<string> {
try {
return __island_eval("function r(n) { return r(n + 1); } r(0)");
} catch {
return "caught overflow on fiber";
}
}
async function main(): Promise<void> {
console.log(await deep());
console.log(__island_eval("'engine still ' + 'usable'"));
console.log(await deep());
}
main();
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(
"caught overflow on fiber\nengine still usable\ncaught overflow on fiber\n",
);
expect(r.stderr).toBe("");
});
test("overflow NAME is the engine's RangeError (island-side catch)", async () => {
const r = await compileAndRun(
"overflow-name",
`async function name(): Promise<string> {
return __island_eval("function r(n) { return r(n + 1); } try { r(0); 'no-overflow' } catch (e) { e.name }");
}
async function main(): Promise<void> {
console.log(await name());
}
main();
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("RangeError\n");
});
test("a never-observed island rejection reports at its checkpoint and exits 1", async () => {
// The island's JS_SetHostPromiseRejectionTracker ledger joins the
// static promise ledger's report — same line shape ("Unhandled promise
// rejection: <String(reason)>", an Error reason rendering name:
// message), same exit code 1. scriptc-only: Node's stderr for this
// wears ERR_UNHANDLED_REJECTION clothing; the LINE is ours to pin.
// The async main gives the program a loop turn (a program with no
// async/timers/embedded modules never runs the loop, and its island
// promise machinery is dormant anyway).
const r = await compileAndRun(
"island-unhandled-rejection",
`async function main(): Promise<void> {
console.log(__island_eval("Promise.reject(new TypeError('island boom')); 'armed'"));
}
main();
`,
);
expect(r.stdout).toBe("armed\n");
expect(r.stderr).toBe("Unhandled promise rejection: TypeError: island boom\n");
expect(r.exitCode).toBe(1);
});
test("a non-Error island rejection reason prints through String()", async () => {
const r = await compileAndRun(
"island-unhandled-primitive",
`async function main(): Promise<void> {
console.log(__island_eval("Promise.reject('plain reason'); 'armed'"));
}
main();
`,
);
expect(r.stdout).toBe("armed\n");
expect(r.stderr).toBe("Unhandled promise rejection: plain reason\n");
expect(r.exitCode).toBe(1);
});
test("an island rejection observed at rejection time never reports", async () => {
const r = await compileAndRun(
"island-rejection-observed",
`async function main(): Promise<void> {
console.log(__island_eval("Promise.reject(new Error('seen')).catch((e) => {}); 'ok'"));
}
main();
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("ok\n");
expect(r.stderr).toBe("");
});
test("an island rejection handled LATER is rescinded, not reported", async () => {
// The tracker signals both directions: the rejection enters the ledger
// when it happens, and attaching a handler afterwards (a later
// microtask) removes it — Node's rejectionHandled rescission. The
// second eval runs BEFORE the loop drains engine jobs, proving the
// catch really attached later.
const r = await compileAndRun(
"island-rejection-rescinded",
`async function main(): Promise<void> {
console.log(
__island_eval(
"globalThis.p = Promise.reject(new Error('late catch'));" +
"Promise.resolve().then(() => { globalThis.p.catch(() => { globalThis.caught = true; }); });" +
"'armed'",
),
);
console.log(__island_eval("globalThis.caught === undefined ? 'not yet' : 'too early'"));
}
main();
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("armed\nnot yet\n");
expect(r.stderr).toBe("");
});
test("the static ledger keeps one voice when both worlds have survivors", async () => {
// Only ONE line reports (the static ledger's), exit stays 1 — the
// island ledger frees silently when the static side already spoke.
const r = await compileAndRun(
"island-and-static-rejections",
`async function fail(): Promise<void> {
throw new RangeError("static first");
}
fail();
console.log(__island_eval("Promise.reject(new TypeError('island second')); 'armed'"));
`,
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("armed\n");
expect(r.stderr).toBe("Unhandled promise rejection: RangeError: static first\n");
});
test("--dynamic does not change emitted LLVM for island-free programs", async () => {
const source = `function greet(who: string): string {
return "hello " + who;
}
console.log(greet("world"), 6 * 7);
`;
// Same program name in two cache dirs: the C is identical modulo the
// hashed cache-dir path embedded in source-location comments.
const [stat, dyn] = await Promise.all([
build("same-c", source, { dynamic: false }),
build("same-c", source, { dynamic: true }),
]);
const body = (r: BuildResult) =>
readFileSync(r.llvmPath, "utf8").replaceAll(dirname(r.llvmPath), "OUTDIR");
expect(body(dyn)).toBe(body(stat));
});
platformTest("static hello-world stays in its size class; island use pays the engine", async () => {
// The engine must NEVER leak into static builds: a default-built
// hello-world stays in the platform's compact class, while entering an
// island carries the embedded engine. An island-FREE --dynamic binary
// stays small too because the linker dead-strips the unreferenced archive.
// Measured on plain (non-ASan) builds in both lanes.
const [stat, dyn] = await Promise.all([
build("size-static", `console.log("hello", "world");\n`, {
dynamic: false,
sanitize: false,
}),
build("size-dynamic", `console.log(__island_eval("'hello ' + 'world'"));\n`, {
dynamic: true,
sanitize: false,
}),
]);
const staticSize = statSync(stat.binaryPath).size;
const dynamicSize = statSync(dyn.binaryPath).size;
// The class is toolchain-specific and page-granular. The canonical
// Ubuntu 24.04/clang Sandbox measures 423,488 bytes after the sparse
// UTF-16 string index added its always-linked cache/allocator path;
// current Mach-O toolchains measure about 434KB. Each bound leaves
// roughly one native page of growth while staying far below the >1MB
// jump measured when the engine is linked.
expect(staticSize).toBeLessThan(process.platform === "linux" ? 440_000 : 450_000);
expect(dynamicSize).toBeGreaterThan(500_000);
});
/* ── the `any` boundary (validated exits) ─────────────────────────────
* Node never checks an `as`, so LYING casts cannot be differential:
* scriptc throws a catchable TypeError (the same trust-but-verify rule
* as the dyn boundary — SEMANTICS.md documents both under the headline
* divergence). Valid casts live in the corpus (760–764). */
test("a lying primitive exit throws a catchable TypeError naming both types", async () => {
const r = await compileAndRun(
"any-lying-primitive",
`const s: any = "abc";
try {
const n = s as number;
console.log("unreachable", n);
} catch {
console.log("caught");
}
const direct = s as number;
console.log("unreachable", direct);
`,
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("caught\n");
expect(r.stderr).toContain("TypeError: expected number, got string");
});
test("a composite exit inherits dynCheck's path-annotated failures", async () => {
const r = await compileAndRun(
"any-lying-record",
`const o: any = { a: 1, b: 2 };
const r = o as { a: number; b: string };
console.log("unreachable", r.b);
`,
);
expect(r.exitCode).toBe(1);
expect(r.stderr).toContain("TypeError");
expect(r.stderr).toContain("$.b"); // the dynCheck walker's path
});
test("an implicit exit at a typed slot validates exactly like an explicit cast", async () => {
const r = await compileAndRun(
"any-implicit-exit",
`function takes(n: number): number {
return n + 1;
}
const good: any = 41;
console.log(takes(good));
const bad: any = "not a number";
try {
console.log(takes(bad));
} catch {
console.log("implicit exit caught");
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("42\nimplicit exit caught\n");
});
test("island values survive the RC audit through churn and try/catch unwinds", async () => {
const r = await compileAndRun(
"any-churn-unwind",
`let recovered = 0;
for (let i = 0; i < 300; i = i + 1) {
const v: any = { n: i, s: \`x\${i}\` };
try {
const bad = v as { n: number; s: number }; // s is a string: throws
console.log("unreachable", bad.n);
} catch {
recovered = recovered + 1;
}
}
console.log(recovered);
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("300\n");
expect(r.stderr).toBe("");
});
/* ── the island-backed ambient surface (Math, methods, globals) ───────
* Value results are differential (corpus 1110–1116 and 2113); this
* checks that --dynamic leaves static .at() behavior unchanged and that
* surface calls re-anchor the engine on fibers inside async bodies. */
test("string .at() returns undefined out of range in both build modes", async () => {
const source = `const s = "abc";
console.log("in range", s.at(1), s.at(-1));
console.log("out of range", s.at(99), s.at(-4));
`;
for (const dynamic of [false, true]) {
const r = await compileAndRun("at-out-of-range", source, { dynamic });
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("in range b c\nout of range undefined undefined\n");
expect(r.stderr).toBe("");
}
});
test("island-backed calls re-anchor the engine on fibers (Math after await)", async () => {
const r = await compileAndRun(
"math-on-fiber",
`async function calc(n: number): Promise<number> {
await new Promise<void>((resolve) => resolve());
return Math.sign(n) + Math.floor(1.9);
}
async function main(): Promise<void> {
console.log(await calc(144));
console.log((await calc(-1)).toFixed(1));
}
main();
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("2\n0.0\n");
expect(r.stderr).toBe("");
});
test("composites cross the boundary as deep copies (the documented aliasing divergence)", async () => {
// In Node `const d: any = point` aliases; scriptc deep-copies at the
// boundary (SEMANTICS.md). This is exactly the case that cannot be a
// differential test — pinned here so the divergence stays deliberate.
const r = await compileAndRun(
"any-copy-divergence",
`const point = { x: 1.5 };
const d: any = point;
d.x = d.x * 2;
console.log(\`\${d.x}\`, point.x);
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("3 1.5\n"); // Node would print "3 3"
});
test("typed arrays cross as copies too — island writes stay island-side", async () => {
// The bytes sibling of the composite copy divergence: Node would alias.
const r = await compileAndRun(
"bytes-copy-divergence",
`const b = new Uint8Array([10, 20]);
const h: any = b;
h[0] = 99;
console.log(\`\${h[0]}\`, b[0]);
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("99 10\n"); // Node would print "99 99"
});
test("the island URL reads every component off the static parser; the base form resolves", async () => {
// The marshal installs the island URL class (no engine URL exists):
// ALL components read off the SAME WHATWG parser's normalized answers
// (host/port/search/hash/username/password/origin derived from the
// parse — the shim burn-down widened the once-minimal class); writes
// stay refused. The (input, base) form RESOLVES relative inputs (dot
// segments removed, an own-scheme input ignores the base — Node's
// answers exactly); protocol-relative inputs keep a narrow fence.
const r = await compileAndRun(
"island-url-fences",
`const box: any = { u: new URL("https://x.dev/a/b?q=1#f") };
console.log(\`\${box.u.href}\`, \`\${box.u.protocol}\`, \`\${box.u.pathname}\`, \`\${box.u}\`);
console.log(\`\${box.u.host}\`, \`\${box.u.origin}\`, \`\${box.u.search}\`, \`\${box.u.hash}\`, \`\${box.u.port === "" ? "no-port" : box.u.port}\`);
const ctor: any = box.u.constructor;
const fresh: any = new ctor("https://y.dev/z");
console.log(\`\${fresh.href}\`);
const rel: any = new ctor("child", "https://y.dev/base/");
console.log(\`\${rel.href}\`);
const dots: any = new ctor("../up.wasm", "https://y.dev/a/b/c.mjs");
console.log(\`\${dots.href}\`);
const abs: any = new ctor("https://other.dev/q", "https://y.dev/base/");
console.log(\`\${abs.href}\`);
const filey: any = new ctor("openh264.wasm", "file:///tmp/pkg/openh264.mjs");
console.log(\`\${filey.href}\`);
try {
const pr: any = new ctor("//host/p", "https://y.dev/base/");
console.log(\`\${pr.href}\`);
} catch (e) {
console.log("protocol-relative fenced");
}
try {
const bad: any = new ctor("not a url at all");
console.log(\`\${bad.href}\`);
} catch (e) {
console.log("invalid input throws");
}
`,
);
expect(r.exitCode).toBe(0);
const lines = r.stdout.split("\n");
expect(lines[0]).toBe("https://x.dev/a/b?q=1#f https: /a/b https://x.dev/a/b?q=1#f");
expect(lines[1]).toBe("x.dev https://x.dev ?q=1 #f no-port");
expect(lines[2]).toBe("https://y.dev/z");
expect(lines[3]).toBe("https://y.dev/base/child");
expect(lines[4]).toBe("https://y.dev/a/up.wasm");
expect(lines[5]).toBe("https://other.dev/q");
expect(lines[6]).toBe("file:///tmp/pkg/openh264.wasm");
expect(lines[7]).toBe("protocol-relative fenced");
expect(lines[8]).toBe("invalid input throws");
});
/* ── dynamic import of the program's OWN modules ──────────────────────
* Value results and evaluation order are differential (corpus
* 2050–2052); what lives here is the deliberately scriptc-only half:
* exports with no island representation cross as TRAP functions (Node's
* namespace holds the real class — divergence 700), and the namespace
* is a SNAPSHOT taken when the import resolves where Node's bindings
* are live (divergence 701). */
test("a class export crosses the dynamic-import namespace as a pointed trap", async () => {
const r = await compileAndRun(
"dynns-class-trap",
`export class C {
n(): number { return 1; }
}
async function main(): Promise<void> {
const ns: any = await import("./dynns-class-trap.ts");
console.log(typeof ns.C); // a function, like Node's namespace slot
try {
const c = new ns.C();
console.log("unreachable");
} catch (e) {
console.log(String(e));
}
}
main();
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(
"function\n" +
"TypeError: the 'C' export is a class of the compiled program, which cannot cross into dynamically-executed code yet\n",
);
expect(r.stderr).toBe("");
});
test("the dynamic-import namespace is a snapshot, not Node's live bindings", async () => {
const r = await compileAndRun(
"dynns-snapshot",
`export var counter = 0;
export function bump(): void { counter = counter + 1; }
async function main(): Promise<void> {
const ns = await import("./dynns-snapshot.ts");
bump();
// Node's namespace would answer 1 (live binding); the snapshot taken
// when the import resolved answers 0 — divergence 701.
console.log(ns.counter, counter);
}
main();
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("0 1\n");
expect(r.stderr).toBe("");
});
});