Drop guards on private constructor stores and borrow construction-only fields

Two uses of the construction-only facts outside the collector
(backend/immutability.ts):

- Publication guards: a store into the constructor's own `this` that lies
  before the constructor's escape point, in a constructor no ancestor of
  which lets `this` escape and that is never invoked directly on an
  existing object, cannot target a published object. It loses its
  rc == SIZE_MAX guard and no longer seeds may-throw, so constructors that
  could only throw through guards lose their pending checks at every `new`.
  Storing `this` into one of its own fields does not expose it.
- Borrowed reads: `x.f` holding an object, record, array or string is
  borrowed as a call argument, receiver or stable alias when `f` is
  construction-only and `x` is itself borrowable (unchanged local,
  borrowed parameter, or such a projection): nothing can replace and
  release it while `x` lives. Not inside constructors, which may still
  write their own fields. Module constants (immutable, not TDZ-checked,
  assigned at most once) are borrowable too.

No object is frozen or marked at run time; publication errors, store order
and reflection are unchanged.

tsc-ts (e469e33), static: published-field guards 6,528 -> 4,462,
sc_release calls 146,056 -> 129,756, sc_retain calls 26,215 -> 22,123,
scr_arr_release 29,865 -> 25,617, binary 23.68 -> 22.96 MB (both commits).
Self-check single-threaded, executed: guards 96.1M -> 60.8M, retains
273.5M -> 247.2M, releases 340.3M -> 314.0M.

Linux sandbox, interleaved medians, both commits against
perf/alloc-hugepages:

  single-threaded (6 runs)   base     new     change
  Excalidraw                 8.62 s   8.34 s  -3.2%
  Playwright                 7.83 s   7.71 s  -1.5%
  tsc-ts self-check          4.36 s   4.22 s  -3.0%
  tRPC                       1.63 s   1.58 s  -3.1%
  TypeORM                    6.68 s   6.50 s  -2.5%

  --checkers 8 (10 runs): -2.3% .. +0.7% (neutral)

Runtime suite (--layouts=4 --runs=16): geomean -1.9%; ast-interp -4.4%,
validate-errors -3.8%, regex-logs -1.7% faster, template-render +1.4%
slower, the rest neutral; sizes unchanged.
This commit is contained in:
Malte Ubl
2026-10-10 15:42:50 -07:00
parent 44f4d0bf19
commit 6ddaa2a140
9 changed files with 827 additions and 3 deletions
@@ -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<IrModule["classes"]>,
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<IrStmt, { kind: "fieldSet" }> => 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<IrModule["lib"]>;
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"]);
});
@@ -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<string, string>();
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<IrStmt>;
}
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<string>();
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<string, number>();
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<string> {
const candidates = new Set(
(mod.globals ?? [])
.filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined)
.map((g) => g.id),
);
const assigned = new Set<string>();
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;
}
+42 -3
View File
@@ -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<string, IrType>();
/** Module constants that keep one value once initialized
* (immutability.ts stableGlobals). */
private stableGlobals: ReadonlySet<string> = new Set();
private readonly tdzGlobals = new Map<string, string>();
readonly constantNumericTables: ReadonlyMap<string, ConstantNumericTable>;
/** 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<string>;
private inConstructor = false;
/** Method names with at least one may-throw implementation — the
* virtualCall pending check's key. */
readonly mayThrowMethods = new Set<string>();
@@ -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<IrStmt, { kind: "fieldSet" }>): 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 (`#<Object>`). */
@@ -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");
@@ -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<string> = new Set([
@@ -45,11 +46,19 @@ export class PublishedTypes {
readonly records = new Set<string>();
/** typeKeys of array, map and set types. */
readonly containers = new Set<string>();
/** Constructor stores into a `this` nothing else can reach yet
* (immutability.ts): never a published target, so never guarded. */
privateStores: ReadonlySet<IrStmt> = 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<string>();
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":
@@ -15749,6 +15749,24 @@
],
"diags": []
},
"<repo>/tests/corpus/4598-publish-constructor-stores.ts": {
"order": [
"<repo>/tests/corpus/4598-publish-constructor-stores.ts"
],
"diags": []
},
"<repo>/tests/corpus/4599-construction-only-field-borrows.ts": {
"order": [
"<repo>/tests/corpus/4599-construction-only-field-borrows.ts"
],
"diags": []
},
"<repo>/tests/corpus/4600-construction-only-fields-dynamic-writes.ts": {
"order": [
"<repo>/tests/corpus/4600-construction-only-fields-dynamic-writes.ts"
],
"diags": []
},
"<repo>/tests/corpus/4601-cycle-holders.ts": {
"order": [
"<repo>/tests/corpus/4601-cycle-holders.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);
@@ -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);
@@ -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);