From 6ddaa2a140d0ffd4e7cee33e694a25f237431655 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:42:50 -0700 Subject: [PATCH 01/12] Drop guards on private constructor stores and borrow construction-only fields Two uses of the construction-only facts outside the collector (backend/immutability.ts): - Publication guards: a store into the constructor's own `this` that lies before the constructor's escape point, in a constructor no ancestor of which lets `this` escape and that is never invoked directly on an existing object, cannot target a published object. It loses its rc == SIZE_MAX guard and no longer seeds may-throw, so constructors that could only throw through guards lose their pending checks at every `new`. Storing `this` into one of its own fields does not expose it. - Borrowed reads: `x.f` holding an object, record, array or string is borrowed as a call argument, receiver or stable alias when `f` is construction-only and `x` is itself borrowable (unchanged local, borrowed parameter, or such a projection): nothing can replace and release it while `x` lives. Not inside constructors, which may still write their own fields. Module constants (immutable, not TDZ-checked, assigned at most once) are borrowable too. No object is frozen or marked at run time; publication errors, store order and reflection are unchanged. tsc-ts (e469e33), static: published-field guards 6,528 -> 4,462, sc_release calls 146,056 -> 129,756, sc_retain calls 26,215 -> 22,123, scr_arr_release 29,865 -> 25,617, binary 23.68 -> 22.96 MB (both commits). Self-check single-threaded, executed: guards 96.1M -> 60.8M, retains 273.5M -> 247.2M, releases 340.3M -> 314.0M. Linux sandbox, interleaved medians, both commits against perf/alloc-hugepages: single-threaded (6 runs) base new change Excalidraw 8.62 s 8.34 s -3.2% Playwright 7.83 s 7.71 s -1.5% tsc-ts self-check 4.36 s 4.22 s -3.0% tRPC 1.63 s 1.58 s -3.1% TypeORM 6.68 s 6.50 s -2.5% --checkers 8 (10 runs): -2.3% .. +0.7% (neutral) Runtime suite (--layouts=4 --runs=16): geomean -1.9%; ast-interp -4.4%, validate-errors -3.8%, regex-logs -1.7% faster, template-render +1.4% slower, the rest neutral; sizes unchanged. --- .../compiler/src/backend/immutability.test.ts | 170 ++++++++++++++ packages/compiler/src/backend/immutability.ts | 215 ++++++++++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 45 +++- .../src/backend/llvm/string-lifetimes.test.ts | 16 ++ packages/compiler/src/backend/publication.ts | 11 + .../test/ts7/baselines/order-parity.json | 18 ++ .../corpus/4598-publish-constructor-stores.ts | 144 ++++++++++++ .../4599-construction-only-field-borrows.ts | 149 ++++++++++++ ...construction-only-fields-dynamic-writes.ts | 62 +++++ 9 files changed, 827 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/src/backend/immutability.test.ts create mode 100644 packages/compiler/src/backend/immutability.ts create mode 100644 tests/corpus/4598-publish-constructor-stores.ts create mode 100644 tests/corpus/4599-construction-only-field-borrows.ts create mode 100644 tests/corpus/4600-construction-only-fields-dynamic-writes.ts diff --git a/packages/compiler/src/backend/immutability.test.ts b/packages/compiler/src/backend/immutability.test.ts new file mode 100644 index 0000000000..8ca4febcae --- /dev/null +++ b/packages/compiler/src/backend/immutability.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "vitest"; +import { + DYN, + F64, + STRING, + VOID, + type IrExpr, + type IrModule, + type IrStmt, + type IrType, +} from "../ir/ir.js"; +import { computeConstructionFacts, computeImmutableFields, stableGlobals } from "./immutability.js"; + +const loc = { file: "immutability.ts", start: 0, end: 1 }; +const obj = (className: string): IrType => ({ kind: "object", className }); +const value = (type: IrType): IrExpr => ({ kind: "varRef", localId: "v", type, loc }); +const thisRef = (className: string): IrExpr => ({ + kind: "varRef", + localId: "this.0", + type: obj(className), + loc, +}); +const setThis = (className: string, field: string): IrStmt => ({ + kind: "fieldSet", + obj: thisRef(className), + className, + field, + value: value(F64), + loc, +}); +const call = (callee: string, args: IrExpr[]): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "call", callee, args, type: VOID, loc }, + loc, +}); +function ctor(className: string, body: IrStmt[]): IrModule["functions"][number] { + return { + name: `%${className}.constructor`, + params: [{ localId: "this.0", name: "this", type: obj(className) }], + locals: [], + returnType: VOID, + body, + loc, + }; +} +function module( + classes: NonNullable, + functions: IrModule["functions"], +): IrModule { + return { + irVersion: 15, + sourceFile: loc.file, + entry: "main", + classes, + functions: [ + { name: "main", locals: [], params: [], returnType: VOID, body: [], loc }, + ...functions, + ], + }; +} +const fields = (...names: string[]) => names.map((name) => ({ name, type: F64 })); + +/** The fields of `className` whose constructor stores are private. */ +function privateFields(mod: IrModule, className: string): string[] { + const facts = computeConstructionFacts(mod); + const fn = mod.functions.find((f) => f.name === `%${className}.constructor`)!; + return fn.body + .filter((s): s is Extract => s.kind === "fieldSet") + .filter((s) => facts.privateStores.has(s)) + .map((s) => s.field); +} + +test("constructor stores before this escapes are private", () => { + const mod = module( + [{ name: "P", fields: fields("a", "b"), loc }], + [ctor("P", [setThis("P", "a"), call("register", [thisRef("P")]), setThis("P", "b")])], + ); + expect(privateFields(mod, "P")).toEqual(["a"]); +}); + +test("storing this into its own field does not expose it", () => { + const self: IrStmt = { ...setThis("P", "self"), value: thisRef("P") } as IrStmt; + const mod = module( + [{ name: "P", fields: [...fields("a"), { name: "self", type: obj("P") }], loc }], + [ctor("P", [self, setThis("P", "a")])], + ); + expect(privateFields(mod, "P")).toEqual(["self", "a"]); +}); + +test("a base constructor's escape makes every subclass store public", () => { + const superCall = call("%Base.constructor", [ + { kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc }, + ]); + const mod = module( + [ + { name: "Base", fields: fields("a"), loc }, + { name: "Sub", base: "Base", fields: fields("a", "b"), loc }, + ], + [ + ctor("Base", [setThis("Base", "a"), call("register", [thisRef("Base")])]), + ctor("Sub", [superCall, setThis("Sub", "b")]), + ], + ); + expect(privateFields(mod, "Base")).toEqual(["a"]); + expect(privateFields(mod, "Sub")).toEqual([]); +}); + +test("a constructor called on an existing object keeps every guard", () => { + const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]); + expect(privateFields(mod, "P")).toEqual(["a"]); + mod.functions[0]!.body.push(call("%P.constructor", [value(obj("P"))])); + expect(privateFields(mod, "P")).toEqual([]); +}); + +test("library builds keep every guard", () => { + const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]); + mod.lib = {} as NonNullable; + expect(privateFields(mod, "P")).toEqual([]); +}); + +test("construction-only fields exclude later writes and dynamic stores", () => { + const mod = module( + [{ name: "P", fields: fields("a", "b"), loc }], + [ctor("P", [setThis("P", "a"), setThis("P", "b")])], + ); + mod.functions[0]!.body.push( + { kind: "fieldSet", obj: value(obj("P")), className: "P", field: "b", value: value(F64), loc }, + { kind: "exprStmt", expr: { kind: "dynFrom", value: value(obj("P")), type: DYN, loc }, loc }, + ); + const facts = computeImmutableFields(mod)!; + expect(facts.classField("P", "a")).toBe(true); + expect(facts.classField("P", "b")).toBe(false); + // A runtime store into a dynamic value can reach P through its capsule. + mod.functions[0]!.body.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [value(DYN), value(STRING), value(DYN)], + type: VOID, + loc, + }, + loc, + }); + expect(computeImmutableFields(mod)!.classField("P", "a")).toBe(false); +}); + +test("module constants are stable once assigned exactly once", () => { + const mod = module([], []); + mod.globals = [ + { id: "%g.k", name: "k", type: STRING, mutable: false }, + { id: "%g.m", name: "m", type: STRING, mutable: true }, + { id: "%g.t", name: "t", type: STRING, mutable: false, tdz: true }, + { id: "%g.twice", name: "twice", type: STRING, mutable: false }, + ]; + const assign = (localId: string): IrStmt => ({ + kind: "assign", + localId, + value: value(STRING), + loc, + }); + mod.functions[0]!.body.push( + assign("%g.k"), + assign("%g.m"), + assign("%g.t"), + assign("%g.twice"), + assign("%g.twice"), + ); + expect([...stableGlobals(mod)]).toEqual(["%g.k"]); +}); diff --git a/packages/compiler/src/backend/immutability.ts b/packages/compiler/src/backend/immutability.ts new file mode 100644 index 0000000000..1db228aade --- /dev/null +++ b/packages/compiler/src/backend/immutability.ts @@ -0,0 +1,215 @@ +/* Static immutability facts the backend uses to drop run-time immutability + * checks (see computeConstructionFacts). Pure functions of the IR module. */ +import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; +import { + computeCycleMutability, + constructorEscapePoint, + constructorThisAliases, + fieldKey, + type CycleMutability, +} from "./cycle-mutability.js"; + +/** Fields that are never written once their object can be observed outside + * its own construction (cycle-mutability.ts derives the facts from every + * store in the IR, including typed-ref commits from checked-dynamic code, + * and disables them for library builds and dynamic islands). + * + * Outside constructors, a reference read from such a field stays valid for + * as long as its owner object is alive: nothing can replace it and release + * the old value. Constructors themselves may still write their own + * `this`'s fields, so the facts do not apply while one runs. */ +export class ImmutableFields { + constructor( + private readonly mutability: CycleMutability, + private readonly unitOfClass: (className: string) => string, + ) {} + + /** `obj.field` on a class instance is construction-only. */ + classField(className: string, field: string): boolean { + return this.unit(this.unitOfClass(className), field); + } + + /** `obj.field` on a record is never written after the literal. */ + recordField(shapeId: string, field: string): boolean { + return this.unit(`record:${shapeId}`, field); + } + + private unit(unit: string, field: string): boolean { + return ( + !this.mutability.all && + !this.mutability.units.has(unit) && + !this.mutability.fields.has(fieldKey(unit, field)) + ); + } +} + +export function computeImmutableFields(mod: IrModule): ImmutableFields | null { + if (mod.lib !== undefined) return null; + const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const)); + const roots = new Map(); + const rootOf = (name: string): string => { + const known = roots.get(name); + if (known !== undefined) return known; + let cur = name; + for (let base = baseOf.get(cur); base !== null && base !== undefined; base = baseOf.get(cur)) + cur = base; + roots.set(name, cur); + return cur; + }; + const unitOfClass = (name: string): string => `object:${rootOf(name)}`; + const mutability = computeCycleMutability(mod, unitOfClass); + return mutability.all ? null : new ImmutableFields(mutability, unitOfClass); +} + +/** Stores that provably target an object no other code can reach yet. + * + * A published object (@scriptc/threads `publish`) rejects every write, so + * the emitter guards each store into a type the program may publish. A + * store into the constructor's own `this` cannot target a published object + * when nothing outside the construction can hold `this` at that point: + * + * - the constructor runs on a fresh object: it is only reached through + * `new` (or a construct thunk) for its class or a subclass, never by a + * direct call on an existing object (`%C.constructor` called anywhere but + * as the direct super call of a subclass constructor disqualifies C and + * every ancestor, which that call reaches with the same receiver); + * - no ancestor constructor, which runs before this body continues, lets + * `this` escape, and the store lies in a top-level statement before the + * first one in which this constructor lets it escape + * (cycle-mutability's constructorEscapePoint: `this`, or a constant alias + * of it, may only be a field-store target, a field-read receiver, the + * direct base constructor's receiver, or stored into one of its own + * fields; closures and class references capturing it count as escapes). + * A subclass constructor's code only runs after this body returns. + * + * Library builds keep every guard: their constructors can be invoked by an + * embedder in ways the module does not show. */ +export interface ConstructionFacts { + /** `fieldSet` statements whose target is a still-private `this`. */ + privateStores: Set; +} + +export function computeConstructionFacts(mod: IrModule): ConstructionFacts { + const facts: ConstructionFacts = { privateStores: new Set() }; + if (mod.lib !== undefined) return facts; + const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const)); + const ctorClass = new Map([...classes.keys()].map((name) => [`%${name}.constructor`, name])); + const fnByName = new Map(mod.functions.map((fn) => [fn.name, fn] as const)); + const thisOf = (name: string): string | null => + fnByName.get(`%${name}.constructor`)?.params[0]?.localId ?? null; + const isThisRef = (e: IrExpr, thisId: string | null): boolean => { + if (thisId === null) return false; + if (e.kind === "varRef") return e.localId === thisId; + if (e.kind === "upcast") return isThisRef(e.value, thisId); + if (e.kind === "seqExpr") return isThisRef(e.result, thisId); + return false; + }; + + // Constructors reached with a receiver that may not be fresh. + const reused = new Set(); + const markReused = (name: string): void => { + for (let c: string | undefined = name; c !== undefined && classes.has(c);) { + if (reused.has(c)) break; + reused.add(c); + c = classes.get(c)!.base; + } + }; + for (const fn of mod.functions) { + const own = ctorClass.get(fn.name); + const thisId = own !== undefined ? thisOf(own) : null; + const baseCtor = + own !== undefined && classes.get(own)!.base !== undefined + ? `%${classes.get(own)!.base}.constructor` + : null; + const expr = (e: IrExpr): boolean => { + if (e.kind === "call") { + const target = ctorClass.get(e.callee); + if ( + target !== undefined && + !(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId)) + ) + markReused(target); + } else if (e.kind === "closure") { + const target = ctorClass.get(e.fnName); + if (target !== undefined) markReused(target); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); + fn.body.every(stmt); + } + + // The first escaping statement of each constructor (body length: none). + // A class without a constructor function of its own (runtime roots) is + // treated as escaping at once. + const points = new Map(); + const escapePoint = (name: string): number => { + const known = points.get(name); + if (known !== undefined) return known; + const fn = fnByName.get(`%${name}.constructor`); + const base = classes.get(name)?.base; + const thisId = thisOf(name); + const point = + fn === undefined || thisId === null || classes.get(name)?.runtime === true + ? 0 + : constructorEscapePoint(fn, thisId, base !== undefined ? `%${base}.constructor` : null, { + selfStores: true, + }); + points.set(name, point); + return point; + }; + const ancestorEscapes = (name: string): boolean => { + for (let c = classes.get(name)?.base; c !== undefined; c = classes.get(c)?.base) { + const fn = fnByName.get(`%${c}.constructor`); + if (!classes.has(c) || fn === undefined || escapePoint(c) < fn.body.length) return true; + } + return false; + }; + + for (const [name] of classes) { + const fn = fnByName.get(`%${name}.constructor`); + if (fn === undefined || reused.has(name) || ancestorEscapes(name)) continue; + const point = escapePoint(name); + if (point === 0) continue; + const aliases = constructorThisAliases(fn, thisOf(name)!); + const isOwn = (e: IrExpr): boolean => + (e.kind === "varRef" && aliases.has(e.localId)) || + (e.kind === "upcast" && isOwn(e.value)) || + (e.kind === "seqExpr" && isOwn(e.result)); + const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt); + const stmt = (s: IrStmt): boolean => { + if (s.kind === "fieldSet" && isOwn(s.obj)) facts.privateStores.add(s); + return everyStmtChild(s, expr, stmt); + }; + for (let i = 0; i < point; i++) stmt(fn.body[i]!); + } + return facts; +} + +/** Module constants whose value never changes once initialized: immutable, + * not TDZ-checked (every read follows the initialization), no import-cycle + * init flag, and at most one assignment in the whole module. Such a global + * owns its value for the rest of the program, so a read of it can be + * borrowed across any later operand or call. */ +export function stableGlobals(mod: IrModule): Set { + const candidates = new Set( + (mod.globals ?? []) + .filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined) + .map((g) => g.id), + ); + const assigned = new Set(); + const expr = (e: IrExpr): boolean => { + if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (s.kind === "assign" && candidates.has(s.localId)) { + if (assigned.has(s.localId)) candidates.delete(s.localId); + assigned.add(s.localId); + } + return everyStmtChild(s, expr, stmt); + }; + for (const fn of mod.functions) fn.body.every(stmt); + return candidates; +} diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8ce49a03d3..6c2d54dade 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -193,6 +193,7 @@ import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js"; import { emitDynamicExpr } from "./expr-dynamic.js"; import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js"; import { PublishWalkers } from "./publish.js"; +import { computeImmutableFields, stableGlobals, type ImmutableFields } from "../immutability.js"; import { computePublishedTypes, type PublishedTypes } from "../publication.js"; import { emitJsInteropExpr, emitExpr } from "./expr-dispatch.js"; import { @@ -622,6 +623,9 @@ export class LlEmitter { readonly ffiHasRetainedCallback: boolean; private readonly ffiHasForeignCallback: boolean; private readonly globalTypes = new Map(); + /** Module constants that keep one value once initialized + * (immutability.ts stableGlobals). */ + private stableGlobals: ReadonlySet = new Set(); private readonly tdzGlobals = new Map(); readonly constantNumericTables: ReadonlyMap; /** May-throw analysis (computeMayThrow): pending @@ -637,6 +641,13 @@ export class LlEmitter { /** Static types whose values can be published (@scriptc/threads): their * stores carry the frozen-object guard. Null without publish calls. */ readonly publishedTypes: PublishedTypes | null; + /** Construction-only class and record fields (immutability.ts); null + * where the facts are disabled. */ + private readonly immutableFields: ImmutableFields | null; + /** Constructor function names: their bodies may still write their own + * `this`'s construction-only fields. */ + private readonly constructorFunctions: ReadonlySet; + private inConstructor = false; /** Method names with at least one may-throw implementation — the * virtualCall pending check's key. */ readonly mayThrowMethods = new Set(); @@ -842,6 +853,8 @@ export class LlEmitter { this.workerEntryPolls = mt.workerEntryPolls ?? null; this.stackChecks = mt.stackChecks ?? null; this.publishedTypes = computePublishedTypes(mod); + this.immutableFields = computeImmutableFields(mod); + this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`)); this.indirectMayThrow = mt.indirect || mod.workers === true; for (const cls of mod.classes ?? []) { for (const m of cls.methods ?? []) { @@ -977,6 +990,7 @@ export class LlEmitter { this.globalTypes.set(g.id, g.type); if (g.tdz) this.tdzGlobals.set(g.id, g.name); } + this.stableGlobals = stableGlobals(mod); // Runtime error, EventEmitter, and stream classes have known layouts. // Subclasses embed the ScrEmitter/ScrStream prefixes from classes.ts. const classes = mod.classes ?? []; @@ -5002,6 +5016,7 @@ export class LlEmitter { this.currentReturnType = fn.returnType; this.currentGenerator = fn.generator ?? null; this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); + this.inConstructor = this.constructorFunctions.has(fn.name); this.currentWasiCoro = null; this.logArgSlots = 0; @@ -5326,8 +5341,14 @@ export class LlEmitter { this.canBorrowCallArgument(value.then) && this.canBorrowCallArgument(value.else_) ); + // A construction-only field of a stable owner keeps its value until the + // owner dies: no later operand or callee can replace and release it. + if ((value.kind === "fieldGet" || value.kind === "recordGet") && this.immutableRead(value)) + return this.canBorrowCallArgument(value.obj); if (value.kind !== "varRef") return false; const binding = this.binding(value.localId); + // A module constant owns its value for the rest of the program. + if (binding.kind === "global") return this.stableGlobals.has(value.localId); return ( binding.kind === "local" && binding.local !== undefined && @@ -5340,6 +5361,17 @@ export class LlEmitter { ); } + /** A plain-pointer read of a construction-only field outside any + * constructor (ImmutableFields). */ + private immutableRead(e: ExprOf<"fieldGet" | "recordGet">): boolean { + if (this.inConstructor || this.immutableFields === null) return false; + const t = e.type.kind; + if (t !== "object" && t !== "record" && t !== "array" && t !== "string") return false; + return e.kind === "fieldGet" + ? this.immutableFields.classField(e.className, e.field) + : this.immutableFields.recordField(e.shapeId, e.field); + } + // ── statements ────────────────────────────────────────────────────────── private emitStmts(stmts: IrStmt[]): void { @@ -5692,21 +5724,21 @@ export class LlEmitter { const obj = this.emitStableReceiver(s.obj, [s.value]); // (The guard precedes the value here: the nullable store // evaluates it itself.) - this.emitPublishedFieldGuard(obj.name, s.className, s.field); + this.emitStoreGuard(obj.name, s); this.emitNullableFieldStore(obj.name, s.className, s.field, nullable, s.value); break; } if (s.kind === "fieldSet" && this.int32Slots.isField(s.className, s.field)) { const obj = this.emitStableReceiver(s.obj, [s.value]); const v = this.emitExpr(s.value); - this.emitPublishedFieldGuard(obj.name, s.className, s.field); + this.emitStoreGuard(obj.name, s); const { ptr } = this.classFieldPtr(obj.name, s.className, s.field); this.storeInt32Field(ptr, v, s.value); break; } const obj = this.emitStableReceiver(s.obj, [s.value]); const v = this.emitExpr(s.value); - if (s.kind === "fieldSet") this.emitPublishedFieldGuard(obj.name, s.className, s.field); + if (s.kind === "fieldSet") this.emitStoreGuard(obj.name, s); else if (this.publishedTypes?.recordGuarded(s.shapeId) === true) this.emitPublishedFieldGuard(obj.name, null, s.field); const { ptr, type } = @@ -7122,6 +7154,13 @@ export class LlEmitter { ]; } + /** The frozen-object guard of a class field store, unless the store + * provably targets a still-private `this` (PublishedTypes.privateStores). */ + private emitStoreGuard(obj: string, s: Extract): void { + if (this.publishedTypes?.privateStores.has(s) === true) return; + this.emitPublishedFieldGuard(obj, s.className, s.field); + } + /** The frozen-object guard of a class field or record field store: * Node's "Cannot assign to read only property" TypeError when the target * is published. `className` null means a record (`#`). */ diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index 7aec30dd62..39b8cfe069 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -216,6 +216,22 @@ test("reference fields borrow across preserving calls but snapshot across writes expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); 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. + 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({ + kind: "recordSet", + obj: ref("target", record), + shapeId: "text", + field: "value", + value: str("written"), + 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"); diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts index 7040539dd1..4f21bec5f3 100644 --- a/packages/compiler/src/backend/publication.ts +++ b/packages/compiler/src/backend/publication.ts @@ -4,6 +4,7 @@ import type { IrExpr, IrModule, IrStmt, IrType } from "../ir/ir.js"; import { typeKey } from "../ir/ir.js"; import { everyStmtList } from "../ir/traverse.js"; +import { computeConstructionFacts } from "./immutability.js"; /** Array methods that write their receiver (the guarded ones). */ export const MUTATING_ARRAY_METHODS: ReadonlySet = new Set([ @@ -45,11 +46,19 @@ export class PublishedTypes { readonly records = new Set(); /** typeKeys of array, map and set types. */ readonly containers = new Set(); + /** Constructor stores into a `this` nothing else can reach yet + * (immutability.ts): never a published target, so never guarded. */ + privateStores: ReadonlySet = new Set(); classGuarded(className: string): boolean { return this.classes.has(className); } + /** Whether a class field store needs the guard. */ + fieldStoreGuarded(s: IrStmt): boolean { + return s.kind === "fieldSet" && this.classes.has(s.className) && !this.privateStores.has(s); + } + recordGuarded(shapeId: string): boolean { return this.records.has(shapeId); } @@ -83,6 +92,7 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null { const records = new Map((mod.records ?? []).map((r) => [r.id, r])); const unions = new Map((mod.unions ?? []).map((u) => [u.id, u])); const out = new PublishedTypes(); + out.privateStores = computeConstructionFacts(mod).privateStores; const seen = new Set(); const stack = [...roots]; const addHierarchy = (className: string): void => { @@ -142,6 +152,7 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null { export function writesPublished(published: PublishedTypes, node: IrExpr | IrStmt): boolean { switch (node.kind) { case "fieldSet": + return published.fieldStoreGuarded(node); case "fieldIncDec": return published.classGuarded(node.className); case "recordSet": diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b99f04249f..bed5497d01 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15749,6 +15749,24 @@ ], "diags": [] }, + "/tests/corpus/4598-publish-constructor-stores.ts": { + "order": [ + "/tests/corpus/4598-publish-constructor-stores.ts" + ], + "diags": [] + }, + "/tests/corpus/4599-construction-only-field-borrows.ts": { + "order": [ + "/tests/corpus/4599-construction-only-field-borrows.ts" + ], + "diags": [] + }, + "/tests/corpus/4600-construction-only-fields-dynamic-writes.ts": { + "order": [ + "/tests/corpus/4600-construction-only-fields-dynamic-writes.ts" + ], + "diags": [] + }, "/tests/corpus/4601-cycle-holders.ts": { "order": [ "/tests/corpus/4601-cycle-holders.ts" diff --git a/tests/corpus/4598-publish-constructor-stores.ts b/tests/corpus/4598-publish-constructor-stores.ts new file mode 100644 index 0000000000..14362ff547 --- /dev/null +++ b/tests/corpus/4598-publish-constructor-stores.ts @@ -0,0 +1,144 @@ +import { publish } from "@scriptc/threads"; + +// Constructor stores into a `this` that nothing else can reach yet can never +// hit a published object, so scriptc drops their frozen-object guard. Every +// constructor below that lets `this` escape before or between its stores +// must keep the guard: the escaped object can be published before the +// constructor finishes, and Node.js then rejects the remaining stores. + +function attempt(label: string, f: () => void): void { + try { + f(); + console.log(`${label}: ok`); + } catch (e) { + console.log(`${label}: ${e instanceof TypeError ? "TypeError" : "other"}: ${(e as Error).message}`); + } +} + +// Private construction: plain field stores, a compound assignment (whose +// receiver is a temporary alias of `this`), and a self reference. +class Plain { + flags = 1; + name: string; + self: Plain | undefined; + constructor(name: string, extra: number) { + this.name = name; + this.flags |= extra; + this.self = this; + } +} + +// The constructor publishes `this` before its last store. +class PublishesItself { + a: number; + b: number; + constructor() { + this.a = 1; + publish(this); + this.b = 2; + } +} + +// A base constructor registers `this`; the subclass constructor's store runs +// after a call that publishes everything registered. +const registry: Base[] = []; +class Base { + id: number; + constructor(id: number) { + this.id = id; + registry.push(this); + } +} +function publishRegistry(): number { + publish(registry); + return 7; +} +class Derived extends Base { + late: number; + constructor(id: number) { + super(id); + this.late = publishRegistry(); + } +} + +// `this` escapes through a closure created in the constructor. +let hook: (() => void) | undefined; +class Captured { + x: number; + y: number; + constructor() { + this.x = 1; + hook = () => { + publish(this); + }; + hook(); + this.y = 2; + } +} + +// `this` escapes as a method receiver. +class ViaMethod { + ready = false; + value = 0; + constructor() { + this.freeze(); + this.value = 5; + } + freeze(): void { + publish(this); + } +} + +attempt("plain", () => { + const p = new Plain("p", 6); + console.log(p.name, p.flags, p.self === p); + publish(p); + attempt("plain write after publish", () => { + p.flags = 9; + }); + attempt("plain compound after publish", () => { + p.flags |= 16; + }); + console.log(p.flags); +}); +attempt("publishes itself", () => { + new PublishesItself(); +}); +attempt("derived after base escape", () => { + new Derived(3); +}); +console.log(registry.length, registry[0]!.id); +attempt("captured", () => { + new Captured(); +}); +attempt("via method", () => { + new ViaMethod(); +}); + +// Many private constructions, published in bulk afterwards: every object +// stays writable until its own publish. +class Point { + x: number; + y: number; + next: Point | null; + constructor(x: number, y: number, next: Point | null) { + this.x = x; + this.y = y; + this.next = next; + } +} +let head: Point | null = null; +for (let i = 0; i < 1000; i++) head = new Point(i, i * 2, head); +let sum = 0; +for (let p = head; p !== null; p = p.next) { + p.y += 1; + sum += p.x + p.y; +} +console.log(sum); +publish(head); +attempt("point write after publish", () => { + head!.next!.x = 0; +}); +const fresh = new Point(1, 2, head); +fresh.x = 10; +console.log(fresh.x, fresh.next === head); diff --git a/tests/corpus/4599-construction-only-field-borrows.ts b/tests/corpus/4599-construction-only-field-borrows.ts new file mode 100644 index 0000000000..f9e7f4bb48 --- /dev/null +++ b/tests/corpus/4599-construction-only-field-borrows.ts @@ -0,0 +1,149 @@ +// A field that is only ever written while its object is being constructed +// keeps its value for as long as the object lives, so scriptc reads it +// without taking a reference when the owner is an unchanged local. Each +// callee below drops every other reference it can reach before it uses the +// argument; fields that can be written later, and reads inside constructors +// (which may still write their own fields), must keep owning their values. + +class Item { + readonly name: string; + readonly tags: string[]; + constructor(name: string, tags: string[]) { + this.name = name; + this.tags = tags; + } +} + +class Holder { + readonly item: Item; + readonly next: Holder | null; + constructor(item: Item, next: Holder | null) { + this.item = item; + this.next = next; + } +} + +class Swappable { + item: Item; + constructor(item: Item) { + this.item = item; + } + swap(next: Item): void { + this.item = next; + } +} + +function describe(item: Item): string { + return `${item.name}[${item.tags.join(",")}]`; +} + +function useAfter(item: Item, drop: () => void): string { + drop(); + // Allocate so a freed item's memory would be reused. + const noise: Item[] = []; + for (let i = 0; i < 64; i++) noise.push(new Item(`noise${i}`, [`t${i}`])); + return describe(item) + ` (${noise.length})`; +} + +let global: Holder | null = new Holder(new Item("g", ["a", "b"]), null); + +// An unchanged local owns the holder while the callback drops the global. +function construction(): string { + const h = global!; + return useAfter(h.item, () => { + global = null; + }); +} + +// A chain of construction-only fields under one local root. +function chain(): string { + let list: Holder | null = null; + for (let i = 0; i < 4; i++) list = new Holder(new Item(`n${i}`, [`${i}`]), list); + global = list; + const root = global!; + return useAfter(root.next!.next!.item, () => { + global = null; + }); +} + +// A field the program writes after construction keeps its owner. +let swapper = new Swappable(new Item("old", ["x"])); +function swapGlobal(): void { + swapper.swap(new Item("new", ["y"])); +} +function written(): string { + const s = swapper; + const before = useAfter(s.item, swapGlobal); + return `${before} -> ${describe(s.item)}`; +} + +// A record field that is never written after the literal. +interface Pair { + left: Item; + right: Item; +} +let pairs: Pair[] = [{ left: new Item("l", ["1"]), right: new Item("r", ["2"]) }]; +function record(): string { + const p = pairs[0]!; + return useAfter(p.right, () => { + pairs = []; + }); +} + +// A stable alias of a construction-only field outlives later calls. +function alias(): string { + global = new Holder(new Item("alias", ["z"]), null); + const h = global; + const it = h.item; + global = null; + const noise: Holder[] = []; + for (let i = 0; i < 32; i++) noise.push(new Holder(new Item(`m${i}`, []), null)); + return `${describe(it)} ${noise.length}`; +} + +// A constructor reads its own field while a later operand replaces it. +function pair(a: Item, b: Item): string { + return `${describe(a)}+${describe(b)}`; +} +class Rebuilt { + current: Item; + label: string; + constructor(first: Item, second: Item) { + this.current = first; + this.label = pair(this.current, (this.current = second)); + } +} +function constructorRead(): string { + const r = new Rebuilt(new Item("first", ["f"]), new Item("second", ["s"])); + return `${r.label} now ${describe(r.current)}`; +} + +// A module constant can be read without a reference. +const TABLE: Item[] = [new Item("t0", ["k"]), new Item("t1", ["k", "l"])]; +function tableSize(items: Item[], drop: () => void): number { + drop(); + return items.length + items[1]!.tags.length; +} +function moduleConstant(): string { + return `${tableSize(TABLE, () => { + global = null; + })}`; +} + +console.log(construction()); +console.log(chain()); +console.log(written()); +console.log(record()); +console.log(alias()); +console.log(constructorRead()); +console.log(moduleConstant()); + +let total = 0; +for (let round = 0; round < 2000; round++) { + global = new Holder(new Item(`r${round}`, ["q"]), new Holder(new Item("inner", []), null)); + const h = global; + total += useAfter(h.next!.item, () => { + global = null; + }).length; +} +console.log(total); diff --git a/tests/corpus/4600-construction-only-fields-dynamic-writes.ts b/tests/corpus/4600-construction-only-fields-dynamic-writes.ts new file mode 100644 index 0000000000..6114046f05 --- /dev/null +++ b/tests/corpus/4600-construction-only-fields-dynamic-writes.ts @@ -0,0 +1,62 @@ +// Untyped code can write a field that the typed program only assigns in its +// constructor: the class instance keeps its identity behind `any`, and the +// write lands in the native field. Once a program contains such a store, +// every field reachable from a value that crosses into untyped code must be +// treated as writable, so reads of it keep owning their values while a +// callee replaces them. + +class Item { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} + +class Holder { + readonly item: Item; + readonly label: string; + constructor(item: Item, label: string) { + this.item = item; + this.label = label; + } +} + +function replace(target: any, value: any): void { + target.item = value; +} + +let shared = new Holder(new Item("before"), "h"); + +function useAfter(item: Item, drop: () => void): string { + drop(); + const noise: Item[] = []; + for (let i = 0; i < 64; i++) noise.push(new Item(`noise${i}`)); + return `${item.name} (${noise.length})`; +} + +function viaAny(): string { + const h = shared; + const seen = useAfter(h.item, () => { + replace(shared, new Item("after")); + }); + return `${seen} -> ${h.item.name}`; +} + +const box: { holder: Holder } = { holder: new Holder(new Item("boxed"), "b") }; +function viaRecord(): string { + const b = box; + const seen = useAfter(b.holder.item, () => { + (b.holder as unknown as { item: Item }).item = new Item("cast"); + }); + return `${seen} -> ${b.holder.item.name}`; +} + +console.log(viaAny()); +console.log(viaRecord()); +let n = 0; +for (let i = 0; i < 500; i++) { + shared = new Holder(new Item(`r${i}`), "loop"); + const h = shared; + n += useAfter(h.item, () => replace(shared, new Item("x"))).length + h.item.name.length; +} +console.log(n); From 7df72c017a6345a37ae7ffaf1a38e9de92888714 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:44:32 -0700 Subject: [PATCH 02/12] 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 af921a1055..16839afd68 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 6c2d54dade..45a01fd18e 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 d707de5558..afd2ae7b08 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 75435e7a91..07ae3ed9f0 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 0000000000..9eeef0cea3 --- /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 39b8cfe069..bfcaeeb6c1 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 7ba0485739..e130fc6d72 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 0000000000..6a2160bd20 --- /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 0000000000..4d5e04ecb8 --- /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 0000000000..98fcdbad31 --- /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 0000000000..8ba1e8991e --- /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 03/12] 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 45a01fd18e..24ac103647 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 04/12] 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 24ac103647..672f56f879 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 e130fc6d72..dc3e03c195 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 0000000000..dab04a34bc --- /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 05/12] 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 f307fde545..55479ad5fd 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 672f56f879..8430c87b04 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 9eeef0cea3..fac6fd35c6 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 06/12] 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 bed5497d01..ec4df36f5f 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 07/12] 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 19ab0ff5aa..646e901c99 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 08/12] 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 4f59d8bbc5..183a1a4262 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"); }); From 74029bada408552e2b3c91196dd94ebaa745cda0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:08 -0700 Subject: [PATCH 09/12] Expect only cycle members to be traced in the self-hosting analysis cases Since #837 a type is traced only if it lies in a cycle-capable component or reaches an intrinsic reference; a type that merely holds a cycle cannot be reached back from it. In both backend-analysis cases `record:outer` only holds `record:cycle`, so the Node oracle and the native compiler agree on ["record:cycle"] and the expectations still listed `record:outer`. --- tests/harness/self-hosting-backend-cases.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/harness/self-hosting-backend-cases.ts b/tests/harness/self-hosting-backend-cases.ts index 649c8b3fc4..6f89aafd3a 100644 --- a/tests/harness/self-hosting-backend-cases.ts +++ b/tests/harness/self-hosting-backend-cases.ts @@ -244,10 +244,12 @@ export function backendAnalysisCases(): BackendAnalysisCase[] { { id: "recursive", arms: [record("cycle"), { kind: "nullT" }] }, { id: "acyclic", arms: [record("chain"), F64] }, ]; + // `outer` holds the cycle but nothing reaches back to it, so only the + // cycle's own component is traced (cycle-analysis.ts). cases.push({ name: "shape and union fixed point", module: graph, - shapes: ["record:outer", "record:cycle"], + shapes: ["record:cycle"], unions: ["recursive"], }); const hierarchy = module(); @@ -343,6 +345,7 @@ export function backendAnalysisCases(): BackendAnalysisCase[] { }); // Constructor-only references are proven acyclic (cycle-analysis.ts), so // the cyclic branch closes through an array edge, which is always mutable. + // `outer` only holds that cycle, so it stays untraced like the leaf. const branch = module(); branch.records = [ { id: "leaf", fields: [{ name: "value", type: F64 }] }, @@ -358,7 +361,7 @@ export function backendAnalysisCases(): BackendAnalysisCase[] { cases.push({ name: "removing a leaf preserves another cyclic branch", module: branch, - shapes: ["record:cycle", "record:outer"], + shapes: ["record:cycle"], }); const tableId = "%g.table"; From 8b5cf78e197ae0f081cf698e3219ad29208c404c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 14:45:30 -0500 Subject: [PATCH 10/12] Update constant-table emission test for exact index checks (cherry picked from commit 9b80f3d4b4bda1d352c4cb2db6638fe168d6cf13) --- packages/compiler/test/constant-table-emission.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/compiler/test/constant-table-emission.test.ts b/packages/compiler/test/constant-table-emission.test.ts index 60be745072..5fddb26630 100644 --- a/packages/compiler/test/constant-table-emission.test.ts +++ b/packages/compiler/test/constant-table-emission.test.ts @@ -33,14 +33,15 @@ console.log(lookup(1), mutated[0] * 1); expect(llvm).toContain("@sc_const_numbers_0 = private constant [3 x double]"); expect(llvm).toContain("%initialized = icmp ne ptr %a, null"); - expect(llvm).toContain("br i1 %safe, label %convert, label %fallback"); - expect(llvm).toContain("br i1 %integer, label %read, label %fallback"); + expect(llvm).toContain("%ix0 = fptosi double %i to i64"); + expect(llvm).toContain("%safe = and i1 %initialized, %ix5"); + expect(llvm).toContain("br i1 %safe, label %read, label %fallback"); expect(llvm).toMatch(/call double @sc_const_numbers_0_get\(/); expect(llvm).toContain("call double @scr_arr_get_number(ptr %a, double %i)"); expect(llvm).toMatch(/call double @scr_arr_push_many\(ptr [^,]+, i64 3, ptr /); const wasm = emitLlvmModule(mod, { pointerBits: 32, wasi: true }); - expect(wasm).toContain("%index = fptoui double %i to i32"); - expect(wasm).toContain("ptr @sc_const_numbers_0, i32 0, i32 %index"); + expect(wasm).toContain("%ix0 = fptosi double %i to i64"); + expect(wasm).toContain("ptr @sc_const_numbers_0, i64 0, i64 %ix1"); } finally { await rm(dir, { recursive: true, force: true }); } From f43ef506db1d928d045f97c8f6c95ef8be1a57bb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 14:48:03 -0500 Subject: [PATCH 11/12] Update byte-loop emission test for exact index checks (cherry picked from commit f3cfa80a8ebfb065de51558df4ade06861a6a422) --- packages/compiler/test/bytes-element-emission.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 9536879939..3c001a148c 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -314,7 +314,7 @@ test("canonical byte loops keep their induction variable and indices integral", expect(ll).toContain("alloca i64 ; integer induction index"); expect(ll).toContain("icmp ult i64"); - expect(ll).not.toContain("bytes.index.range"); + expect(ll).not.toContain("fptosi double"); }); test("a body mutation keeps the byte loop on the general f64 path", () => { @@ -324,8 +324,8 @@ test("a body mutation keeps the byte loop on the general f64 path", () => { const ll = emitLlvmModule(mod); expect(ll).not.toContain("integer induction index"); - expect(ll).toContain("bytes.index.range"); - expect(ll).toContain("fptoui double"); + expect(ll).toContain("bytes.index.valid"); + expect(ll).toContain("fptosi double"); }); test("large record clones stay outlined while small clones remain inlineable", () => { From 757c48c2f808c505b782e04de429019048f90220 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 13:10:06 -0700 Subject: [PATCH 12/12] Anchor the canonical byte-loop check on the element-check blocks f3cfa80a asserted that the canonical byte loop contains no `fptosi double` at all, meaning no exact-index round trip. The fixture also coerces its running sum with ToUint32, whose inline fast path legitimately uses `fptosi double ... to i64`, so the test fails on main (aaccbb39) as well; #865 merged before the shards that run it. Assert instead that no `bytes.index` check block is emitted, which keeps the original intent (no unproven-index path for a proven in-bounds induction index). --- packages/compiler/test/bytes-element-emission.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 3c001a148c..80e3e0ecb9 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -314,7 +314,10 @@ test("canonical byte loops keep their induction variable and indices integral", expect(ll).toContain("alloca i64 ; integer induction index"); expect(ll).toContain("icmp ult i64"); - expect(ll).not.toContain("fptosi double"); + // The proven in-bounds index needs no element check at all. Do not assert + // on `fptosi double` here: the fixture's inline ToUint32 coercion of the + // running sum legitimately uses it. + expect(ll).not.toContain("bytes.index"); }); test("a body mutation keeps the byte loop on the general f64 path", () => {