diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 88183d1b2..4f83f89df 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -99,6 +99,7 @@ import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js" import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js"; import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js"; +import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js"; import { findIntegerViews } from "./integer-views.js"; import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; @@ -734,6 +735,11 @@ export class LlEmitter { { slot: string; type: "i32" | "i64"; signed?: boolean; range?: { min: number; max: number } } >(); private countedLoopsEnabled = false; + /** Whole-program final reference fields (ir/final-fields.ts). */ + private finalFields!: FinalFields; + /** The current function may borrow final fields of borrowable objects: + * not a constructor (where they change), not async or a generator. */ + private finalFieldBorrows = false; integerRanges: IntegerRanges = new Map(); private byteWindowsEnabled = true; private byteWindowEntries: ReturnType = new Map(); @@ -808,6 +814,7 @@ export class LlEmitter { this.constantNumericTables = findConstantNumericTables(mod); this.initializerBindings = findInitializerBindings(mod); this.int32Slots = analyzeInt32Slots(mod); + this.finalFields = analyzeFinalFields(mod); this.sizeType = options.pointerBits === 32 ? "i32" : "i64"; this.ffiExtendNarrowIntegers = options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); @@ -4942,6 +4949,8 @@ export class LlEmitter { ); this.integerLoopBindings.clear(); this.countedLoopsEnabled = this.debug === null && !fn.async && !fn.generator; + this.finalFieldBorrows = + !fn.async && !fn.generator && constructorClass(fn.name) === null && this.finalFields.size > 0; this.byteWindowsEnabled = this.countedLoopsEnabled; this.byteWindowEntries = this.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes") @@ -5311,6 +5320,17 @@ export class LlEmitter { // stable owner. The throwing arm's typed dummy owns nothing. if (value.kind === "unionNarrow") return this.canBorrowCallArgument(value.value); if (value.kind === "libCall") return value.fn === "error.nodeThrow"; + // A final field never changes outside constructors: while its object is + // borrowable, so is the field's value. Nullable-pointer fields have no + // box to lend and keep their owning path. + if ( + value.kind === "fieldGet" && + this.finalFieldBorrows && + isRefCounted(value.type) && + this.finalFields.isFinal(value.className, value.field) && + !this.nullableFieldGet(value) + ) + return this.canBorrowReceiver(value.obj) && this.canBorrowCallArgument(value.obj); if (value.kind === "ternary") return ( this.canBorrowReceiver(value.then) && diff --git a/packages/compiler/src/ir/final-fields.test.ts b/packages/compiler/src/ir/final-fields.test.ts new file mode 100644 index 000000000..385c3fbde --- /dev/null +++ b/packages/compiler/src/ir/final-fields.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "vitest"; +import { analyzeFinalFields, constructorClass } from "./final-fields.js"; +import { + BOOL, + STRING, + VOID, + type IrClassDef, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, + type IrType, +} from "./ir.js"; + +const loc = { file: "final-fields.ts", start: 0, end: 0 }; +const obj = (className: string): IrType => ({ kind: "object", className }); +const ref = (localId: string, type: IrType): IrExpr => ({ loc, kind: "varRef", localId, type }); +const str = (value: string): IrExpr => ({ loc, kind: "strLit", value, type: STRING }); +const set = (target: IrExpr, className: string, field: string, value: IrExpr): IrStmt => ({ + loc, + kind: "fieldSet", + obj: target, + className, + field, + value, +}); +const fn = (name: string, self: string | null, body: IrStmt[]): IrFunction => ({ + loc, + name, + params: self ? [{ localId: "this", name: "this", type: obj(self) }] : [], + locals: self ? [{ id: "this", name: "this", type: obj(self), mutable: false }] : [], + returnType: VOID, + body, +}); +const cls = (name: string, fields: string[], base?: string): IrClassDef => ({ + loc, + name, + ...(base ? { base } : {}), + fields: fields.map((field) => ({ name: field, type: STRING })), +}); +const mod = (classes: IrClassDef[], functions: IrFunction[]): IrModule => ({ + irVersion: 15, + sourceFile: "final-fields.ts", + entry: "%main", + classes, + functions: [fn("%main", null, []), ...functions], + records: [], + unions: [], + globals: [], +}); +const self = (className: string): IrExpr => ref("this", obj(className)); + +test("constructor names map to their class", () => { + expect(constructorClass("%%m1.Parser.constructor")).toBe("%m1.Parser"); + expect(constructorClass("%%m1.Parser.parse")).toBeNull(); +}); + +test("fields written only through their constructors' this are final", () => { + const finals = analyzeFinalFields( + mod( + [cls("A", ["kept", "moved"]), cls("B", ["kept", "moved", "own"], "A")], + [ + fn("%A.constructor", "A", [ + set(self("A"), "A", "kept", str("a")), + set(self("A"), "A", "kept", str("twice")), + set(self("A"), "A", "moved", str("a")), + ]), + // A subclass constructor initializes its base's field through a guard. + fn("%B.constructor", "B", [ + set( + { + loc, + kind: "seqExpr", + stmts: [ + { + loc, + kind: "if", + cond: { + loc, + kind: "unary", + op: "!", + operand: ref("superCalled", BOOL), + type: BOOL, + }, + then: [ + { + loc, + kind: "exprStmt", + expr: { + loc, + kind: "libCall", + fn: "error.nodeThrow", + args: [str("ReferenceError")], + type: VOID, + }, + }, + ], + else_: null, + }, + ], + result: self("B"), + type: obj("B"), + }, + "B", + "own", + str("b"), + ), + ]), + // Any write elsewhere disqualifies the field family. + fn("%A.reset", "A", [set(self("A"), "A", "moved", str("later"))]), + ], + ), + ); + expect(finals.isFinal("A", "kept")).toBe(true); + expect(finals.isFinal("B", "kept")).toBe(true); + expect(finals.isFinal("B", "own")).toBe(true); + expect(finals.isFinal("A", "moved")).toBe(false); + expect(finals.isFinal("B", "moved")).toBe(false); + expect(finals.reasons.get("A\0moved")).toBe("written in %A.reset"); +}); + +test("constructors writing another object, and library builds, are not final", () => { + const other = fn("%A.constructor", "A", [set(ref("peer", obj("A")), "A", "kept", str("a"))]); + other.locals.push({ id: "peer", name: "peer", type: obj("A"), mutable: false }); + expect(analyzeFinalFields(mod([cls("A", ["kept"])], [other])).isFinal("A", "kept")).toBe(false); + const library = mod([cls("A", ["kept"])], []); + library.lib = { profile: "p" } as unknown as NonNullable; + expect(analyzeFinalFields(library).size).toBe(0); +}); diff --git a/packages/compiler/src/ir/final-fields.ts b/packages/compiler/src/ir/final-fields.ts new file mode 100644 index 000000000..e37c13bd6 --- /dev/null +++ b/packages/compiler/src/ir/final-fields.ts @@ -0,0 +1,177 @@ +import { isRefCounted, type IrClassDef, type IrExpr, type IrModule, type IrStmt } from "./ir.js"; +import { dynamicFieldStores } from "./int32-slots.js"; +import { everyExprChild, everyStmtChild } from "./traverse.js"; + +/** Whole-program final reference fields. + * + * A reference-counted class field is final when every write the program can + * make to it is a constructor of its family storing through that + * constructor's own `this`. Outside the constructors such a field never + * changes, so while its object is alive the field keeps its value alive: a + * read of `o.f` may be borrowed for as long as `o` itself is (`this.scanner` + * inside a method, across any call), without a retain and a release. + * Constructors are excluded from that use, because they are where the field + * changes; so are async functions and generators, whose suspensions let + * other code (a constructor that is still running) continue. + * + * Writes the IR does not show disqualify fields up front: library builds + * (instances cross a native boundary), live dynamic views whose stores can + * name the field (see dynamicFieldStores), and classes with runtime-provided + * or generic ancestors. Generated stores only initialize fresh objects or + * tear down dead ones. */ +export class FinalFields { + private readonly finals = new Set(); + private readonly familyOf = new Map(); + /** Disqualified families and the first reason, for tests. */ + readonly reasons = new Map(); + + constructor(private readonly classes: ReadonlyMap = new Map()) {} + + /** The topmost class in the base chain declaring the field, and the field. */ + family(className: string, field: string): string | null { + const key = `${className}\0${field}`; + const cached = this.familyOf.get(key); + if (cached !== undefined) return cached; + let owner: IrClassDef | undefined = this.classes.get(className); + let result: string | null = null; + if (owner?.fields.some((f) => f.name === field)) { + for (;;) { + const base: IrClassDef | undefined = owner.base ? this.classes.get(owner.base) : undefined; + if (!base?.fields.some((f) => f.name === field)) break; + owner = base; + } + result = `${owner.name}\0${field}`; + } + this.familyOf.set(key, result); + return result; + } + + isFinal(className: string, field: string): boolean { + if (this.finals.size === 0) return false; + const family = this.family(className, field); + return family !== null && this.finals.has(family); + } + + get size(): number { + return this.finals.size; + } + + /* ── construction (analyzeFinalFields only) ── */ + add(family: string): void { + this.finals.add(family); + } + drop(family: string, why: string): void { + if (!this.reasons.has(family)) this.reasons.set(family, why); + this.finals.delete(family); + } +} + +const CONSTRUCTOR_SUFFIX = ".constructor"; + +/** `%C.constructor`'s class name, or null for any other function. */ +export function constructorClass(fnName: string): string | null { + return fnName.startsWith("%") && fnName.endsWith(CONSTRUCTOR_SUFFIX) + ? fnName.slice(1, -CONSTRUCTOR_SUFFIX.length) + : null; +} + +export function analyzeFinalFields(mod: IrModule): FinalFields { + const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); + const finals = new FinalFields(classes); + if (mod.lib) return finals; + const dynamic = dynamicFieldStores(mod); + if (dynamic.all) return finals; + + const genericFamilies = new Set(); + for (const cls of classes.values()) + if (cls.genericOf !== undefined) { + genericFamilies.add(cls.name); + genericFamilies.add(cls.genericOf); + } + const plainClass = (cls: IrClassDef): boolean => { + for (let c: IrClassDef | undefined = cls; c; c = c.base ? classes.get(c.base) : undefined) { + if (c.runtime || genericFamilies.has(c.name)) return false; + if (c.base && !classes.has(c.base)) return false; + } + return true; + }; + const inFamily = (className: string, family: string): boolean => { + const owner = family.slice(0, family.indexOf("\0")); + for (let c = classes.get(className); c; c = c.base ? classes.get(c.base) : undefined) + if (c.name === owner) return true; + return false; + }; + + for (const cls of classes.values()) + for (const field of cls.fields) { + if (!isRefCounted(field.type) || field.name.startsWith("%")) continue; + const family = finals.family(cls.name, field.name)!; + if (finals.reasons.has(family)) continue; + if (!plainClass(cls)) finals.drop(family, "runtime-provided or generic class"); + else if (dynamic.names.has(field.name)) + finals.drop(family, "dynamic stores can name the field"); + else finals.add(family); + } + if (finals.size === 0) return finals; + + for (const fn of mod.functions) { + const ctor = constructorClass(fn.name); + const self = ctor !== null ? fn.params[0]?.localId : undefined; + const selfLocal = self !== undefined ? fn.locals.find((l) => l.id === self) : undefined; + // `this` must name the object under construction for the whole body. + const constructing = selfLocal !== undefined && !selfLocal.boxed && !reassigns(fn.body, self!); + const stmt = (s: IrStmt): boolean => { + if (s.kind === "fieldSet") { + const family = finals.family(s.className, s.field); + if (family !== null && finals.isFinal(s.className, s.field)) { + const own = + constructing && ctor !== null && inFamily(ctor, family) && isSelf(s.obj, self!); + if (!own) finals.drop(family, `written in ${fn.name}`); + } + } + return everyStmtChild(s, expr, stmt); + }; + const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt); + for (const s of fn.body) stmt(s); + } + return finals; +} + +/** `this` itself, or behind the derived constructor's guards (`if (!super + * called) throw`), which read no reference and write nothing. */ +function isSelf(e: IrExpr, self: string): boolean { + if (e.kind === "varRef") return e.localId === self; + if (e.kind !== "seqExpr") return false; + for (const s of e.stmts) { + if (s.kind !== "if" || s.else_ !== null || s.then.length !== 1) return false; + const only = s.then[0]!; + if ( + only.kind !== "exprStmt" || + only.expr.kind !== "libCall" || + only.expr.fn !== "error.nodeThrow" || + !only.expr.args.every((arg) => arg.kind === "numLit" || arg.kind === "strLit") || + !throwFreeCondition(s.cond) + ) + return false; + } + return isSelf(e.result, self); +} + +function throwFreeCondition(e: IrExpr): boolean { + if (e.kind === "varRef" || e.kind === "boolLit") return e.type.kind === "bool"; + return e.kind === "unary" && throwFreeCondition(e.operand); +} + +function reassigns(body: IrStmt[], localId: string): boolean { + let found = false; + const expr = (e: IrExpr): boolean => { + if ((e.kind === "assignExpr" || e.kind === "incDec") && e.localId === localId) found = true; + return !found && everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (s.kind === "assign" && s.localId === localId && s.initializes !== true) found = true; + return !found && everyStmtChild(s, expr, stmt); + }; + for (const s of body) if (!stmt(s)) break; + return found; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index ad790b904..2baf80819 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -271,6 +271,72 @@ function walkFunction( for (const s of fn.body) stmt(s); } +/** The class field names that runtime stores through live dynamic views + * (typed-ref capsules) can write: every name when `all` (islands, jsval + * values, or a dynamic store whose key is not a literal), otherwise the + * literal keys of `dyn.keySet`/`dyn.keyDelete`. A view commits a store back + * into the instance's native field slots, which the IR does not show. */ +export function dynamicFieldStores( + mod: IrModule, + records = new Map((mod.records ?? []).map((r) => [r.id, r])), + unions = new Map((mod.unions ?? []).map((u) => [u.id, u])), +): { all: boolean; names: Set } { + const dynTypes = new Map(); + const dynNamed = new Map(); + const dynish = (type: IrType): boolean => { + if (DYN_KINDS.has(type.kind)) return true; + const cached = dynTypes.get(type); + if (cached !== undefined) return cached; + let result = false; + const named = + type.kind === "record" + ? `r:${type.shapeId}` + : type.kind === "union" + ? `u:${type.unionId}` + : null; + if (named) { + const known = dynNamed.get(named); + if (known !== undefined) return known; + dynNamed.set(named, false); + const members = + type.kind === "record" + ? [ + ...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []), + ...(records.get(type.shapeId)?.indexValue + ? [records.get(type.shapeId)!.indexValue!] + : []), + ] + : type.kind === "union" + ? (unions.get(type.unionId)?.arms ?? []) + : []; + result = members.some(dynish); + dynNamed.set(named, result); + } else everyTypeChild(type, (child) => !(result = dynish(child))); + dynTypes.set(type, result); + return result; + }; + const dynWritten = new Set(); + let dynWritesAll = false; + for (const fn of mod.functions) + walkFunction(fn, { + expr: (e) => { + if (dynWritesAll) return; + if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true; + else if ( + e.kind === "libCall" && + !DYN_READ_ONLY.has(e.fn) && + e.args.some((a) => dynish(a.type)) + ) { + const key = e.args[1]; + if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit") + dynWritten.add(key.value); + else dynWritesAll = true; + } + }, + }); + return { all: dynWritesAll, names: dynWritten }; +} + export function analyzeInt32Slots(mod: IrModule): Int32Slots { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const slots = new Int32Slots(classes); @@ -371,59 +437,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { // writes every field from the view, which holds the exact snapshot value // of any property no store named. So reaching dynamic code disqualifies // exactly the field names some dynamic store in the program can name. - const dynTypes = new Map(); - const dynNamed = new Map(); - const dynish = (type: IrType): boolean => { - if (DYN_KINDS.has(type.kind)) return true; - const cached = dynTypes.get(type); - if (cached !== undefined) return cached; - let result = false; - const named = - type.kind === "record" - ? `r:${type.shapeId}` - : type.kind === "union" - ? `u:${type.unionId}` - : null; - if (named) { - const known = dynNamed.get(named); - if (known !== undefined) return known; - dynNamed.set(named, false); - const members = - type.kind === "record" - ? [ - ...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []), - ...(records.get(type.shapeId)?.indexValue - ? [records.get(type.shapeId)!.indexValue!] - : []), - ] - : type.kind === "union" - ? (unions.get(type.unionId)?.arms ?? []) - : []; - result = members.some(dynish); - dynNamed.set(named, result); - } else everyTypeChild(type, (child) => !(result = dynish(child))); - dynTypes.set(type, result); - return result; - }; - const dynWritten = new Set(); - let dynWritesAll = false; - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - if (dynWritesAll) return; - if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true; - else if ( - e.kind === "libCall" && - !DYN_READ_ONLY.has(e.fn) && - e.args.some((a) => dynish(a.type)) - ) { - const key = e.args[1]; - if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit") - dynWritten.add(key.value); - else dynWritesAll = true; - } - }, - }); + const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod, records, unions); for (const fn of mod.functions) walkFunction(fn, { expr: (e) => { diff --git a/tests/corpus/final-field-borrows.ts b/tests/corpus/final-field-borrows.ts new file mode 100644 index 000000000..f98b7dc9f --- /dev/null +++ b/tests/corpus/final-field-borrows.ts @@ -0,0 +1,127 @@ +// Reference fields written only by their class's constructors may be read +// without a retain wherever their object is held. Every other field keeps +// its owning reads: a callee may replace it while the caller still uses the +// old value. + +class Buffer2 { + readonly items: string[] = []; + label: string; + constructor(label: string) { + this.label = label; + } + push(item: string): number { + this.items.push(item); + return this.items.length; + } + describe(): string { + return `${this.label}:${this.items.join(",")}`; + } +} + +class Owner { + // Final: written only here, twice, with a method call between the writes. + readonly primary: Buffer2; + // Not final: replaced by `rotate`. + current: Buffer2; + log: string[] = []; + constructor() { + this.primary = new Buffer2("first"); + this.current = new Buffer2("c0"); + this.log.push(this.peek()); + this.primary = new Buffer2("primary"); + this.log.push(this.peek()); + } + peek(): string { + const primary = this.primary; + primary.push("peeked"); + return primary.describe(); + } + // The callee replaces `current` while the caller's receiver is the old one. + rotate(next: string): Buffer2 { + const old = this.current; + this.current = new Buffer2(next); + return old; + } + useCurrent(): string { + return this.current.push(this.rotate("c1").describe()) + " " + this.current.describe(); + } + usePrimary(): string { + const before = this.primary.push("a"); + const again = this.primary.push(this.rotate("c2").label); + return `${before} ${again} ${this.primary.describe()}`; + } +} + +class Derived extends Owner { + readonly extra: Buffer2; + constructor() { + super(); + this.extra = new Buffer2("extra"); + this.extra.push(this.primary.describe()); + } + report(): string { + return `${this.extra.describe()} / ${this.primary.describe()}`; + } +} + +// A field a closure created in the constructor writes later is not final. +class Callbacks { + target: Buffer2; + readonly reset: () => void; + constructor() { + this.target = new Buffer2("t0"); + this.reset = () => { + this.target = new Buffer2("t1"); + }; + } + run(): string { + return this.target.push(this.fire()) + " " + this.target.describe(); + } + fire(): string { + this.reset(); + return "fired"; + } +} + +// A field written through another object, outside any constructor. +class Node2 { + next: Node2 | undefined; + readonly name: string; + readonly payload: Buffer2; + constructor(name: string) { + this.name = name; + this.payload = new Buffer2(name); + } +} +function relink(a: Node2): string { + const first = a.next!; + a.next = new Node2("b2"); + return first.payload.describe() + " -> " + a.next.payload.describe(); +} + +const owner = new Owner(); +console.log(owner.log.join(" | ")); +console.log(owner.useCurrent()); +console.log(owner.usePrimary()); +console.log(owner.current.describe()); +const derived = new Derived(); +console.log(derived.report()); +const callbacks = new Callbacks(); +console.log(callbacks.run()); +const a = new Node2("a"); +a.next = new Node2("b"); +a.next.payload.push("x"); +console.log(relink(a)); + +// Async methods keep owning reads across their suspensions. +class Pending { + readonly box: Buffer2 = new Buffer2("async"); + async later(): Promise { + const box = this.box; + box.push("before"); + await Promise.resolve(); + box.push("after"); + return box.describe(); + } +} +new Pending().later().then((text) => console.log(text));