mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-22 05:59:31 +08:00
- Borrow rooted collection receivers across safe helpers and typed updates. - Keep small Maps and Sets compact through cloning, growth, and live iteration. - Preserve ownership at reassignment, exception, and capture boundaries.
262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { expect, test } from "vitest";
|
|
import { compile, deserializeModule, serializeModule, validateModule } from "../src/index.js";
|
|
import { analyzeCallLifetimes } from "../src/backend/llvm/call-lifetimes.js";
|
|
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
|
|
import { type IrModule } from "../src/ir/ir.js";
|
|
|
|
async function lower(source: string): Promise<IrModule> {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-input-lifetimes-"));
|
|
try {
|
|
const entry = join(dir, "main.ts"),
|
|
output = join(dir, "main.ir.json");
|
|
await writeFile(entry, source);
|
|
const result = await compile(entry, {
|
|
outDir: dir,
|
|
outPath: output,
|
|
outputKind: "ir",
|
|
dynamic: false,
|
|
});
|
|
if (!result.ok)
|
|
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
|
|
const module = deserializeModule(await readFile(output, "utf8"));
|
|
expect(validateModule(module)).toEqual([]);
|
|
return module;
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
function body(llvm: string, symbol: string): string {
|
|
const found = new RegExp(`^define internal [^\\n]*@${symbol}\\([^]*?^}`, "m").exec(llvm);
|
|
expect(found, symbol).not.toBeNull();
|
|
return found![0];
|
|
}
|
|
|
|
test("projected collection receivers borrow across local helper work and typed mutation", async () => {
|
|
const module = await lower(`
|
|
class Box {
|
|
items = new Map<string, number>();
|
|
keys = new Set<number>();
|
|
}
|
|
function key(input: string): string {
|
|
let selected = input;
|
|
if (selected.length === 0) selected = "fallback";
|
|
return selected;
|
|
}
|
|
function read(box: Box, input: string): number { return box.items.get(key(input)) ?? 0; }
|
|
function write(box: Box, input: string, value: number): number {
|
|
box.items.set(key(input), value);
|
|
box.keys.add(value);
|
|
return box.items.size + box.keys.size;
|
|
}
|
|
const box = new Box();
|
|
console.log(write(box, "entry", 7), read(box, "entry"));
|
|
`);
|
|
for (const pointerBits of [32, 64] as const) {
|
|
const llvm = emitLlvmModule(deserializeModule(serializeModule(module)), { pointerBits });
|
|
for (const name of ["read", "write"]) {
|
|
const work = body(llvm, `sc_bf_${name}`);
|
|
expect(work).not.toContain("@scr_map_retain_v");
|
|
expect(work).not.toContain("@scr_map_release");
|
|
}
|
|
expect(body(llvm, "sc_bf_write")).toContain("@scr_map_set_str_f64");
|
|
}
|
|
});
|
|
|
|
test("projected collection receivers keep snapshots across an owner replacement", async () => {
|
|
const module = await lower(`
|
|
class Box { items = new Map<string, number>(); }
|
|
let root = new Box();
|
|
function replace(): string { root = new Box(); return "entry"; }
|
|
function read(): number { return root.items.get(replace()) ?? 0; }
|
|
console.log(read());
|
|
`);
|
|
for (const pointerBits of [32, 64] as const) {
|
|
const work = body(emitLlvmModule(module, { pointerBits }), "sc_f_read");
|
|
const retain = work.indexOf("@scr_map_retain_v");
|
|
const replace = work.indexOf("@sc_f_replace");
|
|
expect(retain).toBeGreaterThan(0);
|
|
expect(replace).toBeGreaterThan(retain);
|
|
expect(work.slice(replace)).toContain("@scr_map_release");
|
|
}
|
|
});
|
|
|
|
test("source string helpers preserve borrowing after the serialized IR boundary", async () => {
|
|
const module = await lower(`
|
|
function score(text: string, prefix: string): number {
|
|
return text.startsWith(prefix) ? text.length + text.charCodeAt(0) : 0;
|
|
}
|
|
function relay(text: string, prefix: string): number { return score(text, prefix); }
|
|
console.log(relay("ready", "re"));
|
|
`);
|
|
const facts = analyzeCallLifetimes(new Map(module.functions.map((fn) => [fn.name, fn])));
|
|
expect(facts.parameters.get("score")).toEqual(new Set([0, 1]));
|
|
expect(facts.parameters.get("relay")).toEqual(new Set([0, 1]));
|
|
const llvm = emitLlvmModule(module);
|
|
expect(body(llvm, "sc_bf_score")).toContain("@scr_str_starts_with");
|
|
expect(body(llvm, "sc_bf_score")).not.toContain("@scr_str_retain_v");
|
|
expect(body(llvm, "sc_bf_relay")).not.toContain("@scr_str_retain_v");
|
|
expect(body(llvm, "sc_f_score").match(/@scr_str_release/g)).toHaveLength(2);
|
|
expect(emitLlvmModule(deserializeModule(serializeModule(module)))).toBe(llvm);
|
|
});
|
|
|
|
test("direct source array arguments use stack tags without changing the helper ABI", async () => {
|
|
const module = await lower(`
|
|
class Item { value: number; constructor(value: number) { this.value = value; } }
|
|
function value(item: Item): number { return item.value; }
|
|
function work(items: Item[], index: number): number { return value(items[index]); }
|
|
console.log(work([new Item(7)], 0));
|
|
`);
|
|
const value = module.functions.find((fn) => fn.name === "value")!;
|
|
expect(value.params[0]!.type.kind).toBe("union");
|
|
const llvm = emitLlvmModule(module);
|
|
const work = body(llvm, "sc_bf_work");
|
|
expect(work).toContain("alloca %ScrUnion");
|
|
expect(work).toContain("@sc_bf_value");
|
|
expect(work).not.toContain("@scr_union_new_ref");
|
|
expect(work).not.toContain("@scr_union_release");
|
|
expect(work).not.toContain("@sc_retain_Item");
|
|
expect(body(llvm, "sc_f_value")).toContain("@scr_union_release");
|
|
});
|
|
|
|
test("a source array mutation during later argument evaluation retains the earlier payload", async () => {
|
|
const module = await lower(`
|
|
class Item { value: number; constructor(value: number) { this.value = value; } }
|
|
function value(item: Item, ignored: number): number { return item.value + ignored; }
|
|
function clear(items: Item[]): number { items.length = 0; return 1; }
|
|
function work(items: Item[]): number { return value(items[0], clear(items)); }
|
|
console.log(work([new Item(7)]));
|
|
`);
|
|
const llvm = emitLlvmModule(module);
|
|
const work = body(llvm, "sc_bf_work");
|
|
expect(work).toContain("alloca %ScrUnion");
|
|
expect(work).toContain("@sc_retain_Item");
|
|
expect(work).toContain("@sc_release_Item");
|
|
expect(work.indexOf("@sc_retain_Item")).toBeLessThan(work.indexOf("@sc_bf_clear"));
|
|
expect(work).not.toContain("@scr_union_release");
|
|
});
|
|
|
|
test("source helpers that return their input retain a heap representation", async () => {
|
|
const module = await lower(`
|
|
class Item { value = 7; }
|
|
function identity(item: Item): Item { return item; }
|
|
function work(items: Item[]): Item { return identity(items[0]); }
|
|
console.log(work([new Item()]).value);
|
|
`);
|
|
const facts = analyzeCallLifetimes(new Map(module.functions.map((fn) => [fn.name, fn])));
|
|
expect(facts.parameters.has("identity")).toBe(false);
|
|
expect(body(emitLlvmModule(module), "sc_bf_work")).not.toContain("alloca %ScrUnion");
|
|
});
|
|
|
|
test("a helper's string projection retains an escaping result independently of the array", async () => {
|
|
const module = await lower(`
|
|
class Item { label: string; constructor(label: string) { this.label = label; } }
|
|
function label(item: Item): string { return item.label; }
|
|
function work(items: Item[]): string { return label(items[0]); }
|
|
console.log(work([new Item("kept")]));
|
|
`);
|
|
const llvm = emitLlvmModule(module);
|
|
expect(body(llvm, "sc_bf_work")).toContain("alloca %ScrUnion");
|
|
expect(body(llvm, "sc_bf_label")).toContain("@scr_str_retain_v");
|
|
expect(body(llvm, "sc_bf_label")).not.toContain("@scr_union_retain_v");
|
|
});
|
|
|
|
test("source-level helper aliases retain their owned adapter", async () => {
|
|
const module = await lower(`
|
|
function size(value: string): number { return value.length; }
|
|
function invoke(callback: (value: string) => number, value: string): number { return callback(value); }
|
|
console.log(invoke(size, "indirect"));
|
|
`);
|
|
const llvm = emitLlvmModule(module);
|
|
expect(body(llvm, "sc_bf_size")).not.toContain("@scr_str_release");
|
|
expect(body(llvm, "sc_f_size")).toContain("@scr_str_release");
|
|
expect(body(llvm, "sc_bf_invoke")).toContain("@scr_str_retain_v");
|
|
});
|
|
|
|
test("many source call sites share one helper recognition without sharing stack storage", async () => {
|
|
const module = await lower(`
|
|
class Item { value = 7; }
|
|
function pair(left: Item, right: Item): number { return left.value + right.value; }
|
|
function work(items: Item[]): number { return pair(items[0], items[1]) + pair(items[1], items[0]); }
|
|
console.log(work([new Item(), new Item()]));
|
|
`);
|
|
const work = body(emitLlvmModule(module), "sc_bf_work");
|
|
expect(work.match(/alloca %ScrUnion/g)).toHaveLength(4);
|
|
expect(work.match(/@sc_bf_pair/g)).toHaveLength(2);
|
|
expect(work).not.toContain("@scr_union_new_ref");
|
|
expect(work).not.toContain("@scr_union_release");
|
|
});
|
|
|
|
test("a throwing later source argument releases the retained snapshot on its exception edge", async () => {
|
|
const module = await lower(`
|
|
class Item { value = 7; }
|
|
function read(item: Item, ignored: number): number { return item.value + ignored; }
|
|
function fail(): number { throw new Error("failed"); }
|
|
function work(items: Item[]): number { return read(items[0], fail()); }
|
|
try { console.log(work([new Item()])); } catch {}
|
|
`);
|
|
const work = body(emitLlvmModule(module), "sc_bf_work");
|
|
const failure = work.indexOf("@sc_f_fail");
|
|
expect(failure).toBeGreaterThan(0);
|
|
expect(work.slice(failure)).toContain("@scr_exc_pending");
|
|
expect(work.slice(failure)).toContain("@sc_release_Item");
|
|
expect(work).not.toContain("@scr_union_release");
|
|
});
|
|
|
|
test("pure string helpers preserve the array owner of an immediate optional string", async () => {
|
|
const module = await lower(`
|
|
function size(text: string): number { return text.length + text.charCodeAt(0); }
|
|
function work(words: string[], index: number): number { return size(words[index]); }
|
|
console.log(work(["first"], 0));
|
|
`);
|
|
const work = body(emitLlvmModule(module), "sc_bf_work");
|
|
expect(work).toContain("alloca %ScrUnion");
|
|
expect(work).toContain("@sc_bf_size");
|
|
expect(work).not.toContain("@scr_str_retain_v");
|
|
expect(work).not.toContain("@scr_str_release");
|
|
expect(work).not.toContain("@scr_union_release");
|
|
});
|
|
|
|
test("string-producing helpers return an owner while borrowing their array input", async () => {
|
|
const module = await lower(`
|
|
function trim(text: string): string { return text.trim(); }
|
|
function work(words: string[]): string { return trim(words[0]); }
|
|
console.log(work([" first "]));
|
|
`);
|
|
const llvm = emitLlvmModule(module);
|
|
const work = body(llvm, "sc_bf_work");
|
|
expect(work).toContain("alloca %ScrUnion");
|
|
expect(work).not.toContain("@scr_str_retain_v");
|
|
expect(body(llvm, "sc_bf_trim")).toContain("@scr_str_trim");
|
|
expect(body(llvm, "sc_f_trim")).toContain("@scr_union_release");
|
|
});
|
|
|
|
test("string method arguments that mutate an array keep the earlier optional payload owned", async () => {
|
|
const module = await lower(`
|
|
function prefix(text: string, start: string): boolean { return text.startsWith(start); }
|
|
function clear(words: string[]): string { words.length = 0; return "f"; }
|
|
function work(words: string[]): boolean { return prefix(words[0], clear(words)); }
|
|
console.log(work(["first"]));
|
|
`);
|
|
const work = body(emitLlvmModule(module), "sc_bf_work");
|
|
expect(work).toContain("alloca %ScrUnion");
|
|
expect(work).toContain("@scr_str_retain_v");
|
|
expect(work).toContain("@scr_str_release");
|
|
expect(work).not.toContain("@scr_union_release");
|
|
});
|
|
|
|
test("whole-union string comparisons keep their established owned representation", async () => {
|
|
const module = await lower(`
|
|
function size(text: string): number { return text.length + (text === "" ? 1 : 0); }
|
|
function work(words: string[], index: number): number { return size(words[index]); }
|
|
console.log(work([""], 0));
|
|
`);
|
|
const facts = analyzeCallLifetimes(new Map(module.functions.map((fn) => [fn.name, fn])));
|
|
expect(facts.parameters.has("size")).toBe(false);
|
|
const work = body(emitLlvmModule(module), "sc_bf_work");
|
|
expect(work).not.toContain("alloca %ScrUnion");
|
|
expect(work).toContain("@sc_bf_size");
|
|
});
|