Forward pointer-union wraps of call arguments to the callee's parameter proof (#2048)

This commit is contained in:
Chris Tate
2026-10-24 01:56:42 -05:00
committed by GitHub
7 changed files with 487 additions and 14 deletions
@@ -760,3 +760,154 @@ test("a parameter stored as its last use takes the owned convention", () => {
const passed = ctor([{ kind: "exprStmt", expr: call("use", [ref("item", cell)]), loc }]);
expect(analyze(passed).borrowed.get("%Box.constructor")).toEqual(new Set([0, 1]));
});
test("a wrap into a pointer union forwards the wrapped binding to the callee's parameter", () => {
const wrap = (id: string): IrExpr => ({
kind: "unionWrap",
unionId: "optional",
tag: 0,
value: ref(id, record),
type: optional,
loc,
});
const leaf = helper("leaf");
const outer = helper("outer");
outer.params[0]!.type = outer.locals[0]!.type = record;
outer.locals.push(local("cell", record));
outer.body = [
{
kind: "varDecl",
localId: "cell",
init: { kind: "recordLit", fields: [], type: record, loc },
loc,
},
{ kind: "exprStmt", expr: call("leaf", [wrap("value")]), loc },
ret(call("leaf", [wrap("cell")])),
];
const functions = new Map([leaf, outer].map((fn) => [fn.name, fn]));
const analyzeWith = (pointer: boolean) =>
analyzeCallLifetimes(
functions,
undefined,
undefined,
undefined,
undefined,
undefined,
() => pointer,
);
// A boxed union builds a new box around the payload: a whole-value use.
expect(analyzeWith(false).parameters.get("outer")).toBeUndefined();
expect(analyzeWith(false).locals.get("outer")).toEqual(new Set());
// A pointer union passes the payload pointer itself.
expect(analyzeWith(true).parameters.get("outer")).toEqual(new Set([0]));
expect(analyzeWith(true).locals.get("outer")).toEqual(new Set(["cell"]));
// A callee that keeps its parameter keeps the wrapped binding too.
leaf.body.push(ret(ref("value")));
expect(analyzeWith(true).parameters.get("outer")).toBeUndefined();
expect(analyzeWith(true).locals.get("outer")).toEqual(new Set());
});
test("a pointer-wrapped binding needs a callee that keeps the narrowed payload in place", () => {
const wrapCell: IrExpr = {
kind: "unionWrap",
unionId: "optional",
tag: 0,
value: ref("cell", record),
type: optional,
loc,
};
const outer = helper("outer", []);
outer.locals.push(local("cell", record));
outer.body = [
{
kind: "varDecl",
localId: "cell",
init: { kind: "recordLit", fields: [], type: record, loc },
loc,
},
ret(call("leaf", [wrapCell])),
];
const read = (value: IrExpr): IrExpr => ({
kind: "recordGet",
obj: value,
shapeId: "cell",
field: "x",
type: F64,
loc,
});
const keeps = (leaf: IrFunction, ...more: IrFunction[]): boolean => {
const result = analyzeCallLifetimes(
new Map([outer, leaf, ...more].map((fn) => [fn.name, fn])),
(className) => className === "Node",
undefined,
undefined,
undefined,
undefined,
() => true,
);
return result.locals.get("outer")?.has("cell") !== true;
};
const leafWith = (body: IrStmt[]): IrFunction => ({ ...helper("leaf"), body });
// Tag tests and field reads of the narrowed payload leave it in place.
expect(keeps(leafWith([{ kind: "exprStmt", expr: tag("value"), loc }]))).toBe(false);
expect(keeps(leafWith([ret(read(narrow("value")))]))).toBe(false);
// So do field reads through a checked narrowing, which throws on the
// other arm (`value.x` of a `T | undefined` parameter).
const checked: IrExpr = {
kind: "ternary",
cond: tag("value"),
then: { kind: "libCall", fn: "error.nodeThrow", args: [], type: record, loc },
else_: narrow("value"),
type: record,
loc,
};
expect(keeps(leafWith([ret(read(checked))]))).toBe(false);
// Returning it keeps the payload.
const returnsChecked = leafWith([ret(checked)]);
returnsChecked.returnType = record;
expect(keeps(returnsChecked)).toBe(true);
// Returning the narrowed payload, or a narrowed nullish read, keeps it,
// although the union itself is only projected.
const returned = leafWith([ret(narrow("value"))]);
returned.returnType = record;
expect(analyze(returned).parameters.get("leaf")).toEqual(new Set([0]));
expect(keeps(returned)).toBe(true);
const defaulted = leafWith([
ret({ kind: "nullish", left: ref("value"), right: ref("other", record), type: record, loc }),
]);
defaulted.locals.push(local("other", record));
expect(keeps(defaulted)).toBe(true);
// So does a nullable-pointer field store of the union.
const self: IrType = { kind: "object", className: "Node" };
const stored = leafWith([
{
kind: "fieldSet",
obj: ref("this", self),
className: "Node",
field: "next",
value: ref("value"),
loc,
},
]);
stored.params = [{ localId: "value", name: "value", type: optional }];
stored.locals.push(local("this", self));
expect(keeps(stored)).toBe(true);
// A narrowed payload passed on, or the union passed on, is in place when
// the next callee keeps it in place.
const inner = (body: IrStmt[], type: IrType = record): IrFunction => {
const fn = helper("inner");
fn.params[0]!.type = fn.locals[0]!.type = type;
fn.body = body;
return fn;
};
const passNarrowed = leafWith([ret(call("inner", [narrow("value")]))]);
expect(keeps(passNarrowed, inner([ret(read(ref("value", record)))]))).toBe(false);
const returnsRecord = inner([ret(ref("value", record))]);
returnsRecord.returnType = record;
expect(keeps(passNarrowed, returnsRecord)).toBe(true);
const passUnion = leafWith([ret(call("inner", [ref("value")]))]);
expect(keeps(passUnion, inner([ret(read(narrow("value")))], optional))).toBe(false);
const innerReturned = inner([ret(narrow("value"))], optional);
innerReturned.returnType = record;
expect(keeps(passUnion, innerReturned)).toBe(true);
});
@@ -14,6 +14,11 @@ import { storedLastUseRead } from "./local-extents.js";
interface ForwardedUse {
callee: string;
index: number;
/** The binding reached the call inside a nullable-pointer union
* (PointerWrapTest): the callee's parameter holds the binding itself as
* its payload, so the callee must keep the payload, not only the union,
* in place. */
wrapped: boolean;
}
interface Uses {
@@ -21,6 +26,13 @@ interface Uses {
written: Set<string>;
declarations: Map<string, number>;
forwards: Map<string, ForwardedUse[]>;
/** Union bindings whose payload a use may keep: a narrowed read that is
* not projected or passed on, a narrowed nullish read, a store into a
* nullable-pointer field. The union itself can still be only projected. */
payloadKept: Set<string>;
/** Narrowed payloads of union bindings passed directly as call arguments:
* the payload stays in place if the callee's parameter is proved. */
payloadForwards: Map<string, ForwardedUse[]>;
}
interface Parameter {
@@ -99,19 +111,55 @@ const NO_INLINE_FIELDS: InlineFieldTest = { record: () => false, classField: ()
* reads the record and keeps no reference to it. */
export type RecordDynCopyTest = (shapeId: string) => boolean;
/** Wraps into a nullable-pointer union whose value is the payload pointer
* itself (LlEmitter.borrowableNullableWrap): the wrapped binding reaches the
* callee's parameter unchanged. */
export type PointerWrapTest = (wrap: IrExpr & { kind: "unionWrap" }) => boolean;
function collectUses(
fn: IrFunction,
nullableField: NullableFieldTest,
registerReturn: boolean,
inlineField: InlineFieldTest,
recordDynCopy: RecordDynCopyTest,
pointerWrap: PointerWrapTest,
): Uses {
const uses: Uses = {
invalid: new Set(),
written: new Set(),
declarations: new Map(),
forwards: new Map(),
payloadKept: new Set(),
payloadForwards: new Map(),
};
const forward = (
table: Map<string, ForwardedUse[]>,
localId: string,
use: ForwardedUse,
): void => {
let forwards = table.get(localId);
if (!forwards) table.set(localId, (forwards = []));
forwards.push(use);
};
/** The union binding `x` whose payload `value` is: `unionNarrow(x)`, or a
* checked narrowing `unionIsTag(x) ? <libCall> : unionNarrow(x)` (either
* order), whose other branch is visited as any use. */
function narrowedBinding(value: IrExpr): string | null {
if (value.kind === "unionNarrow")
return value.value.kind === "varRef" ? value.value.localId : null;
if (
value.kind !== "ternary" ||
value.cond.kind !== "unionIsTag" ||
value.cond.value.kind !== "varRef"
)
return null;
const id = value.cond.value.localId;
if (value.else_.kind === "libCall" && narrowedBinding(value.then) === id)
return expr(value.else_) ? id : null;
if (value.then.kind === "libCall" && narrowedBinding(value.else_) === id)
return expr(value.then) ? id : null;
return null;
}
const aliases: { alias: string; source: string }[] = [];
const stringInput = (value: IrExpr): boolean => {
if (value.kind === "varRef" && value.type.kind === "string") return true;
@@ -145,6 +193,12 @@ function collectUses(
if (borrowsMapReadInputs(node)) return expr(node.receiver) && node.args.every(stringInput);
break;
case "unionNarrow":
// The payload may escape; the union itself does not.
if (node.value.kind === "varRef") {
uses.payloadKept.add(node.value.localId);
return true;
}
break;
case "unionIsTag":
if (node.value.kind === "varRef") return true;
break;
@@ -161,12 +215,16 @@ function collectUses(
node.left.kind === "varRef" &&
node.left.type.kind === "union" &&
node.type.kind !== "union"
)
) {
uses.payloadKept.add(node.left.localId);
return expr(node.right);
}
break;
case "fieldGet":
case "recordGet":
if (node.obj.kind === "varRef") return true;
// A field read of a binding, or of a union binding's narrowed
// payload, keeps neither.
if (node.obj.kind === "varRef" || narrowedBinding(node.obj) !== null) return true;
break;
case "dynFrom":
// A copied record converts field by field; a live capsule keeps it.
@@ -200,13 +258,19 @@ function collectUses(
return node.args.every(expr);
case "call":
node.args.forEach((arg, index) => {
if (arg.kind !== "varRef") {
const narrowed = narrowedBinding(arg);
if (narrowed !== null) {
forward(uses.payloadForwards, narrowed, { callee: node.callee, index, wrapped: false });
return;
}
const wrapped =
arg.kind === "unionWrap" && arg.value.kind === "varRef" && pointerWrap(arg);
const value = wrapped ? arg.value : arg;
if (value.kind !== "varRef") {
expr(arg);
return;
}
let forwards = uses.forwards.get(arg.localId);
if (!forwards) uses.forwards.set(arg.localId, (forwards = []));
forwards.push({ callee: node.callee, index });
forward(uses.forwards, value.localId, { callee: node.callee, index, wrapped });
});
return true;
case "assignExpr":
@@ -250,8 +314,11 @@ function collectUses(
node.value.kind === "varRef" &&
node.value.type.kind === "union" &&
nullableField(node.className, node.field)
)
) {
// The field retains the payload.
uses.payloadKept.add(node.value.localId);
return expr(node.obj);
}
const stored = fieldStore(
node.obj,
node.value,
@@ -345,10 +412,14 @@ export function analyzeCallLifetimes(
registerReturns: ReadonlySet<string> = new Set(),
inlineField: InlineFieldTest = NO_INLINE_FIELDS,
recordDynCopy: RecordDynCopyTest = () => false,
pointerWrap: PointerWrapTest = () => false,
): CallLifetimes {
const usesByFunction = new Map<string, Uses>();
const lazyCaptures = new LazyCaptures(functions);
const nodes = new Map<string, Parameter[]>();
/** Union parameters that keep their payload in place too, which a
* pointer-wrapped argument needs (ForwardedUse.wrapped). */
const payloadNodes = new Map<string, (Parameter | undefined)[]>();
const unsafe: Parameter[] = [];
const consumed: { fn: string; index: number }[] = [];
const result: CallLifetimes = {
@@ -361,7 +432,7 @@ export function analyzeCallLifetimes(
};
for (const fn of functions.values()) {
if (fn.generator !== undefined && fn.classCaptures === undefined) {
const uses = collectUses(fn, nullableField, false, inlineField, recordDynCopy);
const uses = collectUses(fn, nullableField, false, inlineField, recordDynCopy, pointerWrap);
const locals = new Map(fn.locals.map((local) => [local.id, local]));
const stable = new Set<string>();
for (const param of fn.params) {
@@ -386,6 +457,7 @@ export function analyzeCallLifetimes(
registerReturns.has(fn.name),
inlineField,
recordDynCopy,
pointerWrap,
);
usesByFunction.set(fn.name, uses);
// Class-capturing bodies get only local facts (computed below from
@@ -460,23 +532,50 @@ export function analyzeCallLifetimes(
return node;
}),
);
payloadNodes.set(
fn.name,
fn.params.map((param) => {
if (param.type.kind !== "union") return undefined;
const node: Parameter = { safe: !uses.payloadKept.has(param.localId), callers: new Set() };
if (!node.safe) unsafe.push(node);
return node;
}),
);
}
/** The fact a forwarded use needs from the callee's parameter. */
const target = (use: ForwardedUse): Parameter | undefined =>
use.wrapped ? payloadNodes.get(use.callee)?.[use.index] : nodes.get(use.callee)?.[use.index];
for (const fn of functions.values()) {
const params = nodes.get(fn.name);
const uses = usesByFunction.get(fn.name);
if (!params || !uses) continue;
fn.params.forEach((param, index) => {
const node = params[index]!;
const depend = (node: Parameter, callees: (Parameter | undefined)[]): void => {
if (!node.safe) return;
for (const use of uses.forwards.get(param.localId) ?? []) {
const callee = nodes.get(use.callee)?.[use.index];
for (const callee of callees) {
if (!callee) {
node.safe = false;
unsafe.push(node);
break;
return;
}
callee.callers.add(node);
}
};
fn.params.forEach((param, index) => {
const node = params[index]!;
const forwards = uses.forwards.get(param.localId) ?? [];
depend(node, forwards.map(target));
const payload = payloadNodes.get(fn.name)?.[index];
if (!payload) return;
// The payload stays in place when the union does, when every callee
// the union reaches keeps its payload too, and when every callee its
// narrowed payload reaches proves that parameter.
depend(payload, [
node,
...forwards.map((use) => payloadNodes.get(use.callee)?.[use.index]),
...(uses.payloadForwards.get(param.localId) ?? []).map(
(use) => nodes.get(use.callee)?.[use.index],
),
]);
});
}
for (let i = 0; i < unsafe.length; i++) {
@@ -508,7 +607,7 @@ export function analyzeCallLifetimes(
)
continue;
const forwards = uses.forwards.get(local.id) ?? [];
if (forwards.every((use) => result.parameters.get(use.callee)?.has(use.index) === true)) {
if (forwards.every((use) => target(use)?.safe === true)) {
projected.add(local.id);
if (eligible(local) && !uses.written.has(local.id)) safe.add(local.id);
}
@@ -1781,6 +1781,7 @@ export class LlEmitter {
const shape = this.recordsById.get(shapeId);
return shape !== undefined && !recordDynKeepsIdentity(shape);
},
(wrap) => this.borrowableNullableWrap(wrap) === "ref",
);
this.lentStringParameters =
this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map();
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7660-pointer-union-forwarded-arguments.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7663-pointer-union-stored-payload.ts"
],
"diags": []
}
@@ -0,0 +1,127 @@
// Records and class instances passed to `T | undefined` and `T | null`
// parameters. The callee receives the object pointer itself, so a binding
// that the callee only reads keeps its frame storage; callees that keep,
// return or compare the argument still see one shared object.
interface Vec {
x: number;
y: number;
}
interface Tagged {
name: string;
at: Vec;
}
function vec(x: number, y: number): Vec {
return { x, y };
}
function len(v: Vec | undefined): number {
return v === undefined ? -1 : Math.sqrt(v.x * v.x + v.y * v.y);
}
function sum(a: Vec | undefined, b: Vec): Vec {
return a === undefined ? b : { x: a.x + b.x, y: a.y + b.y };
}
function label(t: Tagged | undefined): string {
return t === undefined ? "none" : t.name + "@" + t.at.x + "," + t.at.y;
}
function depth(v: Vec | undefined, n: number): number {
if (v === undefined || n === 0) return 0;
return v.x + depth(v, n - 1);
}
const kept: (Vec | undefined)[] = [];
function keep(v: Vec | undefined): number {
kept.push(v);
return kept.length;
}
function same(a: Vec | undefined, b: Vec): boolean {
return a === b;
}
function orNull(v: Vec | undefined): Vec | undefined {
return v !== undefined && v.x < 0 ? undefined : v;
}
class Cell {
value: number;
next: Cell | null;
constructor(value: number, next: Cell | null) {
this.value = value;
this.next = next;
}
}
function cellTotal(c: Cell | null): number {
let total = 0;
for (let at = c; at !== null; at = at.next) total += at.value;
return total;
}
function fail(v: Vec | undefined): number {
if (v !== undefined && v.x > 100) throw new Error("too far: " + v.x);
return v === undefined ? 0 : v.x;
}
function projections(): void {
const a = vec(3, 4);
const b = sum(a, vec(1, 1));
console.log("len", len(a), len(b), len(undefined));
const c = sum(sum(a, b), a);
console.log("sum", c.x, c.y, sum(undefined, a).x);
console.log("depth", depth(a, 5), depth(undefined, 3));
let total = 0;
for (let i = 0; i < 1000; i++) {
const p = vec(i, i + 1);
total += len(p) + len(sum(p, a));
}
console.log("loop", total.toFixed(6));
}
function mutated(): void {
const p = vec(1, 2);
console.log("before", len(p));
p.x = 6;
p.y = 8;
console.log("after", len(p));
}
function strings(): void {
const prefix = ["a", "b", "c"].join("-");
const t: Tagged = { name: prefix + "!", at: vec(2, 5) };
console.log("label", label(t), label(undefined));
const u: Tagged = { name: label(t), at: t.at };
console.log("nested", label(u));
}
function escapes(): void {
const a = vec(7, 8);
const count = keep(a);
const b = vec(9, 10);
console.log("kept", count, kept[0] === a, kept[0]!.x, keep(b), kept[1]!.y);
a.x = 70;
console.log("shared", kept[0]!.x);
const c = vec(1, 1);
console.log("same", same(c, c), same(c, vec(1, 1)), same(undefined, c));
const d = vec(-1, 2);
const e = vec(4, 5);
console.log("returned", orNull(d), orNull(e) === e, orNull(e)!.y);
}
function classes(): void {
const tail = new Cell(3, null);
const list = new Cell(1, new Cell(2, tail));
console.log("cells", cellTotal(list), cellTotal(tail), cellTotal(null));
}
function throwing(): void {
const near = vec(5, 0);
const far = vec(500, 0);
try {
console.log("near", fail(near));
console.log("far", fail(far));
} catch (e) {
console.log("caught", (e as Error).message, near.x, far.x);
}
}
projections();
mutated();
strings();
escapes();
classes();
throwing();
@@ -0,0 +1,83 @@
// Records passed to `T | undefined` parameters whose callee keeps the
// narrowed record: pushed into a global array (also through a non-null
// assertion), kept through `??`, stored in a nullable class field or a record
// field, or passed on to a callee that keeps it. Each record must outlive its
// caller's frame. A callee that only reads the narrowed record lets it stay
// where it is.
interface Tagged {
id: number;
tags: string[];
}
interface Slot {
held: Tagged | undefined;
}
const kept: Tagged[] = [];
const slot: Slot = { held: undefined };
function pushNarrowed(t: Tagged | undefined): void {
if (t !== undefined) kept.push(t);
}
function pushAsserted(t: Tagged | undefined): void {
kept.push(t!);
}
function keepDefaulted(t: Tagged | undefined): void {
kept.push(t ?? { id: -1, tags: [] });
}
function storeInSlot(t: Tagged | undefined): void {
if (t !== undefined) slot.held = t;
}
function keepRecord(t: Tagged): void {
kept.push(t);
}
function passOn(t: Tagged | undefined): void {
if (t !== undefined) keepRecord(t);
}
function readOnly(t: Tagged | undefined): number {
return t === undefined ? 0 : t.id + t.tags.length;
}
class Holder {
last: Tagged | undefined = undefined;
take(t: Tagged | undefined): void {
this.last = t;
}
}
const holder = new Holder();
function fill(round: number): number {
let read = 0;
for (let i = 0; i < 3; i++) {
const a: Tagged = { id: round * 10 + i, tags: ["a", String(i)] };
pushNarrowed(a);
const b: Tagged = { id: round * 10 + i + 100, tags: ["b"] };
keepDefaulted(b);
const c: Tagged = { id: round * 10 + i + 200, tags: ["c"] };
storeInSlot(c);
const d: Tagged = { id: round * 10 + i + 300, tags: ["d", "d"] };
holder.take(d);
const e: Tagged = { id: round * 10 + i + 400, tags: [] };
passOn(e);
const g: Tagged = { id: round * 10 + i + 600, tags: ["g", "g", "g"] };
pushAsserted(g);
const f: Tagged = { id: round * 10 + i + 500, tags: ["f"] };
read += readOnly(f);
}
return read;
}
// Reuses the stack the callers above ran on, and allocates collectable
// records, before the kept ones are read.
function churn(n: number): number {
let total = 0;
for (let i = 0; i < n; i++) {
const t: Tagged = { id: i, tags: [String(i), String(i + 1)] };
total += t.tags.length + readOnly(t);
}
return total;
}
let reads = 0;
for (let round = 0; round < 4; round++) {
reads += fill(round);
reads += churn(200);
}
console.log("reads", reads);
console.log("kept", kept.length);
for (const t of kept) console.log(t.id, t.tags.join("+"));
console.log("slot", slot.held === undefined ? "none" : slot.held.id, slot.held?.tags.join("+"));
console.log("holder", holder.last === undefined ? "none" : holder.last.id, holder.last?.tags.join("+"));