diff --git a/packages/compiler/src/backend/llvm/blocks.ts b/packages/compiler/src/backend/llvm/blocks.ts index 01295c549..49c9be2fe 100644 --- a/packages/compiler/src/backend/llvm/blocks.ts +++ b/packages/compiler/src/backend/llvm/blocks.ts @@ -20,6 +20,12 @@ export class BlockBuilder { private tempCounter = 0; private labelCounter = 0; debugLocation: string | null = null; + /** The body is a synchronous IR function: the active exception cell is one + * value from entry to every pending check. Other builders (adapters, + * runtime helpers) keep loading it at each check. */ + excCellInvariant = false; + /** The entry-block load of the active exception cell, once requested. */ + excCell: string | null = null; /** Function epilogue shared by normal and exceptional returns. */ returnEpilogue: string | null = null; /** Temps known to hold immortal values (temp names are only unique diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index 247f809e9..54c0eb602 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -217,6 +217,9 @@ function collectUses(fn: IrFunction, nullableField: NullableFieldTest): Uses { export function analyzeCallLifetimes( functions: ReadonlyMap, nullableField: NullableFieldTest = () => false, + /** Rebound parameters that only ever hold borrowed walk pointers + * (walk-borrows.ts): they keep the borrowed convention although written. */ + walkParameters: ReadonlyMap> = new Map(), ): CallLifetimes { const usesByFunction = new Map(); const lazyCaptures = new LazyCaptures(functions); @@ -255,7 +258,7 @@ export function analyzeCallLifetimes( // any synchronous body; captured cells are boxed and marked invalid. continue; } - const borrowed = new Set(); + const borrowed = new Set(walkParameters.get(fn.name)); // A captured parameter that nothing rebinds keeps its entry value; its // environment box takes a reference of its own when it is created. const unchangedCaptures = lazyCaptures.parameters(fn); diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index cd364f414..614d88fac 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -532,3 +532,22 @@ test("literal zero division emits the JavaScript NaN constant in development bui ); expect(dynamic).toContain("fdiv double"); }); + +test("synchronous bodies load the active exception cell once for all pending checks", () => { + const body = (llvm: string, name: string): string => { + const start = llvm.indexOf(`@${name}(`); + return llvm.slice(start, llvm.indexOf("\n}\n", start)); + }; + const module = moduleFor([call(), call(), call()]); + const work = body(emitLlvmModule(module), "sc_f_work"); + expect(work.match(/= load ptr, ptr @scr_exc_active/g)).toHaveLength(1); + expect(work.slice(work.indexOf("entry:")).split("\n").slice(1).join("\n")).toMatch( + /^(?: %[^\n]* = alloca [^\n]*\n)* %exc\.cell = load ptr, ptr @scr_exc_active/, + ); + expect(work.match(/= load i32, ptr %exc\.cell\b/g)).toHaveLength(3); + // Worker executables fold the context signal into each check and keep + // loading their thread-local cell there. + const workers = body(emitLlvmModule({ ...module, workers: true }), "sc_f_work"); + expect(workers).not.toContain("%exc.cell"); + expect(workers.match(/= load ptr, ptr @scr_exc_active/g)).toHaveLength(3); +}); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4cff15af0..bdda3ff5f 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -157,6 +157,8 @@ 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 { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; import { LlvmDebugInfo } from "./debug-info.js"; import { StackCallbacks } from "./stack-callbacks.js"; @@ -187,7 +189,7 @@ import { emitRecordExpr, } from "./expr-primitives.js"; import { emitControlExpr } from "./expr-control.js"; -import { emitCallExpr } from "./expr-calls.js"; +import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js"; import { emitDynamicExpr } from "./expr-dynamic.js"; import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js"; import { emitJsInteropExpr, emitExpr } from "./expr-dispatch.js"; @@ -588,6 +590,18 @@ export class LlEmitter { /** Parameters proven projection-only: callers may pass stack boxes. */ private projectedParameters = new Set(); private stableCallBindings: ReadonlySet = new Set(); + /** Locals and rebound parameters holding borrowed pointers for their + * whole lifetime (walk-borrows.ts), per function and for the current one. */ + private readonly walkBorrowsByFunction = new Map>(); + private walkBorrows: ReadonlySet = new Set(); + /** Functions whose borrowing body returns its result without a reference + * (analyzeBorrowedReturns), and whether the current body is one. */ + readonly borrowedReturns = new Set(); + /** Rebound parameters that keep the borrowed convention with an owner + * slot (rebound-parameters.ts), per function, and the current owner slots. */ + private readonly reboundByFunction = new Map>(); + private readonly reboundOwnerSlots = new Map(); + private currentBorrowedReturn = false; /** Manifest-bound native imports, used by ffiCall emission. */ readonly ffiByName = new Map(); /** C-ABI callback trampolines and (for raw/no-userdata callbacks) their @@ -828,10 +842,51 @@ export class LlEmitter { this.referenceEffects = new ReferenceEffects( this.fnByName, (call) => this.optionalArrayReads.get(call) !== null, + mod.classes ?? [], ); + const walkParameters = new Map>(); + if (this.debug === null) { + const host = { + pointerLocal: (local: IrLocal) => this.plainReference(local.type), + borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), + }; + for (const fn of this.fnByName.values()) { + if (!this.referenceEffects.functions.has(fn.name)) continue; + const walks = findWalkBorrows(fn, host); + if (walks.size === 0) continue; + this.walkBorrowsByFunction.set(fn.name, walks); + const indexes = new Set(); + fn.params.forEach((param, index) => { + if (walks.has(param.localId)) indexes.add(index); + }); + if (indexes.size > 0) walkParameters.set(fn.name, indexes); + } + } + if (this.debug === null) { + for (const fn of this.fnByName.values()) { + // Strings keep the owned parameter: their in-place append + // (`s = s + x`) needs a uniquely owned binding. + const rebound = findReboundParameters( + fn, + (type) => + this.plainReference(type) && + type.kind !== "string" && + (type.kind !== "union" || this.nullableUnions.get(type.unionId)?.arm.kind !== "string"), + this.walkBorrowsByFunction.get(fn.name) ?? new Set(), + ); + if (rebound.size === 0) continue; + this.reboundByFunction.set(fn.name, rebound); + const indexes = walkParameters.get(fn.name) ?? new Set(); + fn.params.forEach((param, index) => { + if (rebound.has(param.localId)) indexes.add(index); + }); + walkParameters.set(fn.name, indexes); + } + } this.callLifetimes = analyzeCallLifetimes( this.fnByName, (className, field) => this.nullableFields.get(className, field) !== null, + walkParameters, ); this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes); this.stackCallbacks = new StackCallbacks(this.fnByName); @@ -944,6 +999,8 @@ export class LlEmitter { this.fnByName, this.callLifetimes.borrowed, ); + // After the vtable slots: borrowing slots exclude their implementations. + if (this.debug === null) this.analyzeBorrowedReturns(); } /** The borrowed parameters of a hierarchy's vtable slot (empty: owned). */ @@ -3796,9 +3853,21 @@ export class LlEmitter { const tl = this.mod.workers === true ? "thread_local " : ""; this.declare(`@scr_exc_active = external ${tl}global ptr`); this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); - const cell = B.tmp(); + let cell: string; + if (B.excCellInvariant && this.mod.workers !== true) { + // Fiber switches restore the active cell before control returns to a + // synchronous frame, so its pointer is one value for the whole call: + // load it once in the entry block and test only the kind here. + if (B.excCell === null) { + B.excCell = "%exc.cell"; + B.entryAllocas.push(`${B.excCell} = load ptr, ptr @scr_exc_active`); + } + cell = B.excCell; + } else { + cell = B.tmp(); + B.line(`${cell} = load ptr, ptr @scr_exc_active`); + } const kind = B.tmp(); - B.line(`${cell} = load ptr, ptr @scr_exc_active`); B.line(`${kind} = load i32, ptr ${cell}`); let word = kind; if (this.mod.workers === true) { @@ -4766,6 +4835,9 @@ export class LlEmitter { private emitFunction(fn: IrFunction): string { const B = new BlockBuilder(); this.B = B; + // Fiber switches restore the active exception cell before control + // returns to a synchronous frame (emitPendingCheck). + B.excCellInvariant = fn.async !== true && fn.generator === undefined; this.debugScope = this.debug?.function(fn) ?? null; B.debugLocation = this.debug?.location(fn.loc, this.debugScope) ?? null; this.frames = []; @@ -4798,6 +4870,7 @@ export class LlEmitter { const numericFn = withInitializerBindings(fn, initializerBindings); this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l])); this.borrowedParameters.clear(); + this.reboundOwnerSlots.clear(); this.projectedParameters.clear(); const projectedParameterIndexes = this.callLifetimes.parameters.get(fn.name); if (projectedParameterIndexes) @@ -4806,8 +4879,14 @@ export class LlEmitter { this.stableCallBindings = this.callLifetimes.bindings.get(fn.name) ?? new Set(); const borrowedParameterIndexes = this.callLifetimes.borrowed.get(fn.name); if (borrowedParameterIndexes) { + // Rebound walk parameters borrow too, but only unchanged ones may + // serve as stable owners for call arguments and aliases. for (const index of borrowedParameterIndexes) - this.borrowedParameters.add(fn.params[index]!.localId); + if ( + !this.walkBorrowsByFunction.get(fn.name)?.has(fn.params[index]!.localId) && + !this.reboundByFunction.get(fn.name)?.has(fn.params[index]!.localId) + ) + this.borrowedParameters.add(fn.params[index]!.localId); } this.captureIds = new Set( [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), @@ -4857,6 +4936,7 @@ export class LlEmitter { (e) => this.nullableFieldGet(e) !== null, ); this.localUnionStorage = new Map(); + this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); this.integerRanges = analyzeIntegerRanges(numericFn, this.int32Slots.facts(fn.name)); this.bytesBounds = findBytesBounds(numericFn, this.integerRanges); this.chainSlots.clear(); @@ -4864,6 +4944,7 @@ export class LlEmitter { this.tryStack = []; this.currentReturnType = fn.returnType; this.currentGenerator = fn.generator ?? null; + this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); this.currentWasiCoro = null; this.logArgSlots = 0; @@ -5007,6 +5088,15 @@ export class LlEmitter { B.line(`store ${this.llType(p.type)} %p_${mangleLocal(p.localId)}, ptr ${slot}`); this.storeIntegerView(p.localId, { name: `%p_${mangleLocal(p.localId)}`, type: p.type }); if (isRefCounted(p.type) && !borrowed?.has(index)) fnScope.push({ slot, type: p.type }); + if (this.reboundByFunction.get(fn.name)?.has(p.localId) && borrowed?.has(index)) { + // The caller's borrow stays in the parameter slot; values the body + // assigns are owned here until rebinding or exit releases them. + const owner = `${slot}.owner`; + B.entryAllocas.push(`${owner} = alloca ptr`); + B.line(`store ptr null, ptr ${owner}`); + fnScope.push({ slot: owner, type: p.type }); + this.reboundOwnerSlots.set(p.localId, owner); + } } this.scopes.push(fnScope); // Stackful native fibers retain these frames while suspended. The @@ -5117,9 +5207,29 @@ export class LlEmitter { const ret = this.llType(fn.returnType); const call = `call ${ret} @${mangleBorrowedFunction(fn.name)}(${params.join(", ")})`; B.line(ret === "void" ? call : `%result = ${call}`); + // A borrowed result is reachable from the parameters: own it first. A + // throwing body returns a null dummy, which needs no owner (and string + // or array retains do not accept NULL). + let result = "%result"; + if (this.borrowedReturns.has(fn.name)) { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + B.line(`store ptr null, ptr ${slot}`); + const present = B.tmp(); + const own = B.newLabel("result.own"); + const done = B.newLabel("result.done"); + B.line(`${present} = icmp ne ptr %result, null`); + B.condBr(present, own, done); + B.startBlock(own); + B.line(`store ptr ${this.retainValue("%result", fn.returnType)}, ptr ${slot}`); + B.br(done); + B.startBlock(done); + result = B.tmp(); + B.line(`${result} = load ptr, ptr ${slot}`); + } for (const index of borrowed) if (!kept.has(index)) this.releaseValue(`%p${index}`, fn.params[index]!.type); - B.terminate(ret === "void" ? "ret void" : `ret ${ret} %result`); + B.terminate(ret === "void" ? "ret void" : `ret ${ret} ${result}`); const symbol = kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name); return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`; @@ -5141,6 +5251,24 @@ export class LlEmitter { const guarded = guardedValue(value); return guarded !== null && this.canBorrowCallArgument(guarded); } + // A nullable-union wrap reinterprets its payload pointer; an immortal + // unit constant needs no owner at all. + const wrap = this.borrowableNullableWrap(value); + if (wrap === "unit") return true; + if (wrap === "ref" && value.kind === "unionWrap") + return this.canBorrowCallArgument(value.value); + // Checked casts (`x as C`, `x!` on a nullable) lower to a ternary whose + // failing arm always throws; the successful arm projects the same + // stable owner. The throwing arm's typed dummy owns nothing. + if (value.kind === "unionNarrow") return this.canBorrowCallArgument(value.value); + if (value.kind === "libCall") return value.fn === "error.nodeThrow"; + if (value.kind === "ternary") + return ( + this.canBorrowReceiver(value.then) && + this.canBorrowReceiver(value.else_) && + this.canBorrowCallArgument(value.then) && + this.canBorrowCallArgument(value.else_) + ); if (value.kind !== "varRef") return false; const binding = this.binding(value.localId); return ( @@ -5198,6 +5326,12 @@ export class LlEmitter { switch (s.kind) { case "varDecl": { const b = this.binding(s.localId); + if (this.walkBorrows.has(s.localId) && b.kind === "local") { + // A borrowed walk pointer: no retain now, no release at scope exit. + const v = s.init === null ? "null" : this.emitReadReceiver(s.init).name; + B.line(`store ptr ${v}, ptr ${b.slot}`); + break; + } const slice = this.scalarStringSlices.get(s.localId); if (slice) { const snapshot = emitStringSliceSnapshot(this, slice); @@ -5329,6 +5463,23 @@ export class LlEmitter { break; } case "assign": { + const owner = this.reboundOwnerSlots.get(s.localId); + if (owner !== undefined && !this.walkBorrows.has(s.localId)) { + // A rebound borrowed parameter: the new value moves into the owner + // slot, replacing (and releasing) the previous owned value. This + // precedes the in-place string append, which would release the + // caller's borrow. + const param = this.binding(s.localId); + const slot = param.slot; + const v = this.emitExpr(s.value); + this.moveTemp(v); + const old = B.tmp(); + B.line(`${old} = load ptr, ptr ${owner}`); + this.releaseValue(old, param.type); + B.line(`store ptr ${v.name}, ptr ${slot}`); + B.line(`store ptr ${v.name}, ptr ${owner}`); + break; + } const localStorage = this.localUnionStorage.get(s.localId); if (localStorage) { storeLocalUnion(this, localStorage, s.value); @@ -5341,6 +5492,11 @@ export class LlEmitter { break; } const b = this.binding(s.localId); + if (this.walkBorrows.has(s.localId) && b.kind === "local") { + // Rebinding a borrowed walk pointer releases nothing. + B.line(`store ptr ${this.emitReadReceiver(s.value).name}, ptr ${b.slot}`); + break; + } const v = this.emitExpr(s.value); if (b.kind === "global" && !s.initializes) this.checkGlobalTdz(s.localId); if (b.kind === "boxed") { @@ -6126,7 +6282,11 @@ export class LlEmitter { // the actual ret. The parked value owns a synthetic slot-backed // scope entry during each copy so a throwing finally releases it. let v: LlValue | null = null; - if (s.value !== null) { + if (s.value !== null && this.currentBorrowedReturn) { + // A borrowed-return body hands back its walk without a reference + // (it has no try statements, so no finally can intervene). + v = this.emitReadReceiver(s.value); + } else if (s.value !== null) { v = this.emitExpr(s.value); this.moveTemp(v); } @@ -6529,12 +6689,32 @@ export class LlEmitter { * sequence's result only reads an unchanged local (a derived * constructor's `super()` evaluates to `this`), its statements still run * in place, but the result is neither retained nor released. */ + /** Whether a discarded value can be skipped entirely: a borrowable read + * with no effect of its own. canBorrowCallArgument also accepts checked + * projections (a ternary or narrow whose failing arm throws, and the + * throwing call itself) because a borrowed use still evaluates them; + * skipping those would drop the throw. */ + private discardableRead(value: IrExpr): boolean { + if (value.kind === "libCall" || value.kind === "ternary" || value.kind === "unionNarrow") + return false; + if ( + (value.kind === "upcast" || value.kind === "downcast" || value.kind === "unionWrap") && + !this.discardableRead(value.value) + ) + return false; + if (value.kind === "seqExpr") { + const guarded = guardedValue(value); + if (guarded === null || !this.discardableRead(guarded)) return false; + } + return this.canBorrowCallArgument(value); + } + private emitDiscarded(e: IrExpr): void { if ( e.kind === "seqExpr" && isRefCounted(e.result.type) && e.result.kind !== "strLit" && - this.canBorrowCallArgument(e.result) + this.discardableRead(e.result) ) { this.emitSequence(() => { for (const s of e.stmts) this.emitStmt(s); @@ -6815,6 +6995,21 @@ export class LlEmitter { this.B.line(`${value} = load ptr, ptr ${binding.slot}`); return { name: value, type: e.type }; } + if (e.kind === "arrayGet" && this.canBorrowReceiver(e)) { + // The array keeps the element alive until the consumer; a missing + // element still traps exactly like the owned read. + const slot = this.B.slot(); + this.B.entryAllocas.push(`${slot} = alloca ptr`); + emitBorrowedArrayRead(this, e, slot); + const value = this.B.tmp(); + this.B.line(`${value} = load ptr, ptr ${slot}`); + return { name: value, type: e.type }; + } + const wrap = this.borrowableNullableWrap(e); + if (wrap === "unit" && e.kind === "unionWrap") + return { name: this.unitInstanceRef(e.unionId, e.tag), type: e.type }; + if (wrap === "ref" && e.kind === "unionWrap") + return { name: this.emitReadReceiver(e.value).name, type: e.type }; const read = matchMapRead(e, this.boxedUnionsById); if (read) { const result = emitStackMapRead(this, read); @@ -6875,6 +7070,7 @@ export class LlEmitter { B.line(`${value} = load ptr, ptr ${slot}`); return { name: value, type: e.type }; } + if (e.kind === "call" && this.canBorrowReceiver(e)) return emitBorrowedResultCall(this, e); return this.emitExpr(e); } @@ -7079,11 +7275,156 @@ export class LlEmitter { // typed dummy is null and owns no receiver. case "libCall": return e.fn === "error.nodeThrow"; + case "unionWrap": { + const wrap = this.borrowableNullableWrap(e); + return wrap === "unit" || (wrap === "ref" && this.canBorrowReceiver(e.value)); + } + // A borrowed-return callee preserves edges, so its result stays + // reachable from borrowable arguments until the consumer runs. + case "call": + return ( + this.borrowedReturns.has(e.callee) && + e.args.every( + (arg) => + (!isRefCounted(arg.type) || this.canBorrowReceiver(arg)) && + this.referenceEffects.preserves(arg), + ) + ); + // A required element read: the (borrowed) array owns the element + // while an edge-preserving index computes. + case "arrayGet": + return ( + e.arr.type.kind === "array" && + isRefCounted(e.arr.type.elem) && + this.canBorrowReceiver(e.arr) && + this.referenceEffects.preserves(e.index) + ); + default: + return false; + } + } + + /** A reference held as one plain pointer that emitReadReceiver can read + * without owning: class instances, records, arrays, strings and + * nullable-pointer unions. Tagged union boxes, closures and dynamic + * values keep ordinary ownership. */ + plainReference(type: IrType): boolean { + switch (type.kind) { + case "object": + case "record": + case "array": + case "string": + return true; + case "union": + return this.nullableUnions.has(type.unionId); + default: + return false; + } + } + + /** Functions returning a walk of their borrowed parameters return it + * without a reference (+0) from their borrowing body; direct callers + * either consume it as a receiver/walk or retain it at once, and the owned + * adapter retains before releasing its parameters. Candidates preserve + * every heap edge (so the result stays reachable from the arguments), + * borrow every reference parameter and return plain pointers. The set + * grows to a fixpoint because a call to a borrowed-return function with + * walk arguments is itself a walk; walk facts are then recomputed with + * the final set, admitting only parameters whose convention borrows. */ + private analyzeBorrowedReturns(): void { + const pointer = (type: IrType): boolean => this.plainReference(type); + const borrowedIds = (fn: IrFunction): Set => { + const indexes = this.callLifetimes.borrowed.get(fn.name); + return new Set(fn.params.filter((_, i) => indexes?.has(i)).map((p) => p.localId)); + }; + const hostFor = (fn: IrFunction) => { + const allowed = borrowedIds(fn); + return { + pointerLocal: (local: IrLocal) => pointer(local.type), + borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), + borrowedReturn: (callee: string) => this.borrowedReturns.has(callee), + parameterAllowed: (id: string) => allowed.has(id), + }; + }; + const preserving = [...this.fnByName.values()].filter((fn) => + this.referenceEffects.functions.has(fn.name), + ); + const candidates = preserving.filter((fn) => { + if (!pointer(fn.returnType) || fn.async || fn.generator || fn.captures) return false; + // A borrowing vtable slot dispatches to the borrowing body (or its + // virtual adapter) directly, and virtual callers own the result. + if (this.implSlotBorrowed(fn.name).size > 0) return false; + const indexes = this.callLifetimes.borrowed.get(fn.name); + return ( + indexes !== undefined && + indexes.size > 0 && + fn.params.every((p, i) => !isRefCounted(p.type) || indexes.has(i)) + ); + }); + for (let changed = true; changed;) { + changed = false; + for (const fn of candidates) { + if (this.borrowedReturns.has(fn.name)) continue; + if (!analyzeWalks(fn, hostFor(fn)).returnsWalk) continue; + this.borrowedReturns.add(fn.name); + changed = true; + } + } + if (this.borrowedReturns.size === 0) return; + for (const fn of preserving) { + const walks = analyzeWalks(fn, hostFor(fn)).locals; + if (walks.size > 0) this.walkBorrowsByFunction.set(fn.name, walks); + } + } + + /** emitReadReceiver produces this node's pointer without acquiring a + * reference, given operands (and ternary arms) that do the same: plain + * class/record field reads, class casts, nullable-pointer narrows and + * wraps, and checked ternaries. Stack-boxed, map-read and boxed-field + * sources are owned. Depends only on module facts, not the current + * function. */ + borrowsWithoutOwning(e: IrExpr): boolean { + if (!isRefCounted(e.type) || this.isStackUnionSource(e)) return false; + if (matchMapRead(e, this.boxedUnionsById)) return false; + switch (e.kind) { + case "fieldGet": + return this.nullableFieldGet(e) === null; + case "recordGet": + return true; + case "unionNarrow": + return this.nullableUnions.has(e.unionId); + case "downcast": + case "upcast": + return e.value.type.kind === "object"; + case "unionWrap": + return this.borrowableNullableWrap(e) !== null; + // The caller proves each arm separately. + case "ternary": + return true; + // The caller proves the arguments are walks; their evaluation must not + // remove an edge before the call reads them. + case "call": + return ( + this.borrowedReturns.has(e.callee) && + e.args.every((arg) => this.referenceEffects.preserves(arg)) + ); default: return false; } } + /** A wrap into a nullable-pointer union is the payload pointer itself + * (the reference arm) or an immortal constant (a unit arm), so it can + * borrow exactly when its payload can. Void payloads run for effects and + * keep the ordinary path; tagged unions still construct a box. */ + borrowableNullableWrap(e: IrExpr): "unit" | "ref" | null { + if (e.kind !== "unionWrap") return null; + const nullable = this.nullableUnions.get(e.unionId); + if (!nullable) return null; + if (isUnitType(e.value.type)) return e.value.kind === "unitLit" ? "unit" : null; + return e.tag === nullable.refTag && isRefCounted(e.value.type) ? "ref" : null; + } + materializeSplitLocal(localId: string): void { const span = this.splitSpans.get(localId); if (span) materializeSplitPiece(this, localId, span); diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 562ea4956..d707de555 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -110,8 +110,17 @@ export function emitCallExpr( } const t = B.tmp(); B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`); - const out = host.own({ name: t, type: e.type }); - if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + let out: LlValue; + if (borrowed && host.borrowedReturns.has(e.callee)) { + // A borrowed-return body hands back +0 (a null dummy when it threw): + // test the exception first, then own the result before the + // argument snapshots that keep it reachable are released. + if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + out = host.own({ name: host.retainValue(t, e.type), type: e.type }); + } else { + out = host.own({ name: t, type: e.type }); + if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out); + } if (borrowed) { host.moveTemp(out); host.releaseFrame(host.frames.pop()!); @@ -990,3 +999,23 @@ export function emitCallExpr( } } } + +/** A direct call to a borrowed-return function whose result a receiver or + * walk consumes without owning it (LlEmitter.canBorrowReceiver proved every + * reference argument borrowable and every argument edge-preserving). The + * result stays reachable from the borrowed arguments until the consumer. */ +export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call">): LlValue { + const callee = host.fnByName.get(e.callee); + if (!callee || !host.borrowedReturns.has(e.callee)) + throw new InternalCompilerError(`llvm emitter bug: borrowed result of ${e.callee}`); + const args = e.args.map((arg) => + isRefCounted(arg.type) ? host.emitReadReceiver(arg) : host.emitExpr(arg), + ); + const argList = args.map((a, i) => `${host.llType(callee.params[i]!.type)} ${a.name}`).join(", "); + const t = host.B.tmp(); + host.B.line( + `${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}(${argList}) ; borrowed result`, + ); + if (host.mayThrow.has(e.callee)) host.emitPendingCheck(); + return { name: t, type: e.type }; +} diff --git a/packages/compiler/src/backend/llvm/local-array-reads.test.ts b/packages/compiler/src/backend/llvm/local-array-reads.test.ts index 67a37ce56..31b2717b3 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.test.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.test.ts @@ -24,7 +24,8 @@ import { import { analyzeCallLifetimes } from "./call-lifetimes.js"; /** A pending-exception check: the inline active-cell test or a runtime call. */ -const PENDING_CHECK = /@scr_exc_(?:active|pending)\b/; +// Synchronous bodies test the kind of the entry-loaded exception cell. +const PENDING_CHECK = /@scr_exc_(?:active|pending)\b| = load i32, ptr %exc\.cell\b/; const loc = { file: "local-array.ts", start: 0, end: 0 }; const element: IrType = { kind: "record", shapeId: "cell" }; diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 1e3f185a3..6daeb1cd0 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -165,8 +165,19 @@ export class OptionalArrayReads { * that the parameter holding the array is never replaced or captured. */ function stableArrayParameters(fn: IrFunction, lifetimes: CallLifetimes): Set { const borrowed = lifetimes.borrowed.get(fn.name); + // Borrowed walk parameters may be rebound by statements (walk-borrows.ts). + const rebound = new Set(); + everyStmtList(fn.body, { + stmt: (stmt) => { + if (stmt.kind === "assign") rebound.add(stmt.localId); + return true; + }, + expr: () => true, + }); return new Set( - fn.params.filter((_, index) => borrowed?.has(index)).map((param) => param.localId), + fn.params + .filter((param, index) => borrowed?.has(index) && !rebound.has(param.localId)) + .map((param) => param.localId), ); } diff --git a/packages/compiler/src/backend/llvm/rebound-parameters.test.ts b/packages/compiler/src/backend/llvm/rebound-parameters.test.ts new file mode 100644 index 000000000..c2783e91e --- /dev/null +++ b/packages/compiler/src/backend/llvm/rebound-parameters.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "vitest"; +import { F64, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { findReboundParameters } from "./rebound-parameters.js"; + +const loc = { file: "rebound.ts", start: 0, end: 0 }; +const NODE: IrType = { kind: "object", className: "Node" }; +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: NODE, loc }); +const assign = (localId: string, value: IrExpr): IrStmt => ({ + kind: "assign", + localId, + value, + loc, +}); + +function fn(body: IrStmt[], params: { id: string; type?: IrType }[]): IrFunction { + return { + name: "work", + params: params.map((p) => ({ localId: p.id, name: p.id, type: p.type ?? NODE })), + locals: params.map((p) => ({ id: p.id, name: p.id, type: p.type ?? NODE, mutable: true })), + body, + returnType: F64, + loc, + }; +} + +const plain = (type: IrType): boolean => type.kind === "object"; + +test("statement rebinding of reference parameters keeps the borrowed convention", () => { + const body = [ + assign("a", ref("b")), + { kind: "if", cond: ref("a"), then: [assign("b", ref("a"))], else_: null, loc } as IrStmt, + ]; + const f = fn(body, [{ id: "a" }, { id: "b" }, { id: "untouched" }]); + expect(findReboundParameters(f, plain, new Set())).toEqual(new Set(["a", "b"])); + // Parameters already borrowed as walks are left to the walk analysis. + expect(findReboundParameters(f, plain, new Set(["a"]))).toEqual(new Set(["b"])); +}); + +test("expression writes, scalar types, captures and suspension keep the owned parameter", () => { + const sequence: IrExpr = { + kind: "seqExpr", + stmts: [assign("a", ref("b"))], + result: { kind: "numLit", value: 0, type: F64, loc }, + type: F64, + loc, + }; + const inExpression = fn([{ kind: "exprStmt", expr: sequence, loc }], [{ id: "a" }, { id: "b" }]); + expect(findReboundParameters(inExpression, plain, new Set()).size).toBe(0); + const scalar = fn( + [assign("n", { kind: "numLit", value: 1, type: F64, loc })], + [{ id: "n", type: F64 }], + ); + expect(findReboundParameters(scalar, plain, new Set()).size).toBe(0); + const captured = fn( + [ + assign("a", ref("b")), + { + kind: "exprStmt", + expr: { + kind: "closure", + name: "inner", + captures: ["a"], + type: F64, + loc, + } as unknown as IrExpr, + loc, + }, + ], + [{ id: "a" }, { id: "b" }], + ); + expect(findReboundParameters(captured, plain, new Set()).size).toBe(0); + const suspending: IrFunction = { + ...fn([assign("a", ref("b"))], [{ id: "a" }, { id: "b" }]), + async: true, + }; + expect(findReboundParameters(suspending, plain, new Set()).size).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/rebound-parameters.ts b/packages/compiler/src/backend/llvm/rebound-parameters.ts new file mode 100644 index 000000000..b239195dd --- /dev/null +++ b/packages/compiler/src/backend/llvm/rebound-parameters.ts @@ -0,0 +1,72 @@ +import type { IrExpr, IrFunction, IrStmt, IrType } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/** Parameters that keep the borrowed calling convention although the body + * rebinds them (`source = (source as LiteralType).regularType`). The + * incoming value stays the caller's borrow; every value the body assigns is + * owned through a separate owner slot, which each rebinding releases and + * replaces and which the function scope releases on every exit. Reads see + * the parameter slot, so whole-value uses retain as for any binding. + * + * Only plain statement assignments qualify: expression-position writes, + * loop and catch bindings, declarations and captures keep the ordinary + * owned parameter. Suspending bodies and environments use the owned ABI. */ +export function findReboundParameters( + fn: IrFunction, + plainReference: (type: IrType) => boolean, + excluded: ReadonlySet, +): ReadonlySet { + const result = new Set(); + if (fn.async || fn.generator || fn.captures || fn.classCaptures) return result; + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const candidates = new Set( + fn.params + .filter((param) => { + const local = locals.get(param.localId); + return ( + local !== undefined && + !local.boxed && + !local.tdz && + !excluded.has(param.localId) && + plainReference(param.type) + ); + }) + .map((param) => param.localId), + ); + if (candidates.size === 0) return result; + const rebound = new Set(); + const invalid = new Set(); + const expr = (e: IrExpr): boolean => { + switch (e.kind) { + case "assignExpr": + case "incDec": + invalid.add(e.localId); + break; + case "closure": + case "classRef": + for (const id of e.captures ?? []) invalid.add(id); + break; + } + return everyExprChild(e, expr, (s) => stmt(s, true)); + }; + const stmt = (s: IrStmt, inExpression: boolean): boolean => { + switch (s.kind) { + case "assign": + if (inExpression) invalid.add(s.localId); + else rebound.add(s.localId); + break; + case "varDecl": + case "forOf": + case "rethrow": + invalid.add(s.localId); + break; + case "tryCatch": + if (s.catchLocalId !== null) invalid.add(s.catchLocalId); + break; + } + return everyStmtChild(s, expr, (child) => stmt(child, inExpression)); + }; + for (const s of fn.body) stmt(s, false); + for (const id of candidates) if (rebound.has(id) && !invalid.has(id)) result.add(id); + return result; +} diff --git a/packages/compiler/src/backend/llvm/reference-effects.test.ts b/packages/compiler/src/backend/llvm/reference-effects.test.ts index af7dfd79f..ac8b60ca2 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.test.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.test.ts @@ -5,11 +5,12 @@ import { F64, STRING, VOID, + type IrClassDef, type IrExpr, type IrFunction, type IrStmt, } from "../../ir/ir.js"; -import { ReferenceEffects, preservesRegexInputs } from "./reference-effects.js"; +import { ReferenceEffects, VirtualTargets, preservesRegexInputs } from "./reference-effects.js"; const loc = { file: "effects.ts", start: 0, end: 0 }; const number: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; @@ -195,3 +196,42 @@ test("nullish fallbacks and Math calls preserve references; their operands still expect(summary.preserves(fallback(write))).toBe(false); expect(summary.preserves(fallback(call("read")))).toBe(true); }); + +test("virtual calls preserve references only when every reachable override does", () => { + const cls = (name: string, methods: string[], base?: string, extra?: Partial) => + ({ name, base, fields: [], methods, loc, ...extra }) as IrClassDef; + const classes = [ + cls("A", ["run", "size"], undefined, { abstractMethods: ["size"] }), + cls("B", ["size"], "A"), + cls("C", ["run"], "B"), + cls("D", [], "A"), + ]; + const targets = new VirtualTargets(classes); + expect(targets.targets("A", "run")).toEqual(["%A.run", "%C.run"]); + // B inherits A.run; only C overrides below it. Abstract slots have no body. + expect(targets.targets("B", "run")).toEqual(["%A.run", "%C.run"]); + expect(targets.targets("A", "size")).toEqual(["%B.size"]); + expect(targets.targets("D", "run")).toEqual(["%A.run"]); + expect( + new VirtualTargets([...classes, cls("E", ["run"], "D", { runtime: true })]).targets("A", "run"), + ).toBe(null); + const virtual: IrExpr = { + kind: "virtualCall", + className: "A", + method: "run", + args: [], + type: F64, + loc, + }; + const build = (mutating: boolean) => { + const functions = [fn("%A.run"), fn("%C.run"), fn("caller", [virtual])]; + if (mutating) functions[1]!.body.push({ kind: "assign", localId: "owner", value: text, loc }); + return new ReferenceEffects(new Map(functions.map((f) => [f.name, f])), () => false, classes); + }; + expect(build(false).preserves(virtual)).toBe(true); + expect(build(false).functions.has("caller")).toBe(true); + expect(build(true).preserves(virtual)).toBe(false); + expect(build(true).functions.has("caller")).toBe(false); + // Without class facts a virtual call stays a barrier. + expect(effects([fn("%A.run")]).preserves(virtual)).toBe(false); +}); diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index e820a033f..a1e7dcb11 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -1,12 +1,19 @@ import { preservesDynTest } from "./checked-value-lifetimes.js"; import { isStableReceiverOperand } from "../../ir/analysis.js"; -import { isRefCounted, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js"; +import { + isRefCounted, + type IrClassDef, + type IrExpr, + type IrFunction, + type IrStmt, +} from "../../ir/ir.js"; import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; import { borrowsMapReadInputs } from "./map-read-lifetimes.js"; import { byteNumberAccess } from "../../ir/byte-numbers.js"; type Call = IrExpr & { kind: "call" }; +type VirtualCall = IrExpr & { kind: "virtualCall" }; /** These native operations may update lastIndex and allocate results, but * never invoke user code or remove an existing reference edge. Results own @@ -35,6 +42,7 @@ function expressionPreservesEdges( e: IrExpr, call: (value: Call) => boolean, privateLocals?: ReadonlySet, + virtualCall?: (value: VirtualCall) => boolean, ): boolean { switch (e.kind) { case "numLit": @@ -70,7 +78,18 @@ function expressionPreservesEdges( case "caughtTest": case "caughtNarrow": case "caughtCheck": + // An immortal class object, or a fresh one retaining captured boxes. + case "classRef": return true; + // Formatting a primitive allocates a string and runs no user code. + case "toString": + return ( + e.operand.type.kind === "f64" || + e.operand.type.kind === "bool" || + e.operand.type.kind === "string" + ); + case "virtualCall": + return virtualCall?.(e) ?? false; case "dynTest": return preservesDynTest(e.test); case "assignExpr": @@ -115,12 +134,15 @@ function statementPreservesEdges(s: IrStmt, privateLocals?: ReadonlySet) case "exprStmt": case "return": case "throw": + // Throws a fresh Error with a constant message (an unreachable trap). + case "runtimeFence": case "if": case "for": case "forOf": case "while": case "doWhile": case "block": + case "switch": case "break": case "continue": case "bytesSet": @@ -135,6 +157,61 @@ function statementPreservesEdges(s: IrStmt, privateLocals?: ReadonlySet) } } +/** The module functions a virtual call can reach: the implementation the + * static class inherits or declares, plus every override in its subtree. + * Runtime classes keep their dispatch out of view, so they answer null. */ +export class VirtualTargets { + private readonly classes = new Map(); + private readonly children = new Map(); + private readonly cache = new Map(); + + constructor(classes: readonly IrClassDef[]) { + for (const cls of classes) this.classes.set(cls.name, cls); + for (const cls of classes) { + if (cls.base === undefined) continue; + let list = this.children.get(cls.base); + if (!list) this.children.set(cls.base, (list = [])); + list.push(cls); + } + } + + targets(className: string, method: string): readonly string[] | null { + const key = `${className}\0${method}`; + const known = this.cache.get(key); + if (known !== undefined) return known; + const result = this.compute(className, method); + this.cache.set(key, result); + return result; + } + + private compute(className: string, method: string): readonly string[] | null { + const declares = (cls: IrClassDef): "concrete" | "abstract" | null => + cls.methods?.includes(method) !== true + ? null + : cls.abstractMethods?.includes(method) === true + ? "abstract" + : "concrete"; + const targets = new Set(); + // The nearest declaration on the static class or its ancestors. + for (let name: string | undefined = className; name !== undefined;) { + const cls = this.classes.get(name); + if (!cls || cls.runtime) return null; + const declared = declares(cls); + if (declared === "concrete") targets.add(`%${cls.name}.${method}`); + if (declared !== null) break; + name = cls.base; + } + const pending = [...(this.children.get(className) ?? [])]; + while (pending.length > 0) { + const cls = pending.pop()!; + if (cls.runtime) return null; + if (declares(cls) === "concrete") targets.add(`%${cls.name}.${method}`); + pending.push(...(this.children.get(cls.name) ?? [])); + } + return [...targets]; + } +} + /** Reference preservation is weaker than purity: scalar writes, allocation * and throwing are allowed. Caller owners and their reference edges must * survive until the consuming operation; callee-local rebinding is private. @@ -146,12 +223,23 @@ export class ReferenceEffects { readonly functions = new Set(); private readonly expressions = new Map(); + private readonly virtualTargets: VirtualTargets | null; + constructor( functions: ReadonlyMap, private readonly intrinsicCall: (call: Call) => boolean, + classes?: readonly IrClassDef[], ) { + this.virtualTargets = classes ? new VirtualTargets(classes) : null; const callers = new Map>(); const unsafe: string[] = []; + const dependOn = (callee: string, caller: string): boolean => { + if (!functions.has(callee)) return false; + let incoming = callers.get(callee); + if (!incoming) callers.set(callee, (incoming = new Set())); + incoming.add(caller); + return true; + }; for (const fn of functions.values()) { // Rebinding a callee's unboxed local cannot replace the caller's // owner. The same write in a later caller operand still must reject @@ -169,15 +257,14 @@ export class ReferenceEffects { expr: (e) => expressionPreservesEdges( e, - (call) => { - if (intrinsicCall(call)) return true; - if (!functions.has(call.callee)) return false; - let incoming = callers.get(call.callee); - if (!incoming) callers.set(call.callee, (incoming = new Set())); - incoming.add(fn.name); - return true; - }, + (call) => intrinsicCall(call) || dependOn(call.callee, fn.name), privateLocals, + // Every reachable implementation must preserve edges; the + // dispatch itself passes owned arguments that callees release. + (call) => + this.virtualTargets + ?.targets(call.className, call.method) + ?.every((target) => dependOn(target, fn.name)) ?? false, ), }); if (safe) this.functions.add(fn.name); @@ -202,11 +289,21 @@ export class ReferenceEffects { expressionPreservesEdges( value, (call) => this.intrinsicCall(call) || this.functions.has(call.callee), + undefined, + (call) => this.virtualPreserves(call), ) && everyExprChild(value, expr, stmt); this.expressions.set(value, result); return result; } + virtualPreserves(call: VirtualCall): boolean { + return ( + this.virtualTargets + ?.targets(call.className, call.method) + ?.every((target) => this.functions.has(target)) ?? false + ); + } + preservesScope(body: IrStmt[]): boolean { return everyStmtList(body, { stmt: (stmt) => statementPreservesEdges(stmt), diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index bf54116ee..7aec30dd6 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -18,7 +18,8 @@ import { emitLlvmModule } from "./emitter.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; /** A pending-exception check: the inline active-cell test or a runtime call. */ -const PENDING_CHECK = /@scr_exc_(?:active|pending)\b/; +// Synchronous bodies test the kind of the entry-loaded exception cell. +const PENDING_CHECK = /@scr_exc_(?:active|pending)\b| = load i32, ptr %exc\.cell\b/; const loc = { file: "strings.ts", start: 0, end: 1 }; const ref = (id: string, type: IrType = STRING): IrExpr => ({ @@ -109,10 +110,15 @@ test("string comparisons borrow parameters while owned entry adapters release th test("returning a borrowed input acquires a result owner", () => { const identity = fn("identity", ["text"], ref("text")); expect(facts(identity).parameters.size).toBe(0); + // The borrowing body returns its input without a reference; direct + // callers own it themselves and the owned adapter retains the result + // before releasing the parameter. const ir = body(mod(identity), "sc_bf_identity"); - expect(ir).toContain("@scr_str_retain_v"); + expect(ir).not.toContain("@scr_str_retain_v"); expect(ir).not.toContain("@scr_str_release"); - expect(body(mod(identity), "sc_f_identity")).toContain("@scr_str_release"); + const adapter = body(mod(identity), "sc_f_identity"); + expect(adapter.indexOf("@scr_str_retain_v")).toBeGreaterThan(0); + expect(adapter.indexOf("@scr_str_release")).toBeGreaterThan(adapter.indexOf("@scr_str_retain_v")); }); test("literal arguments remain immortal while the called body borrows them", () => { diff --git a/packages/compiler/src/backend/llvm/walk-borrows.test.ts b/packages/compiler/src/backend/llvm/walk-borrows.test.ts new file mode 100644 index 000000000..caa6be94f --- /dev/null +++ b/packages/compiler/src/backend/llvm/walk-borrows.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "vitest"; +import { F64, VOID, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; + +const loc = { file: "walk.ts", start: 0, end: 0 }; +const NODE: IrType = { kind: "object", className: "Node" }; +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: NODE, loc }); +const parent = (obj: IrExpr): IrExpr => ({ + kind: "fieldGet", + obj, + className: "Node", + field: "parent", + type: NODE, + loc, +}); +const decl = (localId: string, init: IrExpr | null): IrStmt => ({ + kind: "varDecl", + localId, + init, + loc, +}); +const assign = (localId: string, value: IrExpr): IrStmt => ({ + kind: "assign", + localId, + value, + loc, +}); +const local = (id: string, mutable = true) => ({ id, name: id, type: NODE, mutable }); + +function fn(body: IrStmt[], locals: string[], params = ["node"]): IrFunction { + return { + name: "walk", + params: params.map((id) => ({ localId: id, name: id, type: NODE })), + locals: [...params, ...locals].map((id) => local(id)), + body, + returnType: VOID, + loc, + }; +} + +const host: WalkBorrowHost = { + pointerLocal: (l) => l.type.kind === "object", + borrowsWithoutOwning: () => true, +}; + +test("parent walks rooted at unwritten parameters borrow", () => { + const body = [ + decl("p", parent(ref("node"))), + decl("next", parent(ref("p"))), + assign("p", ref("next")), + decl("unset", null), + assign("unset", parent(parent(ref("p")))), + ]; + expect(findWalkBorrows(fn(body, ["p", "next", "unset"]), host)).toEqual( + new Set(["p", "next", "unset"]), + ); +}); + +const call: IrExpr = { kind: "call", callee: "make", args: [], type: NODE, loc }; + +test("parameters rebound only to walks keep borrowing", () => { + const body = [assign("node", parent(ref("node"))), decl("up", parent(ref("node")))]; + expect(findWalkBorrows(fn(body, ["up"]), host)).toEqual(new Set(["node", "up"])); + // A parameter re-declared without an initializer is left alone. + expect(findWalkBorrows(fn([decl("node", null)], []), host).size).toBe(0); +}); + +test("rebound parameters, owned sources and other writers keep ownership", () => { + const body = [ + // A parameter rebound to an owned value is no root, and neither is its walk. + assign("node", call), + decl("fromParam", parent(ref("node"))), + // A call result is owned, and the failure propagates transitively. + decl("owned", call), + decl("viaOwned", parent(ref("owned"))), + decl("viaViaOwned", ref("viaOwned")), + // An expression-position write excludes the local. + decl("written", parent(ref("other"))), + { + kind: "exprStmt", + expr: { kind: "assignExpr", localId: "written", value: ref("other"), type: NODE, loc }, + loc, + } as IrStmt, + decl("kept", parent(ref("other"))), + ]; + const result = findWalkBorrows( + fn( + body, + ["fromParam", "owned", "viaOwned", "viaViaOwned", "written", "kept"], + ["node", "other"], + ), + host, + ); + expect(result).toEqual(new Set(["kept"])); +}); + +test("scalar locals, owning projections and suspending bodies are never walks", () => { + const scalar = fn([decl("p", parent(ref("node")))], ["p"]); + scalar.locals[1] = { id: "p", name: "p", type: F64, mutable: true }; + expect(findWalkBorrows(scalar, host).size).toBe(0); + const owning = fn([decl("p", parent(ref("node")))], ["p"]); + expect(findWalkBorrows(owning, { ...host, borrowsWithoutOwning: () => false }).size).toBe(0); + const suspending: IrFunction = { ...fn([decl("p", parent(ref("node")))], ["p"]), async: true }; + expect(findWalkBorrows(suspending, host).size).toBe(0); +}); + +test("returns of walks, including calls to borrowed-return callees, return borrowed", () => { + const ret = (value: IrExpr | null): IrStmt => ({ kind: "return", value, loc }); + const accessor: IrExpr = { kind: "call", callee: "fileOf", args: [ref("node")], type: NODE, loc }; + const borrowedHost = { ...host, borrowedReturn: (callee: string) => callee === "fileOf" }; + const walking = fn([decl("p", accessor), ret(parent(ref("p")))], ["p"]); + expect(analyzeWalks(walking, borrowedHost)).toEqual({ + locals: new Set(["p"]), + returnsWalk: true, + }); + // Without the callee fact the call result is owned. + expect(analyzeWalks(walking, host)).toEqual({ locals: new Set(), returnsWalk: false }); + // Every return must walk, a try statement disqualifies, and parameters + // outside the borrowing convention are no roots. + const mixed = fn([ret(parent(ref("node"))), ret(call)], []); + expect(analyzeWalks(mixed, borrowedHost).returnsWalk).toBe(false); + const guarded = fn( + [ + { + kind: "tryCatch", + tryBody: [ret(ref("node"))], + catchBody: null, + catchLocalId: null, + finallyBody: [], + loc, + }, + ], + [], + ); + expect(analyzeWalks(guarded, borrowedHost).returnsWalk).toBe(false); + const owned = fn([ret(parent(ref("node")))], []); + expect(analyzeWalks(owned, { ...borrowedHost, parameterAllowed: () => false }).returnsWalk).toBe( + false, + ); + expect(analyzeWalks(owned, borrowedHost).returnsWalk).toBe(true); +}); diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts new file mode 100644 index 000000000..7ba048573 --- /dev/null +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -0,0 +1,174 @@ +import { isRefCounted, type IrExpr, type IrFunction, type IrLocal } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; + +/** What the emitter knows about a projection's storage. */ +export interface WalkBorrowHost { + /** The local holds one plain reference pointer (instances, records, + * arrays, strings, nullable-pointer unions). Tagged union boxes, closures + * and dynamic values keep ownership. */ + pointerLocal(local: IrLocal): boolean; + /** emitReadReceiver yields this expression's pointer without acquiring a + * reference (class casts, nullable wraps, plain field reads, checked + * ternaries). */ + borrowsWithoutOwning(e: IrExpr): boolean; + /** The callee returns a borrowed walk of its arguments (findWalkBorrows' + * returnsWalk); a call to it with walk arguments is itself a walk. */ + borrowedReturn?(callee: string): boolean; + /** Parameters that may become candidates; once calling conventions are + * fixed, only borrowed parameters qualify. Defaults to every parameter. */ + parameterAllowed?(localId: string): boolean; +} + +export interface WalkFacts { + locals: ReadonlySet; + /** Every return yields a walk (or a throw), so the body can return its + * pointer without a reference: no try statement, at least one return. */ + returnsWalk: boolean; +} + +/** Locals (including rebound parameters) that may hold borrowed pointers + * for their whole lifetime: every definition (declaration or statement + * assignment) is a projection of an unwritten parameter or of another such + * local, for example the parent walk `let p = node.parent; while (p.parent) + * p = p.parent;`. A rebound parameter in the result keeps the borrowed + * calling convention. + * + * Soundness rests on the whole body preserving heap edges (the caller + * passes only functions in ReferenceEffects.functions): neither the body + * nor anything it calls removes a field, element or global reference, so + * every object reachable from a parameter at entry stays reachable (and + * alive) until the function returns. Such a local therefore needs no + * retain on definition, no release on rebinding and none at scope exit. + * Whole-value uses (returns, stores, owned arguments) still retain their + * own copies when they read the binding. Other locals may be rebound + * freely: they never root a walk, so releasing their old values cannot free + * an object the walk reaches. */ +export function findWalkBorrows(fn: IrFunction, host: WalkBorrowHost): ReadonlySet { + return analyzeWalks(fn, host).locals; +} + +export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { + const result = new Set(); + if (fn.async || fn.generator || fn.captures || fn.classCaptures) + return { locals: result, returnsWalk: false }; + const returns: (IrExpr | null)[] = []; + let guarded = false; + const params = new Set(fn.params.map((param) => param.localId)); + const locals = new Map(fn.locals.map((local) => [local.id, local])); + const definitions = new Map(); + // Locals written in any other way (expression assignments, loop and catch + // bindings, captures) keep the ordinary owned representation. + const excluded = new Set(); + const define = (id: string, value: IrExpr | null): void => { + let list = definitions.get(id); + if (!list) definitions.set(id, (list = [])); + list.push(value); + }; + everyStmtList(fn.body, { + stmt: (s) => { + switch (s.kind) { + case "varDecl": + define(s.localId, s.init); + break; + case "assign": + define(s.localId, s.value); + break; + case "forOf": + excluded.add(s.localId); + break; + case "rethrow": + excluded.add(s.localId); + break; + case "tryCatch": + guarded = true; + if (s.catchLocalId !== null) excluded.add(s.catchLocalId); + break; + case "return": + returns.push(s.value); + break; + } + return true; + }, + expr: (e) => { + switch (e.kind) { + case "assignExpr": + case "incDec": + excluded.add(e.localId); + break; + case "closure": + case "classRef": + for (const id of e.captures ?? []) excluded.add(id); + break; + } + return true; + }, + }); + const plain = (id: string): boolean => { + const local = locals.get(id); + return ( + !!local && + !local.boxed && + !local.tdz && + !excluded.has(id) && + host.pointerLocal(local) && + (!params.has(id) || host.parameterAllowed?.(id) !== false) + ); + }; + // Parameters are roots only while no definition rebinds them. A + // parameter rebound by statement assignments alone (`t = t.regular`) is a + // candidate like any local: its incoming value is the caller's borrow. + const roots = new Set([...params].filter((id) => !definitions.has(id) && plain(id))); + const candidates = new Set(); + for (const [id, values] of definitions) { + if (!plain(id)) continue; + if (params.has(id) && values.some((value) => value === null)) continue; + candidates.add(id); + } + // A walk projects roots or candidates through reads that never acquire a + // reference. Drop candidates until every definition is such a walk. + const walk = (e: IrExpr): boolean => { + switch (e.kind) { + case "varRef": + return roots.has(e.localId) || candidates.has(e.localId); + case "fieldGet": + case "recordGet": + return host.borrowsWithoutOwning(e) && walk(e.obj); + case "unionNarrow": + case "downcast": + case "upcast": + return host.borrowsWithoutOwning(e) && walk(e.value); + case "unionWrap": + if (!host.borrowsWithoutOwning(e)) return false; + return e.value.kind === "unitLit" || walk(e.value); + // Each arm is itself a walk (or a throw), which emitReadReceiver reads + // without owning. + case "ternary": + return host.borrowsWithoutOwning(e) && walkOrThrow(e.then) && walkOrThrow(e.else_); + // A borrowed result is reachable from walk arguments. + case "call": + return ( + host.borrowedReturn?.(e.callee) === true && + host.borrowsWithoutOwning(e) && + e.args.every((arg) => !isRefCounted(arg.type) || walk(arg)) + ); + default: + return false; + } + }; + const walkOrThrow = (e: IrExpr): boolean => + (e.kind === "libCall" && e.fn === "error.nodeThrow") || walk(e); + for (let changed = true; changed;) { + changed = false; + for (const id of candidates) { + if (definitions.get(id)!.every((value) => value === null || walk(value))) continue; + candidates.delete(id); + changed = true; + } + } + for (const id of candidates) result.add(id); + const returnsWalk = + !guarded && + returns.length > 0 && + returns.every((value) => value !== null && walkOrThrow(value)); + return { locals: result, returnsWalk }; +} diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index 6a23b2669..19ab0ff5a 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -313,12 +313,17 @@ function snapshot(item: Item): Item { const saved = item; item = new Item(); ret console.log(alias(new Item()).value, snapshot(new Item()).value); `); const llvm = emitLlvmModule(mod); + // The aliases borrow the parameter, and so does the result: the borrowing + // body returns it without a reference and the owned adapter retains it. const alias = body(llvm, "sc_bf_alias"); - expect(alias.match(/@sc_retain_Item/g)).toHaveLength(1); + expect(alias).not.toContain("@sc_retain_Item"); expect(alias).not.toContain("@sc_release_Item"); - const snapshot = body(llvm, "sc_f_snapshot"); + expect(body(llvm, "sc_f_alias").match(/@sc_retain_Item/g)).toHaveLength(1); + // The rebound parameter keeps borrowing (its new value lives in an owner + // slot), but the alias saved before the rebinding must own its value. // `new Item()` calls the borrowed constructor body, so no retain transfers // the fresh instance into it: the saved alias and the returned value. + const snapshot = body(llvm, "sc_bf_snapshot"); expect(snapshot.match(/@sc_retain_Item/g)).toHaveLength(2); expect(snapshot).toContain("@sc_bf__x25_Item_constructor"); expect(snapshot).toContain("@sc_release_Item"); diff --git a/packages/compiler/test/input-lifetime-emission.test.ts b/packages/compiler/test/input-lifetime-emission.test.ts index caabeed12..e24e73d69 100644 --- a/packages/compiler/test/input-lifetime-emission.test.ts +++ b/packages/compiler/test/input-lifetime-emission.test.ts @@ -44,7 +44,12 @@ console.log(preserve(holder), snapshot(holder)); expect(body(llvm, "preserve")).not.toContain("@sc_release_Cell"); expect(body(llvm, "snapshot")).toContain("@sc_retain_Cell"); expect(body(llvm, "snapshot")).toContain("@sc_release_Cell"); - expect(body(llvm, "read")).toContain("@scr_str_retain_v"); + // `read` returns a projection of its borrowed parameter without a + // reference; its owned adapter takes one for callers outside direct calls. + expect(body(llvm, "read")).not.toContain("@scr_str_retain_v"); + expect(/^define internal [^\n]*@sc_f_read\([^]*?^}/m.exec(llvm)?.[0]).toContain( + "@scr_str_retain_v", + ); }); test("regex runtime inputs borrow independently of result ownership and lastIndex mutation", async () => { diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index 10e496a8d..4f59d8bbc 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -8,7 +8,8 @@ import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; import { type IrModule } from "../src/ir/ir.js"; /** A pending-exception check: the inline active-cell test or a runtime call. */ -const PENDING_CHECK = /@scr_exc_(?:active|pending)\b/; +// Synchronous bodies test the kind of the entry-loaded exception cell. +const PENDING_CHECK = /@scr_exc_(?:active|pending)\b| = load i32, ptr %exc\.cell\b/; async function lower(source: string): Promise { const dir = await mkdtemp(join(tmpdir(), "scriptc-input-lifetimes-")); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index f9534c90d..c1b428527 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15593,6 +15593,42 @@ ], "diags": [] }, + "/tests/corpus/4541-nullable-wrap-borrows.ts": { + "order": [ + "/tests/corpus/4541-nullable-wrap-borrows.ts" + ], + "diags": [] + }, + "/tests/corpus/4542-preserving-call-borrows.ts": { + "order": [ + "/tests/corpus/4542-preserving-call-borrows.ts" + ], + "diags": [] + }, + "/tests/corpus/4544-checked-cast-aliases.ts": { + "order": [ + "/tests/corpus/4544-checked-cast-aliases.ts" + ], + "diags": [] + }, + "/tests/corpus/4543-walk-borrowed-locals.ts": { + "order": [ + "/tests/corpus/4543-walk-borrowed-locals.ts" + ], + "diags": [] + }, + "/tests/corpus/4545-borrowed-returns.ts": { + "order": [ + "/tests/corpus/4545-borrowed-returns.ts" + ], + "diags": [] + }, + "/tests/corpus/4546-rebound-borrowed-params.ts": { + "order": [ + "/tests/corpus/4546-rebound-borrowed-params.ts" + ], + "diags": [] + }, "/tests/corpus/stack-depth-async-bodies.ts": { "order": [ "/tests/corpus/stack-depth-async-bodies.ts" diff --git a/tests/corpus/4541-nullable-wrap-borrows.ts b/tests/corpus/4541-nullable-wrap-borrows.ts new file mode 100644 index 000000000..da218a3dc --- /dev/null +++ b/tests/corpus/4541-nullable-wrap-borrows.ts @@ -0,0 +1,100 @@ +// Values widened into `T | undefined` parameters, identity tests and +// casts: the wrap is the payload pointer itself, so it may borrow when the +// payload can, and must keep an owner when a later operand rebinds it. + +class Item { + name: string; + next: Item | undefined; + constructor(name: string, next: Item | undefined) { + this.name = name; + this.next = next; + } +} + +class Special extends Item { + extra = 1; +} + +function label(item: Item | undefined): string { + return item === undefined ? "none" : item.name; +} + +function same(a: Item | undefined, b: Item | undefined): boolean { + return a === b; +} + +function order(a: Item | undefined, b: Item | undefined): number { + if (a === b) return 0; + if (a === undefined) return 1; + if (b === undefined) return -1; + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; +} + +function pick(first: Item | undefined, replace: () => Item, second: Item | undefined): string { + const fresh = replace(); + return label(first) + "/" + label(second) + "/" + fresh.name; +} + +function chain(item: Item): string { + // A borrowed parameter widened into a nullable argument several times. + return label(item) + ":" + label(item.next) + ":" + String(same(item, item.next)); +} + +function special(item: Item): string { + if (item instanceof Special) { + const s = item as Special; + return label(s) + "+" + s.extra + "+" + String(same(s, item)); + } + return label(item); +} + +let shared: Item | undefined = new Item("shared", undefined); + +function rebinding(): string { + let local = new Item("before", undefined); + // The second operand replaces the local the first operand wraps. + const result = pick( + local, + () => { + local = new Item("after", undefined); + return local; + }, + local, + ); + // A sequence expression rebinding the wrapped binding between operands. + let other = new Item("x", undefined); + const seq = order(other, ((other = new Item("y", undefined)), other)); + return result + " " + seq + " " + label(other); +} + +function globalRebinding(): string { + // A global's wrapped value must survive a callee replacing the global. + return pick( + shared, + () => { + shared = new Item("replaced", undefined); + return shared; + }, + shared, + ); +} + +const a = new Item("a", undefined); +const b = new Item("b", a); +const c = new Special("c", b); +console.log(chain(a), chain(b), chain(c)); +console.log(special(c), special(b)); +console.log(same(a, a), same(a, b), same(undefined, undefined), same(a, undefined)); +console.log(order(a, b), order(b, a), order(a, a), order(undefined, a), order(a, undefined)); +console.log(rebinding()); +console.log(globalRebinding(), label(shared)); +const items: Item[] = [c, b, a, new Item("b", undefined)]; +items.sort((x, y) => order(x, y)); +console.log(items.map((x) => label(x)).join(",")); +let walk: Item | undefined = c; +let names = ""; +while (walk !== undefined) { + names += label(walk); + walk = walk.next; +} +console.log(names); diff --git a/tests/corpus/4542-preserving-call-borrows.ts b/tests/corpus/4542-preserving-call-borrows.ts new file mode 100644 index 000000000..afd1715bf --- /dev/null +++ b/tests/corpus/4542-preserving-call-borrows.ts @@ -0,0 +1,148 @@ +// Callees proven not to remove heap edges (switch statements, `??`, +// Math.min, instanceof, and virtual calls whose every override preserves) +// let callers pass array elements and field reads without owning them. +// A single mutating override, a mutating callee, or a mutating later +// operand must keep the owned path: the sanitized lane catches a borrow of +// a freed element. + +class Node { + kind: number; + name: string; + constructor(kind: number, name: string) { + this.kind = kind; + this.name = name; + } + weight(): number { + return this.kind; + } +} + +class Leaf extends Node { + weight(): number { + return this.kind * 2; + } +} + +class Branch extends Node { + children: Node[] = []; + weight(): number { + let total = this.kind; + for (const child of this.children) total += child.weight(); + return total; + } +} + +function describe(n: Node): string { + switch (n.kind) { + case 1: + return "one:" + n.name; + case 2: + return "two:" + n.name; + default: + return (n instanceof Leaf ? "leaf:" : "node:") + n.name; + } +} + +function compareNames(a: string, b: string): number { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + const d = a.charCodeAt(i) - b.charCodeAt(i); + if (d !== 0) return d; + } + return a.length - b.length; +} + +const ranks = new Map(); + +function compareNodes(a: Node, b: Node): number { + if (a === b) return 0; + const r = (ranks.get(a.name) ?? 0) - (ranks.get(b.name) ?? 0); + if (r !== 0) return r; + const w = a.weight() - b.weight(); + return w !== 0 ? w : compareNames(a.name, b.name); +} + +function search(nodes: readonly Node[], target: Node): number { + let low = 0; + let high = nodes.length - 1; + while (low <= high) { + const middle = (low + high) >> 1; + const r = compareNodes(nodes[middle]!, target); + if (r === 0) return middle; + if (r < 0) low = middle + 1; + else high = middle - 1; + } + return ~low; +} + +function insert(nodes: Node[], n: Node): boolean { + const index = search(nodes, n); + if (index >= 0) return false; + nodes.splice(~index, 0, n); + return true; +} + +// A callee that removes the element it was handed: callers must own it. +function takeAndClear(list: Node[], first: Node): string { + list.length = 0; + return describe(first) + "/" + list.length; +} + +// A subclass whose override drops references: virtual calls through the +// base class can no longer be proven preserving. +class Measured { + name: string; + constructor(name: string) { + this.name = name; + } + measure(): number { + return this.name.length; + } +} + +class Pruning extends Measured { + owner: Measured[]; + constructor(name: string, owner: Measured[]) { + super(name); + this.owner = owner; + } + measure(): number { + this.owner.length = 0; + return -1; + } +} + +function weigh(n: Measured, label: Measured): string { + return n.measure() + ":" + label.name; +} + +const nodes: Node[] = []; +const names = ["delta", "alpha", "charlie", "bravo", "alpha", "echo"]; +names.forEach((name, i) => { + const n = i % 3 === 0 ? new Leaf(1 + (i % 2), name) : new Node(1 + (i % 2), name); + ranks.set(name, name.length % 2); + console.log(name, insert(nodes, n)); +}); +console.log(nodes.map(describe).join(" ")); +const branch = new Branch(3, "root"); +branch.children.push(nodes[0]!, nodes[1]!); +console.log(describe(branch), branch.weight(), search(nodes, nodes[2]!), search(nodes, branch)); + +const temp = [new Node(1, "temp"), new Leaf(2, "temp2")]; +console.log(takeAndClear(temp, temp[0]!)); + +const owned: Measured[] = [new Measured("kept")]; +const pruning = new Pruning("pruner", owned); +const mixed: Measured[] = [pruning, new Measured("plain")]; +console.log(weigh(mixed[0]!, owned[0]!), owned.length); +console.log(weigh(mixed[1]!, mixed[1]!)); + +// A later operand that mutates the array the earlier element came from. +const shrinking = [new Node(1, "first"), new Node(2, "second")]; +console.log( + compareNames( + shrinking[0]!.name, + ((shrinking.length = 0), "z"), + ), + shrinking.length, +); diff --git a/tests/corpus/4543-walk-borrowed-locals.ts b/tests/corpus/4543-walk-borrowed-locals.ts new file mode 100644 index 000000000..d94ff7b75 --- /dev/null +++ b/tests/corpus/4543-walk-borrowed-locals.ts @@ -0,0 +1,153 @@ +// Locals that only walk pointers reachable from parameters (parent chains, +// checked casts, nullable fields) hold borrowed pointers when nothing in the +// function can remove a heap edge. Cases where an edge is removed, a root is +// rebound, or the walk starts at an owned temporary must keep ownership: the +// sanitized lane catches a borrow of a freed object. + +class Node { + parent: Node | undefined; + kind: number; + constructor(kind: number, parent: Node | undefined) { + this.kind = kind; + this.parent = parent; + } +} + +class SourceFile extends Node { + fileName: string; + constructor(fileName: string) { + super(0, undefined); + this.fileName = fileName; + } +} + +function sourceFileOf(node: Node): SourceFile { + let parent = node.parent; + if (parent === undefined) return node as SourceFile; + for (;;) { + const next: Node | undefined = parent.parent; + if (next === undefined) return parent as SourceFile; + parent = next; + } +} + +function depth(node: Node | undefined): number { + let n = 0; + let current = node; + while (current !== undefined) { + n++; + current = current.parent; + } + return n; +} + +function ancestorOfKind(node: Node, kind: number): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined && current.kind !== kind) current = current.parent; + return current; +} + +// Two walks advancing in lockstep, with a checked cast at the end. +function commonRoot(a: Node, b: Node): string { + let x: Node = a; + let y: Node = b; + while (x.parent !== undefined) x = x.parent; + while (y.parent !== undefined) y = y.parent; + const fx = x as SourceFile; + const fy = y as SourceFile; + return fx === fy ? fx.fileName : fx.fileName + "|" + fy.fileName; +} + +// A callee that cuts the chain: the loop's `next` must own its node. +function cut(node: Node): void { + node.parent = undefined; +} + +function cutWalk(node: Node): number { + let count = 0; + let current = node.parent; + while (current !== undefined) { + const next = current.parent; + cut(current); + count += next === undefined ? 0 : next.kind; + current = next; + } + return count; +} + +// A rebound parameter is not a root. +function climb(node: Node, steps: number): number { + let total = 0; + for (let i = 0; i < steps && node.parent !== undefined; i++) { + node = node.parent; + const here = node; + total += here.kind; + } + return total; +} + +// Rebound parameters walk too (they keep the borrowed convention), unless +// the body removes an edge: here the old parent link is cut while the +// rebound parameter still points at the node it held. +function parentKind(node: Node): number { + if (node.parent !== undefined) node = node.parent; + return node.kind; +} + +function parentKindAfterCut(node: Node): number { + if (node.parent !== undefined) { + const child = node; + node = node.parent; + cut(child); + } + const fresh = new Node(100, undefined); + return node.kind + fresh.kind; +} + +interface Inner { + name: string; +} +interface Outer { + inner: Inner; +} + +// The walk starts at an owned local that is later rebound. +function rebound(seed: string): string { + let owner: Outer = { inner: { name: seed + "-1" } }; + const inner = owner.inner; + owner = { inner: { name: seed + "-2" } }; + return inner.name + "/" + owner.inner.name; +} + +// Whole-value uses of a walk local still take their own reference. +function rootOrSelf(node: Node): Node { + let current = node; + while (current.parent !== undefined) current = current.parent; + return current; +} + +const file = new SourceFile("main.ts"); +const other = new SourceFile("other.ts"); +const a = new Node(1, file); +const b = new Node(2, a); +const c = new Node(3, b); +const d = new Node(4, other); +console.log(sourceFileOf(c).fileName, sourceFileOf(file).fileName, sourceFileOf(d).fileName); +console.log(depth(c), depth(file), depth(undefined)); +console.log(ancestorOfKind(c, 1)?.kind, ancestorOfKind(c, 9)?.kind); +console.log(commonRoot(c, b), commonRoot(c, d)); +const held: Node[] = []; +for (let i = 0; i < 3; i++) held.push(rootOrSelf(c)); +console.log(held.length, held[0] === file, held[2] === held[1]); + +// Chains whose only owners are their own parent links. +function chain(length: number): Node { + let node: Node = new SourceFile("chain.ts"); + for (let i = 1; i <= length; i++) node = new Node(i, node); + return node; +} +console.log(sourceFileOf(chain(5)).fileName, depth(chain(7))); +console.log(cutWalk(chain(6))); +console.log(climb(chain(4), 2), climb(chain(4), 10)); +console.log(rebound("x")); +console.log(parentKind(chain(3)), parentKind(file), parentKindAfterCut(chain(3)), parentKindAfterCut(chain(1))); diff --git a/tests/corpus/4544-checked-cast-aliases.ts b/tests/corpus/4544-checked-cast-aliases.ts new file mode 100644 index 000000000..5e33046fe --- /dev/null +++ b/tests/corpus/4544-checked-cast-aliases.ts @@ -0,0 +1,71 @@ +// Constant aliases created by checked casts (`x as Sub`, a narrowed +// nullable) of unchanged parameters and locals borrow their source. The +// source binding keeps the object alive even when everything else that +// referenced it is dropped while the alias is in use. + +class Type { + flags: number; + constructor(flags: number) { + this.flags = flags; + } +} + +class LiteralType extends Type { + value: number; + text: string; + constructor(flags: number, value: number, text: string) { + super(flags); + this.value = value; + this.text = text; + } +} + +class Holder { + item: Type | undefined; + constructor(item: Type | undefined) { + this.item = item; + } +} + +function sameLiteral(a: Type, b: Type): boolean { + const x = a as LiteralType; + const y = b as LiteralType; + if ((a.flags & 1) !== 0) return x.value === y.value || (x.value !== x.value && y.value !== y.value); + return x.text === y.text; +} + +// The alias outlives every other reference the program held. +function aliasAcrossDrop(a: Type, drop: () => void): string { + const x = a as LiteralType; + drop(); + const fresh = new LiteralType(3, 99, "fresh"); + return x.text + ":" + x.value + ":" + fresh.text; +} + +function narrowAlias(holder: Holder): string { + const item = holder.item; + if (item === undefined) return "none"; + const literal = item as LiteralType; + holder.item = undefined; + const fresh = new LiteralType(5, 7, "replacement"); + return literal.text + "/" + fresh.text; +} + +const items: Type[] = [ + new LiteralType(1, 1, "one"), + new LiteralType(1, 1, "uno"), + new LiteralType(2, 2, "two"), + new LiteralType(2, 3, "two"), + new LiteralType(1, NaN, "nan"), +]; +console.log(sameLiteral(items[0], items[1]), sameLiteral(items[2], items[3]), sameLiteral(items[4], items[4])); + +const holder = new Holder(new LiteralType(2, 42, "held")); +console.log( + aliasAcrossDrop(holder.item!, () => { + holder.item = undefined; + }), + holder.item === undefined, +); +const holder2 = new Holder(new LiteralType(2, 8, "narrowed")); +console.log(narrowAlias(holder2), narrowAlias(holder2)); diff --git a/tests/corpus/4545-borrowed-returns.ts b/tests/corpus/4545-borrowed-returns.ts new file mode 100644 index 000000000..4d47ed71d --- /dev/null +++ b/tests/corpus/4545-borrowed-returns.ts @@ -0,0 +1,209 @@ +// Functions that return a projection of their parameters without removing +// any reference (accessors, parent walks) hand back their result without +// taking a reference; callers consume it in place or take their own. Loops +// whose body removes no reference iterate their array without owning it. +// Mutating functions, mutating callees and mutating loop bodies keep the +// owned forms: the sanitized lane catches a borrow of a freed object. + +class Node { + parent: Node | undefined; + kind: number; + children: Node[] = []; + constructor(kind: number, parent: Node | undefined) { + this.kind = kind; + this.parent = parent; + if (parent !== undefined) parent.children.push(this); + } +} + +class SourceFile extends Node { + name: string; + constructor(name: string) { + super(0, undefined); + this.name = name; + } +} + +function sourceFileOfNode(node: Node): SourceFile { + let parent = node.parent; + if (parent === undefined) return node as SourceFile; + for (;;) { + const next: Node | undefined = parent.parent; + if (next === undefined) return parent as SourceFile; + parent = next; + } +} + +function fileOf(node: Node): SourceFile { + return sourceFileOfNode(node); +} + +function childrenOf(node: Node): Node[] { + return node.children; +} + +function nameOf(node: Node): string { + return fileOf(node).name; +} + +// A throwing borrowed-return body hands back no result at all. +const negativeKind = new Error("negative kind"); +function labelOf(node: Node): string { + if (node.kind < 0) throw negativeKind; + return fileOf(node).name; +} + +function grandparent(node: Node): Node { + return node.parent!.parent!; +} + +function firstChildOrSelf(node: Node): Node { + return node.children.length > 0 ? node.children[0]! : node; +} + +const fileIndex = new Map(); + +function compareNodes(a: Node, b: Node): number { + const fa = fileOf(a); + const fb = fileOf(b); + if (fa !== fb) return (fileIndex.get(fa) ?? 0) - (fileIndex.get(fb) ?? 0); + return a.kind - b.kind; +} + +function countKind(node: Node, kind: number): number { + let n = node.kind === kind ? 1 : 0; + for (const child of childrenOf(node)) n += countKind(child, kind); + return n; +} + +// Not edge-preserving: the returned node's only owner was the cut link. +function detachParent(node: Node): Node | undefined { + const parent = node.parent; + node.parent = undefined; + return parent; +} + +// A mutating callee receives a borrowed-return result: it must be owned. +function cutThenName(file: SourceFile, node: Node): string { + let current: Node | undefined = node; + while (current !== undefined) { + const next: Node | undefined = current.parent; + current.parent = undefined; + current = next; + } + const fresh = new SourceFile("fresh"); + return file.name + "/" + fresh.name; +} + +// A loop body that drops the iterated array's owner keeps it owned. +function drainChildren(node: Node): number { + let total = 0; + for (const child of childrenOf(node)) { + node.children = []; + total += child.kind; + } + return total; +} + +class Holder { + file: SourceFile | undefined; +} + +function build(name: string, depth: number): Node { + let node: Node = new SourceFile(name); + for (let i = 1; i <= depth; i++) node = new Node(i, node); + return node; +} + +const leaf = build("a.ts", 4); +const other = build("b.ts", 2); +fileIndex.set(fileOf(leaf), 1); +fileIndex.set(fileOf(other), 2); +console.log(fileOf(leaf).name, nameOf(other), fileOf(fileOf(leaf)).name); +console.log(compareNodes(leaf, other), compareNodes(other, leaf), compareNodes(leaf, leaf.parent!)); +console.log(grandparent(leaf).kind, firstChildOrSelf(fileOf(leaf)).kind, firstChildOrSelf(leaf).kind); +console.log(countKind(fileOf(leaf), 2), childrenOf(fileOf(other)).length); + +// Results that escape take their own reference. +const holder = new Holder(); +holder.file = fileOf(build("held.ts", 3)); +const viaValue: (node: Node) => SourceFile = fileOf; +const fromValue = viaValue(build("value.ts", 2)); +const files = [fileOf(build("array.ts", 1)), fileOf(build("array2.ts", 5))]; +console.log(holder.file.name, fromValue.name, files.map((f) => f.name).join(",")); + +const detached = detachParent(build("detach.ts", 1)); +console.log(detached instanceof SourceFile ? (detached as SourceFile).name : "none"); +const chain = build("cut.ts", 3); +console.log(cutThenName(fileOf(chain), chain)); +const root = build("drain.ts", 1).parent!; +new Node(7, root); +console.log(drainChildren(root), root.children.length); + +const negative = new Node(-1, build("neg.ts", 1)); +const labels: string[] = []; +const viaLabel: (node: Node) => string = labelOf; +for (const attempt of [ + () => labels.push(labelOf(negative)), + () => labels.push(viaLabel(negative)), + () => labels.push(String(labelOf(negative).length)), + () => labels.push(labelOf(leaf), viaLabel(other), String(labelOf(leaf).length)), +]) { + try { + attempt(); + } catch (e) { + labels.push((e as Error).message); + } +} +console.log(labels.join(" | ")); + +// Accessor methods filling a vtable slot that borrows `this`: virtual +// dispatch reaches the borrowing body directly and its callers own the +// result, so these must keep returning an owned reference. Each result +// outlives every other owner of the object it names. +class Owner { + name: string; + constructor(name: string) { + this.name = name; + } +} + +class Shape { + owner: Owner; + constructor(owner: Owner) { + this.owner = owner; + } + ownerOf(): Owner { + return this.owner; + } + labelOf(): string { + return this.owner.name; + } +} + +class Square extends Shape { + ownerOf(): Owner { + return this.owner; + } + labelOf(): string { + return "square:" + this.owner.name; + } +} + +function takeOwner(shapes: Shape[], index: number): Owner { + const owner = shapes[index]!.ownerOf(); + shapes[index] = new Shape(new Owner("replacement")); + return owner; +} + +const shapes: Shape[] = [new Shape(new Owner("first")), new Square(new Owner("second"))]; +const taken = [takeOwner(shapes, 0), takeOwner(shapes, 1)]; +shapes.length = 0; +const kept: Owner[] = []; +const labelled: string[] = []; +for (let i = 0; i < 3; i++) { + const shape: Shape = i % 2 === 0 ? new Shape(new Owner("s" + i)) : new Square(new Owner("q" + i)); + kept.push(shape.ownerOf()); + labelled.push(shape.labelOf()); +} +console.log(taken.map((o) => o.name).join(","), kept.map((o) => o.name).join(","), labelled.join(",")); diff --git a/tests/corpus/4546-rebound-borrowed-params.ts b/tests/corpus/4546-rebound-borrowed-params.ts new file mode 100644 index 000000000..8c0fe475c --- /dev/null +++ b/tests/corpus/4546-rebound-borrowed-params.ts @@ -0,0 +1,85 @@ +// Parameters rebound by plain assignments keep borrowing the caller's +// argument; the values the body assigns are owned separately and released +// when rebound again or when the function exits (normally or by throwing). +// Here the rebinding functions also mutate the heap, so an assigned value +// whose other owners disappear must stay alive through the owned slot. + +class Node { + parent: Node | undefined; + kind: number; + constructor(kind: number, parent: Node | undefined) { + this.kind = kind; + this.parent = parent; + } +} + +class Fresh extends Node { + regular: Node; + constructor(kind: number, regular: Node) { + super(kind, undefined); + this.regular = regular; + } +} + +const seen: number[] = []; + +function related(source: Node, target: Node): boolean { + if (source instanceof Fresh) source = source.regular; + if (target instanceof Fresh) target = target.regular; + seen.push(source.kind * 10 + target.kind); + return source === target; +} + +// The parent's only other owner is the link this function cuts. +function parentAfterCut(node: Node): number { + const child = node; + node = node.parent!; + child.parent = undefined; + const fresh = new Node(100, undefined); + return node.kind + fresh.kind; +} + +function wrap(node: Node, depth: number): Node { + for (let i = 1; i <= depth; i++) node = new Node(node.kind + i, node); + return node; +} + +function count(node: Node | undefined): number { + let n = 0; + while (node !== undefined) { + n++; + seen.push(node.kind); + node = node.parent; + } + return n; +} + +function climb(node: Node, limit: number): number { + while (node.parent !== undefined) { + if (limit-- === 0) throw new Error("limit " + node.kind); + seen.push(node.kind); + node = node.parent; + } + return node.kind; +} + +function chain(length: number): Node { + let node = new Node(0, undefined); + for (let i = 1; i <= length; i++) node = new Node(i, node); + return node; +} + +const base = new Node(1, undefined); +const fresh = new Fresh(2, base); +const other = new Node(3, undefined); +console.log(related(fresh, base), related(base, fresh), related(fresh, other), related(other, other)); +console.log(parentAfterCut(chain(2)), parentAfterCut(new Node(9, new Node(8, undefined)))); +const wrapped = wrap(chain(1), 3); +console.log(wrapped.kind, count(wrapped), count(undefined)); +console.log(climb(chain(3), 10)); +try { + console.log(climb(chain(6), 2)); +} catch (e) { + console.log((e as Error).message); +} +console.log(seen.join(","));