From a07b97f798d8319212b5a2d823cb2ea10548d858 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 05:03:32 -0700 Subject: [PATCH 1/8] Borrow nullable wraps, element reads and preserving virtual calls A wrap into a nullable-pointer union is the payload pointer itself (or an immortal unit constant), so it can borrow exactly when its payload can, as a receiver and as a call argument. tsc-ts widens many `Type` and `Node` parameters to `T | undefined` (they may receive unchecked array reads), so `compareTypes(c, t1, t2)` wrapped `t1` for every identity test and call and retained and released it each time. Required array element reads may borrow as receivers while the borrowed array owns the element and the index preserves heap edges (array operands already borrow; this borrows the element itself). Reference-effect analysis now sees through switch statements, class references, primitive formatting and virtual calls: a virtual call preserves edges when every implementation reachable from its static class (the inherited one plus every override below it) does. Runtime classes keep dispatch opaque. --- packages/compiler/src/backend/llvm/emitter.ts | 47 ++++++ .../backend/llvm/reference-effects.test.ts | 42 ++++- .../src/backend/llvm/reference-effects.ts | 113 +++++++++++-- .../test/ts7/baselines/order-parity.json | 12 ++ tests/corpus/4541-nullable-wrap-borrows.ts | 100 ++++++++++++ tests/corpus/4542-preserving-call-borrows.ts | 148 ++++++++++++++++++ 6 files changed, 452 insertions(+), 10 deletions(-) create mode 100644 tests/corpus/4541-nullable-wrap-borrows.ts create mode 100644 tests/corpus/4542-preserving-call-borrows.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ba71d6bad2..3c7cccd5c0 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -825,6 +825,7 @@ export class LlEmitter { this.referenceEffects = new ReferenceEffects( this.fnByName, (call) => this.optionalArrayReads.get(call) !== null, + mod.classes ?? [], ); this.callLifetimes = analyzeCallLifetimes( this.fnByName, @@ -5117,6 +5118,12 @@ export class LlEmitter { const guarded = guardedValue(value); return guarded !== null && this.canBorrowCallArgument(guarded); } + // A nullable-union wrap reinterprets its payload pointer; an immortal + // unit constant needs no owner at all. + const wrap = this.borrowableNullableWrap(value); + if (wrap === "unit") return true; + if (wrap === "ref" && value.kind === "unionWrap") + return this.canBorrowCallArgument(value.value); if (value.kind !== "varRef") return false; const binding = this.binding(value.localId); return ( @@ -6791,6 +6798,21 @@ export class LlEmitter { this.B.line(`${value} = load ptr, ptr ${binding.slot}`); return { name: value, type: e.type }; } + if (e.kind === "arrayGet" && this.canBorrowReceiver(e)) { + // The array keeps the element alive until the consumer; a missing + // element still traps exactly like the owned read. + const slot = this.B.slot(); + this.B.entryAllocas.push(`${slot} = alloca ptr`); + emitBorrowedArrayRead(this, e, slot); + const value = this.B.tmp(); + this.B.line(`${value} = load ptr, ptr ${slot}`); + return { name: value, type: e.type }; + } + const wrap = this.borrowableNullableWrap(e); + if (wrap === "unit" && e.kind === "unionWrap") + return { name: this.unitInstanceRef(e.unionId, e.tag), type: e.type }; + if (wrap === "ref" && e.kind === "unionWrap") + return { name: this.emitReadReceiver(e.value).name, type: e.type }; const read = matchMapRead(e, this.boxedUnionsById); if (read) { const result = emitStackMapRead(this, read); @@ -7055,11 +7077,36 @@ export class LlEmitter { // typed dummy is null and owns no receiver. case "libCall": return e.fn === "error.nodeThrow"; + case "unionWrap": { + const wrap = this.borrowableNullableWrap(e); + return wrap === "unit" || (wrap === "ref" && this.canBorrowReceiver(e.value)); + } + // A required element read: the (borrowed) array owns the element + // while an edge-preserving index computes. + case "arrayGet": + return ( + e.arr.type.kind === "array" && + isRefCounted(e.arr.type.elem) && + this.canBorrowReceiver(e.arr) && + this.referenceEffects.preserves(e.index) + ); default: return false; } } + /** A wrap into a nullable-pointer union is the payload pointer itself + * (the reference arm) or an immortal constant (a unit arm), so it can + * borrow exactly when its payload can. Void payloads run for effects and + * keep the ordinary path; tagged unions still construct a box. */ + borrowableNullableWrap(e: IrExpr): "unit" | "ref" | null { + if (e.kind !== "unionWrap") return null; + const nullable = this.nullableUnions.get(e.unionId); + if (!nullable) return null; + if (isUnitType(e.value.type)) return e.value.kind === "unitLit" ? "unit" : null; + return e.tag === nullable.refTag && isRefCounted(e.value.type) ? "ref" : null; + } + materializeSplitLocal(localId: string): void { const span = this.splitSpans.get(localId); if (span) materializeSplitPiece(this, localId, span); diff --git a/packages/compiler/src/backend/llvm/reference-effects.test.ts b/packages/compiler/src/backend/llvm/reference-effects.test.ts index af7dfd79fa..ac8b60ca26 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.test.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.test.ts @@ -5,11 +5,12 @@ import { F64, STRING, VOID, + type IrClassDef, type IrExpr, type IrFunction, type IrStmt, } from "../../ir/ir.js"; -import { ReferenceEffects, preservesRegexInputs } from "./reference-effects.js"; +import { ReferenceEffects, VirtualTargets, preservesRegexInputs } from "./reference-effects.js"; const loc = { file: "effects.ts", start: 0, end: 0 }; const number: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; @@ -195,3 +196,42 @@ test("nullish fallbacks and Math calls preserve references; their operands still expect(summary.preserves(fallback(write))).toBe(false); expect(summary.preserves(fallback(call("read")))).toBe(true); }); + +test("virtual calls preserve references only when every reachable override does", () => { + const cls = (name: string, methods: string[], base?: string, extra?: Partial) => + ({ name, base, fields: [], methods, loc, ...extra }) as IrClassDef; + const classes = [ + cls("A", ["run", "size"], undefined, { abstractMethods: ["size"] }), + cls("B", ["size"], "A"), + cls("C", ["run"], "B"), + cls("D", [], "A"), + ]; + const targets = new VirtualTargets(classes); + expect(targets.targets("A", "run")).toEqual(["%A.run", "%C.run"]); + // B inherits A.run; only C overrides below it. Abstract slots have no body. + expect(targets.targets("B", "run")).toEqual(["%A.run", "%C.run"]); + expect(targets.targets("A", "size")).toEqual(["%B.size"]); + expect(targets.targets("D", "run")).toEqual(["%A.run"]); + expect( + new VirtualTargets([...classes, cls("E", ["run"], "D", { runtime: true })]).targets("A", "run"), + ).toBe(null); + const virtual: IrExpr = { + kind: "virtualCall", + className: "A", + method: "run", + args: [], + type: F64, + loc, + }; + const build = (mutating: boolean) => { + const functions = [fn("%A.run"), fn("%C.run"), fn("caller", [virtual])]; + if (mutating) functions[1]!.body.push({ kind: "assign", localId: "owner", value: text, loc }); + return new ReferenceEffects(new Map(functions.map((f) => [f.name, f])), () => false, classes); + }; + expect(build(false).preserves(virtual)).toBe(true); + expect(build(false).functions.has("caller")).toBe(true); + expect(build(true).preserves(virtual)).toBe(false); + expect(build(true).functions.has("caller")).toBe(false); + // Without class facts a virtual call stays a barrier. + expect(effects([fn("%A.run")]).preserves(virtual)).toBe(false); +}); diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index e820a033f1..128463857b 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -1,12 +1,19 @@ import { preservesDynTest } from "./checked-value-lifetimes.js"; import { isStableReceiverOperand } from "../../ir/analysis.js"; -import { isRefCounted, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js"; +import { + isRefCounted, + type IrClassDef, + type IrExpr, + type IrFunction, + type IrStmt, +} from "../../ir/ir.js"; import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; import { borrowsMapReadInputs } from "./map-read-lifetimes.js"; import { byteNumberAccess } from "../../ir/byte-numbers.js"; type Call = IrExpr & { kind: "call" }; +type VirtualCall = IrExpr & { kind: "virtualCall" }; /** These native operations may update lastIndex and allocate results, but * never invoke user code or remove an existing reference edge. Results own @@ -35,6 +42,7 @@ function expressionPreservesEdges( e: IrExpr, call: (value: Call) => boolean, privateLocals?: ReadonlySet, + virtualCall?: (value: VirtualCall) => boolean, ): boolean { switch (e.kind) { case "numLit": @@ -70,7 +78,18 @@ function expressionPreservesEdges( case "caughtTest": case "caughtNarrow": case "caughtCheck": + // An immortal class object, or a fresh one retaining captured boxes. + case "classRef": return true; + // Formatting a primitive allocates a string and runs no user code. + case "toString": + return ( + e.operand.type.kind === "f64" || + e.operand.type.kind === "bool" || + e.operand.type.kind === "string" + ); + case "virtualCall": + return virtualCall?.(e) ?? false; case "dynTest": return preservesDynTest(e.test); case "assignExpr": @@ -121,6 +140,7 @@ function statementPreservesEdges(s: IrStmt, privateLocals?: ReadonlySet) case "while": case "doWhile": case "block": + case "switch": case "break": case "continue": case "bytesSet": @@ -135,6 +155,61 @@ function statementPreservesEdges(s: IrStmt, privateLocals?: ReadonlySet) } } +/** The module functions a virtual call can reach: the implementation the + * static class inherits or declares, plus every override in its subtree. + * Runtime classes keep their dispatch out of view, so they answer null. */ +export class VirtualTargets { + private readonly classes = new Map(); + private readonly children = new Map(); + private readonly cache = new Map(); + + constructor(classes: readonly IrClassDef[]) { + for (const cls of classes) this.classes.set(cls.name, cls); + for (const cls of classes) { + if (cls.base === undefined) continue; + let list = this.children.get(cls.base); + if (!list) this.children.set(cls.base, (list = [])); + list.push(cls); + } + } + + targets(className: string, method: string): readonly string[] | null { + const key = `${className}\0${method}`; + const known = this.cache.get(key); + if (known !== undefined) return known; + const result = this.compute(className, method); + this.cache.set(key, result); + return result; + } + + private compute(className: string, method: string): readonly string[] | null { + const declares = (cls: IrClassDef): "concrete" | "abstract" | null => + cls.methods?.includes(method) !== true + ? null + : cls.abstractMethods?.includes(method) === true + ? "abstract" + : "concrete"; + const targets = new Set(); + // The nearest declaration on the static class or its ancestors. + for (let name: string | undefined = className; name !== undefined;) { + const cls = this.classes.get(name); + if (!cls || cls.runtime) return null; + const declared = declares(cls); + if (declared === "concrete") targets.add(`%${cls.name}.${method}`); + if (declared !== null) break; + name = cls.base; + } + const pending = [...(this.children.get(className) ?? [])]; + while (pending.length > 0) { + const cls = pending.pop()!; + if (cls.runtime) return null; + if (declares(cls) === "concrete") targets.add(`%${cls.name}.${method}`); + pending.push(...(this.children.get(cls.name) ?? [])); + } + return [...targets]; + } +} + /** Reference preservation is weaker than purity: scalar writes, allocation * and throwing are allowed. Caller owners and their reference edges must * survive until the consuming operation; callee-local rebinding is private. @@ -146,12 +221,23 @@ export class ReferenceEffects { readonly functions = new Set(); private readonly expressions = new Map(); + private readonly virtualTargets: VirtualTargets | null; + constructor( functions: ReadonlyMap, private readonly intrinsicCall: (call: Call) => boolean, + classes?: readonly IrClassDef[], ) { + this.virtualTargets = classes ? new VirtualTargets(classes) : null; const callers = new Map>(); const unsafe: string[] = []; + const dependOn = (callee: string, caller: string): boolean => { + if (!functions.has(callee)) return false; + let incoming = callers.get(callee); + if (!incoming) callers.set(callee, (incoming = new Set())); + incoming.add(caller); + return true; + }; for (const fn of functions.values()) { // Rebinding a callee's unboxed local cannot replace the caller's // owner. The same write in a later caller operand still must reject @@ -169,15 +255,14 @@ export class ReferenceEffects { expr: (e) => expressionPreservesEdges( e, - (call) => { - if (intrinsicCall(call)) return true; - if (!functions.has(call.callee)) return false; - let incoming = callers.get(call.callee); - if (!incoming) callers.set(call.callee, (incoming = new Set())); - incoming.add(fn.name); - return true; - }, + (call) => intrinsicCall(call) || dependOn(call.callee, fn.name), privateLocals, + // Every reachable implementation must preserve edges; the + // dispatch itself passes owned arguments that callees release. + (call) => + this.virtualTargets + ?.targets(call.className, call.method) + ?.every((target) => dependOn(target, fn.name)) ?? false, ), }); if (safe) this.functions.add(fn.name); @@ -202,11 +287,21 @@ export class ReferenceEffects { expressionPreservesEdges( value, (call) => this.intrinsicCall(call) || this.functions.has(call.callee), + undefined, + (call) => this.virtualPreserves(call), ) && everyExprChild(value, expr, stmt); this.expressions.set(value, result); return result; } + virtualPreserves(call: VirtualCall): boolean { + return ( + this.virtualTargets + ?.targets(call.className, call.method) + ?.every((target) => this.functions.has(target)) ?? false + ); + } + preservesScope(body: IrStmt[]): boolean { return everyStmtList(body, { stmt: (stmt) => statementPreservesEdges(stmt), diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7404e91755..26697a57f2 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15592,6 +15592,18 @@ "/tests/corpus/4471-unassigned-field-reads.ts" ], "diags": [] + }, + "/tests/corpus/4541-nullable-wrap-borrows.ts": { + "order": [ + "/tests/corpus/4541-nullable-wrap-borrows.ts" + ], + "diags": [] + }, + "/tests/corpus/4542-preserving-call-borrows.ts": { + "order": [ + "/tests/corpus/4542-preserving-call-borrows.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4541-nullable-wrap-borrows.ts b/tests/corpus/4541-nullable-wrap-borrows.ts new file mode 100644 index 0000000000..da218a3dcf --- /dev/null +++ b/tests/corpus/4541-nullable-wrap-borrows.ts @@ -0,0 +1,100 @@ +// Values widened into `T | undefined` parameters, identity tests and +// casts: the wrap is the payload pointer itself, so it may borrow when the +// payload can, and must keep an owner when a later operand rebinds it. + +class Item { + name: string; + next: Item | undefined; + constructor(name: string, next: Item | undefined) { + this.name = name; + this.next = next; + } +} + +class Special extends Item { + extra = 1; +} + +function label(item: Item | undefined): string { + return item === undefined ? "none" : item.name; +} + +function same(a: Item | undefined, b: Item | undefined): boolean { + return a === b; +} + +function order(a: Item | undefined, b: Item | undefined): number { + if (a === b) return 0; + if (a === undefined) return 1; + if (b === undefined) return -1; + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; +} + +function pick(first: Item | undefined, replace: () => Item, second: Item | undefined): string { + const fresh = replace(); + return label(first) + "/" + label(second) + "/" + fresh.name; +} + +function chain(item: Item): string { + // A borrowed parameter widened into a nullable argument several times. + return label(item) + ":" + label(item.next) + ":" + String(same(item, item.next)); +} + +function special(item: Item): string { + if (item instanceof Special) { + const s = item as Special; + return label(s) + "+" + s.extra + "+" + String(same(s, item)); + } + return label(item); +} + +let shared: Item | undefined = new Item("shared", undefined); + +function rebinding(): string { + let local = new Item("before", undefined); + // The second operand replaces the local the first operand wraps. + const result = pick( + local, + () => { + local = new Item("after", undefined); + return local; + }, + local, + ); + // A sequence expression rebinding the wrapped binding between operands. + let other = new Item("x", undefined); + const seq = order(other, ((other = new Item("y", undefined)), other)); + return result + " " + seq + " " + label(other); +} + +function globalRebinding(): string { + // A global's wrapped value must survive a callee replacing the global. + return pick( + shared, + () => { + shared = new Item("replaced", undefined); + return shared; + }, + shared, + ); +} + +const a = new Item("a", undefined); +const b = new Item("b", a); +const c = new Special("c", b); +console.log(chain(a), chain(b), chain(c)); +console.log(special(c), special(b)); +console.log(same(a, a), same(a, b), same(undefined, undefined), same(a, undefined)); +console.log(order(a, b), order(b, a), order(a, a), order(undefined, a), order(a, undefined)); +console.log(rebinding()); +console.log(globalRebinding(), label(shared)); +const items: Item[] = [c, b, a, new Item("b", undefined)]; +items.sort((x, y) => order(x, y)); +console.log(items.map((x) => label(x)).join(",")); +let walk: Item | undefined = c; +let names = ""; +while (walk !== undefined) { + names += label(walk); + walk = walk.next; +} +console.log(names); diff --git a/tests/corpus/4542-preserving-call-borrows.ts b/tests/corpus/4542-preserving-call-borrows.ts new file mode 100644 index 0000000000..afd1715bf8 --- /dev/null +++ b/tests/corpus/4542-preserving-call-borrows.ts @@ -0,0 +1,148 @@ +// Callees proven not to remove heap edges (switch statements, `??`, +// Math.min, instanceof, and virtual calls whose every override preserves) +// let callers pass array elements and field reads without owning them. +// A single mutating override, a mutating callee, or a mutating later +// operand must keep the owned path: the sanitized lane catches a borrow of +// a freed element. + +class Node { + kind: number; + name: string; + constructor(kind: number, name: string) { + this.kind = kind; + this.name = name; + } + weight(): number { + return this.kind; + } +} + +class Leaf extends Node { + weight(): number { + return this.kind * 2; + } +} + +class Branch extends Node { + children: Node[] = []; + weight(): number { + let total = this.kind; + for (const child of this.children) total += child.weight(); + return total; + } +} + +function describe(n: Node): string { + switch (n.kind) { + case 1: + return "one:" + n.name; + case 2: + return "two:" + n.name; + default: + return (n instanceof Leaf ? "leaf:" : "node:") + n.name; + } +} + +function compareNames(a: string, b: string): number { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const d = a.charCodeAt(i) - b.charCodeAt(i); + if (d !== 0) return d; + } + return a.length - b.length; +} + +const ranks = new Map(); + +function compareNodes(a: Node, b: Node): number { + if (a === b) return 0; + const r = (ranks.get(a.name) ?? 0) - (ranks.get(b.name) ?? 0); + if (r !== 0) return r; + const w = a.weight() - b.weight(); + return w !== 0 ? w : compareNames(a.name, b.name); +} + +function search(nodes: readonly Node[], target: Node): number { + let low = 0; + let high = nodes.length - 1; + while (low <= high) { + const middle = (low + high) >> 1; + const r = compareNodes(nodes[middle]!, target); + if (r === 0) return middle; + if (r < 0) low = middle + 1; + else high = middle - 1; + } + return ~low; +} + +function insert(nodes: Node[], n: Node): boolean { + const index = search(nodes, n); + if (index >= 0) return false; + nodes.splice(~index, 0, n); + return true; +} + +// A callee that removes the element it was handed: callers must own it. +function takeAndClear(list: Node[], first: Node): string { + list.length = 0; + return describe(first) + "/" + list.length; +} + +// A subclass whose override drops references: virtual calls through the +// base class can no longer be proven preserving. +class Measured { + name: string; + constructor(name: string) { + this.name = name; + } + measure(): number { + return this.name.length; + } +} + +class Pruning extends Measured { + owner: Measured[]; + constructor(name: string, owner: Measured[]) { + super(name); + this.owner = owner; + } + measure(): number { + this.owner.length = 0; + return -1; + } +} + +function weigh(n: Measured, label: Measured): string { + return n.measure() + ":" + label.name; +} + +const nodes: Node[] = []; +const names = ["delta", "alpha", "charlie", "bravo", "alpha", "echo"]; +names.forEach((name, i) => { + const n = i % 3 === 0 ? new Leaf(1 + (i % 2), name) : new Node(1 + (i % 2), name); + ranks.set(name, name.length % 2); + console.log(name, insert(nodes, n)); +}); +console.log(nodes.map(describe).join(" ")); +const branch = new Branch(3, "root"); +branch.children.push(nodes[0]!, nodes[1]!); +console.log(describe(branch), branch.weight(), search(nodes, nodes[2]!), search(nodes, branch)); + +const temp = [new Node(1, "temp"), new Leaf(2, "temp2")]; +console.log(takeAndClear(temp, temp[0]!)); + +const owned: Measured[] = [new Measured("kept")]; +const pruning = new Pruning("pruner", owned); +const mixed: Measured[] = [pruning, new Measured("plain")]; +console.log(weigh(mixed[0]!, owned[0]!), owned.length); +console.log(weigh(mixed[1]!, mixed[1]!)); + +// A later operand that mutates the array the earlier element came from. +const shrinking = [new Node(1, "first"), new Node(2, "second")]; +console.log( + compareNames( + shrinking[0]!.name, + ((shrinking.length = 0), "z"), + ), + shrinking.length, +); From 12f1966f0660e016a2121091f2f2a23dd67ad625 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 05:09:03 -0700 Subject: [PATCH 2/8] Borrow constant aliases created by checked casts `const x = a as LiteralType` lowers to a ternary whose failing arm always throws and whose successful arm narrows and casts the unchanged source. The alias projects the same stable owner, so it can borrow like a plain copy of the binding instead of retaining at the declaration and releasing at scope exit. Call arguments of the same shape borrow too. tsc-ts's sameLiteralValue (two casts per call, on the relation hot path) loses all of its reference counting. --- packages/compiler/src/backend/llvm/emitter.ts | 12 ++++ .../test/ts7/baselines/order-parity.json | 6 ++ tests/corpus/4544-checked-cast-aliases.ts | 71 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 tests/corpus/4544-checked-cast-aliases.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 3c7cccd5c0..a084e9910a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5124,6 +5124,18 @@ export class LlEmitter { if (wrap === "unit") return true; if (wrap === "ref" && value.kind === "unionWrap") return this.canBorrowCallArgument(value.value); + // Checked casts (`x as C`, `x!` on a nullable) lower to a ternary whose + // failing arm always throws; the successful arm projects the same + // stable owner. The throwing arm's typed dummy owns nothing. + if (value.kind === "unionNarrow") return this.canBorrowCallArgument(value.value); + if (value.kind === "libCall") return value.fn === "error.nodeThrow"; + if (value.kind === "ternary") + return ( + this.canBorrowReceiver(value.then) && + this.canBorrowReceiver(value.else_) && + this.canBorrowCallArgument(value.then) && + this.canBorrowCallArgument(value.else_) + ); if (value.kind !== "varRef") return false; const binding = this.binding(value.localId); return ( diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 26697a57f2..baab306da0 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15604,6 +15604,12 @@ "/tests/corpus/4542-preserving-call-borrows.ts" ], "diags": [] + }, + "/tests/corpus/4544-checked-cast-aliases.ts": { + "order": [ + "/tests/corpus/4544-checked-cast-aliases.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4544-checked-cast-aliases.ts b/tests/corpus/4544-checked-cast-aliases.ts new file mode 100644 index 0000000000..5e33046fe9 --- /dev/null +++ b/tests/corpus/4544-checked-cast-aliases.ts @@ -0,0 +1,71 @@ +// Constant aliases created by checked casts (`x as Sub`, a narrowed +// nullable) of unchanged parameters and locals borrow their source. The +// source binding keeps the object alive even when everything else that +// referenced it is dropped while the alias is in use. + +class Type { + flags: number; + constructor(flags: number) { + this.flags = flags; + } +} + +class LiteralType extends Type { + value: number; + text: string; + constructor(flags: number, value: number, text: string) { + super(flags); + this.value = value; + this.text = text; + } +} + +class Holder { + item: Type | undefined; + constructor(item: Type | undefined) { + this.item = item; + } +} + +function sameLiteral(a: Type, b: Type): boolean { + const x = a as LiteralType; + const y = b as LiteralType; + if ((a.flags & 1) !== 0) return x.value === y.value || (x.value !== x.value && y.value !== y.value); + return x.text === y.text; +} + +// The alias outlives every other reference the program held. +function aliasAcrossDrop(a: Type, drop: () => void): string { + const x = a as LiteralType; + drop(); + const fresh = new LiteralType(3, 99, "fresh"); + return x.text + ":" + x.value + ":" + fresh.text; +} + +function narrowAlias(holder: Holder): string { + const item = holder.item; + if (item === undefined) return "none"; + const literal = item as LiteralType; + holder.item = undefined; + const fresh = new LiteralType(5, 7, "replacement"); + return literal.text + "/" + fresh.text; +} + +const items: Type[] = [ + new LiteralType(1, 1, "one"), + new LiteralType(1, 1, "uno"), + new LiteralType(2, 2, "two"), + new LiteralType(2, 3, "two"), + new LiteralType(1, NaN, "nan"), +]; +console.log(sameLiteral(items[0], items[1]), sameLiteral(items[2], items[3]), sameLiteral(items[4], items[4])); + +const holder = new Holder(new LiteralType(2, 42, "held")); +console.log( + aliasAcrossDrop(holder.item!, () => { + holder.item = undefined; + }), + holder.item === undefined, +); +const holder2 = new Holder(new LiteralType(2, 8, "narrowed")); +console.log(narrowAlias(holder2), narrowAlias(holder2)); From 713f947024cad8ed20f19cdf6f80fdbb2cce5b90 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 05:09:07 -0700 Subject: [PATCH 3/8] Borrow locals that walk pointers in edge-preserving functions A local whose every definition projects an unwritten parameter or another such local through plain field reads, casts, nullable narrows/wraps and checked ternaries (the parent walk `p = p.parent`) now holds a borrowed pointer: no retain at the definition, no release when it is rebound or leaves scope. This is sound only in functions whose whole body preserves heap edges (ReferenceEffects.functions): nothing they run removes a field, element or global reference, so every object reachable from a parameter at entry stays alive until the function returns. Whole-value uses still retain their own copies. The unreachable-completion fence after a `for (;;)` loop throws a fresh constant error and preserves edges like `throw`; it used to make every such function look mutating. tsc-ts's getSourceFileOfNode loop did two increments and two full decrements (with cycle-candidate checks) per parent step; it now only loads and tests the parent pointer. --- packages/compiler/src/backend/llvm/emitter.ts | 50 +++++++ .../src/backend/llvm/reference-effects.ts | 2 + .../src/backend/llvm/walk-borrows.test.ts | 97 +++++++++++++ .../compiler/src/backend/llvm/walk-borrows.ts | 129 +++++++++++++++++ .../test/ts7/baselines/order-parity.json | 6 + tests/corpus/4543-walk-borrowed-locals.ts | 134 ++++++++++++++++++ 6 files changed, 418 insertions(+) create mode 100644 packages/compiler/src/backend/llvm/walk-borrows.test.ts create mode 100644 packages/compiler/src/backend/llvm/walk-borrows.ts create mode 100644 tests/corpus/4543-walk-borrowed-locals.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index a084e9910a..74074cd7d3 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -156,6 +156,7 @@ import { } from "./map-read-lifetimes.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; +import { findWalkBorrows } from "./walk-borrows.js"; import { ReferenceEffects } from "./reference-effects.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { StackCallbacks } from "./stack-callbacks.js"; @@ -585,6 +586,8 @@ export class LlEmitter { /** Parameters proven projection-only: callers may pass stack boxes. */ private projectedParameters = new Set(); private stableCallBindings: ReadonlySet = new Set(); + /** Locals holding borrowed pointers for their whole lifetime (walk-borrows.ts). */ + private walkBorrows: ReadonlySet = new Set(); /** Manifest-bound native imports, used by ffiCall emission. */ readonly ffiByName = new Map(); /** C-ABI callback trampolines and (for raw/no-userdata callbacks) their @@ -4834,6 +4837,16 @@ export class LlEmitter { (e) => this.nullableFieldGet(e) !== null, ); this.localUnionStorage = new Map(); + this.walkBorrows = + this.debug === null && this.referenceEffects.functions.has(fn.name) + ? findWalkBorrows(fn, { + pointerLocal: (local) => + local.type.kind === "object" || + (local.type.kind === "union" && + this.nullableUnions.get(local.type.unionId)?.arm.kind === "object"), + borrowsWithoutOwning: (e) => this.borrowsWithoutOwning(e), + }) + : new Set(); this.integerRanges = analyzeIntegerRanges(numericFn, this.int32Slots.facts(fn.name)); this.bytesBounds = findBytesBounds(numericFn, this.integerRanges); this.chainSlots.clear(); @@ -5193,6 +5206,12 @@ export class LlEmitter { switch (s.kind) { case "varDecl": { const b = this.binding(s.localId); + if (this.walkBorrows.has(s.localId) && b.kind === "local") { + // A borrowed walk pointer: no retain now, no release at scope exit. + const v = s.init === null ? "null" : this.emitReadReceiver(s.init).name; + B.line(`store ptr ${v}, ptr ${b.slot}`); + break; + } const slice = this.scalarStringSlices.get(s.localId); if (slice) { const snapshot = emitStringSliceSnapshot(this, slice); @@ -5336,6 +5355,11 @@ export class LlEmitter { break; } const b = this.binding(s.localId); + if (this.walkBorrows.has(s.localId) && b.kind === "local") { + // Rebinding a borrowed walk pointer releases nothing. + B.line(`store ptr ${this.emitReadReceiver(s.value).name}, ptr ${b.slot}`); + break; + } const v = this.emitExpr(s.value); if (b.kind === "global" && !s.initializes) this.checkGlobalTdz(s.localId); if (b.kind === "boxed") { @@ -7107,6 +7131,32 @@ export class LlEmitter { } } + /** emitReadReceiver produces this node's pointer without acquiring a + * reference, given operands that do the same: plain class/record field + * reads, class casts, nullable-pointer narrows and wraps, and checked + * ternaries. Stack-boxed, map-read and boxed-field sources are owned. */ + borrowsWithoutOwning(e: IrExpr): boolean { + if (!isRefCounted(e.type) || this.isStackUnionSource(e)) return false; + if (matchMapRead(e, this.boxedUnionsById)) return false; + switch (e.kind) { + case "fieldGet": + return this.nullableFieldGet(e) === null; + case "recordGet": + return true; + case "unionNarrow": + return this.nullableUnions.has(e.unionId); + case "downcast": + case "upcast": + return e.value.type.kind === "object"; + case "unionWrap": + return this.borrowableNullableWrap(e) !== null; + case "ternary": + return this.canBorrowReceiver(e.then) && this.canBorrowReceiver(e.else_); + default: + return false; + } + } + /** A wrap into a nullable-pointer union is the payload pointer itself * (the reference arm) or an immortal constant (a unit arm), so it can * borrow exactly when its payload can. Void payloads run for effects and diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index 128463857b..a1e7dcb11e 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -134,6 +134,8 @@ function statementPreservesEdges(s: IrStmt, privateLocals?: ReadonlySet) case "exprStmt": case "return": case "throw": + // Throws a fresh Error with a constant message (an unreachable trap). + case "runtimeFence": case "if": case "for": case "forOf": diff --git a/packages/compiler/src/backend/llvm/walk-borrows.test.ts b/packages/compiler/src/backend/llvm/walk-borrows.test.ts new file mode 100644 index 0000000000..5b64f29f7d --- /dev/null +++ b/packages/compiler/src/backend/llvm/walk-borrows.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "vitest"; +import { F64, VOID, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; + +const loc = { file: "walk.ts", start: 0, end: 0 }; +const NODE: IrType = { kind: "object", className: "Node" }; +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: NODE, loc }); +const parent = (obj: IrExpr): IrExpr => ({ + kind: "fieldGet", + obj, + className: "Node", + field: "parent", + type: NODE, + loc, +}); +const decl = (localId: string, init: IrExpr | null): IrStmt => ({ + kind: "varDecl", + localId, + init, + loc, +}); +const assign = (localId: string, value: IrExpr): IrStmt => ({ + kind: "assign", + localId, + value, + loc, +}); +const local = (id: string, mutable = true) => ({ id, name: id, type: NODE, mutable }); + +function fn(body: IrStmt[], locals: string[], params = ["node"]): IrFunction { + return { + name: "walk", + params: params.map((id) => ({ localId: id, name: id, type: NODE })), + locals: [...params, ...locals].map((id) => local(id)), + body, + returnType: VOID, + loc, + }; +} + +const host: WalkBorrowHost = { + pointerLocal: (l) => l.type.kind === "object", + borrowsWithoutOwning: () => true, +}; + +test("parent walks rooted at unwritten parameters borrow", () => { + const body = [ + decl("p", parent(ref("node"))), + decl("next", parent(ref("p"))), + assign("p", ref("next")), + decl("unset", null), + assign("unset", parent(parent(ref("p")))), + ]; + expect(findWalkBorrows(fn(body, ["p", "next", "unset"]), host)).toEqual( + new Set(["p", "next", "unset"]), + ); +}); + +test("rebound parameters, owned sources and other writers keep ownership", () => { + const call: IrExpr = { kind: "call", callee: "make", args: [], type: NODE, loc }; + const body = [ + // A rebound parameter is no root, and neither is its walk. + assign("node", parent(ref("node"))), + decl("fromParam", parent(ref("node"))), + // A call result is owned, and the failure propagates transitively. + decl("owned", call), + decl("viaOwned", parent(ref("owned"))), + decl("viaViaOwned", ref("viaOwned")), + // An expression-position write excludes the local. + decl("written", parent(ref("other"))), + { + kind: "exprStmt", + expr: { kind: "assignExpr", localId: "written", value: ref("other"), type: NODE, loc }, + loc, + } as IrStmt, + decl("kept", parent(ref("other"))), + ]; + const result = findWalkBorrows( + fn( + body, + ["fromParam", "owned", "viaOwned", "viaViaOwned", "written", "kept"], + ["node", "other"], + ), + host, + ); + expect(result).toEqual(new Set(["kept"])); +}); + +test("scalar locals, owning projections and suspending bodies are never walks", () => { + const scalar = fn([decl("p", parent(ref("node")))], ["p"]); + scalar.locals[1] = { id: "p", name: "p", type: F64, mutable: true }; + expect(findWalkBorrows(scalar, host).size).toBe(0); + const owning = fn([decl("p", parent(ref("node")))], ["p"]); + expect(findWalkBorrows(owning, { ...host, borrowsWithoutOwning: () => false }).size).toBe(0); + const suspending: IrFunction = { ...fn([decl("p", parent(ref("node")))], ["p"]), async: true }; + expect(findWalkBorrows(suspending, host).size).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts new file mode 100644 index 0000000000..910bbf6479 --- /dev/null +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -0,0 +1,129 @@ +import type { IrExpr, IrFunction, IrLocal } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; + +/** What the emitter knows about a projection's storage. */ +export interface WalkBorrowHost { + /** The local holds a plain pointer: a class instance or a nullable-pointer + * union. Tagged union boxes, strings and containers keep ownership. */ + pointerLocal(local: IrLocal): boolean; + /** emitReadReceiver yields this expression's pointer without acquiring a + * reference (class casts, nullable wraps, plain field reads, checked + * ternaries). */ + borrowsWithoutOwning(e: IrExpr): boolean; +} + +/** Locals that may hold borrowed pointers for their whole lifetime: every + * definition (declaration or statement assignment) is a projection of an + * unwritten parameter or of another such local, for example the parent + * walk `let p = node.parent; while (p.parent) p = p.parent;`. + * + * Soundness rests on the whole body preserving heap edges (the caller + * passes only functions in ReferenceEffects.functions): neither the body + * nor anything it calls removes a field, element or global reference, so + * every object reachable from a parameter at entry stays reachable (and + * alive) until the function returns. Such a local therefore needs no + * retain on definition, no release on rebinding and none at scope exit. + * Whole-value uses (returns, stores, owned arguments) still retain their + * own copies when they read the binding. Other locals may be rebound + * freely: they never root a walk, so releasing their old values cannot free + * an object the walk reaches. */ +export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlySet { + const result = new Set(); + if (fn.async || fn.generator || fn.captures || fn.classCaptures) return result; + const params = new Set(fn.params.map((param) => param.localId)); + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const definitions = new Map(); + // Locals written in any other way (expression assignments, loop and catch + // bindings, captures) keep the ordinary owned representation. + const excluded = new Set(); + const define = (id: string, value: IrExpr | null): void => { + let list = definitions.get(id); + if (!list) definitions.set(id, (list = [])); + list.push(value); + }; + everyStmtList(fn.body, { + stmt: (s) => { + switch (s.kind) { + case "varDecl": + define(s.localId, s.init); + break; + case "assign": + define(s.localId, s.value); + break; + case "forOf": + excluded.add(s.localId); + break; + case "rethrow": + excluded.add(s.localId); + break; + case "tryCatch": + if (s.catchLocalId !== null) excluded.add(s.catchLocalId); + break; + } + return true; + }, + expr: (e) => { + switch (e.kind) { + case "assignExpr": + case "incDec": + excluded.add(e.localId); + break; + case "closure": + case "classRef": + for (const id of e.captures ?? []) excluded.add(id); + break; + } + return true; + }, + }); + // Parameters are roots only while no definition rebinds them. + const roots = new Set([...params].filter((id) => !definitions.has(id) && !excluded.has(id))); + const candidates = new Set(); + for (const id of definitions.keys()) { + const local = locals.get(id); + if ( + params.has(id) || + excluded.has(id) || + !local || + local.boxed || + local.tdz || + !host.pointerLocal(local) + ) + continue; + candidates.add(id); + } + // A walk projects roots or candidates through reads that never acquire a + // reference. Drop candidates until every definition is such a walk. + const walk = (e: IrExpr): boolean => { + switch (e.kind) { + case "varRef": + return roots.has(e.localId) || candidates.has(e.localId); + case "fieldGet": + case "recordGet": + return host.borrowsWithoutOwning(e) && walk(e.obj); + case "unionNarrow": + case "downcast": + case "upcast": + return host.borrowsWithoutOwning(e) && walk(e.value); + case "unionWrap": + if (!host.borrowsWithoutOwning(e)) return false; + return e.value.kind === "unitLit" || walk(e.value); + case "ternary": + return host.borrowsWithoutOwning(e) && walkOrThrow(e.then) && walkOrThrow(e.else_); + default: + return false; + } + }; + const walkOrThrow = (e: IrExpr): boolean => + (e.kind === "libCall" && e.fn === "error.nodeThrow") || walk(e); + for (let changed = true; changed;) { + changed = false; + for (const id of candidates) { + if (definitions.get(id)!.every((value) => value === null || walk(value))) continue; + candidates.delete(id); + changed = true; + } + } + for (const id of candidates) result.add(id); + return result; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index baab306da0..b3060504db 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15610,6 +15610,12 @@ "/tests/corpus/4544-checked-cast-aliases.ts" ], "diags": [] + }, + "/tests/corpus/4543-walk-borrowed-locals.ts": { + "order": [ + "/tests/corpus/4543-walk-borrowed-locals.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4543-walk-borrowed-locals.ts b/tests/corpus/4543-walk-borrowed-locals.ts new file mode 100644 index 0000000000..6c07dd12a0 --- /dev/null +++ b/tests/corpus/4543-walk-borrowed-locals.ts @@ -0,0 +1,134 @@ +// Locals that only walk pointers reachable from parameters (parent chains, +// checked casts, nullable fields) hold borrowed pointers when nothing in the +// function can remove a heap edge. Cases where an edge is removed, a root is +// rebound, or the walk starts at an owned temporary must keep ownership: the +// sanitized lane catches a borrow of a freed object. + +class Node { + parent: Node | undefined; + kind: number; + constructor(kind: number, parent: Node | undefined) { + this.kind = kind; + this.parent = parent; + } +} + +class SourceFile extends Node { + fileName: string; + constructor(fileName: string) { + super(0, undefined); + this.fileName = fileName; + } +} + +function sourceFileOf(node: Node): SourceFile { + let parent = node.parent; + if (parent === undefined) return node as SourceFile; + for (;;) { + const next: Node | undefined = parent.parent; + if (next === undefined) return parent as SourceFile; + parent = next; + } +} + +function depth(node: Node | undefined): number { + let n = 0; + let current = node; + while (current !== undefined) { + n++; + current = current.parent; + } + return n; +} + +function ancestorOfKind(node: Node, kind: number): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined && current.kind !== kind) current = current.parent; + return current; +} + +// Two walks advancing in lockstep, with a checked cast at the end. +function commonRoot(a: Node, b: Node): string { + let x: Node = a; + let y: Node = b; + while (x.parent !== undefined) x = x.parent; + while (y.parent !== undefined) y = y.parent; + const fx = x as SourceFile; + const fy = y as SourceFile; + return fx === fy ? fx.fileName : fx.fileName + "|" + fy.fileName; +} + +// A callee that cuts the chain: the loop's `next` must own its node. +function cut(node: Node): void { + node.parent = undefined; +} + +function cutWalk(node: Node): number { + let count = 0; + let current = node.parent; + while (current !== undefined) { + const next = current.parent; + cut(current); + count += next === undefined ? 0 : next.kind; + current = next; + } + return count; +} + +// A rebound parameter is not a root. +function climb(node: Node, steps: number): number { + let total = 0; + for (let i = 0; i < steps && node.parent !== undefined; i++) { + node = node.parent; + const here = node; + total += here.kind; + } + return total; +} + +interface Inner { + name: string; +} +interface Outer { + inner: Inner; +} + +// The walk starts at an owned local that is later rebound. +function rebound(seed: string): string { + let owner: Outer = { inner: { name: seed + "-1" } }; + const inner = owner.inner; + owner = { inner: { name: seed + "-2" } }; + return inner.name + "/" + owner.inner.name; +} + +// Whole-value uses of a walk local still take their own reference. +function rootOrSelf(node: Node): Node { + let current = node; + while (current.parent !== undefined) current = current.parent; + return current; +} + +const file = new SourceFile("main.ts"); +const other = new SourceFile("other.ts"); +const a = new Node(1, file); +const b = new Node(2, a); +const c = new Node(3, b); +const d = new Node(4, other); +console.log(sourceFileOf(c).fileName, sourceFileOf(file).fileName, sourceFileOf(d).fileName); +console.log(depth(c), depth(file), depth(undefined)); +console.log(ancestorOfKind(c, 1)?.kind, ancestorOfKind(c, 9)?.kind); +console.log(commonRoot(c, b), commonRoot(c, d)); +const held: Node[] = []; +for (let i = 0; i < 3; i++) held.push(rootOrSelf(c)); +console.log(held.length, held[0] === file, held[2] === held[1]); + +// Chains whose only owners are their own parent links. +function chain(length: number): Node { + let node: Node = new SourceFile("chain.ts"); + for (let i = 1; i <= length; i++) node = new Node(i, node); + return node; +} +console.log(sourceFileOf(chain(5)).fileName, depth(chain(7))); +console.log(cutWalk(chain(6))); +console.log(climb(chain(4), 2), climb(chain(4), 10)); +console.log(rebound("x")); From 294b443b3297a1f0207b7c25dcc2f4214bf9dffc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 05:17:00 -0700 Subject: [PATCH 4/8] Keep the borrowed convention for parameters rebound to walks A parameter that is rebound only by statement assignments of walk projections (`source = (source as LiteralType).regularType`) used to become an owned parameter: every caller retained the argument, the callee released it on exit, and each rebinding retained the new value and released the old. In an edge-preserving function its incoming value is the caller's borrow and every later value is reachable from the parameters, so it can stay borrowed like any walk local. The walk analysis now runs once per module before call lifetimes, which merge these parameters into the borrowed set. Unchanged-parameter users (stable call arguments and aliases, frame-long array element borrows) still only see parameters that are never rebound. tsc-ts's isTypeRelatedTo, which normalizes fresh literal types this way on entry, no longer touches reference counts for its type arguments. --- .../src/backend/llvm/call-lifetimes.ts | 5 +- packages/compiler/src/backend/llvm/emitter.ts | 53 +++++++++++++------ .../src/backend/llvm/local-array-reads.ts | 13 ++++- .../src/backend/llvm/walk-borrows.test.ts | 14 +++-- .../compiler/src/backend/llvm/walk-borrows.ts | 36 +++++++------ tests/corpus/4543-walk-borrowed-locals.ts | 19 +++++++ 6 files changed, 102 insertions(+), 38 deletions(-) diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index 247f809e95..54c0eb602b 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -217,6 +217,9 @@ function collectUses(fn: IrFunction, nullableField: NullableFieldTest): Uses { export function analyzeCallLifetimes( functions: ReadonlyMap, nullableField: NullableFieldTest = () => false, + /** Rebound parameters that only ever hold borrowed walk pointers + * (walk-borrows.ts): they keep the borrowed convention although written. */ + walkParameters: ReadonlyMap> = new Map(), ): CallLifetimes { const usesByFunction = new Map(); const lazyCaptures = new LazyCaptures(functions); @@ -255,7 +258,7 @@ export function analyzeCallLifetimes( // any synchronous body; captured cells are boxed and marked invalid. continue; } - const borrowed = new Set(); + const borrowed = new Set(walkParameters.get(fn.name)); // A captured parameter that nothing rebinds keeps its entry value; its // environment box takes a reference of its own when it is created. const unchangedCaptures = lazyCaptures.parameters(fn); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 74074cd7d3..8565a38bb9 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -586,7 +586,9 @@ export class LlEmitter { /** Parameters proven projection-only: callers may pass stack boxes. */ private projectedParameters = new Set(); private stableCallBindings: ReadonlySet = new Set(); - /** Locals holding borrowed pointers for their whole lifetime (walk-borrows.ts). */ + /** Locals and rebound parameters holding borrowed pointers for their + * whole lifetime (walk-borrows.ts), per function and for the current one. */ + private readonly walkBorrowsByFunction = new Map>(); private walkBorrows: ReadonlySet = new Set(); /** Manifest-bound native imports, used by ffiCall emission. */ readonly ffiByName = new Map(); @@ -830,9 +832,31 @@ export class LlEmitter { (call) => this.optionalArrayReads.get(call) !== null, mod.classes ?? [], ); + const walkParameters = new Map>(); + if (this.debug === null) { + const host = { + pointerLocal: (local: IrLocal) => + local.type.kind === "object" || + (local.type.kind === "union" && + this.nullableUnions.get(local.type.unionId)?.arm.kind === "object"), + borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), + }; + for (const fn of this.fnByName.values()) { + if (!this.referenceEffects.functions.has(fn.name)) continue; + const walks = findWalkBorrows(fn, host); + if (walks.size === 0) continue; + this.walkBorrowsByFunction.set(fn.name, walks); + const indexes = new Set(); + fn.params.forEach((param, index) => { + if (walks.has(param.localId)) indexes.add(index); + }); + if (indexes.size > 0) walkParameters.set(fn.name, indexes); + } + } this.callLifetimes = analyzeCallLifetimes( this.fnByName, (className, field) => this.nullableFields.get(className, field) !== null, + walkParameters, ); this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes); this.stackCallbacks = new StackCallbacks(this.fnByName); @@ -4786,8 +4810,11 @@ export class LlEmitter { this.stableCallBindings = this.callLifetimes.bindings.get(fn.name) ?? new Set(); const borrowedParameterIndexes = this.callLifetimes.borrowed.get(fn.name); if (borrowedParameterIndexes) { + // Rebound walk parameters borrow too, but only unchanged ones may + // serve as stable owners for call arguments and aliases. for (const index of borrowedParameterIndexes) - this.borrowedParameters.add(fn.params[index]!.localId); + if (!this.walkBorrowsByFunction.get(fn.name)?.has(fn.params[index]!.localId)) + this.borrowedParameters.add(fn.params[index]!.localId); } this.captureIds = new Set( [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), @@ -4837,16 +4864,7 @@ export class LlEmitter { (e) => this.nullableFieldGet(e) !== null, ); this.localUnionStorage = new Map(); - this.walkBorrows = - this.debug === null && this.referenceEffects.functions.has(fn.name) - ? findWalkBorrows(fn, { - pointerLocal: (local) => - local.type.kind === "object" || - (local.type.kind === "union" && - this.nullableUnions.get(local.type.unionId)?.arm.kind === "object"), - borrowsWithoutOwning: (e) => this.borrowsWithoutOwning(e), - }) - : new Set(); + this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); this.integerRanges = analyzeIntegerRanges(numericFn, this.int32Slots.facts(fn.name)); this.bytesBounds = findBytesBounds(numericFn, this.integerRanges); this.chainSlots.clear(); @@ -7132,9 +7150,11 @@ export class LlEmitter { } /** emitReadReceiver produces this node's pointer without acquiring a - * reference, given operands that do the same: plain class/record field - * reads, class casts, nullable-pointer narrows and wraps, and checked - * ternaries. Stack-boxed, map-read and boxed-field sources are owned. */ + * reference, given operands (and ternary arms) that do the same: plain + * class/record field reads, class casts, nullable-pointer narrows and + * wraps, and checked ternaries. Stack-boxed, map-read and boxed-field + * sources are owned. Depends only on module facts, not the current + * function. */ borrowsWithoutOwning(e: IrExpr): boolean { if (!isRefCounted(e.type) || this.isStackUnionSource(e)) return false; if (matchMapRead(e, this.boxedUnionsById)) return false; @@ -7150,8 +7170,9 @@ export class LlEmitter { return e.value.type.kind === "object"; case "unionWrap": return this.borrowableNullableWrap(e) !== null; + // The caller proves each arm separately. case "ternary": - return this.canBorrowReceiver(e.then) && this.canBorrowReceiver(e.else_); + return true; default: return false; } diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 1e3f185a3a..6daeb1cd08 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -165,8 +165,19 @@ export class OptionalArrayReads { * that the parameter holding the array is never replaced or captured. */ function stableArrayParameters(fn: IrFunction, lifetimes: CallLifetimes): Set { const borrowed = lifetimes.borrowed.get(fn.name); + // Borrowed walk parameters may be rebound by statements (walk-borrows.ts). + const rebound = new Set(); + everyStmtList(fn.body, { + stmt: (stmt) => { + if (stmt.kind === "assign") rebound.add(stmt.localId); + return true; + }, + expr: () => true, + }); return new Set( - fn.params.filter((_, index) => borrowed?.has(index)).map((param) => param.localId), + fn.params + .filter((param, index) => borrowed?.has(index) && !rebound.has(param.localId)) + .map((param) => param.localId), ); } diff --git a/packages/compiler/src/backend/llvm/walk-borrows.test.ts b/packages/compiler/src/backend/llvm/walk-borrows.test.ts index 5b64f29f7d..2938caa0ac 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.test.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.test.ts @@ -56,11 +56,19 @@ test("parent walks rooted at unwritten parameters borrow", () => { ); }); +const call: IrExpr = { kind: "call", callee: "make", args: [], type: NODE, loc }; + +test("parameters rebound only to walks keep borrowing", () => { + const body = [assign("node", parent(ref("node"))), decl("up", parent(ref("node")))]; + expect(findWalkBorrows(fn(body, ["up"]), host)).toEqual(new Set(["node", "up"])); + // A parameter re-declared without an initializer is left alone. + expect(findWalkBorrows(fn([decl("node", null)], []), host).size).toBe(0); +}); + test("rebound parameters, owned sources and other writers keep ownership", () => { - const call: IrExpr = { kind: "call", callee: "make", args: [], type: NODE, loc }; const body = [ - // A rebound parameter is no root, and neither is its walk. - assign("node", parent(ref("node"))), + // A parameter rebound to an owned value is no root, and neither is its walk. + assign("node", call), decl("fromParam", parent(ref("node"))), // A call result is owned, and the failure propagates transitively. decl("owned", call), diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts index 910bbf6479..91511f5126 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -12,10 +12,12 @@ export interface WalkBorrowHost { borrowsWithoutOwning(e: IrExpr): boolean; } -/** Locals that may hold borrowed pointers for their whole lifetime: every - * definition (declaration or statement assignment) is a projection of an - * unwritten parameter or of another such local, for example the parent - * walk `let p = node.parent; while (p.parent) p = p.parent;`. +/** Locals (including rebound parameters) that may hold borrowed pointers + * for their whole lifetime: every definition (declaration or statement + * assignment) is a projection of an unwritten parameter or of another such + * local, for example the parent walk `let p = node.parent; while (p.parent) + * p = p.parent;`. A rebound parameter in the result keeps the borrowed + * calling convention. * * Soundness rests on the whole body preserving heap edges (the caller * passes only functions in ReferenceEffects.functions): neither the body @@ -76,20 +78,18 @@ export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlyS return true; }, }); - // Parameters are roots only while no definition rebinds them. - const roots = new Set([...params].filter((id) => !definitions.has(id) && !excluded.has(id))); - const candidates = new Set(); - for (const id of definitions.keys()) { + const plain = (id: string): boolean => { const local = locals.get(id); - if ( - params.has(id) || - excluded.has(id) || - !local || - local.boxed || - local.tdz || - !host.pointerLocal(local) - ) - continue; + return !!local && !local.boxed && !local.tdz && !excluded.has(id) && host.pointerLocal(local); + }; + // Parameters are roots only while no definition rebinds them. A + // parameter rebound by statement assignments alone (`t = t.regular`) is a + // candidate like any local: its incoming value is the caller's borrow. + const roots = new Set([...params].filter((id) => !definitions.has(id) && plain(id))); + const candidates = new Set(); + for (const [id, values] of definitions) { + if (!plain(id)) continue; + if (params.has(id) && values.some((value) => value === null)) continue; candidates.add(id); } // A walk projects roots or candidates through reads that never acquire a @@ -108,6 +108,8 @@ export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlyS case "unionWrap": if (!host.borrowsWithoutOwning(e)) return false; return e.value.kind === "unitLit" || walk(e.value); + // Each arm is itself a walk (or a throw), which emitReadReceiver reads + // without owning. case "ternary": return host.borrowsWithoutOwning(e) && walkOrThrow(e.then) && walkOrThrow(e.else_); default: diff --git a/tests/corpus/4543-walk-borrowed-locals.ts b/tests/corpus/4543-walk-borrowed-locals.ts index 6c07dd12a0..d94ff7b759 100644 --- a/tests/corpus/4543-walk-borrowed-locals.ts +++ b/tests/corpus/4543-walk-borrowed-locals.ts @@ -86,6 +86,24 @@ function climb(node: Node, steps: number): number { return total; } +// Rebound parameters walk too (they keep the borrowed convention), unless +// the body removes an edge: here the old parent link is cut while the +// rebound parameter still points at the node it held. +function parentKind(node: Node): number { + if (node.parent !== undefined) node = node.parent; + return node.kind; +} + +function parentKindAfterCut(node: Node): number { + if (node.parent !== undefined) { + const child = node; + node = node.parent; + cut(child); + } + const fresh = new Node(100, undefined); + return node.kind + fresh.kind; +} + interface Inner { name: string; } @@ -132,3 +150,4 @@ console.log(sourceFileOf(chain(5)).fileName, depth(chain(7))); console.log(cutWalk(chain(6))); console.log(climb(chain(4), 2), climb(chain(4), 10)); console.log(rebound("x")); +console.log(parentKind(chain(3)), parentKind(file), parentKindAfterCut(chain(3)), parentKindAfterCut(chain(1))); From 6ff0b7c2dfb1642002f773c8efbbb7a9847446ef Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 05:22:54 -0700 Subject: [PATCH 5/8] Load the active exception cell once per synchronous body Every inline pending-exception check in an executable loaded the runtime's active-cell pointer and then its kind: two dependent loads after each call that may throw, because LLVM must assume any call can change the pointer. Fiber switches restore the active cell before control returns to a synchronous frame (stack switches, eager spawns and generator resumes all swap back to the caller's cell), so the pointer is one value for the whole invocation. Synchronous IR function bodies now load it once in the entry block and each check reads only the kind. The flag lives on the body's BlockBuilder, so adapters and runtime helpers keep the per-check load. Async functions, generators and worker executables (whose checks also fold in the context stop signal) keep the existing form; libraries still test through scr_exc_pending. --- packages/compiler/src/backend/llvm/blocks.ts | 6 ++++++ .../compiler/src/backend/llvm/emitter.test.ts | 19 +++++++++++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 19 +++++++++++++++++-- .../backend/llvm/local-array-reads.test.ts | 3 ++- .../src/backend/llvm/string-lifetimes.test.ts | 3 ++- .../test/string-and-array-lifetimes.test.ts | 3 ++- 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/backend/llvm/blocks.ts b/packages/compiler/src/backend/llvm/blocks.ts index 01295c549f..49c9be2fea 100644 --- a/packages/compiler/src/backend/llvm/blocks.ts +++ b/packages/compiler/src/backend/llvm/blocks.ts @@ -20,6 +20,12 @@ export class BlockBuilder { private tempCounter = 0; private labelCounter = 0; debugLocation: string | null = null; + /** The body is a synchronous IR function: the active exception cell is one + * value from entry to every pending check. Other builders (adapters, + * runtime helpers) keep loading it at each check. */ + excCellInvariant = false; + /** The entry-block load of the active exception cell, once requested. */ + excCell: string | null = null; /** Function epilogue shared by normal and exceptional returns. */ returnEpilogue: string | null = null; /** Temps known to hold immortal values (temp names are only unique diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index cd364f4142..614d88facd 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -532,3 +532,22 @@ test("literal zero division emits the JavaScript NaN constant in development bui ); expect(dynamic).toContain("fdiv double"); }); + +test("synchronous bodies load the active exception cell once for all pending checks", () => { + const body = (llvm: string, name: string): string => { + const start = llvm.indexOf(`@${name}(`); + return llvm.slice(start, llvm.indexOf("\n}\n", start)); + }; + const module = moduleFor([call(), call(), call()]); + const work = body(emitLlvmModule(module), "sc_f_work"); + expect(work.match(/= load ptr, ptr @scr_exc_active/g)).toHaveLength(1); + expect(work.slice(work.indexOf("entry:")).split("\n").slice(1).join("\n")).toMatch( + /^(?: %[^\n]* = alloca [^\n]*\n)* %exc\.cell = load ptr, ptr @scr_exc_active/, + ); + expect(work.match(/= load i32, ptr %exc\.cell\b/g)).toHaveLength(3); + // Worker executables fold the context signal into each check and keep + // loading their thread-local cell there. + const workers = body(emitLlvmModule({ ...module, workers: true }), "sc_f_work"); + expect(workers).not.toContain("%exc.cell"); + expect(workers.match(/= load ptr, ptr @scr_exc_active/g)).toHaveLength(3); +}); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8565a38bb9..224df49b0e 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3800,9 +3800,21 @@ export class LlEmitter { const tl = this.mod.workers === true ? "thread_local " : ""; this.declare(`@scr_exc_active = external ${tl}global ptr`); this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); - const cell = B.tmp(); + let cell: string; + if (B.excCellInvariant && this.mod.workers !== true) { + // Fiber switches restore the active cell before control returns to a + // synchronous frame, so its pointer is one value for the whole call: + // load it once in the entry block and test only the kind here. + if (B.excCell === null) { + B.excCell = "%exc.cell"; + B.entryAllocas.push(`${B.excCell} = load ptr, ptr @scr_exc_active`); + } + cell = B.excCell; + } else { + cell = B.tmp(); + B.line(`${cell} = load ptr, ptr @scr_exc_active`); + } const kind = B.tmp(); - B.line(`${cell} = load ptr, ptr @scr_exc_active`); B.line(`${kind} = load i32, ptr ${cell}`); let word = kind; if (this.mod.workers === true) { @@ -4770,6 +4782,9 @@ export class LlEmitter { private emitFunction(fn: IrFunction): string { const B = new BlockBuilder(); this.B = B; + // Fiber switches restore the active exception cell before control + // returns to a synchronous frame (emitPendingCheck). + B.excCellInvariant = fn.async !== true && fn.generator === undefined; this.debugScope = this.debug?.function(fn) ?? null; B.debugLocation = this.debug?.location(fn.loc, this.debugScope) ?? null; this.frames = []; diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index 67a37ce566..31b2717b33 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -24,7 +24,8 @@ import { import { analyzeCallLifetimes } from "./call-lifetimes.js"; /** A pending-exception check: the inline active-cell test or a runtime call. */ -const PENDING_CHECK = /@scr_exc_(?:active|pending)\b/; +// Synchronous bodies test the kind of the entry-loaded exception cell. +const PENDING_CHECK = /@scr_exc_(?:active|pending)\b| = load i32, ptr %exc\.cell\b/; const loc = { file: "local-array.ts", start: 0, end: 0 }; const element: IrType = { kind: "record", shapeId: "cell" }; diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index bf54116ee3..6713d5b901 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -18,7 +18,8 @@ import { emitLlvmModule } from "./emitter.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; /** A pending-exception check: the inline active-cell test or a runtime call. */ -const PENDING_CHECK = /@scr_exc_(?:active|pending)\b/; +// Synchronous bodies test the kind of the entry-loaded exception cell. +const PENDING_CHECK = /@scr_exc_(?:active|pending)\b| = load i32, ptr %exc\.cell\b/; const loc = { file: "strings.ts", start: 0, end: 1 }; const ref = (id: string, type: IrType = STRING): IrExpr => ({ diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index 10e496a8d4..4f59d8bbc5 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -8,7 +8,8 @@ import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; import { type IrModule } from "../src/ir/ir.js"; /** A pending-exception check: the inline active-cell test or a runtime call. */ -const PENDING_CHECK = /@scr_exc_(?:active|pending)\b/; +// Synchronous bodies test the kind of the entry-loaded exception cell. +const PENDING_CHECK = /@scr_exc_(?:active|pending)\b| = load i32, ptr %exc\.cell\b/; async function lower(source: string): Promise { const dir = await mkdtemp(join(tmpdir(), "scriptc-input-lifetimes-")); From 5133738a83dfbfefe2c84c7f79fdbee915b7da0c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 12:37:32 -0700 Subject: [PATCH 6/8] Return walks of borrowed parameters without a reference An edge-preserving function whose every return is a walk of its borrowed parameters (accessors such as `node.children`, `t.types`, parent walks, or a call to another such function) now returns its result at +0 from the borrowing body. The result stays reachable from the arguments, so direct callers that consume it as a receiver, a walk, or a borrowable argument use it in place; every other direct caller tests the pending exception and then retains it, before the argument snapshots that keep it reachable are released. The owned adapter retains a present result (a throwing body returns a null dummy, which string and array retains do not accept) before releasing its parameters. Candidates borrow every reference parameter, have no try statements and return a plain reference (instances, records, arrays, strings, nullable-pointer unions). Implementations of a vtable slot that borrows parameters are excluded: virtual dispatch reaches their borrowing body (or its virtual adapter) directly and its callers own the result. Without this, corpus 4545's virtual accessors hit a heap-use-after-free in the sanitized lane. The set grows to a fixpoint, since a call to a borrowed-return function with walk arguments is itself a walk; walk facts are then recomputed with the final set, admitting only parameters whose convention borrows. Walk locals now cover every plain reference type, not only instances. In tsc-ts, compareNodes -> sourceFileOf -> getSourceFileOfNode and maybeTypeOfKind's loop over typesOf(t) no longer touch reference counts. --- packages/compiler/src/backend/llvm/emitter.ts | 136 +++++++++++- .../compiler/src/backend/llvm/expr-calls.ts | 33 ++- .../src/backend/llvm/string-lifetimes.test.ts | 9 +- .../src/backend/llvm/walk-borrows.test.ts | 38 +++- .../compiler/src/backend/llvm/walk-borrows.ts | 55 ++++- packages/compiler/test/call-lifetimes.test.ts | 5 +- .../test/input-lifetime-emission.test.ts | 7 +- .../test/ts7/baselines/order-parity.json | 6 + tests/corpus/4545-borrowed-returns.ts | 209 ++++++++++++++++++ 9 files changed, 477 insertions(+), 21 deletions(-) create mode 100644 tests/corpus/4545-borrowed-returns.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 224df49b0e..7af2604541 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -156,7 +156,7 @@ import { } from "./map-read-lifetimes.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; -import { findWalkBorrows } from "./walk-borrows.js"; +import { analyzeWalks, findWalkBorrows } from "./walk-borrows.js"; import { ReferenceEffects } from "./reference-effects.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { StackCallbacks } from "./stack-callbacks.js"; @@ -187,7 +187,7 @@ import { emitRecordExpr, } from "./expr-primitives.js"; import { emitControlExpr } from "./expr-control.js"; -import { emitCallExpr } from "./expr-calls.js"; +import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js"; import { emitDynamicExpr } from "./expr-dynamic.js"; import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js"; import { emitJsInteropExpr, emitExpr } from "./expr-dispatch.js"; @@ -590,6 +590,10 @@ export class LlEmitter { * whole lifetime (walk-borrows.ts), per function and for the current one. */ private readonly walkBorrowsByFunction = new Map>(); private walkBorrows: ReadonlySet = new Set(); + /** Functions whose borrowing body returns its result without a reference + * (analyzeBorrowedReturns), and whether the current body is one. */ + readonly borrowedReturns = new Set(); + private currentBorrowedReturn = false; /** Manifest-bound native imports, used by ffiCall emission. */ readonly ffiByName = new Map(); /** C-ABI callback trampolines and (for raw/no-userdata callbacks) their @@ -835,10 +839,7 @@ export class LlEmitter { const walkParameters = new Map>(); if (this.debug === null) { const host = { - pointerLocal: (local: IrLocal) => - local.type.kind === "object" || - (local.type.kind === "union" && - this.nullableUnions.get(local.type.unionId)?.arm.kind === "object"), + pointerLocal: (local: IrLocal) => this.plainReference(local.type), borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), }; for (const fn of this.fnByName.values()) { @@ -969,6 +970,8 @@ export class LlEmitter { this.fnByName, this.callLifetimes.borrowed, ); + // After the vtable slots: borrowing slots exclude their implementations. + if (this.debug === null) this.analyzeBorrowedReturns(); } /** The borrowed parameters of a hierarchy's vtable slot (empty: owned). */ @@ -4887,6 +4890,7 @@ export class LlEmitter { this.tryStack = []; this.currentReturnType = fn.returnType; this.currentGenerator = fn.generator ?? null; + this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); this.currentWasiCoro = null; this.logArgSlots = 0; @@ -5140,9 +5144,29 @@ export class LlEmitter { const ret = this.llType(fn.returnType); const call = `call ${ret} @${mangleBorrowedFunction(fn.name)}(${params.join(", ")})`; B.line(ret === "void" ? call : `%result = ${call}`); + // A borrowed result is reachable from the parameters: own it first. A + // throwing body returns a null dummy, which needs no owner (and string + // or array retains do not accept NULL). + let result = "%result"; + if (this.borrowedReturns.has(fn.name)) { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + B.line(`store ptr null, ptr ${slot}`); + const present = B.tmp(); + const own = B.newLabel("result.own"); + const done = B.newLabel("result.done"); + B.line(`${present} = icmp ne ptr %result, null`); + B.condBr(present, own, done); + B.startBlock(own); + B.line(`store ptr ${this.retainValue("%result", fn.returnType)}, ptr ${slot}`); + B.br(done); + B.startBlock(done); + result = B.tmp(); + B.line(`${result} = load ptr, ptr ${slot}`); + } for (const index of borrowed) if (!kept.has(index)) this.releaseValue(`%p${index}`, fn.params[index]!.type); - B.terminate(ret === "void" ? "ret void" : `ret ${ret} %result`); + B.terminate(ret === "void" ? "ret void" : `ret ${ret} ${result}`); const symbol = kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name); return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`; @@ -6178,7 +6202,11 @@ export class LlEmitter { // the actual ret. The parked value owns a synthetic slot-backed // scope entry during each copy so a throwing finally releases it. let v: LlValue | null = null; - if (s.value !== null) { + if (s.value !== null && this.currentBorrowedReturn) { + // A borrowed-return body hands back its walk without a reference + // (it has no try statements, so no finally can intervene). + v = this.emitReadReceiver(s.value); + } else if (s.value !== null) { v = this.emitExpr(s.value); this.moveTemp(v); } @@ -6942,6 +6970,7 @@ export class LlEmitter { B.line(`${value} = load ptr, ptr ${slot}`); return { name: value, type: e.type }; } + if (e.kind === "call" && this.canBorrowReceiver(e)) return emitBorrowedResultCall(this, e); return this.emitExpr(e); } @@ -7150,6 +7179,17 @@ export class LlEmitter { const wrap = this.borrowableNullableWrap(e); return wrap === "unit" || (wrap === "ref" && this.canBorrowReceiver(e.value)); } + // A borrowed-return callee preserves edges, so its result stays + // reachable from borrowable arguments until the consumer runs. + case "call": + return ( + this.borrowedReturns.has(e.callee) && + e.args.every( + (arg) => + (!isRefCounted(arg.type) || this.canBorrowReceiver(arg)) && + this.referenceEffects.preserves(arg), + ) + ); // A required element read: the (borrowed) array owns the element // while an edge-preserving index computes. case "arrayGet": @@ -7164,6 +7204,79 @@ export class LlEmitter { } } + /** A reference held as one plain pointer that emitReadReceiver can read + * without owning: class instances, records, arrays, strings and + * nullable-pointer unions. Tagged union boxes, closures and dynamic + * values keep ordinary ownership. */ + plainReference(type: IrType): boolean { + switch (type.kind) { + case "object": + case "record": + case "array": + case "string": + return true; + case "union": + return this.nullableUnions.has(type.unionId); + default: + return false; + } + } + + /** Functions returning a walk of their borrowed parameters return it + * without a reference (+0) from their borrowing body; direct callers + * either consume it as a receiver/walk or retain it at once, and the owned + * adapter retains before releasing its parameters. Candidates preserve + * every heap edge (so the result stays reachable from the arguments), + * borrow every reference parameter and return plain pointers. The set + * grows to a fixpoint because a call to a borrowed-return function with + * walk arguments is itself a walk; walk facts are then recomputed with + * the final set, admitting only parameters whose convention borrows. */ + private analyzeBorrowedReturns(): void { + const pointer = (type: IrType): boolean => this.plainReference(type); + const borrowedIds = (fn: IrFunction): Set => { + const indexes = this.callLifetimes.borrowed.get(fn.name); + return new Set(fn.params.filter((_, i) => indexes?.has(i)).map((p) => p.localId)); + }; + const hostFor = (fn: IrFunction) => { + const allowed = borrowedIds(fn); + return { + pointerLocal: (local: IrLocal) => pointer(local.type), + borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), + borrowedReturn: (callee: string) => this.borrowedReturns.has(callee), + parameterAllowed: (id: string) => allowed.has(id), + }; + }; + const preserving = [...this.fnByName.values()].filter((fn) => + this.referenceEffects.functions.has(fn.name), + ); + const candidates = preserving.filter((fn) => { + if (!pointer(fn.returnType) || fn.async || fn.generator || fn.captures) return false; + // A borrowing vtable slot dispatches to the borrowing body (or its + // virtual adapter) directly, and virtual callers own the result. + if (this.implSlotBorrowed(fn.name).size > 0) return false; + const indexes = this.callLifetimes.borrowed.get(fn.name); + return ( + indexes !== undefined && + indexes.size > 0 && + fn.params.every((p, i) => !isRefCounted(p.type) || indexes.has(i)) + ); + }); + for (let changed = true; changed;) { + changed = false; + for (const fn of candidates) { + if (this.borrowedReturns.has(fn.name)) continue; + if (!analyzeWalks(fn, hostFor(fn)).returnsWalk) continue; + this.borrowedReturns.add(fn.name); + changed = true; + } + } + if (this.borrowedReturns.size === 0) return; + for (const fn of preserving) { + const walks = analyzeWalks(fn, hostFor(fn)).locals; + if (walks.size > 0) this.walkBorrowsByFunction.set(fn.name, walks); + } + } + /** emitReadReceiver produces this node's pointer without acquiring a * reference, given operands (and ternary arms) that do the same: plain * class/record field reads, class casts, nullable-pointer narrows and @@ -7188,6 +7301,13 @@ export class LlEmitter { // The caller proves each arm separately. case "ternary": return true; + // The caller proves the arguments are walks; their evaluation must not + // remove an edge before the call reads them. + case "call": + return ( + this.borrowedReturns.has(e.callee) && + e.args.every((arg) => this.referenceEffects.preserves(arg)) + ); default: return false; } diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 562ea4956c..d707de5558 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -110,8 +110,17 @@ export function emitCallExpr( } const t = B.tmp(); B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`); - const out = host.own({ name: t, type: e.type }); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + let out: LlValue; + if (borrowed && host.borrowedReturns.has(e.callee)) { + // A borrowed-return body hands back +0 (a null dummy when it threw): + // test the exception first, then own the result before the + // argument snapshots that keep it reachable are released. + if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + out = host.own({ name: host.retainValue(t, e.type), type: e.type }); + } else { + out = host.own({ name: t, type: e.type }); + if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + } if (borrowed) { host.moveTemp(out); host.releaseFrame(host.frames.pop()!); @@ -990,3 +999,23 @@ export function emitCallExpr( } } } + +/** A direct call to a borrowed-return function whose result a receiver or + * walk consumes without owning it (LlEmitter.canBorrowReceiver proved every + * reference argument borrowable and every argument edge-preserving). The + * result stays reachable from the borrowed arguments until the consumer. */ +export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call">): LlValue { + const callee = host.fnByName.get(e.callee); + if (!callee || !host.borrowedReturns.has(e.callee)) + throw new InternalCompilerError(`llvm emitter bug: borrowed result of ${e.callee}`); + const args = e.args.map((arg) => + isRefCounted(arg.type) ? host.emitReadReceiver(arg) : host.emitExpr(arg), + ); + const argList = args.map((a, i) => `${host.llType(callee.params[i]!.type)} ${a.name}`).join(", "); + const t = host.B.tmp(); + host.B.line( + `${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}(${argList}) ; borrowed result`, + ); + if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + return { name: t, type: e.type }; +} diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index 6713d5b901..7aec30dd62 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -110,10 +110,15 @@ test("string comparisons borrow parameters while owned entry adapters release th test("returning a borrowed input acquires a result owner", () => { const identity = fn("identity", ["text"], ref("text")); expect(facts(identity).parameters.size).toBe(0); + // The borrowing body returns its input without a reference; direct + // callers own it themselves and the owned adapter retains the result + // before releasing the parameter. const ir = body(mod(identity), "sc_bf_identity"); - expect(ir).toContain("@scr_str_retain_v"); + expect(ir).not.toContain("@scr_str_retain_v"); expect(ir).not.toContain("@scr_str_release"); - expect(body(mod(identity), "sc_f_identity")).toContain("@scr_str_release"); + const adapter = body(mod(identity), "sc_f_identity"); + expect(adapter.indexOf("@scr_str_retain_v")).toBeGreaterThan(0); + expect(adapter.indexOf("@scr_str_release")).toBeGreaterThan(adapter.indexOf("@scr_str_retain_v")); }); test("literal arguments remain immortal while the called body borrows them", () => { diff --git a/packages/compiler/src/backend/llvm/walk-borrows.test.ts b/packages/compiler/src/backend/llvm/walk-borrows.test.ts index 2938caa0ac..caa6be94fc 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.test.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import { F64, VOID, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; -import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; +import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; const loc = { file: "walk.ts", start: 0, end: 0 }; const NODE: IrType = { kind: "object", className: "Node" }; @@ -103,3 +103,39 @@ test("scalar locals, owning projections and suspending bodies are never walks", const suspending: IrFunction = { ...fn([decl("p", parent(ref("node")))], ["p"]), async: true }; expect(findWalkBorrows(suspending, host).size).toBe(0); }); + +test("returns of walks, including calls to borrowed-return callees, return borrowed", () => { + const ret = (value: IrExpr | null): IrStmt => ({ kind: "return", value, loc }); + const accessor: IrExpr = { kind: "call", callee: "fileOf", args: [ref("node")], type: NODE, loc }; + const borrowedHost = { ...host, borrowedReturn: (callee: string) => callee === "fileOf" }; + const walking = fn([decl("p", accessor), ret(parent(ref("p")))], ["p"]); + expect(analyzeWalks(walking, borrowedHost)).toEqual({ + locals: new Set(["p"]), + returnsWalk: true, + }); + // Without the callee fact the call result is owned. + expect(analyzeWalks(walking, host)).toEqual({ locals: new Set(), returnsWalk: false }); + // Every return must walk, a try statement disqualifies, and parameters + // outside the borrowing convention are no roots. + const mixed = fn([ret(parent(ref("node"))), ret(call)], []); + expect(analyzeWalks(mixed, borrowedHost).returnsWalk).toBe(false); + const guarded = fn( + [ + { + kind: "tryCatch", + tryBody: [ret(ref("node"))], + catchBody: null, + catchLocalId: null, + finallyBody: [], + loc, + }, + ], + [], + ); + expect(analyzeWalks(guarded, borrowedHost).returnsWalk).toBe(false); + const owned = fn([ret(parent(ref("node")))], []); + expect(analyzeWalks(owned, { ...borrowedHost, parameterAllowed: () => false }).returnsWalk).toBe( + false, + ); + expect(analyzeWalks(owned, borrowedHost).returnsWalk).toBe(true); +}); diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts index 91511f5126..7ba0485739 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -1,15 +1,29 @@ -import type { IrExpr, IrFunction, IrLocal } from "../../ir/ir.js"; +import { isRefCounted, type IrExpr, type IrFunction, type IrLocal } from "../../ir/ir.js"; import { everyStmtList } from "../../ir/traverse.js"; /** What the emitter knows about a projection's storage. */ export interface WalkBorrowHost { - /** The local holds a plain pointer: a class instance or a nullable-pointer - * union. Tagged union boxes, strings and containers keep ownership. */ + /** The local holds one plain reference pointer (instances, records, + * arrays, strings, nullable-pointer unions). Tagged union boxes, closures + * and dynamic values keep ownership. */ pointerLocal(local: IrLocal): boolean; /** emitReadReceiver yields this expression's pointer without acquiring a * reference (class casts, nullable wraps, plain field reads, checked * ternaries). */ borrowsWithoutOwning(e: IrExpr): boolean; + /** The callee returns a borrowed walk of its arguments (findWalkBorrows' + * returnsWalk); a call to it with walk arguments is itself a walk. */ + borrowedReturn?(callee: string): boolean; + /** Parameters that may become candidates; once calling conventions are + * fixed, only borrowed parameters qualify. Defaults to every parameter. */ + parameterAllowed?(localId: string): boolean; +} + +export interface WalkFacts { + locals: ReadonlySet; + /** Every return yields a walk (or a throw), so the body can return its + * pointer without a reference: no try statement, at least one return. */ + returnsWalk: boolean; } /** Locals (including rebound parameters) that may hold borrowed pointers @@ -30,8 +44,15 @@ export interface WalkBorrowHost { * freely: they never root a walk, so releasing their old values cannot free * an object the walk reaches. */ export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlySet { + return analyzeWalks(fn, host).locals; +} + +export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { const result = new Set(); - if (fn.async || fn.generator || fn.captures || fn.classCaptures) return result; + if (fn.async || fn.generator || fn.captures || fn.classCaptures) + return { locals: result, returnsWalk: false }; + const returns: (IrExpr | null)[] = []; + let guarded = false; const params = new Set(fn.params.map((param) => param.localId)); const locals = new Map(fn.locals.map((local) => [local.id, local])); const definitions = new Map(); @@ -59,8 +80,12 @@ export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlyS excluded.add(s.localId); break; case "tryCatch": + guarded = true; if (s.catchLocalId !== null) excluded.add(s.catchLocalId); break; + case "return": + returns.push(s.value); + break; } return true; }, @@ -80,7 +105,14 @@ export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlyS }); const plain = (id: string): boolean => { const local = locals.get(id); - return !!local && !local.boxed && !local.tdz && !excluded.has(id) && host.pointerLocal(local); + return ( + !!local && + !local.boxed && + !local.tdz && + !excluded.has(id) && + host.pointerLocal(local) && + (!params.has(id) || host.parameterAllowed?.(id) !== false) + ); }; // Parameters are roots only while no definition rebinds them. A // parameter rebound by statement assignments alone (`t = t.regular`) is a @@ -112,6 +144,13 @@ export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlyS // without owning. case "ternary": return host.borrowsWithoutOwning(e) && walkOrThrow(e.then) && walkOrThrow(e.else_); + // A borrowed result is reachable from walk arguments. + case "call": + return ( + host.borrowedReturn?.(e.callee) === true && + host.borrowsWithoutOwning(e) && + e.args.every((arg) => !isRefCounted(arg.type) || walk(arg)) + ); default: return false; } @@ -127,5 +166,9 @@ export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlyS } } for (const id of candidates) result.add(id); - return result; + const returnsWalk = + !guarded && + returns.length > 0 && + returns.every((value) => value !== null && walkOrThrow(value)); + return { locals: result, returnsWalk }; } diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index 6a23b26693..dcceeda6c6 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -313,9 +313,12 @@ function snapshot(item: Item): Item { const saved = item; item = new Item(); ret console.log(alias(new Item()).value, snapshot(new Item()).value); `); const llvm = emitLlvmModule(mod); + // The aliases borrow the parameter, and so does the result: the borrowing + // body returns it without a reference and the owned adapter retains it. const alias = body(llvm, "sc_bf_alias"); - expect(alias.match(/@sc_retain_Item/g)).toHaveLength(1); + expect(alias).not.toContain("@sc_retain_Item"); expect(alias).not.toContain("@sc_release_Item"); + expect(body(llvm, "sc_f_alias").match(/@sc_retain_Item/g)).toHaveLength(1); const snapshot = body(llvm, "sc_f_snapshot"); // `new Item()` calls the borrowed constructor body, so no retain transfers // the fresh instance into it: the saved alias and the returned value. diff --git a/packages/compiler/test/input-lifetime-emission.test.ts b/packages/compiler/test/input-lifetime-emission.test.ts index caabeed12e..e24e73d697 100644 --- a/packages/compiler/test/input-lifetime-emission.test.ts +++ b/packages/compiler/test/input-lifetime-emission.test.ts @@ -44,7 +44,12 @@ console.log(preserve(holder), snapshot(holder)); expect(body(llvm, "preserve")).not.toContain("@sc_release_Cell"); expect(body(llvm, "snapshot")).toContain("@sc_retain_Cell"); expect(body(llvm, "snapshot")).toContain("@sc_release_Cell"); - expect(body(llvm, "read")).toContain("@scr_str_retain_v"); + // `read` returns a projection of its borrowed parameter without a + // reference; its owned adapter takes one for callers outside direct calls. + expect(body(llvm, "read")).not.toContain("@scr_str_retain_v"); + expect(/^define internal [^\n]*@sc_f_read\([^]*?^}/m.exec(llvm)?.[0]).toContain( + "@scr_str_retain_v", + ); }); test("regex runtime inputs borrow independently of result ownership and lastIndex mutation", async () => { diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b3060504db..8f3437f48a 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15616,6 +15616,12 @@ "/tests/corpus/4543-walk-borrowed-locals.ts" ], "diags": [] + }, + "/tests/corpus/4545-borrowed-returns.ts": { + "order": [ + "/tests/corpus/4545-borrowed-returns.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4545-borrowed-returns.ts b/tests/corpus/4545-borrowed-returns.ts new file mode 100644 index 0000000000..4d47ed71d3 --- /dev/null +++ b/tests/corpus/4545-borrowed-returns.ts @@ -0,0 +1,209 @@ +// Functions that return a projection of their parameters without removing +// any reference (accessors, parent walks) hand back their result without +// taking a reference; callers consume it in place or take their own. Loops +// whose body removes no reference iterate their array without owning it. +// Mutating functions, mutating callees and mutating loop bodies keep the +// owned forms: the sanitized lane catches a borrow of a freed object. + +class Node { + parent: Node | undefined; + kind: number; + children: Node[] = []; + constructor(kind: number, parent: Node | undefined) { + this.kind = kind; + this.parent = parent; + if (parent !== undefined) parent.children.push(this); + } +} + +class SourceFile extends Node { + name: string; + constructor(name: string) { + super(0, undefined); + this.name = name; + } +} + +function sourceFileOfNode(node: Node): SourceFile { + let parent = node.parent; + if (parent === undefined) return node as SourceFile; + for (;;) { + const next: Node | undefined = parent.parent; + if (next === undefined) return parent as SourceFile; + parent = next; + } +} + +function fileOf(node: Node): SourceFile { + return sourceFileOfNode(node); +} + +function childrenOf(node: Node): Node[] { + return node.children; +} + +function nameOf(node: Node): string { + return fileOf(node).name; +} + +// A throwing borrowed-return body hands back no result at all. +const negativeKind = new Error("negative kind"); +function labelOf(node: Node): string { + if (node.kind < 0) throw negativeKind; + return fileOf(node).name; +} + +function grandparent(node: Node): Node { + return node.parent!.parent!; +} + +function firstChildOrSelf(node: Node): Node { + return node.children.length > 0 ? node.children[0]! : node; +} + +const fileIndex = new Map(); + +function compareNodes(a: Node, b: Node): number { + const fa = fileOf(a); + const fb = fileOf(b); + if (fa !== fb) return (fileIndex.get(fa) ?? 0) - (fileIndex.get(fb) ?? 0); + return a.kind - b.kind; +} + +function countKind(node: Node, kind: number): number { + let n = node.kind === kind ? 1 : 0; + for (const child of childrenOf(node)) n += countKind(child, kind); + return n; +} + +// Not edge-preserving: the returned node's only owner was the cut link. +function detachParent(node: Node): Node | undefined { + const parent = node.parent; + node.parent = undefined; + return parent; +} + +// A mutating callee receives a borrowed-return result: it must be owned. +function cutThenName(file: SourceFile, node: Node): string { + let current: Node | undefined = node; + while (current !== undefined) { + const next: Node | undefined = current.parent; + current.parent = undefined; + current = next; + } + const fresh = new SourceFile("fresh"); + return file.name + "/" + fresh.name; +} + +// A loop body that drops the iterated array's owner keeps it owned. +function drainChildren(node: Node): number { + let total = 0; + for (const child of childrenOf(node)) { + node.children = []; + total += child.kind; + } + return total; +} + +class Holder { + file: SourceFile | undefined; +} + +function build(name: string, depth: number): Node { + let node: Node = new SourceFile(name); + for (let i = 1; i <= depth; i++) node = new Node(i, node); + return node; +} + +const leaf = build("a.ts", 4); +const other = build("b.ts", 2); +fileIndex.set(fileOf(leaf), 1); +fileIndex.set(fileOf(other), 2); +console.log(fileOf(leaf).name, nameOf(other), fileOf(fileOf(leaf)).name); +console.log(compareNodes(leaf, other), compareNodes(other, leaf), compareNodes(leaf, leaf.parent!)); +console.log(grandparent(leaf).kind, firstChildOrSelf(fileOf(leaf)).kind, firstChildOrSelf(leaf).kind); +console.log(countKind(fileOf(leaf), 2), childrenOf(fileOf(other)).length); + +// Results that escape take their own reference. +const holder = new Holder(); +holder.file = fileOf(build("held.ts", 3)); +const viaValue: (node: Node) => SourceFile = fileOf; +const fromValue = viaValue(build("value.ts", 2)); +const files = [fileOf(build("array.ts", 1)), fileOf(build("array2.ts", 5))]; +console.log(holder.file.name, fromValue.name, files.map((f) => f.name).join(",")); + +const detached = detachParent(build("detach.ts", 1)); +console.log(detached instanceof SourceFile ? (detached as SourceFile).name : "none"); +const chain = build("cut.ts", 3); +console.log(cutThenName(fileOf(chain), chain)); +const root = build("drain.ts", 1).parent!; +new Node(7, root); +console.log(drainChildren(root), root.children.length); + +const negative = new Node(-1, build("neg.ts", 1)); +const labels: string[] = []; +const viaLabel: (node: Node) => string = labelOf; +for (const attempt of [ + () => labels.push(labelOf(negative)), + () => labels.push(viaLabel(negative)), + () => labels.push(String(labelOf(negative).length)), + () => labels.push(labelOf(leaf), viaLabel(other), String(labelOf(leaf).length)), +]) { + try { + attempt(); + } catch (e) { + labels.push((e as Error).message); + } +} +console.log(labels.join(" | ")); + +// Accessor methods filling a vtable slot that borrows `this`: virtual +// dispatch reaches the borrowing body directly and its callers own the +// result, so these must keep returning an owned reference. Each result +// outlives every other owner of the object it names. +class Owner { + name: string; + constructor(name: string) { + this.name = name; + } +} + +class Shape { + owner: Owner; + constructor(owner: Owner) { + this.owner = owner; + } + ownerOf(): Owner { + return this.owner; + } + labelOf(): string { + return this.owner.name; + } +} + +class Square extends Shape { + ownerOf(): Owner { + return this.owner; + } + labelOf(): string { + return "square:" + this.owner.name; + } +} + +function takeOwner(shapes: Shape[], index: number): Owner { + const owner = shapes[index]!.ownerOf(); + shapes[index] = new Shape(new Owner("replacement")); + return owner; +} + +const shapes: Shape[] = [new Shape(new Owner("first")), new Square(new Owner("second"))]; +const taken = [takeOwner(shapes, 0), takeOwner(shapes, 1)]; +shapes.length = 0; +const kept: Owner[] = []; +const labelled: string[] = []; +for (let i = 0; i < 3; i++) { + const shape: Shape = i % 2 === 0 ? new Shape(new Owner("s" + i)) : new Square(new Owner("q" + i)); + kept.push(shape.ownerOf()); + labelled.push(shape.labelOf()); +} +console.log(taken.map((o) => o.name).join(","), kept.map((o) => o.name).join(","), labelled.join(",")); From d54f705540396578fc537877e3ab71bec20e7a3a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 12:37:35 -0700 Subject: [PATCH 7/8] Keep the borrowed convention for rebound parameters with an owner slot A reference parameter that the body rebinds made the whole parameter owned: every caller retained the argument and the callee released it on exit, even when the rebinding never ran. tsc-ts's isTypeRelatedTo starts with `if (isFreshLiteralType(source)) source = ...regularType` and was the largest single source of Type releases. When every write is a plain statement assignment, the parameter now keeps borrowing the caller's argument; each assigned value moves into a separate owner slot that the next rebinding releases and replaces and that the function scope releases on every exit. Reads use the parameter slot, so whole-value uses still retain. Expression-position writes, captures, loop and catch bindings, suspending bodies and string parameters (whose in-place append needs a uniquely owned binding) keep the owned convention. Unchanged-parameter users (stable call arguments, aliases, frame-long element borrows) still exclude these parameters. The call-lifetimes package test now expects `snapshot` to keep its borrowing body: the alias saved before the rebinding still owns its value. --- packages/compiler/src/backend/llvm/emitter.ts | 58 ++++++++++++- .../backend/llvm/rebound-parameters.test.ts | 77 +++++++++++++++++ .../src/backend/llvm/rebound-parameters.ts | 72 ++++++++++++++++ packages/compiler/test/call-lifetimes.test.ts | 4 +- .../test/ts7/baselines/order-parity.json | 6 ++ tests/corpus/4546-rebound-borrowed-params.ts | 85 +++++++++++++++++++ 6 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/rebound-parameters.test.ts create mode 100644 packages/compiler/src/backend/llvm/rebound-parameters.ts create mode 100644 tests/corpus/4546-rebound-borrowed-params.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 7af2604541..af1a7a3a0f 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -157,6 +157,7 @@ import { import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; import { analyzeWalks, findWalkBorrows } from "./walk-borrows.js"; +import { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { StackCallbacks } from "./stack-callbacks.js"; @@ -593,6 +594,10 @@ export class LlEmitter { /** Functions whose borrowing body returns its result without a reference * (analyzeBorrowedReturns), and whether the current body is one. */ readonly borrowedReturns = new Set(); + /** Rebound parameters that keep the borrowed convention with an owner + * slot (rebound-parameters.ts), per function, and the current owner slots. */ + private readonly reboundByFunction = new Map>(); + private readonly reboundOwnerSlots = new Map(); private currentBorrowedReturn = false; /** Manifest-bound native imports, used by ffiCall emission. */ readonly ffiByName = new Map(); @@ -854,6 +859,27 @@ export class LlEmitter { if (indexes.size > 0) walkParameters.set(fn.name, indexes); } } + if (this.debug === null) { + for (const fn of this.fnByName.values()) { + // Strings keep the owned parameter: their in-place append + // (`s = s + x`) needs a uniquely owned binding. + const rebound = findReboundParameters( + fn, + (type) => + this.plainReference(type) && + type.kind !== "string" && + (type.kind !== "union" || this.nullableUnions.get(type.unionId)?.arm.kind !== "string"), + this.walkBorrowsByFunction.get(fn.name) ?? new Set(), + ); + if (rebound.size === 0) continue; + this.reboundByFunction.set(fn.name, rebound); + const indexes = walkParameters.get(fn.name) ?? new Set(); + fn.params.forEach((param, index) => { + if (rebound.has(param.localId)) indexes.add(index); + }); + walkParameters.set(fn.name, indexes); + } + } this.callLifetimes = analyzeCallLifetimes( this.fnByName, (className, field) => this.nullableFields.get(className, field) !== null, @@ -4820,6 +4846,7 @@ export class LlEmitter { const numericFn = withInitializerBindings(fn, initializerBindings); this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l])); this.borrowedParameters.clear(); + this.reboundOwnerSlots.clear(); this.projectedParameters.clear(); const projectedParameterIndexes = this.callLifetimes.parameters.get(fn.name); if (projectedParameterIndexes) @@ -4831,7 +4858,10 @@ export class LlEmitter { // Rebound walk parameters borrow too, but only unchanged ones may // serve as stable owners for call arguments and aliases. for (const index of borrowedParameterIndexes) - if (!this.walkBorrowsByFunction.get(fn.name)?.has(fn.params[index]!.localId)) + if ( + !this.walkBorrowsByFunction.get(fn.name)?.has(fn.params[index]!.localId) && + !this.reboundByFunction.get(fn.name)?.has(fn.params[index]!.localId) + ) this.borrowedParameters.add(fn.params[index]!.localId); } this.captureIds = new Set( @@ -5034,6 +5064,15 @@ export class LlEmitter { B.line(`store ${this.llType(p.type)} %p_${mangleLocal(p.localId)}, ptr ${slot}`); this.storeIntegerView(p.localId, { name: `%p_${mangleLocal(p.localId)}`, type: p.type }); if (isRefCounted(p.type) && !borrowed?.has(index)) fnScope.push({ slot, type: p.type }); + if (this.reboundByFunction.get(fn.name)?.has(p.localId) && borrowed?.has(index)) { + // The caller's borrow stays in the parameter slot; values the body + // assigns are owned here until rebinding or exit releases them. + const owner = `${slot}.owner`; + B.entryAllocas.push(`${owner} = alloca ptr`); + B.line(`store ptr null, ptr ${owner}`); + fnScope.push({ slot: owner, type: p.type }); + this.reboundOwnerSlots.set(p.localId, owner); + } } this.scopes.push(fnScope); // Stackful native fibers retain these frames while suspended. The @@ -5400,6 +5439,23 @@ export class LlEmitter { break; } case "assign": { + const owner = this.reboundOwnerSlots.get(s.localId); + if (owner !== undefined && !this.walkBorrows.has(s.localId)) { + // A rebound borrowed parameter: the new value moves into the owner + // slot, replacing (and releasing) the previous owned value. This + // precedes the in-place string append, which would release the + // caller's borrow. + const param = this.binding(s.localId); + const slot = param.slot; + const v = this.emitExpr(s.value); + this.moveTemp(v); + const old = B.tmp(); + B.line(`${old} = load ptr, ptr ${owner}`); + this.releaseValue(old, param.type); + B.line(`store ptr ${v.name}, ptr ${slot}`); + B.line(`store ptr ${v.name}, ptr ${owner}`); + break; + } const localStorage = this.localUnionStorage.get(s.localId); if (localStorage) { storeLocalUnion(this, localStorage, s.value); diff --git a/packages/compiler/src/backend/llvm/rebound-parameters.test.ts b/packages/compiler/src/backend/llvm/rebound-parameters.test.ts new file mode 100644 index 0000000000..c2783e91ec --- /dev/null +++ b/packages/compiler/src/backend/llvm/rebound-parameters.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "vitest"; +import { F64, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { findReboundParameters } from "./rebound-parameters.js"; + +const loc = { file: "rebound.ts", start: 0, end: 0 }; +const NODE: IrType = { kind: "object", className: "Node" }; +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: NODE, loc }); +const assign = (localId: string, value: IrExpr): IrStmt => ({ + kind: "assign", + localId, + value, + loc, +}); + +function fn(body: IrStmt[], params: { id: string; type?: IrType }[]): IrFunction { + return { + name: "work", + params: params.map((p) => ({ localId: p.id, name: p.id, type: p.type ?? NODE })), + locals: params.map((p) => ({ id: p.id, name: p.id, type: p.type ?? NODE, mutable: true })), + body, + returnType: F64, + loc, + }; +} + +const plain = (type: IrType): boolean => type.kind === "object"; + +test("statement rebinding of reference parameters keeps the borrowed convention", () => { + const body = [ + assign("a", ref("b")), + { kind: "if", cond: ref("a"), then: [assign("b", ref("a"))], else_: null, loc } as IrStmt, + ]; + const f = fn(body, [{ id: "a" }, { id: "b" }, { id: "untouched" }]); + expect(findReboundParameters(f, plain, new Set())).toEqual(new Set(["a", "b"])); + // Parameters already borrowed as walks are left to the walk analysis. + expect(findReboundParameters(f, plain, new Set(["a"]))).toEqual(new Set(["b"])); +}); + +test("expression writes, scalar types, captures and suspension keep the owned parameter", () => { + const sequence: IrExpr = { + kind: "seqExpr", + stmts: [assign("a", ref("b"))], + result: { kind: "numLit", value: 0, type: F64, loc }, + type: F64, + loc, + }; + const inExpression = fn([{ kind: "exprStmt", expr: sequence, loc }], [{ id: "a" }, { id: "b" }]); + expect(findReboundParameters(inExpression, plain, new Set()).size).toBe(0); + const scalar = fn( + [assign("n", { kind: "numLit", value: 1, type: F64, loc })], + [{ id: "n", type: F64 }], + ); + expect(findReboundParameters(scalar, plain, new Set()).size).toBe(0); + const captured = fn( + [ + assign("a", ref("b")), + { + kind: "exprStmt", + expr: { + kind: "closure", + name: "inner", + captures: ["a"], + type: F64, + loc, + } as unknown as IrExpr, + loc, + }, + ], + [{ id: "a" }, { id: "b" }], + ); + expect(findReboundParameters(captured, plain, new Set()).size).toBe(0); + const suspending: IrFunction = { + ...fn([assign("a", ref("b"))], [{ id: "a" }, { id: "b" }]), + async: true, + }; + expect(findReboundParameters(suspending, plain, new Set()).size).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/rebound-parameters.ts b/packages/compiler/src/backend/llvm/rebound-parameters.ts new file mode 100644 index 0000000000..b239195dd5 --- /dev/null +++ b/packages/compiler/src/backend/llvm/rebound-parameters.ts @@ -0,0 +1,72 @@ +import type { IrExpr, IrFunction, IrStmt, IrType } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/** Parameters that keep the borrowed calling convention although the body + * rebinds them (`source = (source as LiteralType).regularType`). The + * incoming value stays the caller's borrow; every value the body assigns is + * owned through a separate owner slot, which each rebinding releases and + * replaces and which the function scope releases on every exit. Reads see + * the parameter slot, so whole-value uses retain as for any binding. + * + * Only plain statement assignments qualify: expression-position writes, + * loop and catch bindings, declarations and captures keep the ordinary + * owned parameter. Suspending bodies and environments use the owned ABI. */ +export function findReboundParameters( + fn: IrFunction, + plainReference: (type: IrType) => boolean, + excluded: ReadonlySet, +): ReadonlySet { + const result = new Set(); + if (fn.async || fn.generator || fn.captures || fn.classCaptures) return result; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const candidates = new Set( + fn.params + .filter((param) => { + const local = locals.get(param.localId); + return ( + local !== undefined && + !local.boxed && + !local.tdz && + !excluded.has(param.localId) && + plainReference(param.type) + ); + }) + .map((param) => param.localId), + ); + if (candidates.size === 0) return result; + const rebound = new Set(); + const invalid = new Set(); + const expr = (e: IrExpr): boolean => { + switch (e.kind) { + case "assignExpr": + case "incDec": + invalid.add(e.localId); + break; + case "closure": + case "classRef": + for (const id of e.captures ?? []) invalid.add(id); + break; + } + return everyExprChild(e, expr, (s) => stmt(s, true)); + }; + const stmt = (s: IrStmt, inExpression: boolean): boolean => { + switch (s.kind) { + case "assign": + if (inExpression) invalid.add(s.localId); + else rebound.add(s.localId); + break; + case "varDecl": + case "forOf": + case "rethrow": + invalid.add(s.localId); + break; + case "tryCatch": + if (s.catchLocalId !== null) invalid.add(s.catchLocalId); + break; + } + return everyStmtChild(s, expr, (child) => stmt(child, inExpression)); + }; + for (const s of fn.body) stmt(s, false); + for (const id of candidates) if (rebound.has(id) && !invalid.has(id)) result.add(id); + return result; +} diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index dcceeda6c6..19ab0ff5aa 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -319,9 +319,11 @@ console.log(alias(new Item()).value, snapshot(new Item()).value); expect(alias).not.toContain("@sc_retain_Item"); expect(alias).not.toContain("@sc_release_Item"); expect(body(llvm, "sc_f_alias").match(/@sc_retain_Item/g)).toHaveLength(1); - const snapshot = body(llvm, "sc_f_snapshot"); + // The rebound parameter keeps borrowing (its new value lives in an owner + // slot), but the alias saved before the rebinding must own its value. // `new Item()` calls the borrowed constructor body, so no retain transfers // the fresh instance into it: the saved alias and the returned value. + const snapshot = body(llvm, "sc_bf_snapshot"); expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(2); expect(snapshot).toContain("@sc_bf__x25_Item_constructor"); expect(snapshot).toContain("@sc_release_Item"); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 8f3437f48a..16186b0ffa 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15622,6 +15622,12 @@ "/tests/corpus/4545-borrowed-returns.ts" ], "diags": [] + }, + "/tests/corpus/4546-rebound-borrowed-params.ts": { + "order": [ + "/tests/corpus/4546-rebound-borrowed-params.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4546-rebound-borrowed-params.ts b/tests/corpus/4546-rebound-borrowed-params.ts new file mode 100644 index 0000000000..8c0fe475ce --- /dev/null +++ b/tests/corpus/4546-rebound-borrowed-params.ts @@ -0,0 +1,85 @@ +// Parameters rebound by plain assignments keep borrowing the caller's +// argument; the values the body assigns are owned separately and released +// when rebound again or when the function exits (normally or by throwing). +// Here the rebinding functions also mutate the heap, so an assigned value +// whose other owners disappear must stay alive through the owned slot. + +class Node { + parent: Node | undefined; + kind: number; + constructor(kind: number, parent: Node | undefined) { + this.kind = kind; + this.parent = parent; + } +} + +class Fresh extends Node { + regular: Node; + constructor(kind: number, regular: Node) { + super(kind, undefined); + this.regular = regular; + } +} + +const seen: number[] = []; + +function related(source: Node, target: Node): boolean { + if (source instanceof Fresh) source = source.regular; + if (target instanceof Fresh) target = target.regular; + seen.push(source.kind * 10 + target.kind); + return source === target; +} + +// The parent's only other owner is the link this function cuts. +function parentAfterCut(node: Node): number { + const child = node; + node = node.parent!; + child.parent = undefined; + const fresh = new Node(100, undefined); + return node.kind + fresh.kind; +} + +function wrap(node: Node, depth: number): Node { + for (let i = 1; i <= depth; i++) node = new Node(node.kind + i, node); + return node; +} + +function count(node: Node | undefined): number { + let n = 0; + while (node !== undefined) { + n++; + seen.push(node.kind); + node = node.parent; + } + return n; +} + +function climb(node: Node, limit: number): number { + while (node.parent !== undefined) { + if (limit-- === 0) throw new Error("limit " + node.kind); + seen.push(node.kind); + node = node.parent; + } + return node.kind; +} + +function chain(length: number): Node { + let node = new Node(0, undefined); + for (let i = 1; i <= length; i++) node = new Node(i, node); + return node; +} + +const base = new Node(1, undefined); +const fresh = new Fresh(2, base); +const other = new Node(3, undefined); +console.log(related(fresh, base), related(base, fresh), related(fresh, other), related(other, other)); +console.log(parentAfterCut(chain(2)), parentAfterCut(new Node(9, new Node(8, undefined)))); +const wrapped = wrap(chain(1), 3); +console.log(wrapped.kind, count(wrapped), count(undefined)); +console.log(climb(chain(3), 10)); +try { + console.log(climb(chain(6), 2)); +} catch (e) { + console.log((e as Error).message); +} +console.log(seen.join(",")); From 6faa762e871c51e27928057d82a1ba4c00d221e8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 9 Oct 2026 17:40:49 -0700 Subject: [PATCH 8/8] Keep throwing checked projections in discarded statements Borrowing checked-cast aliases taught canBorrowCallArgument to accept a checked projection: the always-throwing `error.nodeThrow` call, a ternary whose failing arm throws, and the narrow of its successful arm. A borrowed use still evaluates them, but emitDiscarded also used that predicate to skip a discarded sequence's result as a pure read, so a statement like `String.prototype.trim.call(undefined)` lost its TypeError (corpus 3040, 3042, 3043 and 2113 on CI). Give the discard path its own predicate that only skips effect-free reads, as before. --- packages/compiler/src/backend/llvm/emitter.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index af1a7a3a0f..35e2944c3a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6665,12 +6665,32 @@ export class LlEmitter { * sequence's result only reads an unchanged local (a derived * constructor's `super()` evaluates to `this`), its statements still run * in place, but the result is neither retained nor released. */ + /** Whether a discarded value can be skipped entirely: a borrowable read + * with no effect of its own. canBorrowCallArgument also accepts checked + * projections (a ternary or narrow whose failing arm throws, and the + * throwing call itself) because a borrowed use still evaluates them; + * skipping those would drop the throw. */ + private discardableRead(value: IrExpr): boolean { + if (value.kind === "libCall" || value.kind === "ternary" || value.kind === "unionNarrow") + return false; + if ( + (value.kind === "upcast" || value.kind === "downcast" || value.kind === "unionWrap") && + !this.discardableRead(value.value) + ) + return false; + if (value.kind === "seqExpr") { + const guarded = guardedValue(value); + if (guarded === null || !this.discardableRead(guarded)) return false; + } + return this.canBorrowCallArgument(value); + } + private emitDiscarded(e: IrExpr): void { if ( e.kind === "seqExpr" && isRefCounted(e.result.type) && e.result.kind !== "strLit" && - this.canBorrowCallArgument(e.result) + this.discardableRead(e.result) ) { this.emitSequence(() => { for (const s of e.stmts) this.emitStmt(s);