From 7df72c017a6345a37ae7ffaf1a38e9de92888714 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:44:32 -0700 Subject: [PATCH 1/7] Borrow references whose locations nothing in their extent can write ReferenceEffects answers one bit per function: does anything remove any heap edge? Any reference store in the transitive callees (including a constructor's stores into its own fresh `this` behind every `new`, a closure call, or atomics in symbol-id allocation) disabled borrowing of field-read and rebound-local arguments, walk locals, borrowed returns and loop element borrows. backend/llvm/reference-writes.ts keeps the location instead: per-function transitive write sets over class fields (keyed by hierarchy root), record fields, array elements, map entries, boxes, globals and the current function's own locals, with key 0 as an unknown heap write. Locals stay exact under unknown writes: no callee can rebind a caller's unboxed local. Constructor stores into their own `this` drop out of `new`/`super` summaries (checked: constructors run only through `new` and `super`). Indirect calls resolve closure targets through function-typed parameters, captures and adapters, otherwise the union of all closure bodies; runtime library families that run no user code count as inert. Uses: - arguments of borrowed parameters (direct, `new`, virtual) and of projection-only array-read parameters borrow when neither the callee nor a later argument writes a location they depend on; - stable locals initialized by a frame-unowned projection borrow when the rest of their block writes none of its locations; optional element reads into projection-only locals likewise; - loop element borrows need only an iteration that writes no array element (nor the array's binding); - walk locals and borrowed returns are admitted in bodies whose whole write set misses the walked locations. tsc-ts e469e33 self-check (single-threaded, executed, non-null and non-immortal RC operations in emitted code, measurement build): 505.3M -> 307.6M (-39%) before return moves (next commit). Output identical. Build time unchanged (98.5 s vs 99.9 s, Linux). --- .../src/backend/llvm/borrowed-inputs.ts | 38 +- packages/compiler/src/backend/llvm/emitter.ts | 230 +++- .../compiler/src/backend/llvm/expr-calls.ts | 40 +- .../src/backend/llvm/loop-array-borrows.ts | 38 +- .../src/backend/llvm/reference-writes.ts | 1082 +++++++++++++++++ .../src/backend/llvm/string-lifetimes.test.ts | 46 +- .../compiler/src/backend/llvm/walk-borrows.ts | 30 +- .../4620-reference-write-call-arguments.ts | 119 ++ tests/corpus/4621-reference-write-aliases.ts | 116 ++ .../4622-reference-write-loops-and-walks.ts | 116 ++ .../4623-reference-write-callbacks-returns.ts | 98 ++ 11 files changed, 1896 insertions(+), 57 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/reference-writes.ts create mode 100644 tests/corpus/4620-reference-write-call-arguments.ts create mode 100644 tests/corpus/4621-reference-write-aliases.ts create mode 100644 tests/corpus/4622-reference-write-loops-and-walks.ts create mode 100644 tests/corpus/4623-reference-write-callbacks-returns.ts diff --git a/packages/compiler/src/backend/llvm/borrowed-inputs.ts b/packages/compiler/src/backend/llvm/borrowed-inputs.ts index af921a105..16839afd6 100644 --- a/packages/compiler/src/backend/llvm/borrowed-inputs.ts +++ b/packages/compiler/src/backend/llvm/borrowed-inputs.ts @@ -1,4 +1,5 @@ import type { IrExpr } from "../../ir/ir.js"; +import type { Writes } from "./reference-writes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; /** For a runtime input whose ABI borrows rather than consumes ownership. @@ -14,18 +15,33 @@ export function emitBorrowedInput(host: LlvmEmitterContext, value: IrExpr): LlVa * while every operation preserves existing owners. Compute suffix facts * once, keeping left-to-right evaluation and the owning fallback for each * argument independently. The consumer must borrow all inputs and preserve - * reference edges itself; mutating runtime operations use emitBorrowedInput. */ + * reference edges itself; mutating runtime operations use emitBorrowedInput. + * With `consumerWrites` (the locations the consumer may overwrite), a + * projection also borrows when neither the consumer nor any later operand + * can write a location its read depends on (ReferenceWrites). */ export function borrowableInputs( host: LlvmEmitterContext, inputs: readonly IrExpr[], consumerPreserves = true, + consumerWrites?: Writes, ): boolean[] { const result: boolean[] = new Array(inputs.length); let preserves = consumerPreserves; + const writes = host.referenceWrites; + let suffix: Writes = + consumerWrites !== undefined + ? consumerWrites + : consumerPreserves + ? null + : (writes?.heapAll ?? null); for (let i = inputs.length - 1; i >= 0; i--) { const value = inputs[i]!; - result[i] = host.canBorrowCallArgument(value) || (preserves && host.canBorrowReceiver(value)); + result[i] = + host.canBorrowCallArgument(value) || + (preserves && host.canBorrowReceiver(value)) || + (writes !== null && host.canBorrowReceiver(value) && host.unwrittenRead(value, suffix)); preserves = preserves && host.referenceEffects.preserves(value); + if (writes !== null) suffix = writes.union([suffix, writes.expr(value)]); } return result; } @@ -36,3 +52,21 @@ export function emitBorrowedInputs(host: LlvmEmitterContext, inputs: readonly Ir borrowed[index] ? host.emitReadReceiver(value) : host.emitExpr(value), ); } + +/** The writes after each input: the consumer's plus every later input's + * (null entries: nothing; an empty array without ReferenceWrites). */ +export function inputSuffixWrites( + host: LlvmEmitterContext, + inputs: readonly IrExpr[], + consumerWrites: Writes, +): Writes[] { + const writes = host.referenceWrites; + if (writes === null) return []; + const out: Writes[] = new Array(inputs.length); + let suffix = consumerWrites; + for (let i = inputs.length - 1; i >= 0; i--) { + out[i] = suffix; + suffix = writes.union([suffix, writes.expr(inputs[i]!)]); + } + return out; +} diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6c2d54dad..45a01fd18 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -157,9 +157,10 @@ import { } from "./map-read-lifetimes.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; -import { analyzeWalks, findWalkBorrows } from "./walk-borrows.js"; +import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; import { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; +import { ReferenceWrites, type Writes } from "./reference-writes.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { StackCallbacks } from "./stack-callbacks.js"; import { findLoopArrayBorrows } from "./loop-array-borrows.js"; @@ -580,6 +581,12 @@ export class LlEmitter { * fiberless-async analysis scans it for suspension entry points. */ private readonly fnDefText = new Map(); readonly referenceEffects: ReferenceEffects; + /** Field-sensitive reference writes (null in debug builds). */ + referenceWrites: ReferenceWrites | null = null; + private readonly borrowedReturnKeyCache = new Map(); + /** The statement list being emitted and the current index, for blocks + * whose bindings end with the list (emitBlock and function bodies). */ + private stmtCursor: { stmts: IrStmt[]; index: number } | null = null; readonly optionalArrayReads: OptionalArrayReads; readonly checkedNarrows: CheckedNarrows; callArrayReads = new Map(); @@ -875,6 +882,15 @@ export class LlEmitter { (call) => this.optionalArrayReads.get(call) !== null, mod.classes ?? [], ); + if (this.debug === null) + this.referenceWrites = new ReferenceWrites( + this.fnByName, + mod.classes ?? [], + (call) => this.optionalArrayReads.get(call) !== null, + new Set((mod.globals ?? []).map((g) => g.id)), + { unions: mod.unions, records: mod.records }, + this.immutableFields, + ); const walkParameters = new Map>(); if (this.debug === null) { const host = { @@ -882,8 +898,9 @@ export class LlEmitter { 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); + const preserving = this.referenceEffects.functions.has(fn.name); + if (!preserving && this.referenceWrites === null) continue; + const walks = findWalkBorrows(fn, preserving ? host : this.unwrittenWalkHost(fn, host)); if (walks.size === 0) continue; this.walkBorrowsByFunction.set(fn.name, walks); const indexes = new Set(); @@ -4941,6 +4958,7 @@ export class LlEmitter { const numericFn = withInitializerBindings(fn, initializerBindings); this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l])); this.borrowedParameters.clear(); + this.referenceWrites?.enter(fn); this.reboundOwnerSlots.clear(); this.projectedParameters.clear(); const projectedParameterIndexes = this.callLifetimes.parameters.get(fn.name); @@ -4979,6 +4997,7 @@ export class LlEmitter { this.callLifetimes, this.referenceEffects, this.optionalArrayReads, + this.referenceWrites, ) : new Set(); this.localArrayReads = findLocalArrayReads( @@ -5183,7 +5202,7 @@ export class LlEmitter { } if (this.stackChecks?.has(fn.name)) this.emitStackCheck(); if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(); - this.emitStmts(fn.body); + this.emitStmts(fn.body, true); // Implicit exit of a void function: release the function scope unless // the body already terminated its final block (return, or a throw // whose unwind released everything down to depth 0). @@ -5372,15 +5391,179 @@ export class LlEmitter { : this.immutableFields.recordField(e.shapeId, e.field); } + /** A projection read whose locations nothing writes while a borrowed + * read of it is in use (ReferenceWrites), given the writes of that + * extent. The root binding itself must stay alive across it too, which + * its own location (`l:`, `g:`, `bx`) covers. */ + unwrittenRead(e: IrExpr, writes: Writes): boolean { + const rw = this.referenceWrites; + if (rw === null) return false; + const keys = rw.readKeys(e, (call) => this.borrowedReturnKeys(call.callee)); + return keys !== null && !rw.conflicts(keys, writes); + } + + /** A stable binding initialized by a projection can borrow it when the + * rest of its block (its whole lifetime) writes none of the projection's + * locations: the source edge then keeps the value alive, and uses that + * escape still retain their own copies. */ + private unwrittenAlias(s: IrStmt & { kind: "varDecl" }): boolean { + const cursor = this.stmtCursor; + const rw = this.referenceWrites; + if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s) + return false; + if ( + !this.plainReference(s.init.type) || + !this.canBorrowReceiver(s.init) || + !this.unownedWalk(s.init) + ) + return false; + const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]); + return this.unwrittenRead(s.init, writes); + } + + /** An optional element read into a projection-only local can borrow the + * element when the rest of the block writes no array element and none of + * the locations keeping the array alive (which must itself be read + * without a frame owner). */ + private unwrittenLocalArrayRead(s: IrStmt & { kind: "varDecl" }, read: LocalArrayRead): boolean { + const cursor = this.stmtCursor; + const rw = this.referenceWrites; + if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s) + return false; + if (!this.unownedWalk(read.array)) return false; + const keys = rw.readKeys(read.array); + if (keys === null || rw.expr(read.index) !== null) return false; + const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]); + return !rw.conflicts(["a", ...keys], writes); + } + + /** An optional element read passed to a projection-only parameter can + * borrow the element when the callee and later arguments (`writes`) + * write no array element and nothing keeping the array alive. */ + unwrittenCallArrayRead(read: LocalArrayRead, writes: Writes): boolean { + const rw = this.referenceWrites; + if (rw === null) return false; + const keys = rw.readKeys(read.array); + if (keys === null || rw.expr(read.index) !== null) return false; + return !rw.conflicts(["a", ...keys], writes); + } + + /** A walk host for a body that may write the heap: each walk must read + * only locations the whole body (with its callees) never writes; + * rebinding the walk locals themselves releases nothing. */ + private unwrittenWalkHost(fn: IrFunction, host: WalkBorrowHost): WalkBorrowHost { + return { + ...host, + unwritten: (value, candidates) => + this.referenceWrites?.unwrittenWalk(fn, value, candidates, (call) => + this.borrowedReturnKeys(call.callee), + ) ?? false, + }; + } + + /** emitReadReceiver yields a plain pointer for `e` that no statement + * frame owns, so it can outlive the statement (walk-borrows' walks, from + * any unboxed plain-pointer binding, plus element reads). */ + private unownedWalk(e: IrExpr): boolean { + switch (e.kind) { + case "varRef": { + const binding = this.binding(e.localId); + if (binding.kind === "global") return this.plainReference(binding.type); + return ( + binding.kind === "local" && + binding.local !== undefined && + !binding.local.boxed && + !binding.local.tdz && + this.plainReference(binding.local.type) && + !this.stringSlices.has(e.localId) && + !this.storedSplits.has(e.localId) && + !this.localUnionStorage.has(e.localId) + ); + } + case "fieldGet": + case "recordGet": + return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj); + case "unionNarrow": + case "downcast": + case "upcast": + return this.borrowsWithoutOwning(e) && this.unownedWalk(e.value); + case "unionWrap": + if (!this.borrowsWithoutOwning(e)) return false; + return e.value.kind === "unitLit" || this.unownedWalk(e.value); + case "ternary": { + const arm = (v: IrExpr): boolean => + (v.kind === "libCall" && v.fn === "error.nodeThrow") || this.unownedWalk(v); + return this.borrowsWithoutOwning(e) && arm(e.then) && arm(e.else_); + } + case "call": + return ( + this.borrowsWithoutOwning(e) && + e.args.every((arg) => !isRefCounted(arg.type) || this.unownedWalk(arg)) + ); + case "arrayGet": + return ( + !this.isStackUnionSource(e) && + this.optionalArrayReads.get(e) === null && + this.unownedWalk(e.arr) + ); + case "seqExpr": { + const guarded = guardedValue(e); + return guarded !== null && this.unownedWalk(guarded); + } + default: + return false; + } + } + + /** Every location a borrowed-return callee's walks can read (its body's + * field, record and element reads, through nested borrowed returns). */ + private borrowedReturnKeys(callee: string): string[] | null { + if (!this.borrowedReturns.has(callee)) return null; + const known = this.borrowedReturnKeyCache.get(callee); + if (known) return known; + const rw = this.referenceWrites!; + const keys = new Set(); + const seen = new Set([callee]); + const pending = [callee]; + while (pending.length > 0) { + const fn = this.fnByName.get(pending.pop()!); + if (!fn) continue; + everyStmtList(fn.body, { + stmt: () => true, + expr: (e) => { + if (e.kind === "fieldGet") keys.add(rw.fieldKey(e.className, e.field)); + else if (e.kind === "recordGet") keys.add(`r:${e.field}`).add("r:*"); + else if (e.kind === "arrayGet") keys.add("a"); + else if (e.kind === "call" && this.borrowedReturns.has(e.callee) && !seen.has(e.callee)) { + seen.add(e.callee); + pending.push(e.callee); + } + return true; + }, + }); + } + const result = [...keys]; + this.borrowedReturnKeyCache.set(callee, result); + return result; + } + // ── statements ────────────────────────────────────────────────────────── - private emitStmts(stmts: IrStmt[]): void { - for (const s of stmts) { - // Statements after a terminator are unreachable (dead code after - // return/break/continue). Skip them so no dropped SSA definition - // can leak forward. - if (this.B.isTerminated()) return; - this.emitStmt(s); + private emitStmts(stmts: IrStmt[], scoped = false): void { + const saved = this.stmtCursor; + const cursor = scoped ? { stmts, index: 0 } : null; + try { + for (let i = 0; i < stmts.length; i++) { + // Statements after a terminator are unreachable (dead code after + // return/break/continue). Skip them so no dropped SSA definition + // can leak forward. + if (this.B.isTerminated()) return; + if (cursor) cursor.index = i; + this.stmtCursor = cursor; + this.emitStmt(stmts[i]!); + } + } finally { + this.stmtCursor = saved; } } @@ -5393,7 +5576,7 @@ export class LlEmitter { const scope: LlScopeEntry[] = []; this.scopes.push(scope); setup?.(scope); - this.emitStmts(stmts); + this.emitStmts(stmts, true); const ended = endsWithJump(stmts); this.scopes.pop(); if (!ended) this.releaseScope(scope); @@ -5453,7 +5636,13 @@ export class LlEmitter { } const localRead = this.localArrayReads.get(s.localId); if (localRead) { - const owner = emitLocalArrayRead(this, localRead, b.slot); + const owner = emitLocalArrayRead( + this, + !localRead.borrow && this.unwrittenLocalArrayRead(s, localRead) + ? { ...localRead, borrow: true } + : localRead, + b.slot, + ); if (owner) this.scopes[this.scopes.length - 1]!.push(owner); break; } @@ -5542,7 +5731,7 @@ export class LlEmitter { b.local && isRefCounted(b.type) && this.stableCallBindings.has(s.localId) && - this.canBorrowCallArgument(s.init) + (this.canBorrowCallArgument(s.init) || this.unwrittenAlias(s)) ) { const v = this.emitReadReceiver(s.init); B.line(`store ptr ${v.name}, ptr ${b.slot}`); @@ -7628,17 +7817,20 @@ export class LlEmitter { 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 hostFor = (fn: IrFunction): WalkBorrowHost => { const allowed = borrowedIds(fn); - return { + const host = { 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), }; + return this.referenceEffects.functions.has(fn.name) ? host : this.unwrittenWalkHost(fn, host); }; - const preserving = [...this.fnByName.values()].filter((fn) => - this.referenceEffects.functions.has(fn.name), + // With ReferenceWrites, a body that writes none of its walks' locations + // qualifies like one that preserves every edge. + const preserving = [...this.fnByName.values()].filter( + (fn) => this.referenceEffects.functions.has(fn.name) || this.referenceWrites !== null, ); const candidates = preserving.filter((fn) => { if (!pointer(fn.returnType) || fn.async || fn.generator || fn.captures) return false; @@ -7658,6 +7850,8 @@ export class LlEmitter { if (this.borrowedReturns.has(fn.name)) continue; if (!analyzeWalks(fn, hostFor(fn)).returnsWalk) continue; this.borrowedReturns.add(fn.name); + // Nested borrowed returns extend the locations a caller depends on. + this.borrowedReturnKeyCache.clear(); changed = true; } } diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index d707de555..afd2ae7b0 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -25,7 +25,13 @@ import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./ import { canStackUnion, emitStackUnion } from "./stack-unions.js"; import { emitCallArrayRead } from "./local-array-reads.js"; import { emitStackMapRead } from "./map-read-lifetimes.js"; -import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; +import { + borrowableInputs, + emitBorrowedInput, + emitBorrowedInputs, + inputSuffixWrites, +} from "./borrowed-inputs.js"; +import type { Writes } from "./reference-writes.js"; export function emitCallExpr( host: LlvmEmitterContext, @@ -58,12 +64,18 @@ export function emitCallExpr( const borrowed = host.callLifetimes.borrowed.get(e.callee); const projected = host.callLifetimes.parameters.get(e.callee); const inputs = borrowed - ? borrowableInputs(host, e.args, host.referenceEffects.functions.has(e.callee)) + ? borrowableInputs( + host, + e.args, + host.referenceEffects.functions.has(e.callee), + host.referenceWrites?.summary(e.callee), + ) : []; // Borrowed snapshots live through this call, not through the whole // enclosing expression. Otherwise a large initializer accumulates // owners and repeats their cleanup at every later throwing call. if (borrowed) host.frames.push([]); + let suffixes: Writes[] | undefined; const args = e.args.map((a, index) => { if (borrowed?.has(index)) { // A heap-borrowing helper may retain or return its argument. @@ -73,7 +85,18 @@ export function emitCallExpr( const callback = host.stackCallbacks.emit(host, a); if (callback) return callback; const read = host.callArrayReads.get(a); - if (read) return emitCallArrayRead(host, read); + if (read) { + suffixes ??= inputSuffixWrites( + host, + e.args, + host.referenceWrites?.summary(e.callee) ?? null, + ); + const lend = + !read.borrow && + index < suffixes.length && + host.unwrittenCallArrayRead(read, suffixes[index]!); + return emitCallArrayRead(host, lend ? { ...read, borrow: true } : read); + } const mapRead = host.mapReadLifetimes.arguments.get(a); if (mapRead) { const result = emitStackMapRead(host, mapRead); @@ -739,6 +762,7 @@ export function emitCallExpr( host, e.args, host.referenceEffects.functions.has(ctorName), + host.referenceWrites?.constructWrites(e.className), ); host.frames.push([]); const args = e.args.map((a, i) => { @@ -951,7 +975,15 @@ export function emitCallExpr( // (LlEmitter.computeVirtualBorrowing): those are passed like borrowed // arguments of a direct call, owned snapshots living in this frame. const borrowed = host.virtualSlotBorrowed(meta.root.def.name, slotIdx); - const inputs = borrowed.size > 0 ? borrowableInputs(host, e.args, false) : []; + const inputs = + borrowed.size > 0 + ? borrowableInputs( + host, + e.args, + false, + host.referenceWrites?.virtualWrites(e.className, e.method), + ) + : []; if (borrowed.size > 0) host.frames.push([]); const args = e.args.map((a, index) => borrowed.has(index) && inputs[index] ? host.emitReadReceiver(a) : host.emitExpr(a), diff --git a/packages/compiler/src/backend/llvm/loop-array-borrows.ts b/packages/compiler/src/backend/llvm/loop-array-borrows.ts index 75435e7a9..07ae3ed9f 100644 --- a/packages/compiler/src/backend/llvm/loop-array-borrows.ts +++ b/packages/compiler/src/backend/llvm/loop-array-borrows.ts @@ -3,18 +3,22 @@ import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import type { ReferenceEffects } from "./reference-effects.js"; import type { OptionalArrayReads } from "./local-array-reads.js"; +import type { ReferenceWrites } from "./reference-writes.js"; /** Limit a borrow to a loop whose complete iteration preserves heap edges. * Initialization and unrelated work elsewhere in the function may mutate. * The array remains owned by its unchanged local or global binding; the * iteration binding can only project it or forward it to proven consumers. * Callbacks, suspension, capture, reference writes, and escaping uses keep - * the ordinary owned read. Missing elements retain the runtime's checks. */ + * the ordinary owned read. Missing elements retain the runtime's checks. + * With ReferenceWrites, an iteration that writes no array element (nor the + * binding holding the array) suffices, whatever else it writes. */ export function findLoopArrayBorrows( fn: IrFunction, lifetimes: CallLifetimes, effects: ReferenceEffects, optionalReads?: OptionalArrayReads, + writes?: ReferenceWrites | null, ): ReadonlySet { const result = new Set(); if (fn.async || fn.generator) return result; @@ -45,19 +49,28 @@ export function findLoopArrayBorrows( !local.tdz && projected?.has(loop.localId) && bindings?.has(loop.localId) && - effects.preservesScope(loop.body) && - effects.preserves(loop.iterable) + ((effects.preservesScope(loop.body) && effects.preserves(loop.iterable)) || + // The loop owns the evaluated array; only element removals can + // release an element it lends. + (writes && !writes.conflicts(["a"], writes.stmts(loop.body)))) ) result.add(loop); return true; } if (loop.kind !== "for" && loop.kind !== "while" && loop.kind !== "doWhile") return true; - if ( - !effects.preservesScope(loop.body) || - (loop.cond && !effects.preserves(loop.cond)) || - (loop.kind === "for" && loop.update && !effects.preservesScope([loop.update])) - ) - return true; + const preserving = + effects.preservesScope(loop.body) && + (!loop.cond || effects.preserves(loop.cond)) && + (loop.kind !== "for" || !loop.update || effects.preservesScope([loop.update])); + const loopWrites = + preserving || !writes + ? null + : writes.union([ + writes.stmts(loop.body), + loop.cond ? writes.expr(loop.cond) : null, + loop.kind === "for" && loop.update ? writes.stmt(loop.update) : null, + ]); + if (!preserving && !writes) return true; everyStmtList(loop.body, { expr: () => true, stmt: (stmt) => { @@ -71,7 +84,12 @@ export function findLoopArrayBorrows( return true; const source = stmt.init.kind === "arrayGet" ? stmt.init.arr : optionalReads?.get(stmt.init)?.array; - if (source && stableSource(source)) result.add(stmt); + if (!source || !stableSource(source)) return true; + if (preserving) result.add(stmt); + else { + const keys = writes!.readKeys(source); + if (keys && !writes!.conflicts(["a", ...keys], loopWrites)) result.add(stmt); + } return true; }, }); diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts new file mode 100644 index 000000000..9eeef0cea --- /dev/null +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -0,0 +1,1082 @@ +import { + isRefCounted, + type IrClassDef, + type IrExpr, + type IrFunction, + type IrRecordShape, + type IrStmt, + type IrType, + type IrUnionDef, +} from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { borrowsMapMutationReceiver, borrowsMapReadInputs } from "./map-read-lifetimes.js"; +import { preservesRegexInputs, VirtualTargets } from "./reference-effects.js"; +import { borrowsStringInputs } from "./string-lifetimes.js"; +import { preservesDynTest } from "./checked-value-lifetimes.js"; +import { isStableReceiverOperand } from "../../ir/analysis.js"; +import type { ImmutableFields } from "../immutability.js"; + +type Call = IrExpr & { kind: "call" }; + +/** A set of reference locations, as a bitset over the interned keys; `null` + * is the empty set. Key 0 (`*`) is an unknown heap write: it covers every + * heap location but no local of the current function. */ +export type Writes = Uint32Array | null; + +/** Every heap location (key 0). */ +const HEAP_KEY = "*"; + +/** Array element slots of every reference array share one location: arrays + * are covariant, so element types do not separate them. */ +const ARRAY_KEY = "a"; +/** Entries of typed maps and sets. */ +const MAP_KEY = "m"; +/** Captured-binding boxes. */ +const BOX_KEY = "bx"; +/** Record fields written through a computed key. */ +const ANY_RECORD_KEY = "r:*"; + +/** The pseudo-function standing for every indirect call target. */ +const ANY_FUNCTION = "%anyFunctionValue"; + +/** Value-producing kinds that only move an existing function value. */ +const FUNCTION_MOVES = new Set([ + "closure", + "selfRef", + "varRef", + "fieldGet", + "recordGet", + "arrayGet", + "call", + "virtualCall", + "callValue", + "ternary", + "logical", + "nullish", + "orDefault", + "unionNarrow", + "seqExpr", + "assignExpr", + "optChain", + "chainRecv", + "upcast", + "downcast", + "mapIntrinsic", + "setIntrinsic", + "arrIntrinsic", + "recordKeyGet", + "unionKeyGet", +]); + +/** Runtime library families that run no user code and never write a + * program reference (scalars, strings, byte buffers, files, clocks). */ +const INERT_LIB_PREFIXES = [ + "math.", + "num.", + "number.", + "string.", + "str.", + "bigint.", + "buffer.", + "arrayBuffer.", + "sharedArrayBuffer.", + "atomics.", + "perf.", + "date.", + "text.", + "path.", + "fs.", + "stats.", + "spawnRes.", +]; +const INERT_LIB_CALLS = new Set([ + "dyn.compare", + "error.new", + "error.nodeThrow", + "dyn.typedRefIs", + "dyn.bagGet", + "json.parse", + "process.stderrWrite", + "process.stdoutWrite", + "process.envGet", + "process.envPairs", + "process.cwd", + "process.argv", + "process.platform", + "process.execPath", + "process.isTTY", + "worker.data", + "worker.isMainThread", + "worker.parentPort", + "worker.root", + "cp.spawnSyncOpts", +]); + +/** Array methods that never release an existing element or run user code. */ +const PRESERVING_ARRAY_METHODS = new Set([ + "length", + "getNumber", + "indexEq", + "push", + "pushSpread", + "concatSpread", + "unshift", + "unshiftSpread", + "indexOf", + "includes", + "nextPresent", + "join", + "slice", + "flatCopy", + "flatOne", + "toReversed", + "toSpliced", + "with", + "withUndefined", + "toSortedPrimitive", + // Permutes primitive payloads in place: every element keeps its owner. + "sortPrimitive", +]); + +/** Which heap reference locations evaluating code may overwrite or remove. + * + * ReferenceEffects answers one bit (does anything remove any edge?). This + * analysis keeps the location, so a borrow that reads `x.f` only conflicts + * with code that can write field `f` of x's hierarchy (or array elements, + * map entries, a global or a captured box, for those reads), not with + * every reference store. Locations: + * + * - `f:.` class fields (all classes of a hierarchy + * that name the field share it, so redeclarations alias); + * - `r:` record fields, plus `r:*` for computed-key writes and + * deletions (every record read also depends on `r:*`); + * - `a` reference array elements, `m` map/set entries, `bx` boxes; + * - `g:` module globals, `l:` the current function's own locals + * (dropped from summaries: a callee cannot rebind its caller's locals); + * - ALL for anything unknown: dynamic code, callbacks, suspension, unknown + * runtime operations. + * + * Stores a constructor makes into its own `this` write a fresh object that + * no borrow outside the construction can reach, so `new` (and `super` + * calls from constructors) use a constructor summary without them, as long + * as constructors are invoked only through `new` and `super`. + * + * Facts belong to one finalized module and one emission. */ +export class ReferenceWrites { + private readonly keys = new Map(); + private readonly roots = new Map(); + private readonly fieldOwners = new Map(); + private readonly classes = new Map(); + private readonly virtualTargets: VirtualTargets; + private readonly summaries = new Map(); + private readonly exprCache = new Map(); + private readonly stmtCache = new Map(); + private words = 0; + /** The unknown heap write alone. */ + heapAll: Uint32Array = new Uint32Array(1).fill(1); + /** Constructors only ever run on fresh objects. */ + private readonly freshConstructors: boolean; + private readonly unions: ReadonlyMap; + private readonly records: ReadonlyMap; + private readonly inertJson = new Map(); + /** Closure bodies an indirect call can reach. */ + private readonly functionValues = new Set(); + private callTargets!: CallTargets; + /** Some function value comes from outside the module's closures. */ + private foreignFunctionValues = false; + private current: IrFunction | null = null; + private currentBoxed = new Set(); + private currentLocals = new Set(); + private constructors: ReadonlySet = new Set(); + private inConstructor = false; + + constructor( + private readonly functions: ReadonlyMap, + classes: readonly IrClassDef[], + private readonly intrinsicCall: (call: Call) => boolean, + private readonly globals: ReadonlySet, + types: { + unions?: readonly IrUnionDef[] | undefined; + records?: readonly IrRecordShape[] | undefined; + } = {}, + /** Fields no code writes once their object is constructed + * (immutability.ts, from cycle-mutability's construction-only facts). */ + private readonly immutable: Pick | null = null, + ) { + this.key(HEAP_KEY); + this.unions = new Map((types.unions ?? []).map((u) => [u.id, u])); + this.records = new Map((types.records ?? []).map((r) => [r.id, r])); + for (const cls of classes) this.classes.set(cls.name, cls); + this.virtualTargets = new VirtualTargets(classes); + const constructors = new Set(classes.map((cls) => `%${cls.name}.constructor`)); + this.constructors = constructors; + this.freshConstructors = [...functions.values()].every((fn) => + fn.body.every((s) => this.onlySuperCalls(fn, s, constructors)), + ); + this.callTargets = new CallTargets(functions, this.virtualTargets); + this.solve(constructors); + } + + /** Every direct call of a constructor is a `super` call from a + * constructor on its own `this`. */ + private onlySuperCalls(fn: IrFunction, s: IrStmt, constructors: ReadonlySet): boolean { + const thisId = constructors.has(fn.name) ? fn.params[0]?.localId : undefined; + const expr = (e: IrExpr): boolean => { + if (e.kind === "call" && constructors.has(e.callee)) { + const self = e.args[0]; + if (thisId === undefined || !self || !isThisRef(self, thisId)) return false; + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (node: IrStmt): boolean => everyStmtChild(node, expr, stmt); + return stmt(s); + } + + private key(k: string): number { + let id = this.keys.get(k); + if (id === undefined) { + id = this.keys.size; + this.keys.set(k, id); + } + return id; + } + + /** The hierarchy root of a class (runtime bases included). */ + private root(className: string): string { + let r = this.roots.get(className); + if (r !== undefined) return r; + r = className; + for (let cls = this.classes.get(className); cls?.base !== undefined;) { + r = cls.base; + cls = this.classes.get(cls.base); + } + this.roots.set(className, r); + return r; + } + + fieldKey(className: string, field: string): string { + return `f:${this.root(className)}.${field}`; + } + + // ── phase 1: per-function local writes and call edges ───────────────── + + private solve(constructors: ReadonlySet): void { + interface Node { + local: Set; + edges: Set; + } + const nodes = new Map(); + const scan = (fn: IrFunction, external: boolean): Node => { + const node: Node = { local: new Set(), edges: new Set() }; + if (fn.async || fn.generator) { + node.local.add(0); + return node; + } + const thisId = external && constructors.has(fn.name) ? (fn.params[0]?.localId ?? null) : null; + const boxed = boxedLocals(fn); + const sink: WriteSink = { + add: (k) => { + // Interned for queries; a callee's locals are private to it. + const id = this.key(k); + if (!k.startsWith("l:")) node.local.add(id); + }, + all: () => node.local.add(0), + call: (callee, fresh) => { + if (callee === ANY_FUNCTION) { + node.edges.add(ANY_FUNCTION); + return true; + } + if (!this.functions.has(callee)) return false; + node.edges.add( + fresh && this.freshConstructors && constructors.has(callee) ? `${callee}#ext` : callee, + ); + return true; + }, + }; + const expr = (e: IrExpr): boolean => { + this.nodeWrites(e, sink, boxed, fn, constructors); + if (e.kind === "closure") this.functionValues.add(e.fnName); + else if ( + e.type?.kind === "func" && + !FUNCTION_MOVES.has(e.kind) && + !(e.kind === "libCall" && e.fn === "error.nodeThrow") + ) + this.foreignFunctionValues = true; + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (thisId !== null && s.kind === "fieldSet" && isThisRef(s.obj, thisId)) { + // A store into the fresh `this`: no outside borrow can reach it. + } else this.stmtWrites(s, sink, boxed); + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + return node; + }; + for (const fn of this.functions.values()) { + nodes.set(fn.name, scan(fn, false)); + if (this.freshConstructors && constructors.has(fn.name)) + nodes.set(`${fn.name}#ext`, scan(fn, true)); + } + // Indirect calls reach any closure body; values made elsewhere + // (dynamic conversions) reach unknown code. + nodes.set(ANY_FUNCTION, { + local: new Set(this.foreignFunctionValues ? [0] : []), + edges: new Set([...this.functionValues].filter((name) => this.functions.has(name))), + }); + this.words = (this.keys.size + 31) >>> 5; + this.heapAll = new Uint32Array(this.words); + this.heapAll[0] = 1; + // Tarjan's SCCs, iteratively; components come out callees first. + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + let counter = 0; + for (const start of nodes.keys()) { + if (index.has(start)) continue; + const work: { name: string; edges: Iterator }[] = []; + const enter = (name: string): void => { + index.set(name, counter); + low.set(name, counter++); + stack.push(name); + onStack.add(name); + work.push({ name, edges: nodes.get(name)!.edges.values() }); + }; + enter(start); + while (work.length > 0) { + const top = work[work.length - 1]!; + const next = top.edges.next(); + if (!next.done) { + const callee = next.value; + if (!nodes.has(callee)) continue; + if (!index.has(callee)) enter(callee); + else if (onStack.has(callee)) + low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!)); + continue; + } + work.pop(); + if (work.length > 0) { + const parent = work[work.length - 1]!.name; + low.set(parent, Math.min(low.get(parent)!, low.get(top.name)!)); + } + if (low.get(top.name) !== index.get(top.name)) continue; + const members: string[] = []; + for (;;) { + const m = stack.pop()!; + onStack.delete(m); + members.push(m); + if (m === top.name) break; + } + const bits = new Uint32Array(this.words); + const inComponent = new Set(members); + for (const m of members) { + const node = nodes.get(m)!; + for (const id of node.local) bits[id >>> 5]! |= 1 << (id & 31); + for (const callee of node.edges) { + if (inComponent.has(callee)) continue; + const s = this.summaries.get(callee); + if (s === undefined || s === null) continue; + for (let w = 0; w < this.words; w++) bits[w]! |= s[w]!; + } + } + // An unknown heap write subsumes the precise heap keys. + const summary: Writes = + (bits[0]! & 1) !== 0 ? this.heapAll : bits.some((w) => w !== 0) ? bits : null; + for (const m of members) this.summaries.set(m, summary); + } + } + } + + /** Writes of one expression node itself (children are separate). */ + private nodeWrites( + e: IrExpr, + sink: WriteSink, + boxed: ReadonlySet, + fn: IrFunction | null, + constructors: ReadonlySet, + ): void { + switch (e.kind) { + case "call": { + const fresh = + fn !== null && + constructors.has(e.callee) && + constructors.has(fn.name) && + e.args[0] !== undefined && + isThisRef(e.args[0], fn.params[0]?.localId ?? ""); + if (!sink.call(e.callee, fresh) && !this.intrinsicCall(e)) sink.all(); + return; + } + case "new": + if (!sink.call(`%${e.className}.constructor`, true)) sink.all(); + return; + case "virtualCall": { + const targets = this.virtualTargets.targets(e.className, e.method); + if (targets === null) sink.all(); + else for (const t of targets) if (!sink.call(t, false)) sink.all(); + return; + } + case "assignExpr": + case "incDec": + if (isRefCounted(e.type)) sink.add(this.localKey(e.localId, boxed)); + return; + case "callValue": { + const targets = fn !== null ? this.callTargets.of(e.callee, fn) : null; + if (targets === null) { + if (!sink.call(ANY_FUNCTION, false)) sink.all(); + } else for (const t of targets) if (!sink.call(t, false)) sink.all(); + return; + } + case "arrIntrinsic": + if (!PRESERVING_ARRAY_METHODS.has(e.method)) { + // The comparator sort orders a private snapshot. + if (e.method === "sortValues") { + const comparator = e.args[1]; + const targets = + fn !== null && comparator !== undefined ? this.callTargets.of(comparator, fn) : null; + if (targets === null) { + if (!sink.call(ANY_FUNCTION, false)) sink.all(); + } else for (const t of targets) if (!sink.call(t, false)) sink.all(); + } + sink.add(ARRAY_KEY); + } + return; + case "mapIntrinsic": + case "setIntrinsic": + if (borrowsMapReadInputs(e)) return; + if (borrowsMapMutationReceiver(e)) sink.add(MAP_KEY); + else if (e.receiver.type.kind === "map" || e.receiver.type.kind === "set") { + // Typed collections: copies and iteration bookkeeping run no + // user code; leaving an iteration may compact removed entries. + if (e.method === "iterEnter" || e.method === "iterExit") sink.add(MAP_KEY); + else if ( + e.method !== "clone" && + e.method !== "keySet" && + e.method !== "valueSet" && + e.method !== "toArray" + ) + sink.all(); + } else sink.all(); + return; + case "intrinsic": + // A TDZ test reads; publication only makes objects immortal. + if (e.name !== "module.tdzCheck" && e.name !== "threads.publish") sink.all(); + return; + case "dynFrom": + case "dynArrLit": + case "dynObjLit": + return; + case "dynCheck": + // Identity and kind tests of class and primitive targets. + if (!this.checkedWithoutCode(e.type)) sink.all(); + return; + case "jsonStringify": + if (!this.jsonInert(e.value.type)) sink.all(); + return; + case "bytesNew": + case "bytesIntrinsic": + return; + case "strIntrinsic": + if (!borrowsStringInputs(e.method)) sink.all(); + return; + case "regexIntrinsic": + if (!preservesRegexInputs(e.method)) sink.all(); + return; + case "libCall": + if (!this.inertLibCall(e)) sink.all(); + return; + case "toString": + if (!this.primitive(e.operand.type)) sink.all(); + return; + case "dynTest": + if (!preservesDynTest(e.test)) sink.all(); + return; + // Reads, tests, arithmetic and fresh allocations. + case "numLit": + case "boolLit": + case "strLit": + case "unitLit": + case "varRef": + case "bin": + case "unary": + case "toBool": + case "logical": + case "nullish": + case "ternary": + case "seqExpr": + case "fieldGet": + case "recordGet": + case "unionNarrow": + case "unionIsTag": + case "unionWrap": + case "downcast": + case "upcast": + case "instanceOf": + case "strConcat": + case "strEq": + case "strCmp": + case "arrayGet": + case "arrayHas": + case "arrayState": + case "unionEq": + case "unionFuncEq": + case "dynScalarEq": + case "caughtTest": + case "caughtNarrow": + case "caughtCheck": + case "classRef": + case "recordLit": + case "arrayLit": + case "arrayNewLen": + case "closure": + case "mapNew": + case "setNew": + case "regexLit": + case "recordHas": + case "recordKeyGet": + case "recordOvfHas": + case "recordOvfKeys": + case "fieldAbsent": + case "unionDisc": + case "unionKeyGet": + case "fieldIncDec": + case "optChain": + case "chainRecv": + case "orDefault": + case "instanceOfValue": + case "promiseVoidWiden": + return; + default: + sink.all(); + } + } + + /** Writes of one statement node itself. */ + private stmtWrites(s: IrStmt, sink: WriteSink, boxed: ReadonlySet): void { + switch (s.kind) { + case "assign": + if (isRefCounted(s.value.type)) sink.add(this.localKey(s.localId, boxed)); + return; + case "fieldSet": + if (isRefCounted(s.value.type)) sink.add(this.fieldKey(s.className, s.field)); + return; + case "recordSet": + if (isRefCounted(s.value.type)) sink.add(`r:${s.field}`); + return; + case "recordKeySet": + case "recordKeyDelete": + sink.add(ANY_RECORD_KEY); + return; + case "arraySet": + case "arraySetUndefined": + case "arrayDelete": + case "arraySetLength": + sink.add(ARRAY_KEY); + return; + case "varDecl": + case "exprStmt": + case "return": + case "throw": + case "runtimeFence": + case "if": + case "for": + case "forOf": + case "while": + case "doWhile": + case "block": + case "switch": + case "break": + case "continue": + case "bytesSet": + case "tryCatch": + case "rethrow": + return; + default: + sink.all(); + } + } + + /** Library calls that run no user code: inert families without callback + * arguments, and an Error options bag that is a typed record (no + * accessors) or absent. */ + private inertLibCall(e: IrExpr & { kind: "libCall" }): boolean { + if (e.fn === "error.nodeThrow" || isStableReceiverOperand(e, "")) return true; + if (e.fn === "error.newOptions") + return e.args.every( + (arg) => + arg.type.kind !== "dyn" || + (arg.kind === "dynFrom" && + (arg.value.type.kind === "record" || this.primitive(arg.value.type))), + ); + if (!INERT_LIB_CALLS.has(e.fn) && !INERT_LIB_PREFIXES.some((p) => e.fn.startsWith(p))) + return false; + return e.args.every( + (arg) => + arg.type.kind !== "func" && + (arg.type.kind !== "dyn" || + (e.fn !== "dyn.compare" && e.fn.startsWith("dyn.")) || + // A wrapped primitive has no methods to call back into. + (arg.kind === "dynFrom" && this.primitive(arg.value.type))), + ); + } + + /** Numbers, booleans, strings and unit values (and unions of them). */ + private primitive(t: IrType): boolean { + switch (t.kind) { + case "f64": + case "bool": + case "string": + case "undefinedT": + case "nullT": + return true; + case "union": + return this.unions.get(t.unionId)?.arms.every((arm) => this.primitive(arm)) ?? false; + default: + return false; + } + } + + /** A dynamic-to-typed check that only tests identity or kind. */ + private checkedWithoutCode(t: IrType): boolean { + if (t.kind === "object" || this.primitive(t)) return true; + if (t.kind !== "union") return false; + return ( + this.unions + .get(t.unionId) + ?.arms.every((arm) => arm.kind === "object" || this.primitive(arm)) ?? false + ); + } + + /** JSON.stringify of this type calls no toJSON or accessor: primitives + * and records/arrays/unions of them (class instances may define both). */ + private jsonInert(t: IrType): boolean { + if (this.primitive(t)) return true; + const key = JSON.stringify(t); + const known = this.inertJson.get(key); + if (known !== undefined) return known; + this.inertJson.set(key, true); // recursive shapes: assume, then confirm + let result: boolean; + if (t.kind === "array") result = this.jsonInert(t.elem); + else if (t.kind === "record") + result = this.records.get(t.shapeId)?.fields.every((f) => this.jsonInert(f.type)) ?? false; + else if (t.kind === "union") + result = this.unions.get(t.unionId)?.arms.every((arm) => this.jsonInert(arm)) ?? false; + else result = false; + this.inertJson.set(key, result); + return result; + } + + private localKey(id: string, boxed: ReadonlySet, locals?: ReadonlySet): string { + if (this.globals.has(id)) return `g:${id}`; + // A binding of another function (never expected) counts as a box. + if (boxed.has(id) || (locals !== undefined && !locals.has(id))) return BOX_KEY; + return `l:${id}`; + } + + // ── queries ──────────────────────────────────────────────────────────── + + /** Start answering expression queries for one function body. */ + enter(fn: IrFunction): void { + this.current = fn; + this.currentBoxed = boxedLocals(fn); + this.currentLocals = new Set(fn.locals.map((local) => local.id)); + this.inConstructor = this.constructors.has(fn.name); + for (const p of fn.params) this.currentLocals.add(p.localId); + this.exprCache.clear(); + this.stmtCache.clear(); + } + + private readonly bodyWrites = new Map(); + + /** Whether a walk of `fn` reads only locations its whole body never + * writes; the candidate walk locals' own rebinding is exempt. */ + unwrittenWalk( + fn: IrFunction, + value: IrExpr, + candidates: ReadonlySet, + call: (e: Call) => readonly string[] | null, + ): boolean { + if (this.current !== fn) this.enter(fn); + const keys = this.readKeys(value, call); + if (keys === null) return false; + let body = this.bodyWrites.get(fn.name); + if (body === undefined) { + body = this.stmts(fn.body); + this.bodyWrites.set(fn.name, body); + } + return !this.conflicts( + keys.filter((k) => !(k.startsWith("l:") && candidates.has(k.slice(2)))), + body, + ); + } + + /** The transitive writes of a call to a module function (`#ext` for a + * constructor reached through `new`). */ + summary(name: string): Writes { + const known = this.summaries.get(name); + return known === undefined ? this.heapAll : known; + } + + constructWrites(className: string): Writes { + const ctor = `%${className}.constructor`; + if (!this.functions.has(ctor)) return this.heapAll; + return this.summary(this.freshConstructors ? `${ctor}#ext` : ctor); + } + + virtualWrites(className: string, method: string): Writes { + const targets = this.virtualTargets.targets(className, method); + if (targets === null) return this.heapAll; + return this.union(targets.map((t) => this.summary(t))); + } + + /** Writes of evaluating an expression, with its operands. */ + expr(e: IrExpr): Writes { + const known = this.exprCache.get(e); + if (known !== undefined) return known; + const parts: Writes[] = []; + const sink = this.querySink(parts); + this.nodeWrites(e, sink, this.currentBoxed, this.current, new Set()); + everyExprChild( + e, + (child) => (parts.push(this.expr(child)), true), + (child) => (parts.push(this.stmt(child)), true), + ); + const result = this.union(parts); + this.exprCache.set(e, result); + return result; + } + + stmt(s: IrStmt): Writes { + const known = this.stmtCache.get(s); + if (known !== undefined) return known; + const parts: Writes[] = []; + this.stmtWrites(s, this.querySink(parts), this.currentBoxed); + everyStmtChild( + s, + (child) => (parts.push(this.expr(child)), true), + (child) => (parts.push(this.stmt(child)), true), + ); + const result = this.union(parts); + this.stmtCache.set(s, result); + return result; + } + + stmts(list: readonly IrStmt[]): Writes { + return this.union(list.map((s) => this.stmt(s))); + } + + private querySink(parts: Writes[]): WriteSink { + return { + add: (k) => parts.push(this.bitsOf([k])), + all: () => parts.push(this.heapAll), + call: (callee, fresh) => { + if (callee === ANY_FUNCTION) { + parts.push(this.summary(ANY_FUNCTION)); + return true; + } + if (!this.functions.has(callee)) return false; + const ctor = fresh && callee.endsWith(".constructor") && this.freshConstructors; + parts.push(this.summary(ctor ? `${callee}#ext` : callee)); + return true; + }, + }; + } + + union(parts: readonly Writes[]): Writes { + let out: Uint32Array | null = null; + for (const p of parts) { + if (p === null) continue; + if (out === null) out = new Uint32Array(this.words); + for (let w = 0; w < this.words; w++) out[w]! |= p[w]!; + } + return out; + } + + /** A bitset over known keys; keys nothing writes are dropped. */ + bitsOf(keys: readonly string[]): Writes { + let out: Uint32Array | null = null; + for (const k of keys) { + const id = this.keys.get(k); + if (id === undefined || id >= this.words * 32) continue; + if (out === null) out = new Uint32Array(this.words); + out[id >>> 5]! |= 1 << (id & 31); + } + return out; + } + + /** Whether writes may touch any of the read keys. */ + conflicts(reads: readonly string[], writes: Writes): boolean { + if (writes === null) return false; + const heap = (writes[0]! & 1) !== 0; + for (const k of reads) { + if (heap && !k.startsWith("l:")) return true; + const id = this.keys.get(k); + if (id === undefined || id >= this.words * 32) continue; + if ((writes[id >>> 5]! & (1 << (id & 31))) !== 0) return true; + } + return false; + } + + /** The locations whose writes could release the object a borrowed read + * of `e` yields, or null when `e` is not such a projection. `call` + * resolves borrowed-return callees to the locations their walks read. */ + readKeys(e: IrExpr, call?: (e: Call) => readonly string[] | null): string[] | null { + const out: string[] = []; + const walk = (node: IrExpr): boolean => { + switch (node.kind) { + case "varRef": + out.push(this.localKey(node.localId, this.currentBoxed, this.currentLocals)); + return true; + case "fieldGet": + // A construction-only field of an existing object never changes; + // only a constructor can still be writing it (its own `this`). + if (this.inConstructor || !this.immutable?.classField(node.className, node.field)) + out.push(this.fieldKey(node.className, node.field)); + return walk(node.obj); + case "recordGet": + if (this.inConstructor || !this.immutable?.recordField(node.shapeId, node.field)) + out.push(`r:${node.field}`, ANY_RECORD_KEY); + return walk(node.obj); + case "arrayGet": + out.push(ARRAY_KEY); + return this.expr(node.index) === null && walk(node.arr); + case "unionNarrow": + case "upcast": + case "downcast": + return walk(node.value); + case "unionWrap": + return node.value.kind === "unitLit" || walk(node.value); + case "ternary": + return this.expr(node.cond) === null && armWalk(node.then) && armWalk(node.else_); + case "seqExpr": { + // Guarded projections: `(check, value)` where the checks only + // read and throw. + if (this.stmts(node.stmts) !== null) return false; + return walk(node.result); + } + case "strLit": + return true; + case "call": { + const keys = call?.(node) ?? null; + if (keys === null) return false; + out.push(...keys); + return node.args.every((arg) => !isRefCounted(arg.type) || walk(arg)); + } + default: + return false; + } + }; + const armWalk = (node: IrExpr): boolean => + (node.kind === "libCall" && node.fn === "error.nodeThrow") || walk(node); + return walk(e) ? out : null; + } +} + +interface WriteSink { + add(key: string): void; + all(): void; + /** False when the callee is not a module function. */ + call(callee: string, fresh: boolean): boolean; +} + +function isThisRef(e: IrExpr, thisId: string): boolean { + if (e.kind === "varRef") return e.localId === thisId; + if (e.kind === "upcast") return isThisRef(e.value, thisId); + return false; +} + +/** Locals living in boxes: declared boxed or captured from an enclosing + * function. */ +function boxedLocals(fn: IrFunction): Set { + const out = new Set(); + for (const local of fn.locals) if (local.boxed) out.add(local.id); + for (const c of fn.captures ?? []) out.add(c.localId); + for (const c of fn.classCaptures ?? []) out.add(c.localId); + return out; +} + +/** The closure bodies a function value can be, when it flows only from + * closure expressions through stable locals and the function-typed + * parameters of functions that are only called directly (null: any + * function value). A small flow analysis over parameter positions: each + * position collects the targets every direct, virtual and `new` call site + * passes, to a fixpoint. */ +class CallTargets { + private readonly sites = new Map(); + private readonly opaque = new Set(); + private readonly params = new Map | null>(); + private readonly definitions = new Map>(); + /** Functions creating each closure body. */ + private readonly creators = new Map(); + + constructor( + private readonly functions: ReadonlyMap, + virtualTargets: VirtualTargets, + ) { + const site = (callee: string, caller: IrFunction, args: readonly IrExpr[]): void => { + let list = this.sites.get(callee); + if (!list) this.sites.set(callee, (list = [])); + list.push({ caller, args }); + }; + for (const fn of functions.values()) { + if (fn.captures !== undefined || fn.classCaptures !== undefined) this.opaque.add(fn.name); + const expr = (e: IrExpr): boolean => { + if (e.kind === "call") site(e.callee, fn, e.args); + else if (e.kind === "new") site(`%${e.className}.constructor`, fn, [e, ...e.args]); + else if (e.kind === "virtualCall") { + const targets = virtualTargets.targets(e.className, e.method); + for (const t of targets ?? []) site(t, fn, e.args); + } else if (e.kind === "closure") { + this.opaque.add(e.fnName); + let list = this.creators.get(e.fnName); + if (!list) this.creators.set(e.fnName, (list = [])); + list.push(fn); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (st: IrStmt): boolean => everyStmtChild(st, expr, stmt); + fn.body.every(stmt); + } + // Fixpoint over function-typed parameter positions. + const positions: [IrFunction, number][] = []; + for (const fn of functions.values()) + fn.params.forEach((p, i) => { + if (p.type.kind === "func") { + positions.push([fn, i]); + this.params.set(`${fn.name}#${i}`, new Set()); + } + }); + for (let changed = true; changed;) { + changed = false; + for (const [fn, i] of positions) { + const key = `${fn.name}#${i}`; + const known = this.params.get(key); + if (known === null) continue; + const sites = this.sites.get(fn.name); + let next: Set | null = new Set(known!); + if (this.opaque.has(fn.name) || !sites || this.assigned(fn, fn.params[i]!.localId)) + next = null; + else + for (const { caller, args } of sites) { + const arg = args[i]; + const t = arg === undefined ? null : this.resolve(arg, caller, 0); + if (t === null) { + next = null; + break; + } + for (const name of t) next.add(name); + } + if (next === null || next.size !== known!.size) { + this.params.set(key, next); + changed = true; + } + } + } + } + + of(callee: IrExpr, fn: IrFunction): readonly string[] | null { + const t = this.resolve(callee, fn, 0); + return t === null ? null : [...t]; + } + + private resolve(e: IrExpr, fn: IrFunction, depth: number): ReadonlySet | null { + if (depth > 8) return null; + switch (e.kind) { + case "closure": + return new Set([e.fnName]); + case "ternary": + case "logical": + case "nullish": + case "orDefault": { + const arms = e.kind === "ternary" ? [e.then, e.else_] : [e.left, e.right]; + const out = new Set(); + for (const arm of arms) { + if (arm.type.kind !== "func") { + if (arm.kind === "libCall" && arm.fn === "error.nodeThrow") continue; + if (e.kind === "ternary") return null; + continue; // the tested operand of `a ?? f`, `a || f` + } + const t = this.resolve(arm, fn, depth + 1); + if (t === null) return null; + for (const name of t) out.add(name); + } + return out; + } + case "varRef": { + if ((fn.captures ?? []).some((c) => c.localId === e.localId)) { + // A captured binding nothing rebinds holds what it held in every + // function creating this closure. + const creators = this.creators.get(fn.name); + if (!creators || this.assigned(fn, e.localId)) return null; + const out = new Set(); + for (const creator of creators) { + const t = this.resolve(e, creator, depth + 1); + if (t === null) return null; + for (const name of t) out.add(name); + } + return out; + } + const index = fn.params.findIndex((p) => p.localId === e.localId); + if (index >= 0) { + if (this.assigned(fn, e.localId)) return null; + return this.params.get(`${fn.name}#${index}`) ?? null; + } + const def = this.definition(fn, e.localId); + return def ? this.resolve(def, fn, depth + 1) : null; + } + case "call": { + // A function value returned by a module function: whatever every + // return yields (adapters return closures over their argument). + const callee = this.functions.get(e.callee); + if (!callee || callee.async || callee.generator) return null; + const out = new Set(); + let ok = true; + let any = false; + const stmt = (st: IrStmt): boolean => { + if (st.kind === "return") { + any = true; + const t = st.value ? this.resolve(st.value, callee, depth + 1) : null; + if (t === null) return (ok = false); + for (const name of t) out.add(name); + } + return everyStmtChild(st, () => true, stmt); + }; + callee.body.every(stmt); + return ok && any ? out : null; + } + default: + return null; + } + } + + /** The single initializer of a never-assigned, unboxed local. */ + private definition(fn: IrFunction, id: string): IrExpr | null { + let defs = this.definitions.get(fn); + if (!defs) { + defs = new Map(); + const boxed = new Set(fn.locals.filter((l) => l.boxed).map((l) => l.id)); + const counts = new Map(); + const expr = (e: IrExpr): boolean => { + if (e.kind === "assignExpr" || e.kind === "incDec") counts.set(e.localId, 2); + return everyExprChild(e, expr, stmt); + }; + const stmt = (st: IrStmt): boolean => { + if (st.kind === "varDecl") { + counts.set(st.localId, (counts.get(st.localId) ?? 0) + 1); + defs!.set(st.localId, st.init); + } else if (st.kind === "assign") counts.set(st.localId, 2); + return everyStmtChild(st, expr, stmt); + }; + fn.body.every(stmt); + for (const [local, n] of counts) if (n !== 1 || boxed.has(local)) defs.set(local, null); + this.definitions.set(fn, defs); + } + return defs.get(id) ?? null; + } + + private assigned(fn: IrFunction, id: string): boolean { + this.definition(fn, id); + const defs = this.definitions.get(fn)!; + return defs.has(id); + } +} diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index 39b8cfe06..bfcaeeb6c 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -167,18 +167,23 @@ test("string operands preserve their left-to-right snapshot when a later assignm expect(facts(f).parameters.has("replace")).toBe(false); }); -test("a later global write keeps mutable string arguments owned", () => { +test("a callee's global write cannot replace the caller's mutable string locals", () => { const source = fn("source", [], str("next")); source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); const compare = fn("compare", ["text"], equal(ref("text"), call("source", [], STRING))); - // A later write anywhere in this function rules out parameter borrowing. + // A write anywhere in this function rules out parameter borrowing, but + // the rebound local itself owns its value across the later operand. compare.body.unshift({ kind: "assign", localId: "text", value: str("first"), loc }); const module = mod(source, compare); module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }]; const ir = body(module, "sc_f_compare"); const sourceCall = ir.indexOf("@sc_f_source"); expect(sourceCall).toBeGreaterThan(0); - expect(ir.slice(0, sourceCall)).toContain("@scr_str_retain_v"); + expect(ir.slice(0, sourceCall)).not.toContain("@scr_str_retain_v"); + // Reading the global the later operand replaces still takes a snapshot. + compare.body = [ret(equal(ref("%g.changed"), call("source", [], STRING)))]; + const global = body(module, "sc_bf_compare"); + expect(global.slice(0, global.indexOf("@sc_f_source"))).toContain("@scr_str_retain_v"); }); test("immutable lexical owners survive arbitrary later operands", () => { @@ -214,24 +219,22 @@ test("reference fields borrow across preserving calls but snapshot across writes source.locals.push({ id: "changed", name: "changed", type: STRING, mutable: true }); source.body.unshift({ kind: "assign", localId: "changed", value: str("effect"), loc }); expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); + // A global write replaces no record field: the projection still borrows. source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); - module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }]; - // The field is never written after its record literal, so no call can - // replace (and release) it while the holder lives. + module.globals = [ + { id: "%g.changed", name: "changed", type: STRING, mutable: true }, + { id: "%g.record", name: "record", type: record, mutable: true }, + ]; expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); - // Once the program writes the field, a reference-writing call snapshots it. - const writer = fn("writer", [], str("written")); - writer.params = [{ localId: "target", name: "target", type: record }]; - writer.locals = [{ id: "target", name: "target", type: record, mutable: false }]; - writer.body.unshift({ + // Writing the projected field of any record does replace it. + source.body.unshift({ kind: "recordSet", - obj: ref("target", record), + obj: ref("%g.record", record), shapeId: "text", field: "value", - value: str("written"), + value: str("effect"), loc, }); - module.functions.push(writer); expect(body(module, "sc_bf_compare")).toContain("@scr_str_retain_v"); f.body = [ret(equal(str("constant"), projection))]; expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); @@ -358,7 +361,7 @@ test.each(methodCases)( }, ); -test("string search snapshots both receiver and needle before a numeric argument writes globally", () => { +test("string search borrows rebound locals but snapshots a global a later argument writes", () => { const position = fn("position", [], num(0)); position.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); const search = fn( @@ -377,9 +380,16 @@ test("string search snapshots both receiver and needle before a numeric argument const searchCall = ir.indexOf("@scr_str_index_of"); expect(start).toBeGreaterThan(0); expect(searchCall).toBeGreaterThan(start); - // Literals need no owner, but both reads take independent snapshots. - expect(ir.slice(0, start).match(/@scr_str_retain_v/g)).toHaveLength(2); - expect(ir.slice(searchCall)).toContain("@scr_str_release"); + // The rebound locals own their values across the call: no snapshots. + expect(ir.slice(0, start)).not.toContain("@scr_str_retain_v"); + // A receiver read from the global the numeric argument replaces does. + search.body = [ + ret(intrinsic(ref("%g.changed"), "indexOf", [ref("needle"), call("position", [], F64)], F64)), + ]; + const global = body(module, "sc_bf_search"); + const call2 = global.indexOf("@sc_f_position"); + expect(global.slice(0, call2).match(/@scr_str_retain_v/g)).toHaveLength(1); + expect(global.slice(global.indexOf("@scr_str_index_of"))).toContain("@scr_str_release"); }); test("global string ownership cannot be inferred from an immutable-looking reference", () => { diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts index 7ba048573..e130fc6d7 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -17,6 +17,11 @@ export interface WalkBorrowHost { /** Parameters that may become candidates; once calling conventions are * fixed, only borrowed parameters qualify. Defaults to every parameter. */ parameterAllowed?(localId: string): boolean; + /** For bodies that may write the heap: whether a walk only reads + * locations the whole body never writes, given the current candidates + * (whose own rebinding releases nothing). Absent: the body preserves + * every edge. */ + unwritten?(value: IrExpr, candidates: ReadonlySet): boolean; } export interface WalkFacts { @@ -34,7 +39,9 @@ export interface WalkFacts { * calling convention. * * Soundness rests on the whole body preserving heap edges (the caller - * passes only functions in ReferenceEffects.functions): neither the body + * passes only functions in ReferenceEffects.functions, or supplies + * `unwritten` to admit only walks over locations the body never writes; + * the argument below then applies to those locations): 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 @@ -155,12 +162,20 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { return false; } }; - const walkOrThrow = (e: IrExpr): boolean => - (e.kind === "libCall" && e.fn === "error.nodeThrow") || walk(e); + const isThrow = (e: IrExpr): boolean => e.kind === "libCall" && e.fn === "error.nodeThrow"; + const walkOrThrow = (e: IrExpr): boolean => isThrow(e) || 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; + if ( + definitions + .get(id)! + .every( + (value) => + value === null || (walk(value) && host.unwritten?.(value, candidates) !== false), + ) + ) + continue; candidates.delete(id); changed = true; } @@ -169,6 +184,11 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { const returnsWalk = !guarded && returns.length > 0 && - returns.every((value) => value !== null && walkOrThrow(value)); + returns.every( + (value) => + value !== null && + walkOrThrow(value) && + (isThrow(value) || host.unwritten?.(value, candidates) !== false), + ); return { locals: result, returnsWalk }; } diff --git a/tests/corpus/4620-reference-write-call-arguments.ts b/tests/corpus/4620-reference-write-call-arguments.ts new file mode 100644 index 000000000..6a2160bd2 --- /dev/null +++ b/tests/corpus/4620-reference-write-call-arguments.ts @@ -0,0 +1,119 @@ +// Field reads and rebound locals passed to borrowing callees: borrowed only +// when neither the callee nor a later argument can replace them. Each +// "replaces" case makes the field the value's last owner, frees it inside +// the call, churns the allocator and then reads the argument again. + +class Name { + text: string; + parts: string[]; + constructor(text: string) { + this.text = text; + this.parts = text.split(""); + } +} + +class Holder { + name: Name | undefined; + other: Name | undefined = undefined; + items: Name[] = []; + constructor(name: Name) { + this.name = name; + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 200; i++) total += new Name("churn-" + i).parts.length; + return total; +} + +function describe(n: Name | undefined): string { + if (n === undefined) return "none"; + return n.text + ":" + n.parts.length; +} + +// Writes an unrelated field: the caller may lend `h.name`. +function touchOther(h: Holder, n: Name | undefined): string { + h.other = new Name("other"); + churn(); + return describe(n); +} + +// Replaces the very field the caller passed: the argument must be owned. +function replaceName(h: Holder, n: Name | undefined): string { + h.name = new Name("replacement"); + churn(); + return describe(n); +} + +// Removes array elements: an element argument must be owned. +function dropItems(h: Holder, n: Name): string { + h.items.length = 0; + churn(); + return describe(n); +} + +function compose(a: string, b: string): string { + return a + "|" + b; +} + +function later(h: Holder): string { + h.name = new Name("later"); + churn(); + return "later"; +} + +// A later argument replaces the field an earlier argument read. +function laterArgument(h: Holder): string { + return compose(describe2(h.name), later(h)); +} +function describe2(n: Name | undefined): string { + churn(); + return describe(n); +} + +// A rebound parameter passed along: no callee can rebind the caller's local. +function rebound(n: Name, h: Holder): string { + if (n.text.length > 3) n = h.name ?? n; + return replaceAndDescribe(n, h) + " " + describe(n); +} +function replaceAndDescribe(n: Name, h: Holder): string { + h.name = undefined; + churn(); + return describe(n); +} + +// A constructor's stores into its own fresh object replace nothing the +// caller lends; a constructor storing into another object does. +class Attach { + label: Name; + constructor(h: Holder, label: Name) { + h.name = undefined; + churn(); + this.label = new Name("own-" + label.text); + } +} +function construct(h: Holder): string { + const a = new Attach(h, h.name!); + return a.label.text; +} + +const h1 = new Holder(new Name("first")); +console.log(touchOther(h1, h1.name)); +const h2 = new Holder(new Name("second")); +console.log(replaceName(h2, h2.name), describe(h2.name)); +const h3 = new Holder(new Name("third")); +h3.items.push(new Name("item0"), new Name("item1")); +console.log(dropItems(h3, h3.items[1]!)); +const h4 = new Holder(new Name("fourth")); +console.log(laterArgument(h4), describe(h4.name)); +const h5 = new Holder(new Name("fifth-name")); +console.log(rebound(new Name("sixth"), h5)); +const h6 = new Holder(new Name("seventh")); +console.log(construct(h6), describe(h6.name)); +let sum = 0; +for (let i = 0; i < 50; i++) { + const h = new Holder(new Name("loop" + i)); + sum += touchOther(h, h.name).length + replaceName(h, h.name).length; +} +console.log("sum", sum); diff --git a/tests/corpus/4621-reference-write-aliases.ts b/tests/corpus/4621-reference-write-aliases.ts new file mode 100644 index 000000000..4d5e04ecb --- /dev/null +++ b/tests/corpus/4621-reference-write-aliases.ts @@ -0,0 +1,116 @@ +// `const t = o.f` borrows the field only when the rest of its block writes +// none of the locations keeping the value alive; returning or storing the +// alias still takes its own reference. + +class Leaf { + text: string; + constructor(text: string) { + this.text = text; + } +} + +class Box { + leaf: Leaf; + spare: Leaf | undefined = undefined; + list: Leaf[] = []; + constructor(leaf: Leaf) { + this.leaf = leaf; + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 200; i++) total += new Leaf("x" + i).text.length; + return total; +} + +// No write to `leaf` in the block: borrowed. +function readOnly(b: Box): string { + const t = b.leaf; + b.spare = new Leaf("spare"); + churn(); + return t.text + "/" + b.spare.text; +} + +// The block replaces the field: the alias must own its value. +function replaced(b: Box): string { + const t = b.leaf; + b.leaf = new Leaf("new"); + churn(); + return t.text + "->" + b.leaf.text; +} + +// The replacement happens in a callee. +function swap(b: Box): void { + b.leaf = new Leaf("swapped"); + churn(); +} +function replacedInCallee(b: Box): string { + const t = b.leaf; + swap(b); + return t.text; +} + +// Element aliases and removals. +function element(b: Box): string { + const first = b.list[0]!; + b.list.pop(); + b.list.length = 0; + churn(); + return first.text; +} + +// The alias escapes into a store and a return value. +function escape(b: Box, other: Box): Leaf { + const t = b.leaf; + other.spare = t; + b.leaf = new Leaf("gone"); + churn(); + return t; +} + +// Aliases in loop bodies are fresh per iteration; `continue` ends one. +function loop(boxes: Box[]): string { + const out: string[] = []; + for (let i = 0; i < boxes.length; i++) { + const t = boxes[i]!.leaf; + if (i % 2 === 0) { + out.push(t.text); + continue; + } + boxes[i]!.leaf = new Leaf("odd" + i); + churn(); + out.push(t.text); + } + return out.join(","); +} + +// A switch case's bindings live for the whole switch. +function sw(b: Box, k: number): string { + let s = ""; + switch (k) { + case 0: { + const t = b.leaf; + b.leaf = new Leaf("case0"); + churn(); + s = t.text; + break; + } + default: + s = b.leaf.text; + } + return s; +} + +console.log(readOnly(new Box(new Leaf("ro")))); +console.log(replaced(new Box(new Leaf("old")))); +console.log(replacedInCallee(new Box(new Leaf("callee")))); +const withList = new Box(new Leaf("l")); +withList.list.push(new Leaf("e0"), new Leaf("e1")); +console.log(element(withList)); +const other = new Box(new Leaf("other")); +console.log(escape(new Box(new Leaf("escaped")), other).text, other.spare?.text); +const boxes: Box[] = []; +for (let i = 0; i < 6; i++) boxes.push(new Box(new Leaf("b" + i))); +console.log(loop(boxes), boxes.map((b) => b.leaf.text).join(" ")); +console.log(sw(new Box(new Leaf("sw0")), 0), sw(new Box(new Leaf("sw1")), 1)); diff --git a/tests/corpus/4622-reference-write-loops-and-walks.ts b/tests/corpus/4622-reference-write-loops-and-walks.ts new file mode 100644 index 000000000..98fcdbad3 --- /dev/null +++ b/tests/corpus/4622-reference-write-loops-and-walks.ts @@ -0,0 +1,116 @@ +// Loop element borrows, optional element reads, parent walks and borrowed +// returns in bodies that write the heap: borrowed only while nothing the +// body (or a callee) writes can release the lent value. + +class Item { + name: string; + parent: Item | undefined; + note: string | undefined = undefined; + constructor(name: string, parent: Item | undefined) { + this.name = name; + this.parent = parent; + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 100; i++) total += new Item("c" + i, undefined).name.length; + return total; +} + +// The body writes another field of each element: elements stay lent. +function annotate(items: Item[]): string { + const names: string[] = []; + for (const it of items) { + it.note = "seen:" + it.name; + names.push(it.name); + } + return names.join(","); +} + +// The body removes elements from the iterated array: elements are owned. +function drain(items: Item[]): string { + const names: string[] = []; + for (const it of items) { + items.length = 0; + churn(); + names.push(it.name); + } + return names.join(","); +} + +// Indexed loop with an optional element read and an unrelated write. +function indexed(items: Item[], log: string[]): string { + let s = ""; + for (let i = 0; i < items.length + 1; i++) { + const it = items[i]; + log.push("i" + i); + s += it === undefined ? "-" : it.name; + } + return s; +} + +// Indexed loop replacing the element it just read. +function replaceEach(items: Item[]): string { + let s = ""; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + items[i] = new Item("r" + i, undefined); + churn(); + s += it === undefined ? "-" : it.name; + } + return s; +} + +// A parent walk in a body that writes notes: the walk borrows. +function rootWithNotes(it: Item): string { + let p = it; + while (p.parent !== undefined) { + p.note = "walked"; + p = p.parent; + } + return p.name; +} + +// A walk whose body detaches parents: the walk owns its steps. +function detach(it: Item): string { + let p = it; + let s = ""; + while (p.parent !== undefined) { + const next = p.parent; + p.parent = undefined; + churn(); + s += next.name; + p = next; + } + return s; +} + +// A borrowed return from a body that writes elsewhere, consumed after a +// call that writes notes. +function topOf(it: Item, log: string[]): Item { + log.push("top"); + let p = it; + while (p.parent !== undefined) p = p.parent; + return p; +} +function useTop(it: Item, log: string[]): string { + const top = topOf(it, log); + annotate([it]); + return top.name + ":" + (it.note ?? ""); +} + +const chain: Item[] = []; +let prev: Item | undefined = undefined; +for (let i = 0; i < 8; i++) { + prev = new Item("n" + i, prev); + chain.push(prev); +} +const log: string[] = []; +console.log(annotate(chain.slice()), chain[3]!.note); +console.log(drain(chain.slice(0, 4))); +console.log(indexed(chain.slice(0, 3), log), log.length); +console.log(replaceEach(chain.slice(0, 3))); +console.log(rootWithNotes(chain[7]!), chain[4]!.note); +console.log(useTop(chain[6]!, log), log.join("")); +console.log(detach(chain[7]!), chain[7]!.parent, chain[2]!.parent); diff --git a/tests/corpus/4623-reference-write-callbacks-returns.ts b/tests/corpus/4623-reference-write-callbacks-returns.ts new file mode 100644 index 000000000..8ba1e8991 --- /dev/null +++ b/tests/corpus/4623-reference-write-callbacks-returns.ts @@ -0,0 +1,98 @@ +// Closure calls resolved through callback parameters, adapters and +// captures, and owned locals moved into return values. + +class Cell { + value: string; + next: Cell | undefined = undefined; + constructor(value: string) { + this.value = value; + } +} + +class Store { + cell: Cell; + constructor(cell: Cell) { + this.cell = cell; + } +} + +function churn(): number { + let n = 0; + for (let i = 0; i < 100; i++) n += new Cell("k" + i).value.length; + return n; +} + +function show(c: Cell | undefined): string { + return c === undefined ? "none" : c.value; +} + +// The callback a caller passes replaces the field the caller lends. +function withCallback(s: Store, c: Cell, f: (s: Store) => void): string { + f(s); + churn(); + return show(c); +} +function each(cells: Cell[], f: (c: Cell) => string): string { + return cells.map(f).join(","); +} + +// Captured state written by the callback. +function capture(s: Store): string { + let seen = s.cell; + const record = (c: Cell): string => { + seen = c; + return c.value; + }; + const listed = each([new Cell("a"), new Cell("b")], record); + return listed + " " + seen.value; +} + +// Returning owned locals: plain, from a loop, and through a finally. +function build(n: number): Cell { + const head = new Cell("head" + n); + let cur = head; + for (let i = 0; i < n; i++) { + const c = new Cell("c" + i); + cur.next = c; + cur = c; + } + return head; +} +function pick(cells: Cell[], want: string): Cell | undefined { + for (const c of cells) { + const found = c; + if (found.value === want) return found; + } + return undefined; +} +function guarded(n: number): Cell { + const c = new Cell("guarded" + n); + try { + if (n > 1) return c; + return new Cell("small"); + } finally { + c.value += "!"; + } +} + +const s1 = new Store(new Cell("one")); +console.log( + withCallback(s1, s1.cell, (s) => { + s.cell = new Cell("replaced"); + }), + s1.cell.value, +); +const s2 = new Store(new Cell("two")); +console.log(withCallback(s2, s2.cell, () => {}), s2.cell.value); +console.log(capture(new Store(new Cell("start")))); +let walk: Cell | undefined = build(3); +const parts: string[] = []; +while (walk !== undefined) { + parts.push(walk.value); + walk = walk.next; +} +console.log(parts.join(">")); +const cells = [new Cell("x"), new Cell("y"), new Cell("z")]; +console.log(show(pick(cells, "y")), show(pick(cells, "w"))); +console.log(guarded(2).value, guarded(0).value); +console.log(each(cells, (c) => c.value.toUpperCase())); From a8113acb8f9041f84ba1ad1f2b96af0131d24a6a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:44:36 -0700 Subject: [PATCH 2/7] Move owned locals into return values `return x` of a local whose slot is an owned scope entry of the return type loaded the value, retained it for the caller and released the local at scope exit. Outside finally regions the return now takes over the local's reference: no retain, and the local's entry is skipped by the return's releases. tsc-ts e469e33 self-check, executed non-null non-immortal RC operations in emitted code: 320.2M -> 307.6M (-3.9%) on top of the previous commit. --- packages/compiler/src/backend/llvm/emitter.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 45a01fd18..24ac10364 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5448,6 +5448,27 @@ export class LlEmitter { return !rw.conflicts(["a", ...keys], writes); } + /** `return x` of a local whose slot is an owned scope entry of the same + * type: the return can take over the local's reference. */ + private movableReturn( + value: IrExpr | null, + ): { scope: LlScopeEntry[]; entry: LlScopeEntry; index: number } | null { + if (value === null || value.kind !== "varRef" || !isRefCounted(value.type)) return null; + if (this.currentBorrowedReturn || this.currentWasiCoro !== null) return null; + const binding = this.binding(value.localId); + if (binding.kind !== "local" || binding.local === undefined || binding.local.boxed) return null; + if (!typeEquals(binding.type, value.type) || !typeEquals(this.currentReturnType, value.type)) + return null; + for (const scope of this.scopes) { + const index = scope.findIndex((entry) => entry.slot === binding.slot); + if (index < 0) continue; + const entry = scope[index]!; + if (entry.boxed || entry.lazy || !typeEquals(entry.type, value.type)) return null; + return { scope, entry, index }; + } + return null; + } + /** A walk host for a body that may write the heap: each walk must read * only locations the whole body (with its callees) never writes; * rebinding the walk locals themselves releases nothing. */ @@ -6597,7 +6618,17 @@ 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 && this.currentBorrowedReturn) { + const moved = this.finallyStack.length === 0 ? this.movableReturn(s.value) : null; + if (moved !== null) { + // Returning an owned local hands its reference to the caller: no + // retain now and no release of the local below. + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${moved.entry.slot}`); + v = { name: t, type: s.value!.type }; + moved.scope.splice(moved.scope.indexOf(moved.entry), 1); + this.releaseForJump(0, 0); + moved.scope.splice(moved.index, 0, moved.entry); + } else 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); @@ -6632,7 +6663,7 @@ export class LlEmitter { this.scopes = savedScopes; this.finallyStack = savedFinally; this.tryStack = savedTry; - } else { + } else if (moved === null) { this.releaseForJump(0, 0); } if (!B.isTerminated()) { From ec5adcfa1856dd9f600d3a5dd6cee89e97935343 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:54:50 -0700 Subject: [PATCH 3/7] Lend optional element reads as borrowed returns Safe index reads of possibly-missing elements lower to lifted helpers (%arr.idxOr.N) that returned an owned element. Walks now admit required element reads of walked arrays (an index whose computation removes no edge), so these helpers qualify as borrowed returns: their body lends the element its array keeps alive (borrowing the optional read directly), and receivers consume the call's result without a retain/release pair, as for any borrowed-return call. Paged lookups such as `this.pages[id >> 8]?.[id & 255]` no longer retain the page array. Corpus 4630-4633 renumbered from 4620-4623 (checkerhot uses 4620/4621); 4634 pins paged lookups and a read followed by a removal. --- packages/compiler/src/backend/llvm/emitter.ts | 25 +++++++++- .../compiler/src/backend/llvm/walk-borrows.ts | 4 ++ ...=> 4630-reference-write-call-arguments.ts} | 0 ...ses.ts => 4631-reference-write-aliases.ts} | 0 ...> 4632-reference-write-loops-and-walks.ts} | 0 ...4633-reference-write-callbacks-returns.ts} | 0 .../4634-optional-element-borrowed-returns.ts | 47 +++++++++++++++++++ 7 files changed, 74 insertions(+), 2 deletions(-) rename tests/corpus/{4620-reference-write-call-arguments.ts => 4630-reference-write-call-arguments.ts} (100%) rename tests/corpus/{4621-reference-write-aliases.ts => 4631-reference-write-aliases.ts} (100%) rename tests/corpus/{4622-reference-write-loops-and-walks.ts => 4632-reference-write-loops-and-walks.ts} (100%) rename tests/corpus/{4623-reference-write-callbacks-returns.ts => 4633-reference-write-callbacks-returns.ts} (100%) create mode 100644 tests/corpus/4634-optional-element-borrowed-returns.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 24ac10364..672f56f87 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6630,8 +6630,17 @@ export class LlEmitter { moved.scope.splice(moved.index, 0, moved.entry); } else 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); + // (it has no try statements, so no finally can intervene). An + // optional element read lends the element its array keeps alive. + const read = this.optionalArrayReads.get(s.value); + if (read) { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + emitLocalArrayRead(this, { ...read, borrow: true }, slot); + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${slot}`); + v = { name: t, type: s.value.type }; + } else v = this.emitReadReceiver(s.value); } else if (s.value !== null) { v = this.emitExpr(s.value); this.moveTemp(v); @@ -7527,6 +7536,10 @@ export class LlEmitter { if (result.owner) this.ownSlot(result.owner.slot, result.owner.type); return result.value; } + // A borrowed-return helper (such as an optional element read) lends its + // result like any borrowed-return call. + if (e.kind === "call" && this.borrowedReturns.has(e.callee) && this.canBorrowReceiver(e)) + return emitBorrowedResultCall(this, e); const arrayRead = this.optionalArrayReads.get(e); if (arrayRead) return emitProjectedArrayRead(this, arrayRead); const narrow = this.checkedNarrows.get(e); @@ -7907,6 +7920,14 @@ export class LlEmitter { return this.nullableFieldGet(e) === null; case "recordGet": return true; + // emitReadReceiver lends a required element of a borrowed array when + // computing the index removes no edge. + case "arrayGet": + return ( + e.arr.type.kind === "array" && + this.optionalArrayReads.get(e) === null && + this.referenceEffects.preserves(e.index) + ); case "unionNarrow": return this.nullableUnions.has(e.unionId); case "downcast": diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts index e130fc6d7..dc3e03c19 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -140,6 +140,10 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { case "fieldGet": case "recordGet": return host.borrowsWithoutOwning(e) && walk(e.obj); + // A required element of a walked array (the read still traps when the + // element is missing). + case "arrayGet": + return host.borrowsWithoutOwning(e) && walk(e.arr); case "unionNarrow": case "downcast": case "upcast": diff --git a/tests/corpus/4620-reference-write-call-arguments.ts b/tests/corpus/4630-reference-write-call-arguments.ts similarity index 100% rename from tests/corpus/4620-reference-write-call-arguments.ts rename to tests/corpus/4630-reference-write-call-arguments.ts diff --git a/tests/corpus/4621-reference-write-aliases.ts b/tests/corpus/4631-reference-write-aliases.ts similarity index 100% rename from tests/corpus/4621-reference-write-aliases.ts rename to tests/corpus/4631-reference-write-aliases.ts diff --git a/tests/corpus/4622-reference-write-loops-and-walks.ts b/tests/corpus/4632-reference-write-loops-and-walks.ts similarity index 100% rename from tests/corpus/4622-reference-write-loops-and-walks.ts rename to tests/corpus/4632-reference-write-loops-and-walks.ts diff --git a/tests/corpus/4623-reference-write-callbacks-returns.ts b/tests/corpus/4633-reference-write-callbacks-returns.ts similarity index 100% rename from tests/corpus/4623-reference-write-callbacks-returns.ts rename to tests/corpus/4633-reference-write-callbacks-returns.ts diff --git a/tests/corpus/4634-optional-element-borrowed-returns.ts b/tests/corpus/4634-optional-element-borrowed-returns.ts new file mode 100644 index 000000000..dab04a34b --- /dev/null +++ b/tests/corpus/4634-optional-element-borrowed-returns.ts @@ -0,0 +1,47 @@ +// Optional element reads (`arr[i]` of `T | undefined` elements) lower to +// helpers that now lend the element their array keeps alive: paged lookups +// borrow, and reads followed by removals still own what they keep. + +class Link { + name: string; + constructor(name: string) { + this.name = name; + } +} + +class Pages { + pages: (Link[] | undefined)[] = []; + get(id: number): Link { + let link = this.pages[id >> 3]?.[id & 7]; + if (link === undefined) { + let page = this.pages[id >> 3]; + if (page === undefined) { + page = []; + this.pages[id >> 3] = page; + } + link = new Link("l" + id); + page[id & 7] = link; + } + return link; + } +} + +function first(links: Link[]): string { + const head = links[0]; + return head === undefined ? "none" : head.name; +} + +const shared: Link[] = [new Link("x"), new Link("y")]; +function readThenClear(): string { + const kept = shared[1]; + shared.length = 0; + let churn = 0; + for (let i = 0; i < 100; i++) churn += new Link("c" + i).name.length; + return (kept === undefined ? "-" : kept.name) + churn; +} + +const pages = new Pages(); +const seen: string[] = []; +for (let i = 0; i < 40; i++) seen.push(pages.get((i * 7) % 19).name); +console.log(seen.join(" ")); +console.log(first([]), first([new Link("a")]), readThenClear(), shared.length); From 9afbabee3cf7c03a10686f638f8caf7c7afd04b2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 16:05:12 -0700 Subject: [PATCH 4/7] Keep the reference-write analysis within the self-hosted compiler's subset The natively compiled compiler rejected compound assignments to typed-array elements, Uint32Array.some, a chained Set.add and a structural Pick of ImmutableFields; bit operations now go through small helpers. The dense reference-read test truncates its array first: a bare element return is now lent as a borrowed return. --- .../backend/llvm/dense-array-access.test.ts | 12 ++++++++- packages/compiler/src/backend/llvm/emitter.ts | 6 +++-- .../src/backend/llvm/reference-writes.ts | 27 ++++++++++++++----- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index f307fde54..55479ad5f 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -77,7 +77,17 @@ test.each([32, 64] as const)("strict scalar reads check dense storage inline (%i test("reference reads retain inline and keep a cold +1 runtime fallback", () => { const read: IrExpr = { kind: "arrayGet", arr: ref("s", words), index, type: STRING, loc }; - const llvm = emitLlvmModule(module("word", STRING, [{ kind: "return", value: read, loc }])); + // An element removal in the body keeps the read from being lent as a + // borrowed return: the result must own its reference. + const truncate: IrStmt = { + kind: "arraySetLength", + arr: ref("s", words), + length: { kind: "numLit", value: 1, type: F64, loc }, + loc, + }; + const llvm = emitLlvmModule( + module("word", STRING, [truncate, { kind: "return", value: read, loc }]), + ); const fn = body(llvm, "word"); expect(fn).toMatch(/icmp ne ptr .*, null/); expect(fn).toContain("@scr_str_retain_v("); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 672f56f87..8430c87b0 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5553,8 +5553,10 @@ export class LlEmitter { stmt: () => true, expr: (e) => { if (e.kind === "fieldGet") keys.add(rw.fieldKey(e.className, e.field)); - else if (e.kind === "recordGet") keys.add(`r:${e.field}`).add("r:*"); - else if (e.kind === "arrayGet") keys.add("a"); + else if (e.kind === "recordGet") { + keys.add(`r:${e.field}`); + keys.add("r:*"); + } else if (e.kind === "arrayGet") keys.add("a"); else if (e.kind === "call" && this.borrowedReturns.has(e.callee) && !seen.has(e.callee)) { seen.add(e.callee); pending.push(e.callee); diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 9eeef0cea..fac6fd35c 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -201,7 +201,7 @@ export class ReferenceWrites { } = {}, /** Fields no code writes once their object is constructed * (immutability.ts, from cycle-mutability's construction-only facts). */ - private readonly immutable: Pick | null = null, + private readonly immutable: ImmutableFields | null = null, ) { this.key(HEAP_KEY); this.unions = new Map((types.unions ?? []).map((u) => [u.id, u])); @@ -372,17 +372,16 @@ export class ReferenceWrites { const inComponent = new Set(members); for (const m of members) { const node = nodes.get(m)!; - for (const id of node.local) bits[id >>> 5]! |= 1 << (id & 31); + for (const id of node.local) setBit(bits, id); for (const callee of node.edges) { if (inComponent.has(callee)) continue; const s = this.summaries.get(callee); if (s === undefined || s === null) continue; - for (let w = 0; w < this.words; w++) bits[w]! |= s[w]!; + orInto(bits, s); } } // An unknown heap write subsumes the precise heap keys. - const summary: Writes = - (bits[0]! & 1) !== 0 ? this.heapAll : bits.some((w) => w !== 0) ? bits : null; + const summary: Writes = (bits[0]! & 1) !== 0 ? this.heapAll : anyBit(bits) ? bits : null; for (const m of members) this.summaries.set(m, summary); } } @@ -787,7 +786,7 @@ export class ReferenceWrites { for (const p of parts) { if (p === null) continue; if (out === null) out = new Uint32Array(this.words); - for (let w = 0; w < this.words; w++) out[w]! |= p[w]!; + orInto(out, p); } return out; } @@ -799,7 +798,7 @@ export class ReferenceWrites { const id = this.keys.get(k); if (id === undefined || id >= this.words * 32) continue; if (out === null) out = new Uint32Array(this.words); - out[id >>> 5]! |= 1 << (id & 31); + setBit(out, id); } return out; } @@ -1080,3 +1079,17 @@ class CallTargets { return defs.has(id); } } + +function setBit(bits: Uint32Array, id: number): void { + const w = id >>> 5; + bits[w] = bits[w]! | (1 << (id & 31)); +} + +function orInto(bits: Uint32Array, other: Uint32Array): void { + for (let w = 0; w < bits.length; w++) bits[w] = bits[w]! | other[w]!; +} + +function anyBit(bits: Uint32Array): boolean { + for (let w = 0; w < bits.length; w++) if (bits[w] !== 0) return true; + return false; +} From 0b744838b42e61780da628be3545d0c76ff5ee6d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 16:05:42 -0700 Subject: [PATCH 5/7] Record TypeScript preflight/order baselines for the reference-write corpus --- .../test/ts7/baselines/order-parity.json | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index bed5497d0..ec4df36f5 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15778,6 +15778,36 @@ "/tests/corpus/4602-constructor-escape-order.ts" ], "diags": [] + }, + "/tests/corpus/4630-reference-write-call-arguments.ts": { + "order": [ + "/tests/corpus/4630-reference-write-call-arguments.ts" + ], + "diags": [] + }, + "/tests/corpus/4631-reference-write-aliases.ts": { + "order": [ + "/tests/corpus/4631-reference-write-aliases.ts" + ], + "diags": [] + }, + "/tests/corpus/4632-reference-write-loops-and-walks.ts": { + "order": [ + "/tests/corpus/4632-reference-write-loops-and-walks.ts" + ], + "diags": [] + }, + "/tests/corpus/4633-reference-write-callbacks-returns.ts": { + "order": [ + "/tests/corpus/4633-reference-write-callbacks-returns.ts" + ], + "diags": [] + }, + "/tests/corpus/4634-optional-element-borrowed-returns.ts": { + "order": [ + "/tests/corpus/4634-optional-element-borrowed-returns.ts" + ], + "diags": [] } } } From f5cf293123d06dee05ed0adef01b5e7f60e88c29 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:16:41 -0700 Subject: [PATCH 6/7] Expect borrowed results and moved returns in the call-lifetime tests Two expectations predate the reference-write analysis and return moves on this branch. `keep(item, items) { items.push(item); return item; }` now qualifies as a borrowed-return function: its write set (array elements) misses the walked parameter, so the body retains only the appended element, returns the parameter +0 while the caller still owns it, and the owned adapter retains the result before releasing its parameters. In `snapshot`, `return saved` moves the saved alias's owned scope entry into the result rather than retaining it again and releasing the local at scope exit, leaving one retain. --- packages/compiler/test/call-lifetimes.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index 19ab0ff5a..646e901c9 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -269,15 +269,22 @@ console.log(work()); expect(facts.borrowed.get("relay")).toEqual(new Set([0, 1])); expect(facts.parameters.has("keep")).toBe(false); const llvm = emitLlvmModule(mod); + // An append only adds an edge, so `keep` preserves every heap edge, borrows + // every reference parameter and returns one of them: it is a borrowed-return + // function. Its body retains only the element stored into the array; the + // returned parameter goes back +0 (the caller still owns it) and the owned + // adapter retains it before releasing its parameters. const keep = body(llvm, "sc_bf_keep"); - expect(keep.match(/@sc_retain_Item/g)).toHaveLength(2); + expect(keep.match(/@sc_retain_Item/g)).toHaveLength(1); expect(keep).not.toContain("@scr_arr_retain_v"); expect(keep).not.toContain("@scr_arr_release"); const relay = body(llvm, "sc_bf_relay"); - expect(relay).toContain("@sc_bf_keep"); + expect(relay).toMatch(/call ptr @sc_bf_keep\([^)]*\) ; borrowed result/); expect(relay).not.toContain("@sc_retain_Item"); expect(relay).not.toContain("@scr_arr_retain_v"); const owned = body(llvm, "sc_f_keep"); + expect(owned.match(/@sc_retain_Item/g)).toHaveLength(1); + expect(owned.indexOf("@sc_retain_Item")).toBeLessThan(owned.indexOf("@sc_release_Item")); expect(owned).toContain("@sc_release_Item"); expect(owned).toContain("@scr_arr_release"); }); @@ -322,11 +329,15 @@ console.log(alias(new Item()).value, snapshot(new Item()).value); // 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. + // the fresh instance into it. The saved alias retains once, and `return + // saved` moves that owned scope entry into the result instead of retaining + // it again and releasing the local at scope exit: the only releases are the + // rebound parameter's owner slot (before the store and at exit). const snapshot = body(llvm, "sc_bf_snapshot"); - expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(2); + expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(1); expect(snapshot).toContain("@sc_bf__x25_Item_constructor"); - expect(snapshot).toContain("@sc_release_Item"); + expect(snapshot.match(/@sc_release_Item/g)).toHaveLength(2); + expect(snapshot).toMatch(/(%t\d+) = load ptr, ptr %sc_l_saved_0\n[^]*ret ptr \1\n/); }); test("unchanged captured parameters are boxed when a closure first needs them", async () => { From 6236fc507f68fb013fcd031449fe59a92787b43b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:16:41 -0700 Subject: [PATCH 7/7] Pin the exception-edge release with a later argument that removes the element The reference-write analysis borrows an argument when neither the callee nor a later argument writes a location it depends on. A later argument that only throws a fresh Error writes no array element, so `read(items[0], fail())` now borrows the element from the borrowed parameter's array and has nothing to release on any edge; the test pins that. The exception-edge release is now exercised by a later argument that pops the array before throwing, which still retains the snapshot and releases it before the exceptional return. --- .../test/string-and-array-lifetimes.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index 4f59d8bbc..183a1a426 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -217,15 +217,39 @@ test("a throwing later source argument releases the retained snapshot on its exc const module = await lower(` class Item { value = 7; } function read(item: Item, ignored: number): number { return item.value + ignored; } +function fail(items: Item[]): number { items.pop(); throw new Error("failed"); } +function work(items: Item[]): number { return read(items[0], fail(items)); } +try { console.log(work([new Item()])); } catch {} +`); + // The later argument removes the element, so the snapshot must own it. + const work = body(emitLlvmModule(module), "sc_bf_work"); + const failure = work.indexOf("@sc_bf_fail"); + expect(failure).toBeGreaterThan(0); + expect(work.slice(0, failure)).toContain("@sc_retain_Item"); + expect(work.slice(failure)).toMatch(PENDING_CHECK); + const exceptionEdge = work.slice(failure, work.indexOf("@sc_bf_read")); + expect(exceptionEdge).toContain("@sc_release_Item"); + expect(work).not.toContain("@scr_union_release"); +}); + +test("a throwing later source argument that writes no element leaves the snapshot borrowed", async () => { + const module = await lower(` +class Item { value = 7; } +function read(item: Item, ignored: number): number { return item.value + ignored; } function fail(): number { throw new Error("failed"); } function work(items: Item[]): number { return read(items[0], fail()); } try { console.log(work([new Item()])); } catch {} `); + // Neither the callee nor the later argument writes an array element (the + // Error constructor's stores into its own fresh object do not count), so + // the borrowed parameter's array keeps the element alive and no edge, + // normal or exceptional, owes a release. const work = body(emitLlvmModule(module), "sc_bf_work"); const failure = work.indexOf("@sc_f_fail"); expect(failure).toBeGreaterThan(0); expect(work.slice(failure)).toMatch(PENDING_CHECK); - expect(work.slice(failure)).toContain("@sc_release_Item"); + expect(work).not.toContain("@sc_retain_Item"); + expect(work).not.toContain("@sc_release_Item"); expect(work).not.toContain("@scr_union_release"); });