diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts index 67196efd7..e5a183db5 100644 --- a/packages/compiler/src/backend/cycle-mutability.ts +++ b/packages/compiler/src/backend/cycle-mutability.ts @@ -292,14 +292,11 @@ export function constructorThisAliases(fn: IrFunction, thisId: string): Set { - if (s.kind === "fieldSet" && isThisRef(s.obj)) { - if (options.selfStores === true) { - // `this` itself, possibly wrapped into the field's union type. - const self = s.value.kind === "unionWrap" ? s.value.value : s.value; - if (isThisRef(self)) return wrapper(s.obj) && wrapper(self); - } - return wrapper(s.obj) && expr(s.value); - } + if (s.kind === "fieldSet" && isThisRef(s.obj)) return wrapper(s.obj) && expr(s.value); if (s.kind === "exprStmt" && isThisRef(s.expr)) return wrapper(s.expr); // Declaring an alias is not an escape; its uses are checked like `this`. if (s.kind === "varDecl" && aliases.has(s.localId) && s.init !== null) return wrapper(s.init); diff --git a/packages/compiler/src/backend/immutability.test.ts b/packages/compiler/src/backend/immutability.test.ts new file mode 100644 index 000000000..98ecf91ef --- /dev/null +++ b/packages/compiler/src/backend/immutability.test.ts @@ -0,0 +1,172 @@ +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 ends private construction", () => { + // A later read of the field yields `this` again, so any use of it + // (publishing it, handing it to a worker) would expose the object. + const self: IrStmt = { ...setThis("P", "self"), value: thisRef("P") } as IrStmt; + const mod = module( + [{ name: "P", fields: [...fields("a", "b"), { name: "self", type: obj("P") }], loc }], + [ctor("P", [setThis("P", "a"), self, setThis("P", "b")])], + ); + expect(privateFields(mod, "P")).toEqual(["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 000000000..a0b792496 --- /dev/null +++ b/packages/compiler/src/backend/immutability.ts @@ -0,0 +1,214 @@ +/* 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 or the + * direct base constructor's receiver; storing it anywhere, including + * into one of its own fields whose later reads would hand it out again, + * and 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); + 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/borrowed-inputs.ts b/packages/compiler/src/backend/llvm/borrowed-inputs.ts index af921a105..16839afd6 100644 --- a/packages/compiler/src/backend/llvm/borrowed-inputs.ts +++ b/packages/compiler/src/backend/llvm/borrowed-inputs.ts @@ -1,4 +1,5 @@ import type { IrExpr } from "../../ir/ir.js"; +import type { Writes } from "./reference-writes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; /** For a runtime input whose ABI borrows rather than consumes ownership. @@ -14,18 +15,33 @@ export function emitBorrowedInput(host: LlvmEmitterContext, value: IrExpr): LlVa * while every operation preserves existing owners. Compute suffix facts * once, keeping left-to-right evaluation and the owning fallback for each * argument independently. The consumer must borrow all inputs and preserve - * reference edges itself; mutating runtime operations use emitBorrowedInput. */ + * reference edges itself; mutating runtime operations use emitBorrowedInput. + * With `consumerWrites` (the locations the consumer may overwrite), a + * projection also borrows when neither the consumer nor any later operand + * can write a location its read depends on (ReferenceWrites). */ export function borrowableInputs( host: LlvmEmitterContext, inputs: readonly IrExpr[], consumerPreserves = true, + consumerWrites?: Writes, ): boolean[] { const result: boolean[] = new Array(inputs.length); let preserves = consumerPreserves; + const writes = host.referenceWrites; + let suffix: Writes = + consumerWrites !== undefined + ? consumerWrites + : consumerPreserves + ? null + : (writes?.heapAll ?? null); for (let i = inputs.length - 1; i >= 0; i--) { const value = inputs[i]!; - result[i] = host.canBorrowCallArgument(value) || (preserves && host.canBorrowReceiver(value)); + result[i] = + host.canBorrowCallArgument(value) || + (preserves && host.canBorrowReceiver(value)) || + (writes !== null && host.canBorrowReceiver(value) && host.unwrittenRead(value, suffix)); preserves = preserves && host.referenceEffects.preserves(value); + if (writes !== null) suffix = writes.union([suffix, writes.expr(value)]); } return result; } @@ -36,3 +52,21 @@ export function emitBorrowedInputs(host: LlvmEmitterContext, inputs: readonly Ir borrowed[index] ? host.emitReadReceiver(value) : host.emitExpr(value), ); } + +/** The writes after each input: the consumer's plus every later input's + * (null entries: nothing; an empty array without ReferenceWrites). */ +export function inputSuffixWrites( + host: LlvmEmitterContext, + inputs: readonly IrExpr[], + consumerWrites: Writes, +): Writes[] { + const writes = host.referenceWrites; + if (writes === null) return []; + const out: Writes[] = new Array(inputs.length); + let suffix = consumerWrites; + for (let i = inputs.length - 1; i >= 0; i--) { + out[i] = suffix; + suffix = writes.union([suffix, writes.expr(inputs[i]!)]); + } + return out; +} diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index 4ff7b1b16..8101008a5 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 114e03e0f..11f02805d 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -159,10 +159,11 @@ import { type MapReadLifetimes, } 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 { borrowableInputs, emitBorrowedInput } from "./borrowed-inputs.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"; @@ -197,6 +198,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 { @@ -598,6 +600,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(); @@ -641,6 +649,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 @@ -656,6 +667,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(); @@ -884,6 +902,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 ?? []) { @@ -907,6 +927,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 = { @@ -914,8 +943,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(); @@ -1023,6 +1053,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 ?? []; @@ -5093,6 +5124,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); @@ -5133,6 +5165,7 @@ export class LlEmitter { this.callLifetimes, this.referenceEffects, this.optionalArrayReads, + this.referenceWrites, ) : new Set(); this.localArrayReads = findLocalArrayReads( @@ -5173,6 +5206,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; @@ -5339,7 +5373,7 @@ export class LlEmitter { } if (this.stackChecks?.has(fn.name)) this.emitStackCheck(); if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(undefined, true); - 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). @@ -5514,11 +5548,17 @@ 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); // An optional chain's bound receiver stays unchanged until its body // finishes (see emitReadReceiver). if (value.kind === "chainRecv") return this.chainSlots.has(value.id); 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 && @@ -5531,15 +5571,213 @@ 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); + } + + /** 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); + } + + /** `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. */ + 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}`); + 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); + } + 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; } } @@ -5552,7 +5790,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); @@ -5616,7 +5854,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; } @@ -5705,7 +5949,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}`); @@ -5887,21 +6131,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 } = @@ -6606,10 +6850,29 @@ 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); + // (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); @@ -6641,7 +6904,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()) { @@ -7352,6 +7615,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 (`#`). */ @@ -7508,6 +7778,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); @@ -7773,17 +8047,21 @@ export class LlEmitter { const wrap = this.borrowableNullableWrap(e); return wrap === "unit" || (wrap === "ref" && this.canBorrowReceiver(e.value)); } - // A borrowed-return callee preserves edges, so its result stays - // reachable from borrowable arguments until the consumer runs. - case "call": - return ( - this.borrowedReturns.has(e.callee) && - e.args.every( - (arg) => - (!isRefCounted(arg.type) || this.canBorrowReceiver(arg)) && - this.referenceEffects.preserves(arg), - ) - ); + // A borrowed-return result stays reachable from its borrowed + // arguments until the consumer runs. Each argument is lent without a + // snapshot, so neither the callee nor a later argument may replace + // what it was read from: a callee that preserves every edge cannot, + // and any other one must leave the argument's locations unwritten. + case "call": { + if (!this.borrowedReturns.has(e.callee)) return false; + if (!e.args.every((arg) => this.referenceEffects.preserves(arg))) return false; + const preserving = this.referenceEffects.functions.has(e.callee); + if (preserving) + return e.args.every((arg) => !isRefCounted(arg.type) || this.canBorrowReceiver(arg)); + if (this.referenceWrites === null) return false; + const lent = borrowableInputs(this, e.args, false, this.referenceWrites.summary(e.callee)); + return e.args.every((arg, i) => !isRefCounted(arg.type) || lent[i] === true); + } // A required element read: the (borrowed) array owns the element // while an edge-preserving index computes. case "arrayGet": @@ -7831,17 +8109,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; @@ -7861,6 +8142,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; } } @@ -7885,6 +8168,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/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index d707de555..afd2ae7b0 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -25,7 +25,13 @@ import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./ import { canStackUnion, emitStackUnion } from "./stack-unions.js"; import { emitCallArrayRead } from "./local-array-reads.js"; import { emitStackMapRead } from "./map-read-lifetimes.js"; -import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; +import { + borrowableInputs, + emitBorrowedInput, + emitBorrowedInputs, + inputSuffixWrites, +} from "./borrowed-inputs.js"; +import type { Writes } from "./reference-writes.js"; export function emitCallExpr( host: LlvmEmitterContext, @@ -58,12 +64,18 @@ export function emitCallExpr( const borrowed = host.callLifetimes.borrowed.get(e.callee); const projected = host.callLifetimes.parameters.get(e.callee); const inputs = borrowed - ? borrowableInputs(host, e.args, host.referenceEffects.functions.has(e.callee)) + ? borrowableInputs( + host, + e.args, + host.referenceEffects.functions.has(e.callee), + host.referenceWrites?.summary(e.callee), + ) : []; // Borrowed snapshots live through this call, not through the whole // enclosing expression. Otherwise a large initializer accumulates // owners and repeats their cleanup at every later throwing call. if (borrowed) host.frames.push([]); + let suffixes: Writes[] | undefined; const args = e.args.map((a, index) => { if (borrowed?.has(index)) { // A heap-borrowing helper may retain or return its argument. @@ -73,7 +85,18 @@ export function emitCallExpr( const callback = host.stackCallbacks.emit(host, a); if (callback) return callback; const read = host.callArrayReads.get(a); - if (read) return emitCallArrayRead(host, read); + if (read) { + suffixes ??= inputSuffixWrites( + host, + e.args, + host.referenceWrites?.summary(e.callee) ?? null, + ); + const lend = + !read.borrow && + index < suffixes.length && + host.unwrittenCallArrayRead(read, suffixes[index]!); + return emitCallArrayRead(host, lend ? { ...read, borrow: true } : read); + } const mapRead = host.mapReadLifetimes.arguments.get(a); if (mapRead) { const result = emitStackMapRead(host, mapRead); @@ -739,6 +762,7 @@ export function emitCallExpr( host, e.args, host.referenceEffects.functions.has(ctorName), + host.referenceWrites?.constructWrites(e.className), ); host.frames.push([]); const args = e.args.map((a, i) => { @@ -951,7 +975,15 @@ export function emitCallExpr( // (LlEmitter.computeVirtualBorrowing): those are passed like borrowed // arguments of a direct call, owned snapshots living in this frame. const borrowed = host.virtualSlotBorrowed(meta.root.def.name, slotIdx); - const inputs = borrowed.size > 0 ? borrowableInputs(host, e.args, false) : []; + const inputs = + borrowed.size > 0 + ? borrowableInputs( + host, + e.args, + false, + host.referenceWrites?.virtualWrites(e.className, e.method), + ) + : []; if (borrowed.size > 0) host.frames.push([]); const args = e.args.map((a, index) => borrowed.has(index) && inputs[index] ? host.emitReadReceiver(a) : host.emitExpr(a), diff --git a/packages/compiler/src/backend/llvm/loop-array-borrows.ts b/packages/compiler/src/backend/llvm/loop-array-borrows.ts index 75435e7a9..07ae3ed9f 100644 --- a/packages/compiler/src/backend/llvm/loop-array-borrows.ts +++ b/packages/compiler/src/backend/llvm/loop-array-borrows.ts @@ -3,18 +3,22 @@ import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import type { ReferenceEffects } from "./reference-effects.js"; import type { OptionalArrayReads } from "./local-array-reads.js"; +import type { ReferenceWrites } from "./reference-writes.js"; /** Limit a borrow to a loop whose complete iteration preserves heap edges. * Initialization and unrelated work elsewhere in the function may mutate. * The array remains owned by its unchanged local or global binding; the * iteration binding can only project it or forward it to proven consumers. * Callbacks, suspension, capture, reference writes, and escaping uses keep - * the ordinary owned read. Missing elements retain the runtime's checks. */ + * the ordinary owned read. Missing elements retain the runtime's checks. + * With ReferenceWrites, an iteration that writes no array element (nor the + * binding holding the array) suffices, whatever else it writes. */ export function findLoopArrayBorrows( fn: IrFunction, lifetimes: CallLifetimes, effects: ReferenceEffects, optionalReads?: OptionalArrayReads, + writes?: ReferenceWrites | null, ): ReadonlySet { const result = new Set(); if (fn.async || fn.generator) return result; @@ -45,19 +49,28 @@ export function findLoopArrayBorrows( !local.tdz && projected?.has(loop.localId) && bindings?.has(loop.localId) && - effects.preservesScope(loop.body) && - effects.preserves(loop.iterable) + ((effects.preservesScope(loop.body) && effects.preserves(loop.iterable)) || + // The loop owns the evaluated array; only element removals can + // release an element it lends. + (writes && !writes.conflicts(["a"], writes.stmts(loop.body)))) ) result.add(loop); return true; } if (loop.kind !== "for" && loop.kind !== "while" && loop.kind !== "doWhile") return true; - if ( - !effects.preservesScope(loop.body) || - (loop.cond && !effects.preserves(loop.cond)) || - (loop.kind === "for" && loop.update && !effects.preservesScope([loop.update])) - ) - return true; + const preserving = + effects.preservesScope(loop.body) && + (!loop.cond || effects.preserves(loop.cond)) && + (loop.kind !== "for" || !loop.update || effects.preservesScope([loop.update])); + const loopWrites = + preserving || !writes + ? null + : writes.union([ + writes.stmts(loop.body), + loop.cond ? writes.expr(loop.cond) : null, + loop.kind === "for" && loop.update ? writes.stmt(loop.update) : null, + ]); + if (!preserving && !writes) return true; everyStmtList(loop.body, { expr: () => true, stmt: (stmt) => { @@ -71,7 +84,12 @@ export function findLoopArrayBorrows( return true; const source = stmt.init.kind === "arrayGet" ? stmt.init.arr : optionalReads?.get(stmt.init)?.array; - if (source && stableSource(source)) result.add(stmt); + if (!source || !stableSource(source)) return true; + if (preserving) result.add(stmt); + else { + const keys = writes!.readKeys(source); + if (keys && !writes!.conflicts(["a", ...keys], loopWrites)) result.add(stmt); + } return true; }, }); diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts new file mode 100644 index 000000000..fac6fd35c --- /dev/null +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -0,0 +1,1095 @@ +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: ImmutableFields | 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) 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; + orInto(bits, s); + } + } + // An unknown heap write subsumes the precise heap keys. + const summary: Writes = (bits[0]! & 1) !== 0 ? this.heapAll : anyBit(bits) ? 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); + orInto(out, p); + } + 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); + setBit(out, id); + } + 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); + } +} + +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; +} diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index 7aec30dd6..bfcaeeb6c 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -167,18 +167,23 @@ test("string operands preserve their left-to-right snapshot when a later assignm expect(facts(f).parameters.has("replace")).toBe(false); }); -test("a later global write keeps mutable string arguments owned", () => { +test("a callee's global write cannot replace the caller's mutable string locals", () => { const source = fn("source", [], str("next")); source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); const compare = fn("compare", ["text"], equal(ref("text"), call("source", [], STRING))); - // A later write anywhere in this function rules out parameter borrowing. + // A write anywhere in this function rules out parameter borrowing, but + // the rebound local itself owns its value across the later operand. compare.body.unshift({ kind: "assign", localId: "text", value: str("first"), loc }); const module = mod(source, compare); module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }]; const ir = body(module, "sc_f_compare"); const sourceCall = ir.indexOf("@sc_f_source"); expect(sourceCall).toBeGreaterThan(0); - expect(ir.slice(0, sourceCall)).toContain("@scr_str_retain_v"); + expect(ir.slice(0, sourceCall)).not.toContain("@scr_str_retain_v"); + // Reading the global the later operand replaces still takes a snapshot. + compare.body = [ret(equal(ref("%g.changed"), call("source", [], STRING)))]; + const global = body(module, "sc_bf_compare"); + expect(global.slice(0, global.indexOf("@sc_f_source"))).toContain("@scr_str_retain_v"); }); test("immutable lexical owners survive arbitrary later operands", () => { @@ -214,8 +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 }]; + 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"); + // Writing the projected field of any record does replace it. + source.body.unshift({ + kind: "recordSet", + obj: ref("%g.record", record), + shapeId: "text", + field: "value", + value: str("effect"), + loc, + }); 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"); @@ -342,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( @@ -361,9 +380,16 @@ test("string search snapshots both receiver and needle before a numeric argument const searchCall = ir.indexOf("@scr_str_index_of"); expect(start).toBeGreaterThan(0); expect(searchCall).toBeGreaterThan(start); - // Literals need no owner, but both reads take independent snapshots. - expect(ir.slice(0, start).match(/@scr_str_retain_v/g)).toHaveLength(2); - expect(ir.slice(searchCall)).toContain("@scr_str_release"); + // The rebound locals own their values across the call: no snapshots. + expect(ir.slice(0, start)).not.toContain("@scr_str_retain_v"); + // A receiver read from the global the numeric argument replaces does. + search.body = [ + ret(intrinsic(ref("%g.changed"), "indexOf", [ref("needle"), call("position", [], F64)], F64)), + ]; + const global = body(module, "sc_bf_search"); + const call2 = global.indexOf("@sc_f_position"); + expect(global.slice(0, call2).match(/@scr_str_retain_v/g)).toHaveLength(1); + expect(global.slice(global.indexOf("@scr_str_index_of"))).toContain("@scr_str_release"); }); test("global string ownership cannot be inferred from an immutable-looking reference", () => { diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts index 7ba048573..dc3e03c19 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 @@ -133,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": @@ -155,12 +166,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 +188,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/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts index 7040539dd..4f21bec5f 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/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 08da691ee..80e3e0ecb 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("bytes.index.valid"); + // 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", () => { diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index dde71c67c..646e901c9 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -329,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 () => { diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index 4f59d8bbc..183a1a426 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -217,15 +217,39 @@ test("a throwing later source argument releases the retained snapshot on its exc const module = await lower(` class Item { value = 7; } function read(item: Item, ignored: number): number { return item.value + ignored; } +function fail(items: Item[]): number { items.pop(); throw new Error("failed"); } +function work(items: Item[]): number { return read(items[0], fail(items)); } +try { console.log(work([new Item()])); } catch {} +`); + // The later argument removes the element, so the snapshot must own it. + const work = body(emitLlvmModule(module), "sc_bf_work"); + const failure = work.indexOf("@sc_bf_fail"); + expect(failure).toBeGreaterThan(0); + expect(work.slice(0, failure)).toContain("@sc_retain_Item"); + expect(work.slice(failure)).toMatch(PENDING_CHECK); + const exceptionEdge = work.slice(failure, work.indexOf("@sc_bf_read")); + expect(exceptionEdge).toContain("@sc_release_Item"); + expect(work).not.toContain("@scr_union_release"); +}); + +test("a throwing later source argument that writes no element leaves the snapshot borrowed", async () => { + const module = await lower(` +class Item { value = 7; } +function read(item: Item, ignored: number): number { return item.value + ignored; } function fail(): number { throw new Error("failed"); } function work(items: Item[]): number { return read(items[0], fail()); } try { console.log(work([new Item()])); } catch {} `); + // Neither the callee nor the later argument writes an array element (the + // Error constructor's stores into its own fresh object do not count), so + // the borrowed parameter's array keeps the element alive and no edge, + // normal or exceptional, owes a release. const work = body(emitLlvmModule(module), "sc_bf_work"); const failure = work.indexOf("@sc_f_fail"); expect(failure).toBeGreaterThan(0); expect(work.slice(failure)).toMatch(PENDING_CHECK); - expect(work.slice(failure)).toContain("@sc_release_Item"); + expect(work).not.toContain("@sc_retain_Item"); + expect(work).not.toContain("@sc_release_Item"); expect(work).not.toContain("@scr_union_release"); }); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4598-publish-constructor-stores.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4598-publish-constructor-stores.ts.json new file mode 100644 index 000000000..7852d2bf5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4598-publish-constructor-stores.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4598-publish-constructor-stores.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4599-construction-only-field-borrows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4599-construction-only-field-borrows.ts.json new file mode 100644 index 000000000..032d5a0c1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4599-construction-only-field-borrows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4599-construction-only-field-borrows.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4600-construction-only-fields-dynamic-writes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4600-construction-only-fields-dynamic-writes.ts.json new file mode 100644 index 000000000..3d60affd3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4600-construction-only-fields-dynamic-writes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4600-construction-only-fields-dynamic-writes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4630-reference-write-call-arguments.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4630-reference-write-call-arguments.ts.json new file mode 100644 index 000000000..150de69d4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4630-reference-write-call-arguments.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4630-reference-write-call-arguments.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4631-reference-write-aliases.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4631-reference-write-aliases.ts.json new file mode 100644 index 000000000..1a3ec8157 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4631-reference-write-aliases.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4631-reference-write-aliases.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4632-reference-write-loops-and-walks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4632-reference-write-loops-and-walks.ts.json new file mode 100644 index 000000000..01069d0c0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4632-reference-write-loops-and-walks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4632-reference-write-loops-and-walks.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4633-reference-write-callbacks-returns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4633-reference-write-callbacks-returns.ts.json new file mode 100644 index 000000000..baa78ce49 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4633-reference-write-callbacks-returns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4633-reference-write-callbacks-returns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4634-optional-element-borrowed-returns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4634-optional-element-borrowed-returns.ts.json new file mode 100644 index 000000000..8260148ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4634-optional-element-borrowed-returns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4634-optional-element-borrowed-returns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4691-publish-constructor-self-alias.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4691-publish-constructor-self-alias.ts.json new file mode 100644 index 000000000..15f8fea3c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4691-publish-constructor-self-alias.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4691-publish-constructor-self-alias.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4692-borrowed-result-mutating-callees.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4692-borrowed-result-mutating-callees.ts.json new file mode 100644 index 000000000..af24efa51 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4692-borrowed-result-mutating-callees.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4692-borrowed-result-mutating-callees.ts" + ], + "diags": [] +} diff --git a/tests/corpus/4598-publish-constructor-stores.ts b/tests/corpus/4598-publish-constructor-stores.ts new file mode 100644 index 000000000..47f6c9f5e --- /dev/null +++ b/tests/corpus/4598-publish-constructor-stores.ts @@ -0,0 +1,145 @@ +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`), then a self reference (which +// ends the private part: later reads of the field would hand `this` out). +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 000000000..f9e7f4bb4 --- /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 000000000..6114046f0 --- /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); diff --git a/tests/corpus/4630-reference-write-call-arguments.ts b/tests/corpus/4630-reference-write-call-arguments.ts new file mode 100644 index 000000000..6a2160bd2 --- /dev/null +++ b/tests/corpus/4630-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/4631-reference-write-aliases.ts b/tests/corpus/4631-reference-write-aliases.ts new file mode 100644 index 000000000..4d5e04ecb --- /dev/null +++ b/tests/corpus/4631-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/4632-reference-write-loops-and-walks.ts b/tests/corpus/4632-reference-write-loops-and-walks.ts new file mode 100644 index 000000000..98fcdbad3 --- /dev/null +++ b/tests/corpus/4632-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/4633-reference-write-callbacks-returns.ts b/tests/corpus/4633-reference-write-callbacks-returns.ts new file mode 100644 index 000000000..8ba1e8991 --- /dev/null +++ b/tests/corpus/4633-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())); diff --git a/tests/corpus/4634-optional-element-borrowed-returns.ts b/tests/corpus/4634-optional-element-borrowed-returns.ts new file mode 100644 index 000000000..dab04a34b --- /dev/null +++ b/tests/corpus/4634-optional-element-borrowed-returns.ts @@ -0,0 +1,47 @@ +// Optional element reads (`arr[i]` of `T | undefined` elements) lower to +// helpers that now lend the element their array keeps alive: paged lookups +// borrow, and reads followed by removals still own what they keep. + +class Link { + name: string; + constructor(name: string) { + this.name = name; + } +} + +class Pages { + pages: (Link[] | undefined)[] = []; + get(id: number): Link { + let link = this.pages[id >> 3]?.[id & 7]; + if (link === undefined) { + let page = this.pages[id >> 3]; + if (page === undefined) { + page = []; + this.pages[id >> 3] = page; + } + link = new Link("l" + id); + page[id & 7] = link; + } + return link; + } +} + +function first(links: Link[]): string { + const head = links[0]; + return head === undefined ? "none" : head.name; +} + +const shared: Link[] = [new Link("x"), new Link("y")]; +function readThenClear(): string { + const kept = shared[1]; + shared.length = 0; + let churn = 0; + for (let i = 0; i < 100; i++) churn += new Link("c" + i).name.length; + return (kept === undefined ? "-" : kept.name) + churn; +} + +const pages = new Pages(); +const seen: string[] = []; +for (let i = 0; i < 40; i++) seen.push(pages.get((i * 7) % 19).name); +console.log(seen.join(" ")); +console.log(first([]), first([new Link("a")]), readThenClear(), shared.length); diff --git a/tests/corpus/4691-publish-constructor-self-alias.ts b/tests/corpus/4691-publish-constructor-self-alias.ts new file mode 100644 index 000000000..01ff2b8c2 --- /dev/null +++ b/tests/corpus/4691-publish-constructor-self-alias.ts @@ -0,0 +1,68 @@ +import { Worker, isMainThread, workerData } from "node:worker_threads"; +import { publish } from "@scriptc/threads"; + +// A constructor that stores `this` into one of its own fields and then hands +// that field to other code has let `this` escape: the object may already be +// published, so every later store must still hit the frozen-object guard. +// Node.js deep-freezes the published object and throws on those stores, and +// a worker receiving the object sees only the values written before. + +let shared: Job | undefined; + +class Job { + label: string; + value = 1; + self: Job; + constructor(label: string) { + this.label = label; + this.self = this; + shared = publish(this.self); + this.value = 2; + } +} + +// The self alias escapes into a registry that is published later. +const registry: Task[] = []; +function publishRegistry(): number { + publish(registry); + return 3; +} + +class Task { + me: Task; + count = 0; + constructor() { + this.me = this; + registry.push(this.me); + this.count = publishRegistry(); + } +} + +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}`); + } +} + +if (isMainThread) { + attempt("publishes self alias", () => { + new Job("job"); + }); + const job = shared!; + console.log("main sees", job.label, job.value, job.self === job); + attempt("registers self alias", () => { + new Task(); + }); + console.log("registry", registry.length, registry[0]!.count, registry[0]!.me === registry[0]); + const worker = new Worker(new URL(import.meta.url), { workerData: { job } }); + worker.on("exit", (code: number) => { + console.log("exit", code); + }); +} else { + const data = workerData as { job: Job }; + const job = publish(data.job); + console.log("worker sees", job.label, job.value, job.self === job); +} diff --git a/tests/corpus/4692-borrowed-result-mutating-callees.ts b/tests/corpus/4692-borrowed-result-mutating-callees.ts new file mode 100644 index 000000000..4bc5c145f --- /dev/null +++ b/tests/corpus/4692-borrowed-result-mutating-callees.ts @@ -0,0 +1,78 @@ +// A call whose result is read right away (a receiver) may lend its +// arguments only while the callee cannot replace what they were read from. +// Each helper below makes the caller's field (or array) the argument's last +// owner, replaces it, churns the allocator, reads the argument again and +// returns it, so the caller projects the returned object afterwards. + +class Name { + text: string; + parts: string[]; + constructor(text: string) { + this.text = text; + this.parts = text.split(""); + } +} + +class Holder { + name: Name; + maybe: Name | undefined; + items: Name[] = []; + constructor(name: Name) { + this.name = name; + this.maybe = new Name("maybe-" + name.text); + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 200; i++) total += new Name("churn-" + i).parts.length; + return total; +} + +// Replaces the field the caller passed, then returns the argument. +function replaceName(h: Holder, n: Name): Name { + h.name = new Name("replacement"); + churn(); + console.log("inside", n.text, n.parts.length); + return n; +} + +// Clears the optional field the caller passed. +function clearMaybe(h: Holder, n: Name): Name { + h.maybe = undefined; + churn(); + console.log("inside", n.text, n.parts.length); + return n; +} + +// Empties the array whose element the caller passed. +function dropItems(h: Holder, n: Name): Name { + h.items.length = 0; + churn(); + console.log("inside", n.text, n.parts.length); + return n; +} + +// Writes an unrelated field: lending the argument stays sound. +function touchItems(h: Holder, n: Name): Name { + h.items.push(new Name("pushed")); + churn(); + return n; +} + +function run(round: number): void { + const h = new Holder(new Name("first-" + round)); + console.log("name", replaceName(h, h.name).text); + console.log("parts", replaceName(h, h.name).parts.length); + const m = h.maybe; + if (m !== undefined) console.log("maybe", clearMaybe(h, m).text); + h.maybe = new Name("again-" + round); + if (h.maybe !== undefined) console.log("maybe again", clearMaybe(h, h.maybe).parts.length); + h.items.push(new Name("item-" + round)); + console.log("item", dropItems(h, h.items[0]!).text); + h.items.push(new Name("kept-" + round)); + console.log("kept", touchItems(h, h.name).text, h.items.length); + console.log("final", h.name.text, h.items.length); +} + +for (let round = 0; round < 3; round++) run(round);