diff --git a/packages/compiler/src/backend/immutability.test.ts b/packages/compiler/src/backend/immutability.test.ts new file mode 100644 index 000000000..8ca4febca --- /dev/null +++ b/packages/compiler/src/backend/immutability.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "vitest"; +import { + DYN, + F64, + STRING, + VOID, + type IrExpr, + type IrModule, + type IrStmt, + type IrType, +} from "../ir/ir.js"; +import { computeConstructionFacts, computeImmutableFields, stableGlobals } from "./immutability.js"; + +const loc = { file: "immutability.ts", start: 0, end: 1 }; +const obj = (className: string): IrType => ({ kind: "object", className }); +const value = (type: IrType): IrExpr => ({ kind: "varRef", localId: "v", type, loc }); +const thisRef = (className: string): IrExpr => ({ + kind: "varRef", + localId: "this.0", + type: obj(className), + loc, +}); +const setThis = (className: string, field: string): IrStmt => ({ + kind: "fieldSet", + obj: thisRef(className), + className, + field, + value: value(F64), + loc, +}); +const call = (callee: string, args: IrExpr[]): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "call", callee, args, type: VOID, loc }, + loc, +}); +function ctor(className: string, body: IrStmt[]): IrModule["functions"][number] { + return { + name: `%${className}.constructor`, + params: [{ localId: "this.0", name: "this", type: obj(className) }], + locals: [], + returnType: VOID, + body, + loc, + }; +} +function module( + classes: NonNullable, + functions: IrModule["functions"], +): IrModule { + return { + irVersion: 15, + sourceFile: loc.file, + entry: "main", + classes, + functions: [ + { name: "main", locals: [], params: [], returnType: VOID, body: [], loc }, + ...functions, + ], + }; +} +const fields = (...names: string[]) => names.map((name) => ({ name, type: F64 })); + +/** The fields of `className` whose constructor stores are private. */ +function privateFields(mod: IrModule, className: string): string[] { + const facts = computeConstructionFacts(mod); + const fn = mod.functions.find((f) => f.name === `%${className}.constructor`)!; + return fn.body + .filter((s): s is Extract => s.kind === "fieldSet") + .filter((s) => facts.privateStores.has(s)) + .map((s) => s.field); +} + +test("constructor stores before this escapes are private", () => { + const mod = module( + [{ name: "P", fields: fields("a", "b"), loc }], + [ctor("P", [setThis("P", "a"), call("register", [thisRef("P")]), setThis("P", "b")])], + ); + expect(privateFields(mod, "P")).toEqual(["a"]); +}); + +test("storing this into its own field does not expose it", () => { + const self: IrStmt = { ...setThis("P", "self"), value: thisRef("P") } as IrStmt; + const mod = module( + [{ name: "P", fields: [...fields("a"), { name: "self", type: obj("P") }], loc }], + [ctor("P", [self, setThis("P", "a")])], + ); + expect(privateFields(mod, "P")).toEqual(["self", "a"]); +}); + +test("a base constructor's escape makes every subclass store public", () => { + const superCall = call("%Base.constructor", [ + { kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc }, + ]); + const mod = module( + [ + { name: "Base", fields: fields("a"), loc }, + { name: "Sub", base: "Base", fields: fields("a", "b"), loc }, + ], + [ + ctor("Base", [setThis("Base", "a"), call("register", [thisRef("Base")])]), + ctor("Sub", [superCall, setThis("Sub", "b")]), + ], + ); + expect(privateFields(mod, "Base")).toEqual(["a"]); + expect(privateFields(mod, "Sub")).toEqual([]); +}); + +test("a constructor called on an existing object keeps every guard", () => { + const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]); + expect(privateFields(mod, "P")).toEqual(["a"]); + mod.functions[0]!.body.push(call("%P.constructor", [value(obj("P"))])); + expect(privateFields(mod, "P")).toEqual([]); +}); + +test("library builds keep every guard", () => { + const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]); + mod.lib = {} as NonNullable; + expect(privateFields(mod, "P")).toEqual([]); +}); + +test("construction-only fields exclude later writes and dynamic stores", () => { + const mod = module( + [{ name: "P", fields: fields("a", "b"), loc }], + [ctor("P", [setThis("P", "a"), setThis("P", "b")])], + ); + mod.functions[0]!.body.push( + { kind: "fieldSet", obj: value(obj("P")), className: "P", field: "b", value: value(F64), loc }, + { kind: "exprStmt", expr: { kind: "dynFrom", value: value(obj("P")), type: DYN, loc }, loc }, + ); + const facts = computeImmutableFields(mod)!; + expect(facts.classField("P", "a")).toBe(true); + expect(facts.classField("P", "b")).toBe(false); + // A runtime store into a dynamic value can reach P through its capsule. + mod.functions[0]!.body.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [value(DYN), value(STRING), value(DYN)], + type: VOID, + loc, + }, + loc, + }); + expect(computeImmutableFields(mod)!.classField("P", "a")).toBe(false); +}); + +test("module constants are stable once assigned exactly once", () => { + const mod = module([], []); + mod.globals = [ + { id: "%g.k", name: "k", type: STRING, mutable: false }, + { id: "%g.m", name: "m", type: STRING, mutable: true }, + { id: "%g.t", name: "t", type: STRING, mutable: false, tdz: true }, + { id: "%g.twice", name: "twice", type: STRING, mutable: false }, + ]; + const assign = (localId: string): IrStmt => ({ + kind: "assign", + localId, + value: value(STRING), + loc, + }); + mod.functions[0]!.body.push( + assign("%g.k"), + assign("%g.m"), + assign("%g.t"), + assign("%g.twice"), + assign("%g.twice"), + ); + expect([...stableGlobals(mod)]).toEqual(["%g.k"]); +}); diff --git a/packages/compiler/src/backend/immutability.ts b/packages/compiler/src/backend/immutability.ts new file mode 100644 index 000000000..1db228aad --- /dev/null +++ b/packages/compiler/src/backend/immutability.ts @@ -0,0 +1,215 @@ +/* Static immutability facts the backend uses to drop run-time immutability + * checks (see computeConstructionFacts). Pure functions of the IR module. */ +import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; +import { + computeCycleMutability, + constructorEscapePoint, + constructorThisAliases, + fieldKey, + type CycleMutability, +} from "./cycle-mutability.js"; + +/** Fields that are never written once their object can be observed outside + * its own construction (cycle-mutability.ts derives the facts from every + * store in the IR, including typed-ref commits from checked-dynamic code, + * and disables them for library builds and dynamic islands). + * + * Outside constructors, a reference read from such a field stays valid for + * as long as its owner object is alive: nothing can replace it and release + * the old value. Constructors themselves may still write their own + * `this`'s fields, so the facts do not apply while one runs. */ +export class ImmutableFields { + constructor( + private readonly mutability: CycleMutability, + private readonly unitOfClass: (className: string) => string, + ) {} + + /** `obj.field` on a class instance is construction-only. */ + classField(className: string, field: string): boolean { + return this.unit(this.unitOfClass(className), field); + } + + /** `obj.field` on a record is never written after the literal. */ + recordField(shapeId: string, field: string): boolean { + return this.unit(`record:${shapeId}`, field); + } + + private unit(unit: string, field: string): boolean { + return ( + !this.mutability.all && + !this.mutability.units.has(unit) && + !this.mutability.fields.has(fieldKey(unit, field)) + ); + } +} + +export function computeImmutableFields(mod: IrModule): ImmutableFields | null { + if (mod.lib !== undefined) return null; + const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const)); + const roots = new Map(); + const rootOf = (name: string): string => { + const known = roots.get(name); + if (known !== undefined) return known; + let cur = name; + for (let base = baseOf.get(cur); base !== null && base !== undefined; base = baseOf.get(cur)) + cur = base; + roots.set(name, cur); + return cur; + }; + const unitOfClass = (name: string): string => `object:${rootOf(name)}`; + const mutability = computeCycleMutability(mod, unitOfClass); + return mutability.all ? null : new ImmutableFields(mutability, unitOfClass); +} + +/** Stores that provably target an object no other code can reach yet. + * + * A published object (@scriptc/threads `publish`) rejects every write, so + * the emitter guards each store into a type the program may publish. A + * store into the constructor's own `this` cannot target a published object + * when nothing outside the construction can hold `this` at that point: + * + * - the constructor runs on a fresh object: it is only reached through + * `new` (or a construct thunk) for its class or a subclass, never by a + * direct call on an existing object (`%C.constructor` called anywhere but + * as the direct super call of a subclass constructor disqualifies C and + * every ancestor, which that call reaches with the same receiver); + * - no ancestor constructor, which runs before this body continues, lets + * `this` escape, and the store lies in a top-level statement before the + * first one in which this constructor lets it escape + * (cycle-mutability's constructorEscapePoint: `this`, or a constant alias + * of it, may only be a field-store target, a field-read receiver, the + * direct base constructor's receiver, or stored into one of its own + * fields; closures and class references capturing it count as escapes). + * A subclass constructor's code only runs after this body returns. + * + * Library builds keep every guard: their constructors can be invoked by an + * embedder in ways the module does not show. */ +export interface ConstructionFacts { + /** `fieldSet` statements whose target is a still-private `this`. */ + privateStores: Set; +} + +export function computeConstructionFacts(mod: IrModule): ConstructionFacts { + const facts: ConstructionFacts = { privateStores: new Set() }; + if (mod.lib !== undefined) return facts; + const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const)); + const ctorClass = new Map([...classes.keys()].map((name) => [`%${name}.constructor`, name])); + const fnByName = new Map(mod.functions.map((fn) => [fn.name, fn] as const)); + const thisOf = (name: string): string | null => + fnByName.get(`%${name}.constructor`)?.params[0]?.localId ?? null; + const isThisRef = (e: IrExpr, thisId: string | null): boolean => { + if (thisId === null) return false; + if (e.kind === "varRef") return e.localId === thisId; + if (e.kind === "upcast") return isThisRef(e.value, thisId); + if (e.kind === "seqExpr") return isThisRef(e.result, thisId); + return false; + }; + + // Constructors reached with a receiver that may not be fresh. + const reused = new Set(); + const markReused = (name: string): void => { + for (let c: string | undefined = name; c !== undefined && classes.has(c);) { + if (reused.has(c)) break; + reused.add(c); + c = classes.get(c)!.base; + } + }; + for (const fn of mod.functions) { + const own = ctorClass.get(fn.name); + const thisId = own !== undefined ? thisOf(own) : null; + const baseCtor = + own !== undefined && classes.get(own)!.base !== undefined + ? `%${classes.get(own)!.base}.constructor` + : null; + const expr = (e: IrExpr): boolean => { + if (e.kind === "call") { + const target = ctorClass.get(e.callee); + if ( + target !== undefined && + !(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId)) + ) + markReused(target); + } else if (e.kind === "closure") { + const target = ctorClass.get(e.fnName); + if (target !== undefined) markReused(target); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); + fn.body.every(stmt); + } + + // The first escaping statement of each constructor (body length: none). + // A class without a constructor function of its own (runtime roots) is + // treated as escaping at once. + const points = new Map(); + const escapePoint = (name: string): number => { + const known = points.get(name); + if (known !== undefined) return known; + const fn = fnByName.get(`%${name}.constructor`); + const base = classes.get(name)?.base; + const thisId = thisOf(name); + const point = + fn === undefined || thisId === null || classes.get(name)?.runtime === true + ? 0 + : constructorEscapePoint(fn, thisId, base !== undefined ? `%${base}.constructor` : null, { + selfStores: true, + }); + points.set(name, point); + return point; + }; + const ancestorEscapes = (name: string): boolean => { + for (let c = classes.get(name)?.base; c !== undefined; c = classes.get(c)?.base) { + const fn = fnByName.get(`%${c}.constructor`); + if (!classes.has(c) || fn === undefined || escapePoint(c) < fn.body.length) return true; + } + return false; + }; + + for (const [name] of classes) { + const fn = fnByName.get(`%${name}.constructor`); + if (fn === undefined || reused.has(name) || ancestorEscapes(name)) continue; + const point = escapePoint(name); + if (point === 0) continue; + const aliases = constructorThisAliases(fn, thisOf(name)!); + const isOwn = (e: IrExpr): boolean => + (e.kind === "varRef" && aliases.has(e.localId)) || + (e.kind === "upcast" && isOwn(e.value)) || + (e.kind === "seqExpr" && isOwn(e.result)); + const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt); + const stmt = (s: IrStmt): boolean => { + if (s.kind === "fieldSet" && isOwn(s.obj)) facts.privateStores.add(s); + return everyStmtChild(s, expr, stmt); + }; + for (let i = 0; i < point; i++) stmt(fn.body[i]!); + } + return facts; +} + +/** Module constants whose value never changes once initialized: immutable, + * not TDZ-checked (every read follows the initialization), no import-cycle + * init flag, and at most one assignment in the whole module. Such a global + * owns its value for the rest of the program, so a read of it can be + * borrowed across any later operand or call. */ +export function stableGlobals(mod: IrModule): Set { + const candidates = new Set( + (mod.globals ?? []) + .filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined) + .map((g) => g.id), + ); + const assigned = new Set(); + const expr = (e: IrExpr): boolean => { + if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (s.kind === "assign" && candidates.has(s.localId)) { + if (assigned.has(s.localId)) candidates.delete(s.localId); + assigned.add(s.localId); + } + return everyStmtChild(s, expr, stmt); + }; + for (const fn of mod.functions) fn.body.every(stmt); + return candidates; +} diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8ce49a03d..6c2d54dad 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -193,6 +193,7 @@ import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js"; import { emitDynamicExpr } from "./expr-dynamic.js"; import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js"; import { PublishWalkers } from "./publish.js"; +import { computeImmutableFields, stableGlobals, type ImmutableFields } from "../immutability.js"; import { computePublishedTypes, type PublishedTypes } from "../publication.js"; import { emitJsInteropExpr, emitExpr } from "./expr-dispatch.js"; import { @@ -622,6 +623,9 @@ export class LlEmitter { readonly ffiHasRetainedCallback: boolean; private readonly ffiHasForeignCallback: boolean; private readonly globalTypes = new Map(); + /** Module constants that keep one value once initialized + * (immutability.ts stableGlobals). */ + private stableGlobals: ReadonlySet = new Set(); private readonly tdzGlobals = new Map(); readonly constantNumericTables: ReadonlyMap; /** May-throw analysis (computeMayThrow): pending @@ -637,6 +641,13 @@ export class LlEmitter { /** Static types whose values can be published (@scriptc/threads): their * stores carry the frozen-object guard. Null without publish calls. */ readonly publishedTypes: PublishedTypes | null; + /** Construction-only class and record fields (immutability.ts); null + * where the facts are disabled. */ + private readonly immutableFields: ImmutableFields | null; + /** Constructor function names: their bodies may still write their own + * `this`'s construction-only fields. */ + private readonly constructorFunctions: ReadonlySet; + private inConstructor = false; /** Method names with at least one may-throw implementation — the * virtualCall pending check's key. */ readonly mayThrowMethods = new Set(); @@ -842,6 +853,8 @@ export class LlEmitter { this.workerEntryPolls = mt.workerEntryPolls ?? null; this.stackChecks = mt.stackChecks ?? null; this.publishedTypes = computePublishedTypes(mod); + this.immutableFields = computeImmutableFields(mod); + this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`)); this.indirectMayThrow = mt.indirect || mod.workers === true; for (const cls of mod.classes ?? []) { for (const m of cls.methods ?? []) { @@ -977,6 +990,7 @@ export class LlEmitter { this.globalTypes.set(g.id, g.type); if (g.tdz) this.tdzGlobals.set(g.id, g.name); } + this.stableGlobals = stableGlobals(mod); // Runtime error, EventEmitter, and stream classes have known layouts. // Subclasses embed the ScrEmitter/ScrStream prefixes from classes.ts. const classes = mod.classes ?? []; @@ -5002,6 +5016,7 @@ export class LlEmitter { this.currentReturnType = fn.returnType; this.currentGenerator = fn.generator ?? null; this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); + this.inConstructor = this.constructorFunctions.has(fn.name); this.currentWasiCoro = null; this.logArgSlots = 0; @@ -5326,8 +5341,14 @@ export class LlEmitter { this.canBorrowCallArgument(value.then) && this.canBorrowCallArgument(value.else_) ); + // A construction-only field of a stable owner keeps its value until the + // owner dies: no later operand or callee can replace and release it. + if ((value.kind === "fieldGet" || value.kind === "recordGet") && this.immutableRead(value)) + return this.canBorrowCallArgument(value.obj); if (value.kind !== "varRef") return false; const binding = this.binding(value.localId); + // A module constant owns its value for the rest of the program. + if (binding.kind === "global") return this.stableGlobals.has(value.localId); return ( binding.kind === "local" && binding.local !== undefined && @@ -5340,6 +5361,17 @@ export class LlEmitter { ); } + /** A plain-pointer read of a construction-only field outside any + * constructor (ImmutableFields). */ + private immutableRead(e: ExprOf<"fieldGet" | "recordGet">): boolean { + if (this.inConstructor || this.immutableFields === null) return false; + const t = e.type.kind; + if (t !== "object" && t !== "record" && t !== "array" && t !== "string") return false; + return e.kind === "fieldGet" + ? this.immutableFields.classField(e.className, e.field) + : this.immutableFields.recordField(e.shapeId, e.field); + } + // ── statements ────────────────────────────────────────────────────────── private emitStmts(stmts: IrStmt[]): void { @@ -5692,21 +5724,21 @@ export class LlEmitter { const obj = this.emitStableReceiver(s.obj, [s.value]); // (The guard precedes the value here: the nullable store // evaluates it itself.) - this.emitPublishedFieldGuard(obj.name, s.className, s.field); + this.emitStoreGuard(obj.name, s); this.emitNullableFieldStore(obj.name, s.className, s.field, nullable, s.value); break; } if (s.kind === "fieldSet" && this.int32Slots.isField(s.className, s.field)) { const obj = this.emitStableReceiver(s.obj, [s.value]); const v = this.emitExpr(s.value); - this.emitPublishedFieldGuard(obj.name, s.className, s.field); + this.emitStoreGuard(obj.name, s); const { ptr } = this.classFieldPtr(obj.name, s.className, s.field); this.storeInt32Field(ptr, v, s.value); break; } const obj = this.emitStableReceiver(s.obj, [s.value]); const v = this.emitExpr(s.value); - if (s.kind === "fieldSet") this.emitPublishedFieldGuard(obj.name, s.className, s.field); + if (s.kind === "fieldSet") this.emitStoreGuard(obj.name, s); else if (this.publishedTypes?.recordGuarded(s.shapeId) === true) this.emitPublishedFieldGuard(obj.name, null, s.field); const { ptr, type } = @@ -7122,6 +7154,13 @@ export class LlEmitter { ]; } + /** The frozen-object guard of a class field store, unless the store + * provably targets a still-private `this` (PublishedTypes.privateStores). */ + private emitStoreGuard(obj: string, s: Extract): void { + if (this.publishedTypes?.privateStores.has(s) === true) return; + this.emitPublishedFieldGuard(obj, s.className, s.field); + } + /** The frozen-object guard of a class field or record field store: * Node's "Cannot assign to read only property" TypeError when the target * is published. `className` null means a record (`#`). */ diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index 7aec30dd6..39b8cfe06 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -216,6 +216,22 @@ test("reference fields borrow across preserving calls but snapshot across writes expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); source.body.unshift({ kind: "assign", localId: "%g.changed", value: str("effect"), loc }); module.globals = [{ id: "%g.changed", name: "changed", type: STRING, mutable: true }]; + // The field is never written after its record literal, so no call can + // replace (and release) it while the holder lives. + expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); + // Once the program writes the field, a reference-writing call snapshots it. + const writer = fn("writer", [], str("written")); + writer.params = [{ localId: "target", name: "target", type: record }]; + writer.locals = [{ id: "target", name: "target", type: record, mutable: false }]; + writer.body.unshift({ + kind: "recordSet", + obj: ref("target", record), + shapeId: "text", + field: "value", + value: str("written"), + loc, + }); + module.functions.push(writer); expect(body(module, "sc_bf_compare")).toContain("@scr_str_retain_v"); f.body = [ret(equal(str("constant"), projection))]; expect(body(module, "sc_bf_compare")).not.toContain("@scr_str_retain_v"); diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts index 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/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index b99f04249..bed5497d0 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15749,6 +15749,24 @@ ], "diags": [] }, + "/tests/corpus/4598-publish-constructor-stores.ts": { + "order": [ + "/tests/corpus/4598-publish-constructor-stores.ts" + ], + "diags": [] + }, + "/tests/corpus/4599-construction-only-field-borrows.ts": { + "order": [ + "/tests/corpus/4599-construction-only-field-borrows.ts" + ], + "diags": [] + }, + "/tests/corpus/4600-construction-only-fields-dynamic-writes.ts": { + "order": [ + "/tests/corpus/4600-construction-only-fields-dynamic-writes.ts" + ], + "diags": [] + }, "/tests/corpus/4601-cycle-holders.ts": { "order": [ "/tests/corpus/4601-cycle-holders.ts" diff --git a/tests/corpus/4598-publish-constructor-stores.ts b/tests/corpus/4598-publish-constructor-stores.ts new file mode 100644 index 000000000..14362ff54 --- /dev/null +++ b/tests/corpus/4598-publish-constructor-stores.ts @@ -0,0 +1,144 @@ +import { publish } from "@scriptc/threads"; + +// Constructor stores into a `this` that nothing else can reach yet can never +// hit a published object, so scriptc drops their frozen-object guard. Every +// constructor below that lets `this` escape before or between its stores +// must keep the guard: the escaped object can be published before the +// constructor finishes, and Node.js then rejects the remaining stores. + +function attempt(label: string, f: () => void): void { + try { + f(); + console.log(`${label}: ok`); + } catch (e) { + console.log(`${label}: ${e instanceof TypeError ? "TypeError" : "other"}: ${(e as Error).message}`); + } +} + +// Private construction: plain field stores, a compound assignment (whose +// receiver is a temporary alias of `this`), and a self reference. +class Plain { + flags = 1; + name: string; + self: Plain | undefined; + constructor(name: string, extra: number) { + this.name = name; + this.flags |= extra; + this.self = this; + } +} + +// The constructor publishes `this` before its last store. +class PublishesItself { + a: number; + b: number; + constructor() { + this.a = 1; + publish(this); + this.b = 2; + } +} + +// A base constructor registers `this`; the subclass constructor's store runs +// after a call that publishes everything registered. +const registry: Base[] = []; +class Base { + id: number; + constructor(id: number) { + this.id = id; + registry.push(this); + } +} +function publishRegistry(): number { + publish(registry); + return 7; +} +class Derived extends Base { + late: number; + constructor(id: number) { + super(id); + this.late = publishRegistry(); + } +} + +// `this` escapes through a closure created in the constructor. +let hook: (() => void) | undefined; +class Captured { + x: number; + y: number; + constructor() { + this.x = 1; + hook = () => { + publish(this); + }; + hook(); + this.y = 2; + } +} + +// `this` escapes as a method receiver. +class ViaMethod { + ready = false; + value = 0; + constructor() { + this.freeze(); + this.value = 5; + } + freeze(): void { + publish(this); + } +} + +attempt("plain", () => { + const p = new Plain("p", 6); + console.log(p.name, p.flags, p.self === p); + publish(p); + attempt("plain write after publish", () => { + p.flags = 9; + }); + attempt("plain compound after publish", () => { + p.flags |= 16; + }); + console.log(p.flags); +}); +attempt("publishes itself", () => { + new PublishesItself(); +}); +attempt("derived after base escape", () => { + new Derived(3); +}); +console.log(registry.length, registry[0]!.id); +attempt("captured", () => { + new Captured(); +}); +attempt("via method", () => { + new ViaMethod(); +}); + +// Many private constructions, published in bulk afterwards: every object +// stays writable until its own publish. +class Point { + x: number; + y: number; + next: Point | null; + constructor(x: number, y: number, next: Point | null) { + this.x = x; + this.y = y; + this.next = next; + } +} +let head: Point | null = null; +for (let i = 0; i < 1000; i++) head = new Point(i, i * 2, head); +let sum = 0; +for (let p = head; p !== null; p = p.next) { + p.y += 1; + sum += p.x + p.y; +} +console.log(sum); +publish(head); +attempt("point write after publish", () => { + head!.next!.x = 0; +}); +const fresh = new Point(1, 2, head); +fresh.x = 10; +console.log(fresh.x, fresh.next === head); diff --git a/tests/corpus/4599-construction-only-field-borrows.ts b/tests/corpus/4599-construction-only-field-borrows.ts new file mode 100644 index 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);