mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user