From 068e77274d2efa9d14e43813a55d5586e044073f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 17:26:47 -0500 Subject: [PATCH 1/2] Forward pointer-union wraps of call arguments to the callee's parameter proof A call argument that wraps a binding into a nullable-pointer union passes the binding's pointer itself. The call-lifetimes analysis now treats it like the bare binding, so a record or instance passed to a `T | undefined` parameter that is only projected keeps its frame storage and result slot. --- .../src/backend/llvm/call-lifetimes.test.ts | 46 +++++++ .../src/backend/llvm/call-lifetimes.ts | 20 ++- packages/compiler/src/backend/llvm/emitter.ts | 1 + ...-pointer-union-forwarded-arguments.ts.json | 6 + .../7660-pointer-union-forwarded-arguments.ts | 127 ++++++++++++++++++ 5 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7660-pointer-union-forwarded-arguments.ts.json create mode 100644 tests/corpus/7660-pointer-union-forwarded-arguments.ts diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts index 90909a024..52b2a8775 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts @@ -760,3 +760,49 @@ 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()); +}); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index db5bdb574..8055c7958 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -99,12 +99,18 @@ 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(), @@ -200,12 +206,16 @@ function collectUses( return node.args.every(expr); case "call": node.args.forEach((arg, index) => { - if (arg.kind !== "varRef") { + const value = + arg.kind === "unionWrap" && arg.value.kind === "varRef" && pointerWrap(arg) + ? 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 = [])); + let forwards = uses.forwards.get(value.localId); + if (!forwards) uses.forwards.set(value.localId, (forwards = [])); forwards.push({ callee: node.callee, index }); }); return true; @@ -345,6 +355,7 @@ export function analyzeCallLifetimes( registerReturns: ReadonlySet = new Set(), inlineField: InlineFieldTest = NO_INLINE_FIELDS, recordDynCopy: RecordDynCopyTest = () => false, + pointerWrap: PointerWrapTest = () => false, ): CallLifetimes { const usesByFunction = new Map(); const lazyCaptures = new LazyCaptures(functions); @@ -361,7 +372,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(); for (const param of fn.params) { @@ -386,6 +397,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 diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8e6b88a3a..b2454b9fa 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1774,6 +1774,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(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7660-pointer-union-forwarded-arguments.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7660-pointer-union-forwarded-arguments.ts.json new file mode 100644 index 000000000..8f317668f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7660-pointer-union-forwarded-arguments.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7660-pointer-union-forwarded-arguments.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7660-pointer-union-forwarded-arguments.ts b/tests/corpus/7660-pointer-union-forwarded-arguments.ts new file mode 100644 index 000000000..664f79fcb --- /dev/null +++ b/tests/corpus/7660-pointer-union-forwarded-arguments.ts @@ -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(); From 9496e429689146193892d09933645783348478e0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 01:27:44 -0500 Subject: [PATCH 2/2] Keep a pointer-wrapped argument on the heap when its callee keeps the narrowed payload A union parameter's proof covered the union, not its payload: narrowing it and storing, returning or pushing the result, a narrowed nullish read, and a store into a nullable-pointer field all counted as projections. That holds for a boxed union, whose payload is a separate object. A record passed through a nullable-pointer union is that payload, so forwarding it to such a callee let a record in its caller's frame escape the frame. A pointer-wrapped argument now needs a second fact from the callee's union parameter: its payload stays in place too. The payload is in place when it is only tested, narrowed for a field read (directly or through the checked narrowing a field read of a `T | undefined` parameter compiles to), or passed on to a parameter that is proved in turn. The facts that other arguments use are unchanged. Corpus 7663 has callees that push (directly and through a non-null assertion), default, store in a global record field or a class field, or pass on the narrowed record, and one that only reads it. --- .../src/backend/llvm/call-lifetimes.test.ts | 105 ++++++++++++++++ .../src/backend/llvm/call-lifetimes.ts | 119 +++++++++++++++--- .../7663-pointer-union-stored-payload.ts.json | 6 + .../7663-pointer-union-stored-payload.ts | 83 ++++++++++++ 4 files changed, 297 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7663-pointer-union-stored-payload.ts.json create mode 100644 tests/corpus/7663-pointer-union-stored-payload.ts diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts index 52b2a8775..5c40bb4ce 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts @@ -806,3 +806,108 @@ test("a wrap into a pointer union forwards the wrapped binding to the callee's p 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); +}); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index 8055c7958..becd24805 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -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; declarations: Map; forwards: Map; + /** 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; + /** Narrowed payloads of union bindings passed directly as call arguments: + * the payload stays in place if the callee's parameter is proved. */ + payloadForwards: Map; } interface Parameter { @@ -117,7 +129,37 @@ function collectUses( written: new Set(), declarations: new Map(), forwards: new Map(), + payloadKept: new Set(), + payloadForwards: new Map(), }; + const forward = ( + table: Map, + 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) ? : 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; @@ -151,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; @@ -167,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. @@ -206,17 +258,19 @@ function collectUses( return node.args.every(expr); case "call": node.args.forEach((arg, index) => { - const value = - arg.kind === "unionWrap" && arg.value.kind === "varRef" && pointerWrap(arg) - ? arg.value - : arg; + 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(value.localId); - if (!forwards) uses.forwards.set(value.localId, (forwards = [])); - forwards.push({ callee: node.callee, index }); + forward(uses.forwards, value.localId, { callee: node.callee, index, wrapped }); }); return true; case "assignExpr": @@ -260,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, @@ -360,6 +417,9 @@ export function analyzeCallLifetimes( const usesByFunction = new Map(); const lazyCaptures = new LazyCaptures(functions); const nodes = new Map(); + /** Union parameters that keep their payload in place too, which a + * pointer-wrapped argument needs (ForwardedUse.wrapped). */ + const payloadNodes = new Map(); const unsafe: Parameter[] = []; const consumed: { fn: string; index: number }[] = []; const result: CallLifetimes = { @@ -472,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++) { @@ -520,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); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7663-pointer-union-stored-payload.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7663-pointer-union-stored-payload.ts.json new file mode 100644 index 000000000..51998f0eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7663-pointer-union-stored-payload.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7663-pointer-union-stored-payload.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7663-pointer-union-stored-payload.ts b/tests/corpus/7663-pointer-union-stored-payload.ts new file mode 100644 index 000000000..92ac68bfb --- /dev/null +++ b/tests/corpus/7663-pointer-union-stored-payload.ts @@ -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("+"));