Improve module cycles and typed returns

- Initialize import cycles in module evaluation order and expand typed iterator and optional-value support.
- Reduce boxing for captured parameters and optional array reads.
- Adapt cycle collection to long-lived graphs while keeping static executables small.
This commit is contained in:
Chris Tate
2026-10-08 20:16:26 -05:00
committed by GitHub
105 changed files with 3985 additions and 421 deletions
@@ -266,11 +266,22 @@ function freeLines(host: AllocHost, cyc: boolean): string[] {
];
if (cyc) {
host.declare(`@scr_cyc_live = external global i64`);
host.declare(`@scr_cyc_old_freed = external global i64`);
lines.push(
` %sf.live = load i64, ptr @scr_cyc_live`,
` %sf.live1 = sub i64 %sf.live, 1`,
` store i64 %sf.live1, ptr @scr_cyc_live`,
` %sf.blk = getelementptr inbounds i8, ptr %o, i64 -${CYC_HDR}`,
` %sf.genp = getelementptr inbounds i8, ptr %sf.blk, i64 22`,
` %sf.gen = load i16, ptr %sf.genp`,
` %sf.old = icmp eq i16 %sf.gen, 2`,
` br i1 %sf.old, label %sf.oldcount, label %sf.accounted`,
`sf.oldcount:`,
` %sf.freed = load i64, ptr @scr_cyc_old_freed`,
` %sf.freed1 = add i64 %sf.freed, 1`,
` store i64 %sf.freed1, ptr @scr_cyc_old_freed`,
` br label %sf.accounted`,
`sf.accounted:`,
);
}
const blk = cyc ? "%sf.blk" : "%o";
@@ -473,6 +473,50 @@ test("unchanged let bindings can own call inputs but subsequent writes and captu
expect(analyze(fn).bindings.get(fn.name)?.size).toBe(0);
});
test("strict union equality projects both operands without escaping either box", () => {
const equal = (left: IrExpr, right: IrExpr): IrExpr => ({
kind: "unionEq",
unionId: "optional",
negated: false,
sameValue: false,
left,
right,
type: BOOL,
loc,
});
const fn = helper("same", ["value", "other"]);
fn.body = [ret(equal(ref("value"), ref("other")))];
expect(analyze(fn).parameters.get("same")).toEqual(new Set([0, 1]));
// A nested consumer inside an operand is still visited.
fn.body = [ret(equal(ref("value"), call("escape", [ref("other")])))];
expect(analyze(fn).parameters.get("same")).toEqual(new Set([0]));
});
test("environment bodies prove their own locals but never their parameters", () => {
const leaf = helper("leaf");
const fn = helper("lifted");
fn.captures = [{ localId: "box", name: "box", type: optional }];
fn.locals.push({ ...local("box"), boxed: true }, local("item"));
fn.body = [
{
kind: "varDecl",
localId: "item",
init: { kind: "unionWrap", value: num(), unionId: "optional", tag: 0, type: optional, loc },
loc,
},
ret(call("leaf", [ref("item")])),
ret(call("leaf", [ref("box")])),
ret(call("leaf", [ref("value")])),
];
const facts = analyze(fn, leaf);
expect(facts.locals.get("lifted")).toEqual(new Set(["item"]));
expect(facts.bindings.get("lifted")).toEqual(new Set(["item"]));
expect(facts.parameters.has("lifted")).toBe(false);
expect(facts.borrowed.has("lifted")).toBe(false);
fn.async = true;
expect(analyze(fn, leaf).locals.has("lifted")).toBe(false);
});
test("strict union equality is a borrowing use of a local operand", () => {
const fn = helper("same", ["left", "right"]);
fn.returnType = BOOL;
@@ -261,7 +261,26 @@ function work(expr: IrExpr, parameter: IrType, boxed = false, tdz = false): stri
...(tdz ? { tdz: true } : {}),
},
],
body: [{ kind: "return", value: expr, loc: receiverLoc }],
body: [
{ kind: "return", value: expr, loc: receiverLoc },
// An unreachable rebinding keeps a boxed parameter in its shared
// box; a captured parameter that is never rebound uses a plain slot.
...(boxed
? [
{
kind: "assign" as const,
localId: "value",
value: {
kind: "varRef" as const,
localId: "value",
type: parameter,
loc: receiverLoc,
},
loc: receiverLoc,
},
]
: []),
],
loc: receiverLoc,
},
],
+75 -7
View File
@@ -137,8 +137,8 @@ import {
import { BlockBuilder } from "./blocks.js";
import { NullableRefFields, type NullableRefField } from "./nullable-fields.js";
import {
emitCallArrayRead,
emitLocalArrayRead,
emitProjectedArrayRead,
emitBorrowedArrayRead,
findLocalArrayReads,
findCallArrayReads,
@@ -164,6 +164,7 @@ import {
emitStringSliceSnapshot,
type StringSliceSnapshot,
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
import {
ABSENT_FIELD_PAYLOAD,
@@ -519,6 +520,11 @@ export class LlEmitter {
readonly stackCallbacks: StackCallbacks;
readonly receiverReaders: AmbientReceiverReaders;
private readonly stackCaptures: StackCaptures;
private readonly lazyCaptures: LazyCaptures;
/** Unchanged captured parameters of the current function: each maps to
* the slot caching its box, NULL until the first closure needs it. */
private readonly lazyCaptureBoxes = new Map<string, string>();
private readonly lazyStackBoxes = new Map<string, string>();
private borrowedParameters = new Set<string>();
/** Parameters proven projection-only: callers may pass stack boxes. */
private projectedParameters = new Set<string>();
@@ -737,6 +743,7 @@ export class LlEmitter {
this.stackCallbacks = new StackCallbacks(this.fnByName);
this.receiverReaders = new AmbientReceiverReaders(this.fnByName);
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
this.lazyCaptures = new LazyCaptures(this.fnByName);
for (const r of mod.records ?? []) this.recordsById.set(r.id, r);
const traced = computeTraced(mod);
this.tracedShapes = traced.shapes;
@@ -4029,6 +4036,33 @@ export class LlEmitter {
}
}
/** The box a new environment captures for `localId` (borrowed). An
* unchanged parameter gets one box on first capture, holding its own
* reference to the value; the function scope releases the cached box. */
captureBox(localId: string): string {
const cache = this.lazyCaptureBoxes.get(localId);
if (cache === undefined) return this.loadBox(`%${mangleLocal(localId)}`);
const B = this.B;
const type = this.currentLocals.get(localId)!.type;
const current = this.loadBox(cache);
const missing = B.tmp();
B.line(`${missing} = icmp eq ptr ${current}, null`);
const create = B.newLabel("lazy.box"),
join = B.newLabel("lazy.box.j");
B.condBr(missing, create, join);
B.startBlock(create);
const stack = this.lazyStackBoxes.get(localId);
const box = stack ?? B.tmp();
if (!stack) B.line(`${box} = ${boxNewCall(this.shapeHost, type)}`);
const value = B.tmp();
B.line(`${value} = load ${this.llType(type)}, ptr %${mangleLocal(localId)}`);
this.boxSet(box, type, isRefCounted(type) ? this.retainValue(value, type) : value);
B.line(`store ptr ${box}, ptr ${cache}`);
B.br(join);
B.startBlock(join);
return this.loadBox(cache);
}
retainBox(box: string): string {
this.needsRetainBox = true;
const t = this.B.tmp();
@@ -4076,6 +4110,13 @@ export class LlEmitter {
this.throwIfUninitialized(empty, name);
}
/** Throws the TDZ ReferenceError for `name` unless the i1 `flag` is set. */
checkInitializedFlag(flag: string, name: string): void {
const empty = this.B.tmp();
this.B.line(`${empty} = xor i1 ${flag}, true`);
this.throwIfUninitialized(empty, name);
}
private throwIfUninitialized(empty: string, name: string): void {
const B = this.B;
const lt = B.newLabel("tdz.t");
@@ -4269,13 +4310,23 @@ export class LlEmitter {
this.unwindCleanups.clear();
this.jumpTargets = [];
this.currentLocals = new Map(fn.locals.map((l) => [l.id, l]));
this.lazyCaptureBoxes.clear();
this.lazyStackBoxes.clear();
// An unchanged captured parameter keeps its value in a plain slot; the
// binding is boxed only for closures (captureBox).
const lazyParameters = this.lazyCaptures.parameters(fn);
for (const id of lazyParameters) {
const { boxed: _boxed, ...plain } = this.currentLocals.get(id)!;
this.currentLocals.set(id, plain);
this.lazyCaptureBoxes.set(id, "");
}
this.currentConstantCallbacks =
this.debug === null ? (this.constantCallbacks.get(fn.name) ?? new Map()) : new Map();
// Preserve concrete source bindings for debugger inspection. Suspended
// functions keep the established array lifetime across continuations.
this.streamingSplitsEnabled = this.debug === null && !fn.async && !fn.generator;
this.stackCallbacks.reset(this.streamingSplitsEnabled);
this.stackCaptures.reset(fn, this.streamingSplitsEnabled);
this.stackCaptures.reset(fn, this.streamingSplitsEnabled, lazyParameters);
this.privateSplitLocals = this.streamingSplitsEnabled ? findPrivateSplitLocals(fn) : new Map();
this.storedSplits.clear();
this.splitSpans.clear();
@@ -4403,7 +4454,7 @@ export class LlEmitter {
B.line(`${value} = load double, ptr @${mangleGlobal(global.id)}`);
this.storeIntegerView(global.id, { name: value, type: global.type });
}
for (const local of fn.locals) {
for (const local of this.currentLocals.values()) {
// Boxed locals' slots hold their capture BOX (a ptr); captured
// (env-borrowed) locals bind the incoming box below. A caught-typed
// local is a catch binding: its slot holds the ScrCaught snapshot
@@ -4459,6 +4510,20 @@ export class LlEmitter {
// params (callees own their params — callers passed +1). Boxed params
// allocate the shared binding and move the raw value in.
const fnScope: LlScopeEntry[] = [];
for (const id of this.lazyCaptureBoxes.keys()) {
const cache = B.slot();
B.entryAllocas.push(`${cache} = alloca ptr ; lazy box ${this.currentLocals.get(id)!.name}`);
B.line(`store ptr null, ptr ${cache}`);
fnScope.push({ slot: cache, type: this.currentLocals.get(id)!.type, boxed: true });
this.lazyCaptureBoxes.set(id, cache);
// Environments that finish during the call can share an immortal
// frame box. Its payload owner is released with the function scope.
const stack = this.stackCaptures.emit(this, id, this.currentLocals.get(id)!.type);
if (stack) {
this.lazyStackBoxes.set(id, stack.box);
if (stack.owner) fnScope.push(stack.owner);
}
}
const borrowed = this.callLifetimes.borrowed.get(fn.name);
for (const [index, p] of fn.params.entries()) {
const local = this.currentLocals.get(p.localId)!;
@@ -6149,11 +6214,9 @@ export class LlEmitter {
if (result.owner) this.ownSlot(result.owner.slot, result.owner.type);
return result.value;
}
const arrayRead = this.optionalArrayReads.get(e);
if (arrayRead) return emitProjectedArrayRead(this, arrayRead);
if (canStackUnion(e, this.unionsById)) return emitStackUnion(this, e).value;
// An optional array read consumed by a projection needs no heap box:
// a private stack box owns the payload until the statement frame ends.
const arrayRead = e.type.kind === "union" ? this.optionalArrayReads.get(e) : null;
if (arrayRead) return emitCallArrayRead(this, arrayRead, true);
if (e.kind === "unionNarrow") {
const union = this.emitUnionProjection(e.value);
return { name: this.unionPeek(union.name), type: e.type };
@@ -6200,6 +6263,11 @@ export class LlEmitter {
return this.emitExpr(e);
}
/** A private stack union source with an independently owned payload. */
canStackReceiver(e: IrExpr): boolean {
return this.isStackUnionSource(e);
}
/** True for union sources that emitReadReceiver can produce as a private
* stack box with an independently owned payload (no heap union). */
isStackUnionSource(e: IrExpr): boolean {
@@ -13,6 +13,16 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic
const B = host.B;
switch (e.kind) {
case "intrinsic": {
if (e.name === "module.tdzCheck") {
// An import-cycle binding's initialization flag: false is the
// temporal dead zone (Node's catchable ReferenceError).
const flag = host.emitExpr(e.args[0]!);
const name = e.args[1]!;
if (name.kind !== "strLit")
throw new InternalCompilerError("llvm emitter bug: module.tdzCheck name");
host.checkInitializedFlag(flag.name, name.value);
return { name: "", type: e.type };
}
if (e.name === "module.await") {
// Internal ESM dependency wait: pending promises park the module
// fiber, while settled ones continue synchronously.
@@ -15,7 +15,6 @@ import {
mangleClassStruct,
mangleFnClosure,
mangleFunction,
mangleLocal,
mangleWrapper,
mangleVtStruct,
} from "../mangle.js";
@@ -587,7 +586,7 @@ export function emitCallExpr(
B.line(`store i32 ${functionKind}, ptr ${kindPtr}`);
}
e.captures.forEach((localId, i) => {
const box = host.loadBox(`%${mangleLocal(localId)}`);
const box = host.captureBox(localId);
const retained = host.retainBox(box);
const caps = B.tmp();
const capp = B.tmp();
@@ -786,7 +785,7 @@ export function emitCallExpr(
`${value} = call ptr @scr_classobj_new(ptr @${sym}, ${host.sizeType} ${e.captures.length})`,
);
e.captures.forEach((id, index) => {
const box = host.loadBox(`%${mangleLocal(id)}`);
const box = host.captureBox(id);
const owned = host.retainBox(box);
const caps = B.tmp();
const slot = B.tmp();
@@ -279,7 +279,12 @@ export function emitControlExpr(
throw new InternalCompilerError("llvm emitter bug: optChain union arms");
const multiple = def.arms.length - unitTags.length > 1;
const narrowed = multiple ? e.receiver.type : def.arms[narrowIdx]!;
const r = host.emitExpr(e.receiver);
// A single present arm is only tested and extracted (+1) before the
// body runs, so a fresh receiver box can stay on the stack.
const r =
!multiple && host.canStackReceiver(e.receiver)
? host.emitReadReceiver(e.receiver)
: host.emitExpr(e.receiver);
const bind = B.slot();
B.entryAllocas.push(`${bind} = alloca ${host.llType(narrowed)}`);
B.line(
@@ -481,6 +486,8 @@ export function emitControlExpr(
const unitTags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : []));
if (unitTags.length === 0)
throw new InternalCompilerError("llvm emitter bug: nullish union lacks unit arms");
const fused = emitNullishOptionalChain(host, e);
if (fused) return fused;
if (
!typeEquals(e.type, e.left.type) &&
(e.left.kind === "varRef" ||
@@ -547,3 +554,62 @@ export function emitControlExpr(
}
}
}
/** `a?.b ?? d` narrowed to the chain body's present arm. The body wraps a
* value of the result type in a non-unit arm, so the default runs exactly
* when the receiver is nullish and the intermediate result box is never
* built. Receiver, body and default keep their evaluation order. */
function emitNullishOptionalChain(host: LlvmEmitterContext, e: ExprOf<"nullish">): LlValue | null {
const chain = e.left;
if (
chain.kind !== "optChain" ||
chain.receiver.type.kind !== "union" ||
chain.type.kind !== "union" ||
chain.body.kind !== "unionWrap" ||
chain.body.unionId !== chain.type.unionId ||
!typeEquals(chain.body.value.type, e.type) ||
isUnitType(e.type) ||
e.type.kind === "void"
)
return null;
const resultArm = host.unionsById.get(chain.type.unionId)?.arms[chain.body.tag];
const receiverDef = host.unionsById.get(chain.receiver.type.unionId);
if (!resultArm || isUnitType(resultArm) || !typeEquals(resultArm, e.type) || !receiverDef)
return null;
const unitTags = receiverDef.arms.flatMap((a, i) => (isUnitType(a) ? [i] : []));
const present = receiverDef.arms.flatMap((a, i) => (isUnitType(a) ? [] : [i]));
if (unitTags.length === 0 || present.length !== 1) return null;
const B = host.B;
const narrowed = receiverDef.arms[present[0]!]!;
const r = host.canStackReceiver(chain.receiver)
? host.emitReadReceiver(chain.receiver)
: host.emitExpr(chain.receiver);
const bindTy = host.llType(narrowed);
const bind = B.slot();
B.entryAllocas.push(`${bind} = alloca ${bindTy}`);
B.line(
`store ${bindTy} ${bindTy === "ptr" ? "null" : bindTy === "double" ? f64Lit(0) : "false"}, ptr ${bind}`,
);
host.ownSlot(bind, narrowed);
const isUnit = host.tagInSet(r.name, unitTags);
const ty = host.llType(e.type);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ${ty}`);
const lu = B.newLabel("nulc.u"),
lb = B.newLabel("nulc.b"),
lj = B.newLabel("nulc.j");
B.condBr(isUnit, lu, lb);
B.startBlock(lu);
host.emitBranchInto(slot, e.right);
B.br(lj);
B.startBlock(lb);
B.line(`store ${bindTy} ${host.unionExtract(r.name, narrowed)}, ptr ${bind}`);
host.chainSlots.set(chain.id, { name: bind, type: narrowed, slot: true });
host.emitBranchInto(slot, chain.body.value);
host.chainSlots.delete(chain.id);
B.br(lj);
B.startBlock(lj);
const t = B.tmp();
B.line(`${t} = load ${ty}, ptr ${slot}`);
return host.own({ name: t, type: e.type });
}
@@ -1089,12 +1089,14 @@ function emitUnionEqWrappedScalar(
const arm = def.arms[wrap.tag]!;
let union: LlValue;
let plain: LlValue | null = null;
const unionInput = (value: IrExpr): LlValue =>
host.canStackReceiver(value) ? host.emitReadReceiver(value) : emitBorrowedInput(host, value);
if (rightWrap) {
union = emitBorrowedInput(host, e.left);
union = unionInput(e.left);
if (!isUnitType(arm)) plain = host.emitExpr(wrap.value);
} else {
if (!isUnitType(arm)) plain = host.emitExpr(wrap.value);
union = emitBorrowedInput(host, e.right);
union = unionInput(e.right);
}
const tag = host.unionTag(union.name);
const tagMatch = B.tmp();
@@ -0,0 +1,89 @@
import { expect, test } from "vitest";
import {
F64,
funcOf,
type IrExpr,
type IrFunction,
type IrStmt,
type IrType,
} from "../../ir/ir.js";
import { LazyCaptures } from "./lazy-captures.js";
const loc = { file: "captures.ts", start: 0, end: 0 };
const record: IrType = { kind: "record", shapeId: "options" };
const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: record, loc });
const closure = (fnName: string, captures: string[]): IrStmt => ({
kind: "exprStmt",
expr: { kind: "closure", fnName, captures, type: funcOf([], F64), loc },
loc,
});
const assign = (localId: string): IrStmt => ({ kind: "assign", localId, value: ref(localId), loc });
function outer(body: IrStmt[], extra: Partial<IrFunction> = {}): IrFunction {
return {
name: "outer",
params: [
{ localId: "options", name: "options", type: record },
{ localId: "plain", name: "plain", type: record },
],
returnType: F64,
locals: [
{ id: "options", name: "options", type: record, mutable: true, boxed: true },
{ id: "plain", name: "plain", type: record, mutable: true },
],
body,
loc,
...extra,
};
}
function lifted(name: string, body: IrStmt[], captured = "options"): IrFunction {
return {
name,
params: [],
returnType: F64,
captures: [{ localId: captured, name: captured, type: record }],
locals: [{ id: captured, name: captured, type: record, mutable: true, boxed: true }],
body,
loc,
};
}
function lazy(...functions: IrFunction[]): Set<string> {
const map = new Map(functions.map((fn) => [fn.name, fn]));
return new LazyCaptures(map).parameters(functions[0]!);
}
test("captured parameters that nothing rebinds are boxed lazily", () => {
const reader = lifted("reader", [{ kind: "return", value: ref("options"), loc }]);
expect(lazy(outer([closure("reader", ["options"])]), reader)).toEqual(new Set(["options"]));
});
test("rebinding in the body or in any nested function keeps the eager box", () => {
const reader = lifted("reader", []);
expect(lazy(outer([closure("reader", ["options"]), assign("options")]), reader).size).toBe(0);
const writer = lifted("writer", [assign("options")]);
expect(lazy(outer([closure("writer", ["options"])]), writer).size).toBe(0);
// A write two closures deep reaches the same binding.
const inner = lifted("inner", [{ kind: "return", value: ref("x"), loc }], "x");
const middle = lifted("middle", [closure("inner", ["mid"])], "mid");
expect(lazy(outer([closure("middle", ["options"])]), middle, inner).size).toBe(1);
const deep = lifted("inner", [assign("x")], "x");
expect(lazy(outer([closure("middle", ["options"])]), middle, deep).size).toBe(0);
});
test("class environments, async bodies and unknown targets keep the eager box", () => {
const classRef: IrStmt = {
kind: "exprStmt",
expr: {
kind: "classRef",
className: "Local",
captures: ["options"],
type: { kind: "classval", className: "Local" },
loc,
},
loc,
};
expect(lazy(outer([classRef])).size).toBe(0);
const reader = lifted("reader", []);
expect(lazy(outer([closure("reader", ["options"])], { async: true }), reader).size).toBe(0);
expect(lazy(outer([closure("missing", ["options"])])).size).toBe(0);
});
@@ -0,0 +1,87 @@
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
/** The local a node rebinds. Writes through the bound reference, such as a
* field store or an array push, leave the binding unchanged. Catch bindings
* and rethrow targets are conservatively treated as rebinding. */
function rebound(node: IrStmt | IrExpr): string | null {
switch (node.kind) {
case "varDecl":
case "assign":
case "forOf":
case "rethrow":
case "assignExpr":
case "incDec":
return node.localId;
case "tryCatch":
return node.catchLocalId;
default:
return null;
}
}
/** A captured parameter that no code rebinds holds its entry value for the
* whole call, so every environment may observe a box created when the first
* closure is built instead of on function entry. Paths that never create a
* closure then allocate no box. The proof follows each capture into nested
* function bodies; class environments keep the eager box. */
export class LazyCaptures {
private readonly unchanged = new Map<string, boolean>();
constructor(private readonly functions: ReadonlyMap<string, IrFunction>) {}
parameters(fn: IrFunction): Set<string> {
const result = new Set<string>();
if (fn.async || fn.generator) return result;
const locals = new Map(fn.locals.map((local) => [local.id, local]));
for (const param of fn.params) {
const local = locals.get(param.localId);
if (local?.boxed && !local.tdz) result.add(param.localId);
}
if (result.size === 0) return result;
for (const id of this.rebindings(fn, result)) result.delete(id);
return result;
}
/** The subset of `ids` rebound in `fn` or in any function that captures
* one of them, directly or through further nesting. */
private rebindings(fn: IrFunction, ids: ReadonlySet<string>): Set<string> {
const changed = new Set<string>();
const nested: { target: string; local: string; source: string }[] = [];
const visit = (node: IrStmt | IrExpr): boolean => {
const target = rebound(node);
if (target !== null && ids.has(target)) changed.add(target);
if (node.kind === "classRef") {
for (const id of node.captures ?? []) if (ids.has(id)) changed.add(id);
} else if (node.kind === "closure") {
const callee = this.functions.get(node.fnName);
node.captures.forEach((id, index) => {
if (!ids.has(id)) return;
const capture = callee?.captures?.[index];
if (!capture) changed.add(id);
else nested.push({ target: node.fnName, local: capture.localId, source: id });
});
}
return true;
};
everyStmtList(fn.body, { stmt: visit, expr: visit });
for (const edge of nested) {
if (!changed.has(edge.source) && !this.isUnchanged(edge.target, edge.local))
changed.add(edge.source);
}
return changed;
}
private isUnchanged(fnName: string, localId: string): boolean {
const key = `${fnName}\0${localId}`;
const known = this.unchanged.get(key);
if (known !== undefined) return known;
// Capture chains follow lexical nesting and cannot cycle; the
// provisional entry only bounds work on malformed input.
this.unchanged.set(key, false);
const fn = this.functions.get(fnName);
const result = fn !== undefined && this.rebindings(fn, new Set([localId])).size === 0;
this.unchanged.set(key, result);
return result;
}
}
@@ -272,6 +272,19 @@ export function emitCallArrayRead(
return { name: value, type: read.type };
}
/** A tag or payload projection consumes the box before its frame ends. The
* payload owns one reference, so later operands may mutate the array without
* invalidating the snapshot; the box itself never reaches runtime code. */
export function emitProjectedArrayRead(host: LlvmEmitterContext, read: LocalArrayRead): LlValue {
const slot = host.B.slot();
host.B.entryAllocas.push(`${slot} = alloca ptr`);
const owner = emitLocalArrayRead(host, { ...read, borrow: false }, slot);
if (owner) host.ownSlot(owner.slot, owner.type);
const value = host.B.tmp();
host.B.line(`${value} = load ptr, ptr ${slot}`);
return { name: value, type: read.type };
}
/** Share the checked dense lookup between optional stack boxes and strict
* borrowed reads. Capacity, length, and presence guards precede every load;
* sparse and noncanonical indices keep their runtime lookup semantics. */
@@ -1,6 +1,5 @@
import type { IrExpr, IrFunction } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { mangleLocal } from "../mangle.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
/** A synchronous, invocation-only consumer cannot retain the environment.
@@ -39,7 +38,7 @@ export class StackCallbacks {
B.line(`store i32 4, ptr ${kind}`);
}
value.captures.forEach((localId, index) => {
const box = host.retainBox(host.loadBox(`%${mangleLocal(localId)}`));
const box = host.retainBox(host.captureBox(localId));
const caps = B.tmp();
const slot = B.tmp();
B.line(`${caps} = getelementptr inbounds %ScrClosure, ptr ${closure}, i32 1`);
@@ -25,7 +25,9 @@ export class StackCaptures {
private readonly callbacks: StackCallbacks,
) {}
reset(fn: IrFunction, enabled: boolean): void {
/** `parameters` are unchanged captured parameters (LazyCaptures). Their
* scope is the whole call, so they need no single declaration. */
reset(fn: IrFunction, enabled: boolean, parameters: ReadonlySet<string> = new Set()): void {
this.locals = new Set();
this.remaining = enabled ? 2048 : 0;
if (!enabled) return;
@@ -34,7 +36,7 @@ export class StackCaptures {
);
if (possible.size === 0) return;
const invalid = new Set([
...fn.params.map((param) => param.localId),
...fn.params.map((param) => param.localId).filter((id) => !parameters.has(id)),
...(fn.captures ?? []).map((capture) => capture.localId),
...(fn.classCaptures ?? []).map((capture) => capture.localId),
]);
@@ -89,7 +91,8 @@ export class StackCaptures {
};
if (!fn.body.every(stmt)) return;
for (const id of possible) {
if (!invalid.has(id) && declarations.get(id) === 1) this.locals.add(id);
if (!invalid.has(id) && (declarations.get(id) === 1 || parameters.has(id)))
this.locals.add(id);
}
}
@@ -142,6 +142,8 @@ export function computeMayThrow(mod: IrModule): { fns: Set<string>; indirect: bo
// Module dependency evaluation has await's rejection behavior,
// while deliberately avoiding await's extra settled-promise turn.
if (rec.name === "module.await") f.throws = true;
// A cycle binding read before its declaration ran (TDZ).
if (rec.name === "module.tdzCheck") f.throws = true;
break;
case "yieldExpr":
// A consumer .throw() surfaces at the yield (and .return()'s
@@ -197,17 +197,19 @@ export const UNSUPPORTED: Record<string, UnsupportedEntry> = {
hint: "export declarations directly: export function f() {}",
},
SC1015: { feature: "dynamic import()", milestone: "M4" },
// Benign cycles are ADMITTED (Node runs them as cache hits): cycles of
// ES modules whose top levels are declaration-only and whose
// cycle-crossing bindings are used only inside function bodies (mutual
// recursion, cross-module class references), plus closing edges that
// bind nothing readable. SC1016 remains for exactly the cycles where
// Node's partial initialization is observable — the message names the
// offending binding or top-level statement.
// ES-module cycles are ADMITTED and evaluate in Node's order: module
// bodies run depth-first, function declarations are callable from the
// start, and let/const bindings that can be read before their
// declaration carry an initialization check (Node's TDZ ReferenceError).
// SC1016 remains for the shapes without an exact representation —
// CommonJS members, top-level await, default-export expressions,
// namespace imports or re-exports of cycle members, and var or class
// bindings reachable before their declaration ran. The message names
// the offending construct.
SC1016: {
feature: "circular imports",
milestone: "later",
hint: "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window",
hint: "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge",
},
// Class DECLARATIONS shipped; SC1020's remaining use is class expressions.
SC1020: { feature: "class expressions", milestone: "later" },
@@ -1,3 +1,4 @@
import { generatorDrain } from "../iterator-adapters.js";
import { identityPreservingWidening } from "../coercions/identity.js";
import { InternalCompilerError } from "../../../errors.js";
import {
@@ -987,6 +988,12 @@ export function lowerArrayFromCall(
if (src.type.kind === "dyn" && args.length === 1) {
return lowerCheckedArrayFrom(lowerer, src, loc);
}
// A generator (or an iterator adapted into one) drains in order.
if (src.type.kind === "generator" && args.length === 1) {
const arrT = lowerer.mapTypeOf(lowerer.typeOf(call));
const drained = arrT?.kind === "array" ? generatorDrain(lowerer, src, arrT.elem, loc) : null;
if (drained) return drained;
}
lowerer.noLowering(
"Array.from with this argument shape",
call,
@@ -1,3 +1,4 @@
import { lowerArrayIteratorValue } from "../iterator-adapters.js";
import { identityPreservingWidening } from "../coercions/identity.js";
import { arrayConcatHelper } from "./array-concat.js";
import { boolLit, countedFor, numLit, strLit, varRef } from "../../../ir/build.js";
@@ -158,7 +159,15 @@ export function lowerArrayMethodCall(
): IrExpr | null {
if (lowerer.chainBlocked(access, call)) return null;
const name = access.name.text;
if (!ARRAY_METHODS.has(name) && name !== "sort" && name !== "shift" && name !== "splice")
const iteratorMethod =
(name === "values" || name === "keys" || name === "entries") && call.arguments.length === 0;
if (
!ARRAY_METHODS.has(name) &&
name !== "sort" &&
name !== "shift" &&
name !== "splice" &&
!iteratorMethod
)
return null;
let receiverIr = lowerer.mapTypeOf(lowerer.typeOf(access.expression));
const checkerReceiver = lowerer.checker.getTypeAtLocation(access.expression);
@@ -201,6 +210,18 @@ export function lowerArrayMethodCall(
if (!probedUntyped && !lowerer.isStdlibMember(access)) return null;
let elem = receiverIr.elem;
const loc = locOf(call);
if (iteratorMethod) {
const type = lowerer.mapTypeOf(lowerer.typeOf(call));
const receiver = lowerer.lowerExpr(access.expression);
// Only a static array resolves to a generator here; other lowered
// receivers (island arrays, narrowed unions, checked dynamic values)
// continue to their dedicated handling below.
if (receiver.type.kind === "array") {
const value = type ? lowerArrayIteratorValue(lowerer, receiver, name, type, loc) : null;
if (value) return value;
lowerer.unsupported("SC1090", call, `'${name}()' iterators over this array element type`);
}
}
// An island handle behind an array-typed .d.ts surface
// (`parts().join("-")` — arrays never exit eagerly, so the value
// stays jsval): the ENGINE's own Array.prototype method runs on the
@@ -0,0 +1,316 @@
/* Import-cycle initialization state: module bindings of a cycle that some
* read or write can reach before their declarations ran
* (cycleEarlyBindings) get a BOOL initialization flag. The declaring
* module's %init sets the flag right after the declaration's store, and
* every access from code that can run earlier checks it first — Node's
* temporal-dead-zone ReferenceError, with the same message. */
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { cycleEarlyBindings, locOf } from "../program.js";
import { unsupportedDiag } from "../../diagnostics/diagnostic.js";
import {
BOOL,
STRING,
VOID,
type IrExpr,
type IrFunction,
type IrGlobal,
type IrModule,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
import { everyStmt, transformStmtList } from "../../ir/traverse.js";
/** One flagged binding: its storage, its flag, and the module bodies whose
* top-level code only runs after the declaration (no check needed). */
export interface CycleInitFlag {
global: IrGlobal;
flagId: string;
name: string;
/** The declaring module. */
module: ts.SourceFile;
settled: ReadonlySet<ts.SourceFile>;
}
/** Registers the initialization flags after global collection. Bindings
* whose early access has no exact representation are refused: a `var`
* (Node reads `undefined` there, but module storage only starts when the
* declaring module's body starts), a class (its references are static), a
* binding without module storage, or one with a use the lowering resolves
* from the binding's type (a property key, a `typeof` operand). */
export function markCycleEarlyBindings(lowerer: Lowerer): void {
const early = cycleEarlyBindings(
lowerer.program,
lowerer.entry,
lowerer.moduleOrder,
lowerer.forkTargets.length === 0 && lowerer.workerTargets.length === 0,
);
const refuse = (node: ts.Node, reason: string): void => {
if (lowerer.remainder) return;
lowerer.pushDiag(unsupportedDiag("SC1016", locOf(node), `circular imports (${reason})`));
};
for (const binding of early) {
const where = `'${binding.name.text}' declared in ${binding.module.fileName} can be accessed before its declaration runs`;
if (binding.kind === "var") {
refuse(binding.reference, `the var binding ${where}, where Node reads undefined`);
continue;
}
if (binding.kind === "class") {
refuse(binding.reference, `the class ${where}`);
continue;
}
const global = lowerer.globalsBySymbol.get(binding.symbol);
if (global === undefined) {
refuse(binding.reference, `the binding ${where}, and it has no module storage to check`);
continue;
}
if (binding.folded !== undefined) {
refuse(
binding.folded,
`the binding ${where}, and this use is resolved from its type rather than its storage`,
);
continue;
}
if (lowerer.cycleInitFlags.has(global.id)) continue;
const flagId = `${global.id}%initialized`;
lowerer.globalsList.push({ id: flagId, name: "%initialized", type: BOOL, mutable: true });
global.initFlag = flagId;
const flag: CycleInitFlag = {
global,
flagId,
name: binding.name.text,
module: binding.module,
settled: binding.settled,
};
lowerer.cycleInitFlags.set(global.id, flag);
const stmt = declaringStatement(binding.name);
if (stmt !== null) {
const list = lowerer.cycleInitDeclarations.get(stmt) ?? [];
list.push(flag);
lowerer.cycleInitDeclarations.set(stmt, list);
}
}
}
function declaringStatement(name: ts.Node): ts.Statement | null {
for (let p: ts.Node | undefined = name.parent; p !== undefined; p = p.parent) {
if (ts.isVariableStatement(p)) return p;
if (ts.isSourceFile(p)) return null;
}
return null;
}
function setFlag(flag: CycleInitFlag, loc: SrcLoc): IrStmt {
return {
kind: "assign",
localId: flag.flagId,
value: { kind: "boolLit", value: true, type: BOOL, loc },
loc,
};
}
/** The declaring statement's IR with each flagged binding's flag set right
* after the binding's first store (or after the whole statement when the
* declaration stores nothing, as `let x;` can). */
export function withCycleInitFlags(
lowerer: Lowerer,
stmt: ts.Statement,
lowered: IrStmt[],
): IrStmt[] {
const flags = lowerer.cycleInitDeclarations.get(stmt);
if (flags === undefined) return lowered;
const pending = new Map(flags.map((f) => [f.global.id, f] as const));
const place = (list: IrStmt[]): IrStmt[] => {
const out: IrStmt[] = [];
for (const s of list) {
if (pending.size === 0) {
out.push(s);
continue;
}
if (s.kind === "assign" && pending.has(s.localId)) {
out.push({ ...s, initializes: true }, setFlag(pending.get(s.localId)!, s.loc));
pending.delete(s.localId);
continue;
}
if (s.kind === "block") {
out.push({ ...s, body: place(s.body) });
continue;
}
out.push(s);
// A store nested in another construct: the flag follows the construct.
for (const [id, flag] of [...pending]) {
let stores = false;
let nested = false;
everyStmt(s, {
expr: (e) => {
if (e.kind === "assignExpr" && e.localId === id) nested = true;
return true;
},
stmt: (inner) => {
if (inner.kind === "assign" && inner.localId === id) {
(inner as { initializes?: true }).initializes = true;
stores = true;
}
return true;
},
});
if (nested && !lowerer.remainder) {
lowerer.pushDiag(
unsupportedDiag(
"SC1016",
locOf(stmt),
`circular imports (the declaration of '${flag.name}' stores it inside an expression, where its initialization cannot be marked)`,
),
);
}
if (stores || nested) {
out.push(setFlag(flag, s.loc));
pending.delete(id);
}
}
}
return out;
};
const out = place(lowered);
const loc = locOf(stmt);
for (const flag of pending.values()) out.push(setFlag(flag, loc));
return out;
}
/** Inserts the initialization checks into every function that can access a
* flagged binding before its declaration ran: every function except the
* %init bodies of settled modules, and in the declaring module's own %init
* only the statements up to the one that sets the flag (earlier reads
* there come from inlined callback bodies; tsc rejects direct ones). Reads
* check before reading; writes check after evaluating the assigned value,
* as PutValue does. The declaration's own store is never checked. */
export function checkCycleBindingAccesses(lowerer: Lowerer, mod: IrModule): void {
if (lowerer.cycleInitFlags.size === 0) return;
const fileOfInit = new Map<string, ts.SourceFile>();
for (const [sf, name] of lowerer.initNameOf) fileOfInit.set(name, sf);
for (const fn of mod.functions) rewriteFunction(lowerer, fn, fileOfInit.get(fn.name));
}
function rewriteFunction(
lowerer: Lowerer,
fn: IrFunction,
initOf: ts.SourceFile | undefined,
): void {
const active = new Map<string, CycleInitFlag>();
for (const [id, flag] of lowerer.cycleInitFlags) {
if (initOf === undefined || initOf === flag.module || !flag.settled.has(initOf)) {
active.set(id, flag);
}
}
if (active.size === 0) return;
const produced = new WeakSet<object>();
let temps = 0;
const check = (flag: CycleInitFlag, loc: SrcLoc): IrStmt => {
const expr: IrExpr = {
kind: "intrinsic",
name: "module.tdzCheck",
args: [
{ kind: "varRef", localId: flag.flagId, type: BOOL, loc },
{ kind: "strLit", value: flag.name, type: STRING, loc },
],
type: VOID,
loc,
};
return { kind: "exprStmt", expr, loc };
};
const temp = (type: IrType): string => {
const id = `%cycleInit.${temps++}`;
fn.locals.push({ id, name: "%cycleInit", type, mutable: false });
return id;
};
const transform = {
expr: (e: IrExpr): IrExpr => {
if (produced.has(e)) return e;
if (e.kind !== "varRef" && e.kind !== "incDec" && e.kind !== "assignExpr") return e;
const flag = active.get(e.localId);
if (flag === undefined) return e;
if (e.kind === "assignExpr") {
const t = temp(e.value.type);
const value: IrExpr = { kind: "varRef", localId: t, type: e.value.type, loc: e.loc };
const write: IrExpr = { ...e, value };
produced.add(write);
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: t, init: e.value, loc: e.loc }, check(flag, e.loc)],
result: write,
type: e.type,
loc: e.loc,
};
}
produced.add(e);
return { kind: "seqExpr", stmts: [check(flag, e.loc)], result: e, type: e.type, loc: e.loc };
},
stmt: (s: IrStmt): IrStmt => {
if (produced.has(s) || s.kind !== "assign" || s.initializes) return s;
const flag = active.get(s.localId);
if (flag === undefined) return s;
const t = temp(s.value.type);
const write: IrStmt = {
kind: "assign",
localId: s.localId,
value: { kind: "varRef", localId: t, type: s.value.type, loc: s.loc },
loc: s.loc,
};
produced.add(write);
return {
kind: "block",
body: [
{ kind: "varDecl", localId: t, init: s.value, loc: s.loc },
check(flag, s.loc),
write,
],
loc: s.loc,
};
},
};
if (initOf === undefined) {
fn.body = transformStmtList(fn.body, transform);
return;
}
// A module %init: its own bindings stop needing checks once the
// top-level statement setting their flag completed.
fn.body = fn.body.map((s) => {
if (active.size === 0) return s;
const out = transformStmtList([s], transform)[0]!;
for (const [id, flag] of [...active]) {
if (flag.module !== initOf) continue;
const sets = !everyStmt(out, {
expr: () => true,
stmt: (inner) => !(inner.kind === "assign" && inner.localId === flag.flagId),
});
if (sets) active.delete(id);
}
return out;
});
}
/** Class layouts that read module storage directly (an evaluated heritage
* value, a symbol-keyed field) bypass the checks: refuse when that storage
* is a flagged binding. */
export function refuseUncheckedCycleReads(
lowerer: Lowerer,
classes: readonly { baseValueGlobal?: string; symbolFields?: { globalId: string }[] }[],
): void {
if (lowerer.cycleInitFlags.size === 0 || lowerer.remainder) return;
for (const cls of classes) {
const ids = [cls.baseValueGlobal, ...(cls.symbolFields ?? []).map((f) => f.globalId)];
for (const id of ids) {
const flag = id === undefined ? undefined : lowerer.cycleInitFlags.get(id);
if (flag === undefined) continue;
const loc = flag.global.source?.loc ?? { file: lowerer.entry.fileName, start: 0, end: 0 };
lowerer.pushDiag(
unsupportedDiag(
"SC1016",
loc,
`circular imports (the class layout reads '${flag.name}' directly, before an initialization check can run)`,
),
);
}
}
}
@@ -0,0 +1,651 @@
import { dynUndefinedExpr, varRef } from "../../ir/build.js";
import {
BOOL,
DYN,
F64,
STRING,
type IrExpr,
type IrLocal,
type IrStmt,
type IrType,
type SrcLoc,
typeEquals,
UNDEFINED_T,
VOID,
} from "../../ir/ir.js";
import { streamTypedRefEligible } from "../../ir/analysis.js";
import { typeKey, genResultRecord } from "../type-mapper.js";
import type { Lowerer } from "./lowerer.js";
import { extractIteratorValue } from "./lower-generators.js";
import { lowerSafeIndexRead } from "./array-values.js";
/** The conversion into an iterator-typed slot (a generator type): a
* checked-dynamic iterator or a generator with other channel types steps
* through an interned adapter generator. Null when neither applies. */
export function iteratorSlotAdapter(
lowerer: Lowerer,
expr: IrExpr,
expected: IrType & { kind: "generator" },
): IrExpr | null {
if (expr.type.kind === "dyn") return dynIteratorAdapter(lowerer, expr, expected);
if (expr.type.kind === "generator") return generatorRetypeAdapter(lowerer, expr, expected);
return null;
}
/** `resumed.0 = value` — the adapters' early-exit marker around a yield. */
function setResumed(value: boolean, loc: SrcLoc): IrStmt {
return {
kind: "assign",
localId: "resumed.0",
value: { kind: "boolLit", value, type: BOOL, loc },
loc,
};
}
/** A yield expression's type: a valueless next channel resumes with void. */
function resumeType(nextT: IrType): IrType {
return nextT.kind === "undefinedT" ? VOID : nextT;
}
/** Convert a stepped value into a channel type. Records and arrays box into
* checked values by reference, so consumers observe the same objects. */
function intoChannel(lowerer: Lowerer, value: IrExpr, target: IrType): IrExpr | null {
const coerced = lowerer.coerceToExpected(value, target);
if (!typeEquals(coerced.type, target)) return null;
if (
coerced.kind === "dynFrom" &&
streamTypedRefEligible(coerced.value.type) &&
coerced.value.type.kind !== "bytes"
)
coerced.liveRef = true;
return coerced;
}
/** A checked-dynamic iterator (a Map, Set or array iterator, or any other
* object following the iterator protocol) flowing into a generator-typed
* slot (`IterableIterator<T>`, `Iterator<T>`, `Generator<T>`). The interned
* adapter is a generator over the source: each `next()` steps the source
* once and forwards its value, so iteration stays lazy and observes the
* collection live, exactly like the source iterator. Values convert into
* the slot's channel types with the usual checked extraction. Null when
* the slot is not a synchronous generator with a yielding channel. */
function dynIteratorAdapter(
lowerer: Lowerer,
expr: IrExpr,
expected: IrType & { kind: "generator" },
): IrExpr | null {
if (expected.async || expected.yieldT.kind === "void") return null;
const resultType = genResultRecord(
expected.yieldT,
expected.retT,
lowerer.shapes,
lowerer.unions,
);
if (!resultType) return null;
const loc = expr.loc;
const key = `iterAdapt:${typeKey(expected)}`;
let name = lowerer.valueHelpers.get(key);
if (!name) {
name = `%iter.adapt.${lowerer.valueHelpers.size}`;
lowerer.valueHelpers.set(key, name);
const locals: IrLocal[] = [];
const local = (id: string, type: IrType, mutable = false): IrExpr => {
locals.push({ id, name: id.slice(0, id.indexOf(".")), type, mutable });
return varRef(id, type, loc);
};
const source = local("source.0", DYN);
const iterator = local("iterator.0", DYN);
const next = local("next.0", DYN);
const step = local("step.0", DYN, true);
const forwards = expected.nextT.kind === "dyn";
const sent = forwards ? local("sent.0", DYN, true) : null;
const get = (value: IrExpr, field: string): IrExpr => ({
kind: "dynKeyGet",
value,
key: { kind: "strLit", value: field, type: STRING, loc },
type: DYN,
loc,
});
const advance = (args: IrExpr[]): IrExpr => ({
kind: "libCall",
fn: "dyn.iteratorResult",
args: [
{
kind: "dynCall",
callee: next,
receiver: iterator,
calleeName: "iterator.next",
args,
type: DYN,
loc,
},
],
type: DYN,
loc,
});
const returned =
expected.retT.kind === "void"
? null
: intoChannel(lowerer, get(step, "value"), expected.retT);
const yielded = (): IrExpr | null => intoChannel(lowerer, get(step, "value"), expected.yieldT);
if ((expected.retT.kind !== "void" && !returned) || !yielded()) {
lowerer.valueHelpers.delete(key);
return null;
}
const finish: IrStmt = { kind: "return", value: returned, loc };
// One suspension: yield the current value and take the resume value.
const suspend = (): IrStmt => {
const resumed: IrExpr = {
kind: "yieldExpr",
value: yielded()!,
type: resumeType(expected.nextT),
loc,
};
return sent
? { kind: "assign", localId: "sent.0", value: resumed, loc }
: { kind: "exprStmt", expr: resumed, loc };
};
const resumedFlag = local("resumed.0", BOOL, true);
const closer = local("closer.0", DYN);
const stepOnce = (): IrStmt => ({
kind: "assign",
localId: "step.0",
value: advance(sent ? [sent] : []),
loc,
});
// An early exit from a consumer's loop closes the adapter. JS calls the
// source's own return() then; a source without one (the native Map, Set
// and array iterators) stays open, so the adapter suspends again on the
// same value instead of completing, and later steps resume the source.
const closeOrPark: IrStmt = {
kind: "if",
cond: { kind: "unary", op: "!", operand: resumedFlag, type: BOOL, loc },
then: [
{ kind: "varDecl", localId: "closer.0", init: get(iterator, "return"), loc },
{
kind: "if",
cond: { kind: "dynTest", test: "nullish", value: closer, type: BOOL, loc },
then: [suspend(), stepOnce(), { kind: "continue", loc }],
else_: [
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.iteratorResult",
args: [
{
kind: "dynCall",
callee: closer,
receiver: iterator,
calleeName: "iterator.return",
args: [],
type: DYN,
loc,
},
],
type: DYN,
loc,
},
loc,
},
],
loc,
},
],
else_: null,
loc,
};
const body: IrStmt[] = [
{
kind: "varDecl",
localId: "iterator.0",
init: {
kind: "libCall",
fn: "dyn.iteratorResult",
args: [
{
kind: "libCall",
fn: "dyn.iterator",
args: [source, { kind: "strLit", value: "value is not iterable", type: STRING, loc }],
type: DYN,
loc,
},
],
type: DYN,
loc,
},
loc,
},
{ kind: "varDecl", localId: "next.0", init: get(iterator, "next"), loc },
{ kind: "varDecl", localId: "step.0", init: advance([]), loc },
...(sent
? [{ kind: "varDecl" as const, localId: "sent.0", init: dynUndefinedExpr(loc), loc }]
: []),
{
kind: "varDecl",
localId: "resumed.0",
init: { kind: "boolLit", value: false, type: BOOL, loc },
loc,
},
{
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
body: [
{
kind: "if",
cond: { kind: "dynTest", test: "truthy", value: get(step, "done"), type: BOOL, loc },
then: [finish],
else_: null,
loc,
},
setResumed(false, loc),
{
kind: "tryCatch",
tryBody: [suspend(), setResumed(true, loc)],
catchBody: null,
catchLocalId: null,
finallyBody: [closeOrPark],
loc,
},
stepOnce(),
],
loc,
},
];
lowerer.liftedFns.push({
name,
params: [{ localId: "source.0", name: "source", type: DYN }],
returnType: expected.retT,
locals,
body,
generator: { yieldT: expected.yieldT, nextT: expected.nextT, resultType },
loc,
});
}
return { kind: "call", callee: name, args: [expr], type: expected, loc };
}
/** A generator flowing into a slot with other channel types (a
* `Generator<number, void, undefined>` returned through an
* `IterableIterator<number>` annotation). The interned adapter generator
* resumes the source once per step and converts each value between the
* channel types, so iteration stays lazy. Null when a channel has no
* conversion. */
function generatorRetypeAdapter(
lowerer: Lowerer,
expr: IrExpr,
expected: IrType & { kind: "generator" },
): IrExpr | null {
const from = expr.type;
if (from.kind !== "generator" || from.async || expected.async) return null;
if (from.yieldT.kind === "void" || expected.yieldT.kind === "void") return null;
const fromResult = genResultRecord(from.yieldT, from.retT, lowerer.shapes, lowerer.unions);
const toResult = genResultRecord(expected.yieldT, expected.retT, lowerer.shapes, lowerer.unions);
const fromShape = fromResult ? lowerer.shapes.get(fromResult.shapeId) : undefined;
const valueT = fromShape?.fields.find((field) => field.name === "value")?.type;
if (!fromResult || !toResult || !valueT) return null;
const loc = expr.loc;
const key = `genRetype:${typeKey(from)}:${typeKey(expected)}`;
let name = lowerer.valueHelpers.get(key);
if (!name) {
const source = varRef("source.0", from, loc);
const step = varRef("step.0", fromResult, loc);
const field = (f: "value" | "done", type: IrType): IrExpr => ({
kind: "recordGet",
obj: step,
shapeId: fromResult.shapeId,
field: f,
type,
loc,
});
const extracted = extractIteratorValue(
lowerer,
from.yieldT,
valueT,
field("value", valueT),
loc,
);
const yielded = extracted ? intoChannel(lowerer, extracted, expected.yieldT) : null;
if (!yielded) return null;
// The completion value: the source's return value, or undefined.
let returned: IrExpr | null = null;
if (expected.retT.kind !== "void") {
returned =
from.retT.kind === "void"
? intoChannel(
lowerer,
{ kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc },
expected.retT,
)
: intoChannel(lowerer, field("value", valueT), expected.retT);
if (!returned) return null;
}
// The resume value: forwarded when both sides carry one.
const forwards = expected.nextT.kind !== "undefinedT" && from.nextT.kind !== "undefinedT";
const sentRef = varRef("sent.0", expected.nextT, loc);
const sent = forwards ? intoChannel(lowerer, sentRef, from.nextT) : null;
if (forwards && !sent) return null;
if (!forwards && from.nextT.kind !== "undefinedT" && from.nextT.kind !== "dyn") return null;
const resume = (arg: IrExpr | null): IrExpr => ({
kind: "genResume",
mode: "next",
gen: source,
arg,
type: fromResult,
loc,
});
const resumed: IrExpr = {
kind: "yieldExpr",
value: yielded,
type: resumeType(expected.nextT),
loc,
};
name = `%gen.retype.${lowerer.valueHelpers.size}`;
lowerer.valueHelpers.set(key, name);
lowerer.liftedFns.push({
name,
params: [{ localId: "source.0", name: "source", type: from }],
returnType: expected.retT,
locals: [
{ id: "source.0", name: "source", type: from, mutable: false },
{ id: "step.0", name: "step", type: fromResult, mutable: true },
...(forwards ? [{ id: "sent.0", name: "sent", type: expected.nextT, mutable: true }] : []),
{ id: "resumed.0", name: "resumed", type: BOOL, mutable: true },
],
body: [
{ kind: "varDecl", localId: "step.0", init: resume(null), loc },
...(forwards ? [{ kind: "varDecl" as const, localId: "sent.0", init: null, loc }] : []),
{
kind: "varDecl",
localId: "resumed.0",
init: { kind: "boolLit", value: false, type: BOOL, loc },
loc,
},
{
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
body: [
{
kind: "if",
cond: field("done", BOOL),
then: [{ kind: "return", value: returned, loc }],
else_: null,
loc,
},
setResumed(false, loc),
{
kind: "tryCatch",
tryBody: [
forwards
? { kind: "assign", localId: "sent.0", value: resumed, loc }
: { kind: "exprStmt", expr: resumed, loc },
setResumed(true, loc),
],
catchBody: null,
catchLocalId: null,
// Closing the adapter early closes the source generator too.
finallyBody: [
{
kind: "if",
cond: {
kind: "unary",
op: "!",
operand: varRef("resumed.0", BOOL, loc),
type: BOOL,
loc,
},
then: [
{
kind: "exprStmt",
expr: {
kind: "genResume",
mode: "return",
gen: source,
arg: null,
type: fromResult,
loc,
},
loc,
},
],
else_: null,
loc,
},
],
loc,
},
{ kind: "assign", localId: "step.0", value: resume(sent), loc },
],
loc,
},
],
generator: { yieldT: expected.yieldT, nextT: expected.nextT, resultType: toResult },
loc,
});
}
return { kind: "call", callee: name, args: [expr], type: expected, loc };
}
/** Drain a synchronous generator into a fresh `elem[]` — the eager
* consumers of an iterator (`[...it]`, `Array.from(it)`). Each step resumes
* the generator once and appends its value; completion stops the drain
* without appending the return value, as in JS. Null when the yielded
* values cannot be extracted into the element type. */
export function generatorDrain(
lowerer: Lowerer,
source: IrExpr,
elem: IrType,
loc: SrcLoc,
): IrExpr | null {
const gen = source.type;
if (gen.kind !== "generator" || gen.async || gen.yieldT.kind === "void") return null;
const resultType = genResultRecord(gen.yieldT, gen.retT, lowerer.shapes, lowerer.unions);
const shape = resultType ? lowerer.shapes.get(resultType.shapeId) : undefined;
const valueT = shape?.fields.find((field) => field.name === "value")?.type;
if (!resultType || !valueT) return null;
const arrT: IrType = { kind: "array", elem };
const key = `genDrain:${typeKey(gen)}:${typeKey(elem)}`;
let name = lowerer.valueHelpers.get(key);
if (!name) {
const genRef = varRef("gen.0", gen, loc);
const outRef = varRef("out.0", arrT, loc);
const stepRef = varRef("step.0", resultType, loc);
const raw = extractIteratorValue(
lowerer,
gen.yieldT,
valueT,
{
kind: "recordGet",
obj: stepRef,
shapeId: resultType.shapeId,
field: "value",
type: valueT,
loc,
},
loc,
);
if (!raw) return null;
const value = lowerer.coerceToExpected(raw, elem);
if (!typeEquals(value.type, elem)) return null;
name = `%gen.drain.${lowerer.valueHelpers.size}`;
lowerer.valueHelpers.set(key, name);
lowerer.liftedFns.push({
name,
params: [{ localId: "gen.0", name: "gen", type: gen }],
returnType: arrT,
locals: [
{ id: "gen.0", name: "gen", type: gen, mutable: false },
{ id: "out.0", name: "out", type: arrT, mutable: false },
{ id: "step.0", name: "step", type: resultType, mutable: false },
],
body: [
{
kind: "varDecl",
localId: "out.0",
init: { kind: "arrayLit", elems: [], type: arrT, loc },
loc,
},
{
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
body: [
{
kind: "varDecl",
localId: "step.0",
init: {
kind: "genResume",
mode: "next",
gen: genRef,
arg: null,
type: resultType,
loc,
},
loc,
},
{
kind: "if",
cond: {
kind: "recordGet",
obj: stepRef,
shapeId: resultType.shapeId,
field: "done",
type: BOOL,
loc,
},
then: [{ kind: "break", loc }],
else_: null,
loc,
},
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: outRef,
args: [value],
type: F64,
loc,
},
loc,
},
],
loc,
},
{ kind: "return", value: outRef, loc },
],
loc,
});
}
return { kind: "call", callee: name, args: [source], type: arrT, loc };
}
/** `list.values()`, `list.keys()` and `list.entries()` as values: a
* generator over the live array. Each step compares the index with the
* current length and reads the current element, so elements appended
* during iteration are visited and completion is final, as with the
* native array iterator. `type` is the call's generator type. */
export function lowerArrayIteratorValue(
lowerer: Lowerer,
receiver: IrExpr,
method: "values" | "keys" | "entries",
type: IrType,
loc: SrcLoc,
): IrExpr | null {
const arrT = receiver.type;
if (arrT.kind !== "array" || type.kind !== "generator") return null;
const resultType = genResultRecord(type.yieldT, type.retT, lowerer.shapes, lowerer.unions);
if (!resultType || type.retT.kind !== "void" || type.nextT.kind !== "undefinedT") return null;
const tuple = type.yieldT.kind === "record" ? lowerer.shapes.get(type.yieldT.shapeId) : undefined;
if (method === "entries" && (!tuple?.tuple || tuple.fields.length !== 2)) return null;
const key = `arrIter:${method}:${typeKey(arrT)}:${typeKey(type)}`;
let name = lowerer.valueHelpers.get(key);
if (!name) {
const list = varRef("list.0", arrT, loc);
const index = varRef("index.0", F64, loc);
const element = (): IrExpr | null => {
const read = lowerSafeIndexRead(lowerer, list, index, loc);
if (!read) return null;
const target =
method === "values" ? type.yieldT : tuple!.fields.find((f) => f.name === "1")!.type;
const value = lowerer.coerceToExpected(read, target);
return typeEquals(value.type, target) ? value : null;
};
let yielded: IrExpr | null;
if (method === "keys") yielded = index;
else if (method === "values") yielded = element();
else {
const value = element();
yielded =
value === null
? null
: {
kind: "recordLit",
fields: [
{ name: "0", value: index },
{ name: "1", value },
],
type: type.yieldT,
loc,
};
}
if (!yielded || !typeEquals(yielded.type, type.yieldT)) return null;
name = `%arr.iter.${lowerer.valueHelpers.size}`;
lowerer.valueHelpers.set(key, name);
lowerer.liftedFns.push({
name,
params: [{ localId: "list.0", name: "list", type: arrT }],
returnType: VOID,
locals: [
{ id: "list.0", name: "list", type: arrT, mutable: false },
{ id: "index.0", name: "index", type: F64, mutable: true },
],
body: [
{
kind: "varDecl",
localId: "index.0",
init: { kind: "numLit", value: 0, type: F64, loc },
loc,
},
{
kind: "while",
cond: {
kind: "bin",
op: "<",
left: index,
right: {
kind: "arrIntrinsic",
method: "length",
receiver: list,
args: [],
type: F64,
loc,
},
type: BOOL,
loc,
},
body: [
{ kind: "exprStmt", expr: { kind: "yieldExpr", value: yielded, type: VOID, loc }, loc },
{
kind: "assign",
localId: "index.0",
value: {
kind: "bin",
op: "+",
left: index,
right: { kind: "numLit", value: 1, type: F64, loc },
type: F64,
loc,
},
loc,
},
],
loc,
},
{ kind: "return", value: null, loc },
],
generator: { yieldT: type.yieldT, nextT: UNDEFINED_T, resultType },
loc,
});
}
return { kind: "call", callee: name, args: [receiver], type, loc };
}
@@ -1,3 +1,4 @@
import { generatorDrain } from "./iterator-adapters.js";
import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js";
import { isOptionalProcessStreamProperty } from "./builtins/process.js";
import { lowerWorkerMetadata } from "./builtins/workers.js";
@@ -359,7 +360,10 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr {
}
lowerExprDepth++;
try {
return lowerExprInner(lowerer, expr);
const lowered = lowerExprInner(lowerer, expr);
if (lowered.type.kind === "void" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Never) !== 0)
lowerer.neverValued.add(lowered);
return lowered;
} finally {
lowerExprDepth--;
}
@@ -4344,6 +4348,37 @@ export function lowerNullishCoalesce(
return result;
}
/** A plain value read that may be runtime-optional: an unchecked element
* read, or a binding, member or call that carries one. Lowering these
* without a destination type is the same as lowering them into one. */
function isPlainValueRead(node: ts.Expression): boolean {
while (ts.isParenthesizedExpression(node)) node = node.expression;
return (
ts.isElementAccessExpression(node) ||
ts.isIdentifier(node) ||
ts.isPropertyAccessExpression(node) ||
ts.isCallExpression(node)
);
}
/** A member access or call receiver needs the present value itself (JS
* throws a TypeError reading a member of undefined). */
function isMemberReceiver(node: ts.Expression): boolean {
let child: ts.Node = node;
let parent = node.parent;
while (ts.isParenthesizedExpression(parent)) {
child = parent;
parent = parent.parent;
}
return (
((ts.isPropertyAccessExpression(parent) ||
ts.isElementAccessExpression(parent) ||
ts.isCallExpression(parent)) &&
parent.expression === child) ||
(ts.isTaggedTemplateExpression(parent) && parent.tag === child)
);
}
function lowerNullishPair(
lowerer: Lowerer,
expr: ts.BinaryExpression,
@@ -4414,6 +4449,29 @@ function lowerNullishPair(
loc,
};
}
// A runtime-optional default (`name ?? names[0]`): the checker types an
// unchecked element read as the bare element type, but an out-of-range
// read is undefined at runtime. When the default shares the left's
// optional union, that union is the result, so the undefined state
// reaches the consumer (an optional return or parameter takes it as its
// undefined arm; a plain slot still extracts the element with its
// checked narrow).
if (
rest.length === 1 &&
typeEquals(type, rest[0]!) &&
isPlainValueRead(expr.right) &&
!isMemberReceiver(expr)
) {
const raw = lowerer.lowerExpr(expr.right);
if (typeEquals(raw.type, left.type))
return { kind: "nullish", left, right: raw, type: left.type, loc };
// `names[0] ?? undefined`: an explicit unit default stays the left's arm.
if (isUnitType(raw.type) && lowerer.armTag(left.type.unionId, raw.type) >= 0) {
const right = lowerer.coerceInto(expr.right, raw, left.type);
return { kind: "nullish", left, right, type: left.type, loc };
}
return { kind: "nullish", left, right: lowerer.coerceInto(expr.right, raw, type), type, loc };
}
if (typeEquals(type, left.type) || (rest.length === 1 && typeEquals(type, rest[0]!))) {
const right = lowerer.lowerExprExpecting(expr.right, type);
return { kind: "nullish", left, right, type, loc };
@@ -5927,6 +5985,12 @@ export function lowerArrayLiteral(
loc: locOf(el),
};
}
// `[...it]` over a generator (or an iterator adapted into one):
// drain it once, in order, into a fresh element array.
if (src.type.kind === "generator") {
const drained = generatorDrain(lowerer, src, type.elem, locOf(el));
if (drained) src = drained;
}
// A native checked iterable can supply scalar elements even when
// the checker inferred a typed array from its producer. Drain once
// and validate the elements before the ordinary spread copy.
@@ -33,6 +33,7 @@ import {
resolveNpmImport,
} from "../program.js";
import type { CycleEdge } from "../program.js";
import { markCycleEarlyBindings, withCycleInitFlags } from "./cycle-initialization.js";
import {
invalidJsonModuleDiag,
npmEmbedFailedDiag,
@@ -507,6 +508,7 @@ export function collectProgram(lowerer: Lowerer, parts: FileParts[]): void {
}
lowerer.collecting = wasCollecting;
}
markCycleEarlyBindings(lowerer);
}
/** The npm-import chokepoint, run once per pass during collection (so
@@ -3088,8 +3090,16 @@ export function lowerFileInit(
body.push(...lowerer.lowerStaticFieldInits(statics[at]!.info));
at++;
}
const deferred = lowerer.deferredModuleInitializers?.defer(lowerer, stmt);
const stmtIr = deferred ? [deferred] : lowerer.lowerStmts([stmt]);
// A cycle binding's declaration stays at its position: its
// initialization flag must be set where the declaration runs.
const deferred = lowerer.cycleInitDeclarations.has(stmt)
? null
: lowerer.deferredModuleInitializers?.defer(lowerer, stmt);
const stmtIr = withCycleInitFlags(
lowerer,
stmt,
deferred ? [deferred] : lowerer.lowerStmts([stmt]),
);
// Class EXPRESSIONS inside this statement queued their static
// inits while it lowered: they land immediately before it — JS's
// order for the supported whole-initializer positions.
@@ -4242,11 +4242,15 @@ export function bindPatternTarget(
// through later locals, templates, operators, and narrowing instead
// of being extracted with a checked arm read.
let runtimeOptional = false;
// The optional-read pre-pass may already have widened the binding to
// that union; its reads still need the runtime-optional treatment.
if (
!dynStays &&
value.type.kind === "union" &&
lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 &&
typeEquals(lowerer.stripUndefinedArm(value.type), type)
(typeEquals(lowerer.stripUndefinedArm(value.type), type) ||
(typeEquals(value.type, type) &&
typeEquals(lowerer.stripUndefinedArm(value.type), lowerer.irTypeOf(name))))
) {
type = value.type;
runtimeOptional = true;
@@ -1,3 +1,8 @@
import {
checkCycleBindingAccesses,
refuseUncheckedCycleReads,
type CycleInitFlag,
} from "./cycle-initialization.js";
import { identityPreservingWidening } from "./coercions/identity.js";
import { checkedClassAssertion } from "./class-assertions.js";
import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js";
@@ -5,6 +10,7 @@ import { isOptionalProcessStreamProperty } from "./builtins/process.js";
import { concreteCollectionNarrow } from "./collection-narrowing.js";
import { lowerWorkerMetadata } from "./builtins/workers.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js";
import { iteratorSlotAdapter } from "./iterator-adapters.js";
import type { FieldLift } from "./coercions/structural-plans.js";
import { lowerBuiltinCall } from "./builtin-calls.js";
import { CoercionState } from "./coercions/state.js";
@@ -60,6 +66,7 @@ import {
utilTypesModuleValue,
} from "./lower-builtin-values.js";
import { everyExprChild, everyStmtChild, transformStmtList } from "../../ir/traverse.js";
import { RuntimeOptionalProvenance } from "./runtime-optional-provenance.js";
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js";
import { UnregisteredClassTypes } from "./unregistered-class-types.js";
@@ -2220,6 +2227,10 @@ export class Lowerer {
* node carrying the ?. token is marked handled so the receiver-typed
* lowerings stop declining it (chainBlocked). */
readonly chainRecvByNode = new Map<ts.Node, IrExpr>();
/** Lowered void calls whose checked type is `never` (they throw or exit).
* TypeScript lets such a call stand where any value is expected;
* coerceToExpected turns them into diverging sequences. */
readonly neverValued = new Set<IrExpr>();
readonly chainNarrowedType = new Map<ts.Node, ts.Type>();
readonly chainHandled = new Set<ts.Node>();
/** for-of-over-matchAll bindings whose `.index` reads the companion-index
@@ -2463,6 +2474,10 @@ export class Lowerer {
* function called above the declaration statement reads that, never a
* NULL slot. Filled by collectGlobals. */
readonly varGlobalEntryInits = new Map<ts.SourceFile, IrGlobal[]>();
/** Import-cycle bindings with an initialization flag, by global id, and
* the top-level statements declaring them (cycle-initialization.ts). */
readonly cycleInitFlags = new Map<string, CycleInitFlag>();
readonly cycleInitDeclarations = new Map<ts.Statement, CycleInitFlag[]>();
get ctx(): FnCtx {
const top = this.fnStack[this.fnStack.length - 1];
@@ -3641,6 +3656,9 @@ export class Lowerer {
* but it is not a runtime proof; promoting only the affected slots keeps
* the rest of the dense ABI unchanged. */
analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void {
const provenance = RuntimeOptionalProvenance.fromEnvironment();
// The site whose value the current promotion follows (debug only).
let provenanceSite: ts.Node | undefined;
const optionalSymbols = new Set<ts.Symbol>();
const optionalReturns = new Set<ts.Symbol>();
const arithmeticReturns = new Map<ts.Symbol, IrType>();
@@ -3943,6 +3961,7 @@ export class Lowerer {
const symbol = symbolOf(el.name);
if (symbol && !el.initializer && !optionalSymbols.has(symbol)) {
optionalSymbols.add(symbol);
provenance?.note("pattern-binding", el.name, init);
// Indexed flow facts can survive a mutating method call in the
// checker. The array's element ABI describes every value a
// position may hold after that call, so bind from that type.
@@ -4089,6 +4108,7 @@ export class Lowerer {
const widened = this.runtimeOptionalType(current);
const previous = this.runtimeOptionalBindingTypes.get(symbol);
this.runtimeOptionalBindingTypes.set(symbol, widened);
provenance?.note("callback-parameter", parameter.name, provenanceSite);
if (!previous || !typeEquals(previous, widened)) changed = true;
if (!optionalSymbols.has(symbol)) {
optionalSymbols.add(symbol);
@@ -4099,6 +4119,7 @@ export class Lowerer {
const fnType = this.mapTypeOf(this.typeOf(fn));
if (fnType?.kind === "func" && bodyReturnsOptional(fn)) {
const widenedReturn = this.runtimeOptionalType(fnType.ret);
provenance?.note("callback-return", fn, provenanceSite);
const previousReturn = this.runtimeOptionalFunctionReturnType(fn, fnType.ret);
this.runtimeOptionalFunctionReturns.set(fn, widenedReturn);
if (!typeEquals(previousReturn, widenedReturn)) changed = true;
@@ -4248,6 +4269,7 @@ export class Lowerer {
const symbol = symbolOf(node.name);
if (symbol && !optionalSymbols.has(symbol)) {
optionalSymbols.add(symbol);
provenance?.note("binding", node.name, node.initializer);
changed = true;
}
}
@@ -4270,6 +4292,7 @@ export class Lowerer {
if (symbol && valueSymbol && sourceOptional) {
const aliasNew = !optionalReturns.has(symbol);
if (aliasNew) optionalReturns.add(symbol);
if (aliasNew) provenance?.note("function-alias", node.name, node.initializer);
if (aliasNew) changed = true;
const valueType = this.mapTypeOf(this.typeOf(node.name));
if (valueType?.kind === "func" && sourceSig) {
@@ -4306,7 +4329,10 @@ export class Lowerer {
const value = ts.isPropertyAssignment(prop)
? prop.initializer
: (prop.name as ts.Expression);
if (mayBeOptional(value) && noteField(node, name.text)) changed = true;
if (mayBeOptional(value) && noteField(node, name.text)) {
provenance?.note("literal-field", name, value);
changed = true;
}
}
}
}
@@ -4323,16 +4349,21 @@ export class Lowerer {
const symbol = symbolOf(node.left);
if (symbol && !optionalSymbols.has(symbol)) {
optionalSymbols.add(symbol);
provenance?.note("assigned-binding", node.left, node.right);
changed = true;
}
} else if (ts.isPropertyAccessExpression(node.left)) {
const field = symbolOf(node.left.name);
if (field && staticFieldsBySymbol.has(field) && !optionalSymbols.has(field)) {
optionalSymbols.add(field);
provenance?.note("static-field", node.left, node.right);
changed = true;
} else if (ts.isIdentifier(node.left.expression)) {
const symbol = symbolOf(node.left.expression);
if (symbol && noteFieldSymbol(symbol, node.left.name.text)) changed = true;
if (symbol && noteFieldSymbol(symbol, node.left.name.text)) {
provenance?.note("assigned-field", node.left, node.right);
changed = true;
}
}
}
}
@@ -4384,6 +4415,7 @@ export class Lowerer {
}
}
if (nativeArrayReceiver) {
provenanceSite = node;
if (promoteHofCallback(callback, callbackIndices)) changed = true;
const method = node.expression.name.text;
if (
@@ -4435,6 +4467,7 @@ export class Lowerer {
if (typeEquals(shape.type, widened)) continue;
shape.type = widened;
const parameter = functionDeclBySymbol.get(target)?.parameters[i];
provenance?.note("parameter", parameter, node, "(omitted or undefined argument)");
if (parameter) {
for (const bound of boundIdentifiersOf(parameter.name)) {
const boundSymbol = symbolOf(bound);
@@ -4464,6 +4497,7 @@ export class Lowerer {
const optionalCallbackParams = callbackSlot.params.flatMap((type, index) =>
type.kind === "union" && this.armTag(type.unionId, UNDEFINED_T) >= 0 ? [index] : [],
);
provenanceSite = node;
if (
optionalCallbackParams.length > 0 &&
promoteHofCallback(arg, optionalCallbackParams)
@@ -4483,6 +4517,7 @@ export class Lowerer {
if (typeEquals(rest.type, widened)) continue;
rest.type = widened;
const parameter = functionDeclBySymbol.get(target)?.parameters[i];
provenance?.note("rest-parameter", parameter, arg);
if (parameter && ts.isIdentifier(parameter.name)) {
const bound = symbolOf(parameter.name);
if (bound) this.runtimeOptionalBindingTypes.set(bound, widened);
@@ -4508,6 +4543,12 @@ export class Lowerer {
if (set.size !== before) {
changed = true;
const parameter = functionDeclBySymbol.get(target)?.parameters[i];
provenance?.note(
"parameter",
parameter,
arg,
target === symbol ? "" : "(override)",
);
if (parameter) {
for (const bound of boundIdentifiersOf(parameter.name)) {
const boundSymbol = symbolOf(bound);
@@ -4530,6 +4571,7 @@ export class Lowerer {
for (const expression of returnsOf(decl)) {
if (mayBeOptional(expression) && !optionalReturns.has(symbol)) {
optionalReturns.add(symbol);
provenance?.note("return", decl.name ?? decl, expression);
changed = true;
}
const arithmetic = optionalPrimitiveResultType(expression);
@@ -4546,6 +4588,7 @@ export class Lowerer {
for (const [symbol, field] of staticFieldsBySymbol) {
if (!optionalSymbols.has(symbol) && mayBeOptional(field.initializer)) {
optionalSymbols.add(symbol);
provenance?.note("static-field", field.initializer.parent, field.initializer);
changed = true;
}
}
@@ -4574,6 +4617,12 @@ export class Lowerer {
for (const symbol of family) {
if (!optionalReturns.has(symbol)) {
optionalReturns.add(symbol);
provenance?.note(
"return",
functionDeclBySymbol.get(symbol),
undefined,
"(override family)",
);
changed = true;
}
}
@@ -4717,6 +4766,8 @@ export class Lowerer {
}
});
}
provenance?.flush();
}
run(): LowerResult {
@@ -4952,6 +5003,7 @@ export class Lowerer {
// the emitted program references, flushing deferred class diagnostics
// that a reached type makes relevant.
const artifacts = this.moduleArtifacts(functions);
refuseUncheckedCycleReads(this, artifacts.classes ?? []);
// Types still naming a class that never REGISTERED after retention's
// flush (JS graphs whose class fences deferred to runtime — the
// sentence-walker's path params, printer tables whose func-typed
@@ -4980,6 +5032,7 @@ export class Lowerer {
...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}),
};
if (module) sanitizeUnregisteredClassTypes(module, (name) => this.classes.has(name));
if (module) checkCycleBindingAccesses(this, module);
return {
module,
diagnostics: this.diags,
@@ -6432,6 +6485,15 @@ export class Lowerer {
);
throw new PoisonError();
}
// Iterator-typed slots hold generators and built-in collection
// iterators; any other iterator object (a user class) stays fenced.
if (expected.kind === "generator" && actual.kind !== "generator") {
this.unsupported(
"SC1090",
node,
`'${this.fmt(actual)}' values in an iterator-typed slot (Iterator and IterableIterator slots hold generators and built-in collection iterators)`,
);
}
// Everything else — a plain-kind mismatch like a string flowing into a
// class-instance slot — is tsc-rejected in the lowering world and only
// reaches here through preflight's project-world second chance (e.g. a
@@ -6523,6 +6585,14 @@ export class Lowerer {
* else (including a DIFFERENT union) is left for requireExactShape, which
* rejects union mismatches with SC2003. */
coerceToExpected(expr: IrExpr, expected: IrType): IrExpr {
if (expr.type.kind === "void" && expected.kind !== "void" && this.neverValued.has(expr))
return this.divergentValue(expr, expected);
// Iterator-typed slots: native iterators and differently typed
// generators step through an adapter generator.
if (expected.kind === "generator" && !typeEquals(expr.type, expected)) {
const adapted = iteratorSlotAdapter(this, expr, expected);
if (adapted) return adapted;
}
// Island boundary, both directions. IN: any static value flowing into
// an any-typed slot marshals implicitly (tsc allows the assignment;
// the marshal is where its semantics live). OUT: an 'any' value
@@ -8598,6 +8668,33 @@ export class Lowerer {
);
}
/** A never-returning call in a value position of type `expected`: the
* call runs for its effects (it throws or exits), and the expression ends
* in an unreachable trap instead of producing a value. The result reads an
* uninitialized hidden local that control never reaches. */
divergentValue(call: IrExpr, expected: IrType): IrExpr {
const loc = call.loc;
const slot = this.declareHiddenLocal("%never", expected);
slot.mutable = true;
return {
kind: "seqExpr",
stmts: [
{ kind: "exprStmt", expr: call, loc },
{ kind: "varDecl", localId: slot.id, init: null, loc },
{
kind: "runtimeFence",
code: "SC9002",
message: "unreachable: a 'never' call returned — please report this",
loc,
},
],
result: varRef(slot.id, expected, loc),
diverges: true,
type: expected,
loc,
};
}
/** A function-scope local bound to NO ts.Symbol — the hidden ABI slot of a
* defaulted parameter (the parameter's symbol binds to the separately-
* declared body local; nothing in the source can name this one). */
@@ -0,0 +1,41 @@
import * as ts from "../ts7/adapter.js";
/** Debug-only record of why the indexed-read analysis widened a binding,
* parameter, return, or field to a runtime-optional representation. It is
* enabled by SCRIPTC_DEBUG_RUNTIME_OPTIONAL=1 and writes one line per
* widening to stderr; the disabled path records nothing. Not a public API:
* the output format may change at any time. */
export class RuntimeOptionalProvenance {
private readonly lines: string[] = [];
private readonly seen = new Set<string>();
static fromEnvironment(): RuntimeOptionalProvenance | null {
return process.env["SCRIPTC_DEBUG_RUNTIME_OPTIONAL"] === "1"
? new RuntimeOptionalProvenance()
: null;
}
/** `target` names the widened slot; `cause` is the expression or site
* whose value made it optional. */
note(kind: string, target: ts.Node | undefined, cause: ts.Node | undefined, detail = ""): void {
const line = `${kind} ${describe(target)}${detail ? ` ${detail}` : ""} <- ${describe(cause)}`;
if (this.seen.has(line)) return;
this.seen.add(line);
this.lines.push(line);
}
flush(): void {
for (const line of this.lines) process.stderr.write(`scriptc runtime-optional ${line}\n`);
this.lines.length = 0;
}
}
function describe(node: ts.Node | undefined): string {
if (!node) return "?";
const sf = node.getSourceFile();
const start = node.getStart();
const pos = ts.getLineAndCharacterOfPosition(sf, start);
const text = sf.text.slice(start, node.end).replace(/\s+/g, " ");
const short = text.length > 60 ? `${text.slice(0, 57)}...` : text;
return `${sf.fileName}:${pos.line + 1}:${pos.character + 1} \`${short}\``;
}
+62 -5
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { expect, test, vi } from "vitest";
import {
checkPreflight,
cycleEarlyBindings,
isNodeEsmFile,
loadProgram,
makeCycleAdmission,
@@ -198,9 +199,17 @@ test.for([
["export { first, alias }; export function read() { return 1; }", null],
["type Value = typeof alias; export function read(first: number = 1) { return first; }", null],
["class Box { value = alias; } export function read(value = first) { return value; }", null],
["const value = alias; export function read() { return value; }", "alias"],
["class Box { static value = first; } export function read() { return 1; }", "first"],
["class Box { [alias] = 1; } export function read() { return 1; }", "alias"],
// Top-level reads during the cycle are admitted by the exact
// initialization rule (the lowering checks them); shapes without an
// initialization state keep the fence.
["const value = alias; export function read() { return value; }", null],
["class Box { static value = first; } export function read() { return 1; }", null],
["class Box { [alias] = 1; } export function read() { return 1; }", null],
[
'import * as whole from "./main.ts"; const value = whole.first; export function read() { return value; }',
"the namespace import 'whole'",
],
["export default first + 1; export function read() { return 1; }", "default export expression"],
])("cycle binding indexes preserve initialization safety: %s", ([body, refused]) => {
const directory = mkdtempSync(
join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-cycle-bindings-"),
@@ -219,8 +228,8 @@ test.for([
const cycles = checkPreflight(load).filter((diagnostic) => diagnostic.code === "SC1016");
if (refused === null) expect(cycles).toEqual([]);
else {
expect(cycles).toHaveLength(1);
expect(cycles[0]!.message).toContain(`binding '${refused}' is read`);
expect(cycles.length).toBeGreaterThan(0);
for (const cycle of cycles) expect(cycle.message).toContain(refused);
}
} finally {
load.dispose();
@@ -269,3 +278,51 @@ test("repeated cycle edges reuse a file's binding index", () => {
rmSync(directory, { recursive: true, force: true });
}
});
test.for([
[
// A shared instance built by a user constructor: the reads of it in
// the partner's function can run early; the class cannot.
{
"main.ts": 'import { shared } from "./a.ts"; console.log(shared.n);',
"a.ts":
'import { f } from "./b.ts"; export class C { n = 1; } export const shared = new C(); console.log(f());',
"b.ts": 'import { shared } from "./a.ts"; export function f(): number { return shared.n; }',
},
["shared"],
],
[
// The partner's top level reads the importer's const before it ran.
{
"main.ts": 'import { a } from "./a.ts"; console.log(a);',
"a.ts": 'import { b } from "./b.ts"; export const a: number = b + 1;',
"b.ts": 'import { a } from "./a.ts"; export const b: number = a + 1;',
},
["a"],
],
[
// Declarations complete before any user code runs: nothing to check.
{
"main.ts": 'import { start } from "./a.ts"; console.log(start());',
"a.ts":
'import { hop } from "./b.ts"; export const limit = 3; export function start(): number { return hop(limit); } console.log(start());',
"b.ts":
'import { limit } from "./a.ts"; export function hop(n: number): number { return n + limit; }',
},
[],
],
] as const)("cycle bindings read before initialization: %j", ([files, early]) => {
const directory = mkdtempSync(
join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-cycle-early-"),
);
for (const [name, text] of Object.entries(files)) writeFileSync(join(directory, name), text);
const load = loadProgram(join(directory, "main.ts"));
try {
expect(checkPreflight(load)).toEqual([]);
const found = cycleEarlyBindings(load.program, load.entry, load.moduleOrder, true);
expect(found.map((binding) => binding.name.text).sort()).toEqual([...early]);
} finally {
load.dispose();
rmSync(directory, { recursive: true, force: true });
}
});
+494 -58
View File
@@ -1812,10 +1812,11 @@ function nsBindingUsesAreBareStatements7(
* Node runs legal ESM cycles all over the ecosystem: the revisited module
* is a cache hit, and the only hazard is a READ of a binding before its
* declaration executed (TDZ ReferenceError for let/const/class, a stale
* `undefined` for var) — runtime evaluation-order semantics the static
* story does not model, so cycles where such a read is REACHABLE keep the
* SC1016 fence. A cycle is admitted when partial initialization is
* provably unobservable — the DECLARATION-ONLY INIT WINDOW rule:
* `undefined` for var). Cycles where such a read is REACHABLE are admitted
* by the exact initialization rule (exactCycleRefusal7 below), which
* models the TDZ with initialization checks. A cycle needs no checks when
* partial initialization is provably unobservable — the DECLARATION-ONLY
* INIT WINDOW rule:
*
* 1. Every module of the cycle cluster (the strongly-connected component
* — any member may be mid-initialization while another's top level
@@ -1906,6 +1907,18 @@ function inTypePosition7(node: ts.Node): boolean {
* callback-invoking builtin, a getter, an iterator, or an implicit
* coercion of a user object) disqualifies the module's cycles. */
function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | null {
const nonInert = topLevelInertness7(program);
for (const stmt of sf.statements) {
const offence = nonInert(stmt);
if (offence !== null) return offence;
}
return null;
}
/** The per-statement form of nonInertTopLevel7: answers, for one
* top-level statement, the first construct that could execute user code
* while the statement runs, or null when the statement is inert. */
function topLevelInertness7(program: ts.Program): (stmt: ts.Statement) => ts.Node | null {
const checker = program.getTypeChecker();
const PRIM =
ts.TypeFlags.StringLike |
@@ -2070,7 +2083,7 @@ function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | nu
}
return false;
};
for (const stmt of sf.statements) {
return (stmt: ts.Statement): ts.Node | null => {
if (
ts.isImportDeclaration(stmt) ||
ts.isExportDeclaration(stmt) ||
@@ -2083,36 +2096,25 @@ function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | nu
ts.ModifierFlags.Ambient) !==
0
) {
continue;
return null;
}
if (ts.isEnumDeclaration(stmt)) {
const bad = stmt.members.find((m) => m.initializer !== undefined && !inert(m.initializer));
if (bad !== undefined) return bad;
continue;
}
if (ts.isClassDeclaration(stmt)) {
const bad = inertClass(stmt);
if (bad !== null) return bad;
continue;
return stmt.members.find((m) => m.initializer !== undefined && !inert(m.initializer)) ?? null;
}
if (ts.isClassDeclaration(stmt)) return inertClass(stmt);
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) return d.name; // patterns can invoke getters/iterators
if (d.initializer !== undefined && !inert(d.initializer)) return d.initializer;
}
continue;
}
if (ts.isExpressionStatement(stmt)) {
if (!inert(stmt.expression)) return stmt;
continue;
return null;
}
if (ts.isExpressionStatement(stmt)) return inert(stmt.expression) ? null : stmt;
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
if (!inert(stmt.expression)) return stmt;
continue;
return inert(stmt.expression) ? null : stmt;
}
return stmt;
}
return null;
};
}
/** The head of a property/element-access chain. */
@@ -2182,13 +2184,16 @@ function backEdgeUseOffence7(
* e.dep already mid-initialization in a depth-first walk), answers null
* to ADMIT the cycle — the guarded %init calls reproduce Node's cache-hit
* order and nothing can observe the partial initialization — or the
* human-readable reason it keeps the SC1016 fence. Two admission chances:
* human-readable reason it keeps the SC1016 fence. Three admission chances:
* - the CHEAP PER-EDGE rule: the closing statement binds nothing (a
* side-effect import) or binds only namespace objects whose every use
* is a bare expression statement — no read crosses the edge at all;
* - the DECLARATION-ONLY INIT WINDOW rule (the admission block above):
* every cluster member is an ES module with an inert top level, and
* the closing edge's bindings are used only in deferred positions.
* the closing edge's bindings are used only in deferred positions;
* - the EXACT INITIALIZATION rule (exactCycleRefusal7): partial
* initialization is observable, but every observation is modeled —
* the lowering checks each binding cycleEarlyBindings reports.
* Tarjan SCCs (lazy, rooted at each queried importer) and cluster
* verdicts are memoized across calls, so `edgesOf` must answer the same
* edges for the same file every time. */
@@ -2238,10 +2243,11 @@ export function makeCycleAdmission(
const nsf = node.getSourceFile();
return `${nsf.fileName}:${ts.getLineAndCharacterOfPosition(nsf, node.getStart(nsf)).line + 1}`;
};
// Cluster verdict memo (keyed by the component array identity): the
// reason the cluster's cycles stay fenced, or null when its every
// member passes the inert-top-level bar.
const sccVerdict = new Map<ts.SourceFile[], string | null>();
// Cluster verdict memo (keyed by the component array identity): true
// when every member is an ES module passing the inert-top-level bar.
const sccVerdict = new Map<ts.SourceFile[], boolean>();
// The exact-initialization verdict, same keying (exactCycleRefusal7).
const exactVerdict = new Map<ts.SourceFile[], string | null>();
// A module can close many cycle edges, each importing many bindings.
// Index its identifiers once for this admission pass. ASTs are immutable;
// the pass owns the index so it cannot retain a disposed program.
@@ -2283,29 +2289,455 @@ export function makeCycleAdmission(
return "the cycle's module cluster could not be analyzed";
}
if (!sccVerdict.has(comp)) {
let reason: string | null = null;
for (const m of comp) {
if (isCjsJsFile7(m, program)) {
reason = `${m.fileName} is a CommonJS module — admission covers ES-module cycles only`;
break;
sccVerdict.set(
comp,
comp.every((m) => !isCjsJsFile7(m, program) && nonInertTopLevel7(program, m) === null),
);
}
if (
sccVerdict.get(comp) === true &&
backEdgeUseOffence7(program, e.stmt, usesOf(importer)) === null
) {
return null;
}
// The EXACT INITIALIZATION rule: the cycle runs in Node's evaluation
// order and every binding that can be observed before its declaration
// runs carries an initialization check (cycleEarlyBindings).
if (!exactVerdict.has(comp)) {
const reason = exactCycleRefusal7(program, comp, lineOf);
exactVerdict.set(comp, reason);
if (reason === null) {
let members = exactCycleMembers7.get(program);
if (members === undefined) {
members = new Set();
exactCycleMembers7.set(program, members);
}
const off = nonInertTopLevel7(program, m);
if (off !== null) {
reason = `top-level code at ${lineOf(off)} can run user code during the cycle's init window — only declaration-only module bodies are admitted`;
break;
for (const m of comp) members.add(m);
}
}
return exactVerdict.get(comp) ?? null;
};
}
/* ── exact cyclic initialization ─────────────────────────────────────────
* Cycles that fail the declaration-only rule still evaluate exactly like
* Node when their shape is representable: the guarded %init calls already
* run every module body in ES evaluation order (depth-first postorder,
* a module mid-evaluation answered from the cache), function declarations
* are callable from the start, and live bindings are shared globals. What
* remains is the temporal dead zone: a let/const binding read before its
* declaration ran throws ReferenceError. cycleEarlyBindings finds every
* such binding whose read CAN run early; the lowering gives each one an
* initialization flag and checks it at every read or write that can run
* before the declaration. The shapes below have no such representation
* and keep the SC1016 fence. */
/** Program → the modules of every cycle the exact rule admitted. */
const exactCycleMembers7 = new WeakMap<ts.Program, Set<ts.SourceFile>>();
/** The modules of `program` whose import cycles were admitted by the
* exact initialization rule (not the declaration-only rule). */
export function exactCycleMembers(program: ts.Program): ReadonlySet<ts.SourceFile> {
return exactCycleMembers7.get(program) ?? new Set();
}
/** True when the module's top level (outside every function-like body)
* contains `await` or `for await`. */
function hasTopLevelAwait7(sf: ts.SourceFile): boolean {
let found = false;
ts.walkPreorder(sf, (node) => {
if (found) return "stop";
if (node !== sf && ts.isFunctionLike(node)) return "skip";
if (
ts.isAwaitExpression(node) ||
(ts.isForOfStatement(node) && node.awaitModifier !== undefined)
) {
found = true;
return "stop";
}
return undefined;
});
return found;
}
/** The reason a strongly-connected module cluster cannot be initialized
* exactly, or null when it can. */
function exactCycleRefusal7(
program: ts.Program,
comp: readonly ts.SourceFile[],
lineOf: (node: ts.Node) => string,
): string | null {
const members = new Set(comp);
const targetOf = (stmt: ts.ImportDeclaration | ts.ExportDeclaration): ts.SourceFile | null => {
const spec = stmt.moduleSpecifier;
if (spec === undefined || !ts.isStringLiteral(spec)) return null;
const sf = stmt.getSourceFile();
return resolveImport7(program, sf, spec.text) ?? npmStaticDepSf7(program, sf, spec.text);
};
for (const m of comp) {
if (isCjsJsFile7(m, program) || /\.c[jt]s$/.test(m.fileName)) {
return `${m.fileName} is a CommonJS module — cyclic initialization covers ES modules only`;
}
if (hasTopLevelAwait7(m)) {
return `${m.fileName} uses top-level await inside the cycle`;
}
for (const stmt of m.statements) {
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
return `the default export expression at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
if (
ts.isExportDeclaration(stmt) &&
!stmt.isTypeOnly &&
(stmt.exportClause === undefined || ts.isNamespaceExport(stmt.exportClause))
) {
const dep = targetOf(stmt);
if (dep !== null && members.has(dep)) {
return `the namespace re-export at ${lineOf(stmt)} names a module of the cycle`;
}
}
sccVerdict.set(comp, reason);
if (
ts.isImportDeclaration(stmt) &&
stmt.importClause !== undefined &&
!stmt.importClause.isTypeOnly &&
stmt.importClause.namedBindings !== undefined &&
ts.isNamespaceImport(stmt.importClause.namedBindings)
) {
const dep = targetOf(stmt);
const nsName = stmt.importClause.namedBindings.name;
if (
dep !== null &&
members.has(dep) &&
!nsBindingUsesAreBareStatements7(program, m, nsName)
) {
return `the namespace import '${nsName.text}' at ${lineOf(stmt)} names a module of the cycle`;
}
}
if (ts.isClassDeclaration(stmt) && stmt.name === undefined) {
return `the anonymous default class at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
const ambient =
(ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) &
ts.ModifierFlags.Ambient) !==
0;
if (ambient) continue;
if (
ts.isEnumDeclaration(stmt) &&
(ts.getCombinedModifierFlags(stmt) & ts.ModifierFlags.Const) === 0
) {
return `the enum '${stmt.name.text}' at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
if (ts.isModuleDeclaration(stmt) && !typeOnlyNamespace7(stmt)) {
return `the namespace at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
}
const clusterReason = sccVerdict.get(comp);
if (clusterReason === undefined) throw new Error("missing module-cycle verdict");
if (clusterReason !== null) return clusterReason;
const use = backEdgeUseOffence7(program, e.stmt, usesOf(importer));
if (use !== null) {
return `the cycle-crossing binding '${use.name}' is read at ${lineOf(use.node)}, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled`;
}
return null;
}
/** A module-scope binding of an exactly-initialized cycle that some read
* or write can reach before its declaration ran. */
export interface CycleEarlyBinding {
symbol: ts.Symbol;
/** The declaration's name. */
name: ts.Identifier;
kind: "lexical" | "var" | "class";
module: ts.SourceFile;
/** One reference that can run before the declaration. */
reference: ts.Identifier;
/** A reference in a position the lowering may resolve at compile time
* from the binding's type (a property key, a `typeof` operand). */
folded?: ts.Identifier;
/** Modules whose TOP-LEVEL code runs only after the declaration ran:
* modules outside the cycle and cycle members evaluated later. */
settled: ReadonlySet<ts.SourceFile>;
}
/** Every binding of the program's exactly-initialized cycles that can be
* observed before its declaration executes. `order` is the compiled module
* order; `singleRoot` is false when more than one module graph root runs
* (fork or worker entries), so a cycle can be entered at different
* members. With one root and a purely ES-module graph the evaluation order
* is static and the analysis is precise up to "user code may run": a
* binding needs its check when its declaration completes after some
* top-level code that can call user functions and a deferred position
* (function body, parameter default, instance field) references it, or
* when the top level of a cycle member evaluated before it reads it.
* Otherwise every deferred reference and every top-level reference from
* another member counts. */
export function cycleEarlyBindings(
program: ts.Program,
entry: ts.SourceFile,
order: readonly ts.SourceFile[],
singleRoot: boolean,
): CycleEarlyBinding[] {
const exact = exactCycleMembers7.get(program);
if (exact === undefined || exact.size === 0) return [];
// Every lowering pass of one compile asks with the same inputs.
const key = `${entry.fileName}\0${order.map((sf) => sf.fileName).join("\0")}\0${singleRoot}`;
const memo = earlyBindingsMemo7.get(program);
if (memo !== undefined && memo.key === key) return memo.bindings;
const bindings = computeCycleEarlyBindings7(program, entry, order, singleRoot, exact);
earlyBindingsMemo7.set(program, { key, bindings });
return bindings;
}
const earlyBindingsMemo7 = new WeakMap<
ts.Program,
{ key: string; bindings: CycleEarlyBinding[] }
>();
function computeCycleEarlyBindings7(
program: ts.Program,
entry: ts.SourceFile,
order: readonly ts.SourceFile[],
singleRoot: boolean,
exact: ReadonlySet<ts.SourceFile>,
): CycleEarlyBinding[] {
const inOrder = new Set(order);
const members = [...exact].filter((m) => inOrder.has(m));
if (members.length === 0) return [];
const memberSet = new Set(members);
const depsOf = (sf: ts.SourceFile): ts.SourceFile[] =>
orderedImportsOf(program, sf).flatMap(({ dep }) =>
dep !== null && dep !== sf && inOrder.has(dep) ? [dep] : [],
);
// Strongly-connected components among the exactly-initialized members.
const comps: ts.SourceFile[][] = [];
{
const index = new Map<ts.SourceFile, number>();
const low = new Map<ts.SourceFile, number>();
const onStack = new Set<ts.SourceFile>();
const stack: ts.SourceFile[] = [];
let next = 0;
const connect = (v: ts.SourceFile): void => {
index.set(v, next);
low.set(v, next);
next++;
stack.push(v);
onStack.add(v);
for (const w of depsOf(v)) {
if (!memberSet.has(w)) continue;
if (!index.has(w)) {
connect(w);
low.set(v, Math.min(low.get(v)!, low.get(w)!));
} else if (onStack.has(w)) {
low.set(v, Math.min(low.get(v)!, index.get(w)!));
}
}
if (low.get(v) === index.get(v)) {
const comp: ts.SourceFile[] = [];
for (;;) {
const w = stack.pop()!;
onStack.delete(w);
comp.push(w);
if (w === v) break;
}
if (comp.length > 1) comps.push(comp);
}
};
for (const m of members) if (!index.has(m)) connect(m);
}
if (comps.length === 0) return [];
// The static evaluation order: Node's depth-first postorder from the
// entry over ES import edges. Known only for one root and a graph with
// no CommonJS module (a require() evaluates its target mid-body).
const position = new Map<ts.SourceFile, number>();
if (
singleRoot &&
!order.some((sf) => isCjsJsFile7(sf, program) || /\.c[jt]s$/.test(sf.fileName))
) {
const seen = new Set<ts.SourceFile>();
const visit = (sf: ts.SourceFile): void => {
seen.add(sf);
for (const dep of depsOf(sf)) if (!seen.has(dep)) visit(dep);
position.set(sf, position.size);
};
visit(entry);
}
const checker = program.getTypeChecker();
const nonInert = topLevelInertness7(program);
const out: CycleEarlyBinding[] = [];
for (const comp of comps) {
const known = comp.every((m) => position.has(m));
const evaluation = known
? comp.slice().sort((a, b) => position.get(a)! - position.get(b)!)
: comp.slice();
interface Candidate {
symbol: ts.Symbol;
name: ts.Identifier;
kind: CycleEarlyBinding["kind"];
module: ts.SourceFile;
/** User code can run between the cycle's start and the end of the
* declaration (always true when the order is unknown). */
exposed: boolean;
rank: number;
start: number;
end: number;
reference: ts.Identifier | null;
folded: ts.Identifier | null;
}
return null;
};
const candidates = new Map<ts.Symbol, Candidate>();
const names = new Set<string>();
let dirty = !known;
evaluation.forEach((m, rank) => {
for (const stmt of m.statements) {
const offence = nonInert(stmt);
const exposed = dirty || offence !== null;
const declared: { name: ts.Identifier; kind: CycleEarlyBinding["kind"] }[] = [];
if (
ts.isVariableStatement(stmt) &&
(ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) &
ts.ModifierFlags.Ambient) ===
0
) {
const lexical =
(stmt.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0;
for (const d of stmt.declarationList.declarations) {
for (const name of boundNames7(d.name)) {
declared.push({ name, kind: lexical ? "lexical" : "var" });
}
}
} else if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) {
declared.push({ name: stmt.name, kind: "class" });
}
for (const { name, kind } of declared) {
const symbol = checker.getSymbolAtLocation(name);
if (symbol === undefined) continue;
candidates.set(symbol, {
symbol,
name,
kind,
module: m,
exposed,
rank,
start: stmt.getStart(m),
end: stmt.getEnd(),
reference: null,
folded: null,
});
names.add(name.text);
}
if (offence !== null) dirty = true;
}
});
if (candidates.size === 0) continue;
const rankOf = new Map(evaluation.map((m, i) => [m, i] as const));
// References: every identifier spelled like a candidate, resolved
// through import aliases to the declaration's symbol.
for (const sf of order) {
const refs: ts.Identifier[] = [];
ts.walkPreorder(sf, (node) => {
if (ts.isImportDeclaration(node)) return "skip";
if (ts.isIdentifier(node) && names.has(node.text)) refs.push(node);
return undefined;
});
if (refs.length === 0) continue;
checker.prefetchSymbolNodesExact(refs);
for (const ref of refs) {
const parent = ref.parent;
if (parent !== undefined && ts.isExportSpecifier(parent)) continue;
if (inTypePosition7(ref)) continue;
let symbol = checker.getSymbolAtLocation(ref);
if (
parent !== undefined &&
ts.isShorthandPropertyAssignment(parent) &&
parent.name === ref
) {
symbol = checker.getShorthandAssignmentValueSymbol(parent) ?? symbol;
}
if (symbol !== undefined && symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol);
}
const c = symbol === undefined ? undefined : candidates.get(symbol);
if (c === undefined || ref === c.name) continue;
if (c.folded === null && foldablePosition7(ref)) c.folded = ref;
if (c.reference !== null) continue;
// A class body names its own inner binding, initialized throughout.
if (
c.kind === "class" &&
sf === c.module &&
ref.getStart(sf) >= c.start &&
ref.getEnd() <= c.end
)
continue;
let early: boolean;
if (inDeferredPosition7(ref)) early = c.exposed;
else if (sf === c.module) early = ref.getStart(sf) < c.start;
else if (!memberSet.has(sf) || !rankOf.has(sf)) early = false;
else early = !known || rankOf.get(sf)! < c.rank;
if (early) c.reference = ref;
}
}
for (const c of candidates.values()) {
if (c.reference === null) continue;
const settled = new Set<ts.SourceFile>(order.filter((sf) => !rankOf.has(sf)));
if (known) for (const m of evaluation.slice(c.rank + 1)) settled.add(m);
out.push({
symbol: c.symbol,
name: c.name,
kind: c.kind,
module: c.module,
reference: c.reference,
...(c.folded !== null ? { folded: c.folded } : {}),
settled,
});
}
}
return out;
}
/** True for a reference whose value the lowering can take from the
* binding's checker type instead of its storage: a property key (element
* access argument, computed name, `in` operand, through parentheses,
* casts and template spans) or a `typeof` operand. */
function foldablePosition7(ref: ts.Identifier): boolean {
let child: ts.Node = ref;
for (let p: ts.Node | undefined = ref.parent; p !== undefined; p = p.parent) {
if (
ts.isParenthesizedExpression(p) ||
ts.isAsExpression(p) ||
ts.isSatisfiesExpression(p) ||
ts.isNonNullExpression(p) ||
ts.isTemplateSpan(p) ||
ts.isTemplateExpression(p)
) {
child = p;
continue;
}
if (ts.isTypeOfExpression(p)) return true;
if (ts.isElementAccessExpression(p)) return p.argumentExpression === child;
if (ts.isComputedPropertyName(p)) return true;
if (ts.isBinaryExpression(p))
return p.operatorToken.kind === ts.SyntaxKind.InKeyword && p.left === child;
return false;
}
return false;
}
/** The identifiers a binding name (identifier or destructuring pattern)
* declares. */
function boundNames7(name: ts.BindingName): ts.Identifier[] {
if (ts.isIdentifier(name)) return [name];
const out: ts.Identifier[] = [];
for (const el of name.elements) {
if (ts.isOmittedExpression(el) || el.name === undefined) continue;
out.push(...boundNames7(el.name));
}
return out;
}
/** A namespace declaration whose body holds only types (interfaces, type
* aliases, nested type-only namespaces) — no runtime object exists. */
function typeOnlyNamespace7(decl: ts.ModuleDeclaration): boolean {
let body = decl.body;
while (body !== undefined && ts.isModuleDeclaration(body)) body = body.body;
if (body === undefined || !ts.isModuleBlock(body)) return true;
return body.statements.every(
(s) =>
ts.isInterfaceDeclaration(s) ||
ts.isTypeAliasDeclaration(s) ||
(ts.isModuleDeclaration(s) && typeOnlyNamespace7(s)),
);
}
/** Resolves an import specifier from `from` to a source file of the
@@ -2774,10 +3206,9 @@ function preflight7(load: LoadResult): {
// benign-cycle admission block above). Node evaluates such cycles
// benignly (the revisited module is a cache hit, and no binding read
// can hit TDZ or a stale slot), so the guarded %init calls reproduce
// its order exactly. Cycles that fail both keep the SC1016 fence,
// because a read through them mid-cycle observes TDZ (let/const) or
// undefined (var) in Node — runtime evaluation-order semantics the
// static story does not model.
// its order exactly. Cycles that fail both are judged by the exact
// initialization rule: admitted when every early read is modeled by an
// initialization check, fenced with SC1016 otherwise.
const edges = new Map<ts.SourceFile, CycleEdge[]>();
// Import edges Node's RESOLUTION refuses before any module evaluates —
// recorded in source order per file (interleaved with the resolved deps
@@ -3334,9 +3765,10 @@ function preflight7(load: LoadResult): {
// Node's evaluation order: depth-first postorder from the entry, each
// module once, back-edges answered from the cache (a module already
// evaluating is skipped) — which the guarded %init calls reproduce
// exactly. Cycles are admitted only where Node's partially-initialized
// semantics are UNOBSERVABLE (JS's partial-initialization is otherwise a
// silent-misbehavior trap, so SC1016 stays for the rest):
// exactly. Cycles are admitted where Node's partially-initialized
// semantics are UNOBSERVABLE or exactly modeled (JS's partial
// initialization is otherwise a silent-misbehavior trap, so SC1016
// stays for the rest):
// - a DIRECT SELF-IMPORT (the package self-name reference resolving to
// the importing module itself — Node's self-reference rule): the
// imported bindings alias the module's OWN top-level bindings with
@@ -3348,7 +3780,11 @@ function preflight7(load: LoadResult): {
// benign-cycle admission block: every module of the cycle's
// strongly-connected component has an inert top level and the
// closing edge's bindings are used only in deferred positions, so no
// read can execute before every member initialized).
// read can execute before every member initialized);
// - the EXACT INITIALIZATION rule: an ES-module cluster whose early
// reads are all let/const bindings with module storage — the
// lowering checks each one (cycleEarlyBindings) and throws Node's
// ReferenceError on a read before the declaration ran.
const cycleAdmissionReason = makeCycleAdmission(program, (sf) => edges.get(sf) ?? []);
const order: ts.SourceFile[] = [];
const state = new Map<ts.SourceFile, "visiting" | "done">();
@@ -3362,9 +3798,9 @@ function preflight7(load: LoadResult): {
if (s === "done") continue;
if (s === "visiting") {
// A back-edge: Node answers it from the cache (the module is
// already evaluating) — benign exactly when nothing can observe
// the partial initialization through this edge's bindings, by the
// cheap per-edge rule or the declaration-only init window rule.
// already evaluating) — admitted by the cheap per-edge rule, the
// declaration-only init window rule, or the exact initialization
// rule.
const reason = cycleAdmissionReason(sf, e);
if (reason !== null) {
const cycleStart = stack.indexOf(e.dep.fileName);
+13 -1
View File
@@ -2735,6 +2735,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
}
// Native collection iterators use checked runtime iterator objects.
if (isStdlibInterface("MapIterator") || isStdlibInterface("SetIterator")) return DYN;
// Array iterators (`list.values()` and friends) are generators over the
// live array: each step reads the current length and element.
if (isStdlibInterface("ArrayIterator")) {
const arg = checker.getTypeArguments(widened as ts.TypeReference)[0];
const yieldT = arg ? mapType(arg, ctx) : null;
if (!yieldT || yieldT.kind === "void" || isUnitType(yieldT)) return null;
if (!genResultRecord(yieldT, VOID, ctx.shapes, unions)) return null;
return { kind: "generator", yieldT, retT: VOID, nextT: UNDEFINED_T };
}
// Generator<T, TReturn, TNext>, AsyncGenerator<T, TReturn, TNext> (and
// the lib's IterableIterator<T, ...>, the older sync annotation spelling):
// the generator kind. Channel normalization keeps the runtime honest:
@@ -2756,7 +2765,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (
isStdlibInterface("Generator") ||
isStdlibInterface("AsyncGenerator") ||
isStdlibInterface("IterableIterator")
isStdlibInterface("IterableIterator") ||
// An Iterator<T> annotation holds a single-pass iterator object: a
// generator, or a native iterator adapted into one.
isStdlibInterface("Iterator")
) {
const args = checker.getTypeArguments(widened as ts.TypeReference);
const channels = genChannels(args[0], args[1], args[2], ctx);
+17 -2
View File
@@ -1551,6 +1551,11 @@ export interface IrGlobal {
/** Lexical record/function/checked-value bindings use their initially-null
* pointer as a TDZ sentinel. Reads and later writes throw until initializing assign. */
tdz?: true;
/** An import-cycle binding whose initialization state lives in this BOOL
* global instead: the declaration's store is the `assign` marked
* `initializes`, followed by the flag's set; accesses that can run
* earlier test it with the module.tdzCheck intrinsic. */
initFlag?: string;
/** Original declaration and lexical scope, when this is a source binding. */
source?: IrBindingSource;
}
@@ -3028,6 +3033,11 @@ export type IrExpr =
stmts: IrStmt[];
result: IrExpr;
generatorDelegate?: true;
/** The statements end in a `runtimeFence` that control never reaches:
* the value of a call whose checked type is `never` (it throws or exits)
* flowing into a typed slot. `result` is a placeholder read of an
* uninitialized hidden local and is never evaluated. */
diverges?: true;
type: IrType;
loc: SrcLoc;
}
@@ -3261,7 +3271,11 @@ export type IrExpr =
* result's inner type (promise arguments never reach here — the
* frontend returns them as-is, the spec's native-promise identity;
* thenables and promise-armed unions fence); the backend mints a fresh
* promise and fulfills it immediately per the inner kind. */
* promise and fulfills it immediately per the inner kind.
* module.tdzCheck: two args, a BOOL initialization flag and the binding's
* source name as a strLit; void. Throws the catchable ReferenceError
* "Cannot access 'name' before initialization" while the flag is false
* (a module binding of an import cycle read before its declaration). */
| {
kind: "intrinsic";
name:
@@ -3271,7 +3285,8 @@ export type IrExpr =
| "promise.all"
| "promise.reject"
| "promise.resolve"
| "module.await";
| "module.await"
| "module.tdzCheck";
args: IrExpr[];
type: IrType;
loc: SrcLoc;
+18 -1
View File
@@ -1639,6 +1639,12 @@ function validateFunction(
allowed.add("throw");
allowed.add("runtimeFence");
}
if (e.diverges) {
const last = e.stmts[e.stmts.length - 1];
if (last?.kind !== "runtimeFence")
err("diverging seqExpr must end in a runtimeFence", e.loc);
allowed.add("runtimeFence");
}
const flat = (ss: IrStmt[]): void => {
for (const s of ss) {
if (!allowed.has(s.kind)) {
@@ -3717,6 +3723,17 @@ function validateFunction(
}
function checkIntrinsicExpr(e: IrExpr & { kind: "intrinsic" }): void {
if (e.name === "module.tdzCheck") {
const [flag, name] = e.args;
if (e.args.length !== 2 || flag === undefined || name?.kind !== "strLit") {
err("module.tdzCheck takes a flag and a binding-name literal", e.loc);
return;
}
checkExpr(flag);
if (flag.type.kind !== "bool") err("module.tdzCheck flag must be bool", e.loc);
if (e.type.kind !== "void") err("module.tdzCheck must be void", e.loc);
return;
}
if (e.name === "module.await") {
if (e.args.length !== 1) err("module.await takes exactly one argument", e.loc);
for (const a of e.args) {
@@ -5860,7 +5877,7 @@ function validateFunction(
}
case "assign": {
const binding = locals.get(s.localId) ?? globals.get(s.localId);
if (s.initializes && !binding?.tdz) {
if (s.initializes && !binding?.tdz && globals.get(s.localId)?.initFlag === undefined) {
err(`initializing assign requires a TDZ binding "${s.localId}"`, s.loc);
}
if (!binding) err(`assign to undeclared local/global "${s.localId}"`, s.loc);
+1 -1
View File
@@ -59,7 +59,7 @@
"name": "circular imports",
"status": "unsupported",
"code": "SC1016",
"note": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
"note": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge"
},
{
"id": "diagnostic.sc1020",
@@ -318,3 +318,37 @@ console.log(alias(new Item()).value, snapshot(new Item()).value);
expect(snapshot).toContain("@sc_bf__x25_Item_constructor");
expect(snapshot).toContain("@sc_release_Item");
});
test("unchanged captured parameters are boxed when a closure first needs them", async () => {
const mod = await lower(`
function invoke(fn: (value: number) => number, value: number): number { return fn(value); }
function framed(seed: number, label: string): number {
return invoke((value) => value + seed + label.length, 3);
}
function deferred(options: { limit: number }, skip: boolean): () => number {
if (skip) return () => -1;
return () => options.limit;
}
function rebound(count: number): () => number {
const read = () => count;
count++;
return read;
}
console.log(framed(1, "ab"), deferred({ limit: 4 }, false)(), deferred({ limit: 4 }, true)(), rebound(5)());
`);
const llvm = emitLlvmModule(mod);
// The entry block runs on every call; the lazy box is created later.
const entry = (text: string): string => {
const labels = [...text.matchAll(/^[\w.]+:$/gm)];
return labels.length > 1 ? text.slice(0, labels[1]!.index) : text;
};
const framed = body(llvm, "sc_f_framed");
expect(framed).toContain("alloca %ScrBox");
expect(framed).not.toContain("@scr_box_new");
expect(framed).not.toContain("@scr_closure_new");
const deferred = body(llvm, "sc_f_deferred");
expect(deferred).toContain("lazy.box");
expect(entry(deferred)).not.toContain("@scr_box_new");
expect(deferred).toContain("@scr_box_new");
expect(entry(body(llvm, "sc_f_rebound"))).toContain("@scr_box_new");
});
@@ -247,17 +247,57 @@ console.log(work(["first"]));
expect(work).not.toContain("@scr_union_release");
});
test("whole-union string comparisons borrow a caller's stack box", async () => {
test("whole-union string comparisons only project their operands", async () => {
const module = await lower(`
function size(text: string): number { return text.length + (text === "" ? 1 : 0); }
function work(words: string[], index: number): number { return size(words[index]); }
console.log(work([""], 0));
`);
const facts = analyzeCallLifetimes(new Map(module.functions.map((fn) => [fn.name, fn])));
// Strict union equality only reads the tag and payload of both boxes.
expect(facts.parameters.has("size")).toBe(true);
expect(facts.parameters.get("size")?.has(0)).toBe(true);
const work = body(emitLlvmModule(module), "sc_bf_work");
expect(work).toContain("alloca %ScrUnion");
expect(work).not.toMatch(/@scr_union_new/);
expect(work).not.toContain("@scr_union_new");
expect(work).toContain("@sc_bf_size");
});
test("class element comparisons, tests and optional chains read through stack tags", async () => {
const module = await lower(`
class Part { weight = 1; }
function same(parts: Part[], index: number, probe: Part): boolean { return parts[index] === probe; }
function missing(parts: Part[], index: number): boolean { return parts[index] === undefined; }
function weight(parts: Part[], index: number): number { return parts[index]?.weight ?? 0; }
function count(parts: Part[], probe: Part): number {
let hits = 0;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part === probe) hits++;
}
return hits;
}
const parts = [new Part()];
console.log(same(parts, 0, parts[0]), missing(parts, 3), weight(parts, 2), count(parts, parts[0]));
`);
const llvm = emitLlvmModule(module);
for (const name of ["same", "missing", "weight", "count"]) {
const work = body(llvm, `sc_bf_${name}`);
expect(work, name).toContain("alloca %ScrUnion");
expect(work, name).not.toContain("@scr_union_new");
expect(work, name).not.toMatch(/call ptr @sc_bf__x25_arr_idxOr/);
}
});
test("an element read stored beyond its comparison keeps a heap union", async () => {
const module = await lower(`
class Part { weight = 1; }
const kept: (Part | undefined)[] = [];
function keep(parts: Part[], index: number, probe: Part): boolean {
const part = parts[index];
kept.push(part);
return part === probe;
}
console.log(keep([new Part()], 4, new Part()), kept.length);
`);
const work = body(emitLlvmModule(module), "sc_bf_keep");
expect(work).toMatch(/@scr_union_new|@sc_bf__x25_arr_idxOr/);
});
@@ -11677,7 +11677,7 @@
"end": 0
},
"milestone": "later",
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
"hint": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge"
}
]
},
@@ -11771,66 +11771,6 @@
],
"diags": []
},
"<repo>/tests/diagnostics/cycle-tdz-read/main.ts": {
"order": [
"<repo>/tests/diagnostics/cycle-tdz-read/b.ts",
"<repo>/tests/diagnostics/cycle-tdz-read/a.ts",
"<repo>/tests/diagnostics/cycle-tdz-read/main.ts"
],
"diags": [
{
"code": "SC1016",
"message": "circular imports (<repo>/tests/diagnostics/cycle-tdz-read/a.ts → <repo>/tests/diagnostics/cycle-tdz-read/b.ts → <repo>/tests/diagnostics/cycle-tdz-read/a.ts; the cycle-crossing binding 'a' is read at <repo>/tests/diagnostics/cycle-tdz-read/b.ts:2, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet",
"loc": {
"file": "<repo>/tests/diagnostics/cycle-tdz-read/a.ts",
"start": 0,
"end": 0
},
"milestone": "later",
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
}
]
},
"<repo>/tests/diagnostics/cycle-window-call/main.ts": {
"order": [
"<repo>/tests/diagnostics/cycle-window-call/b.ts",
"<repo>/tests/diagnostics/cycle-window-call/a.ts",
"<repo>/tests/diagnostics/cycle-window-call/main.ts"
],
"diags": [
{
"code": "SC1016",
"message": "circular imports (<repo>/tests/diagnostics/cycle-window-call/a.ts → <repo>/tests/diagnostics/cycle-window-call/b.ts → <repo>/tests/diagnostics/cycle-window-call/a.ts; top-level code at <repo>/tests/diagnostics/cycle-window-call/a.ts:5 can run user code during the cycle's init window — only declaration-only module bodies are admitted) are not supported yet",
"loc": {
"file": "<repo>/tests/diagnostics/cycle-window-call/a.ts",
"start": 0,
"end": 0
},
"milestone": "later",
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
}
]
},
"<repo>/tests/diagnostics/cycle/main.ts": {
"order": [
"<repo>/tests/diagnostics/cycle/b.ts",
"<repo>/tests/diagnostics/cycle/a.ts",
"<repo>/tests/diagnostics/cycle/main.ts"
],
"diags": [
{
"code": "SC1016",
"message": "circular imports (<repo>/tests/diagnostics/cycle/a.ts → <repo>/tests/diagnostics/cycle/b.ts → <repo>/tests/diagnostics/cycle/a.ts; the cycle-crossing binding 'a' is read at <repo>/tests/diagnostics/cycle/b.ts:2, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet",
"loc": {
"file": "<repo>/tests/diagnostics/cycle/a.ts",
"start": 0,
"end": 0
},
"milestone": "later",
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
}
]
},
"<repo>/tests/diagnostics/dataview.ts": {
"order": [
"<repo>/tests/diagnostics/dataview.ts"
@@ -11900,12 +11840,6 @@
],
"diags": []
},
"<repo>/tests/diagnostics/dyn-import-cycle-tdz/main.ts": {
"order": [
"<repo>/tests/diagnostics/dyn-import-cycle-tdz/main.ts"
],
"diags": []
},
"<repo>/tests/diagnostics/dynamic-import-static.ts": {
"order": [
"<repo>/tests/diagnostics/dynamic-import-static.ts"
@@ -14825,6 +14759,173 @@
],
"diags": []
},
"<repo>/tests/corpus/iterator-typed-returns.ts": {
"order": [
"<repo>/tests/corpus/iterator-typed-returns.ts"
],
"diags": []
},
"<repo>/tests/corpus/never-returning-calls.ts": {
"order": [
"<repo>/tests/corpus/never-returning-calls.ts"
],
"diags": []
},
"<repo>/tests/corpus/optional-element-defaults.ts": {
"order": [
"<repo>/tests/corpus/optional-element-defaults.ts"
],
"diags": []
},
"<repo>/tests/diagnostics/iterator-slot-fences.ts": {
"order": [
"<repo>/tests/diagnostics/iterator-slot-fences.ts"
],
"diags": []
},
"<repo>/tests/corpus/4060-cycle-shared-instance/main.ts": {
"order": [
"<repo>/tests/corpus/4060-cycle-shared-instance/report.ts",
"<repo>/tests/corpus/4060-cycle-shared-instance/inventory.ts",
"<repo>/tests/corpus/4060-cycle-shared-instance/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4061-cycle-evaluation-order/main.ts": {
"order": [
"<repo>/tests/corpus/4061-cycle-evaluation-order/south.ts",
"<repo>/tests/corpus/4061-cycle-evaluation-order/east.ts",
"<repo>/tests/corpus/4061-cycle-evaluation-order/north.ts",
"<repo>/tests/corpus/4061-cycle-evaluation-order/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4062-cycle-tdz-read/main.ts": {
"order": [
"<repo>/tests/corpus/4062-cycle-tdz-read/probe.ts",
"<repo>/tests/corpus/4062-cycle-tdz-read/limits.ts",
"<repo>/tests/corpus/4062-cycle-tdz-read/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4063-cycle-hoisted-calls/main.ts": {
"order": [
"<repo>/tests/corpus/4063-cycle-hoisted-calls/odd.ts",
"<repo>/tests/corpus/4063-cycle-hoisted-calls/even.ts",
"<repo>/tests/corpus/4063-cycle-hoisted-calls/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4064-cycle-classes-after-init/main.ts": {
"order": [
"<repo>/tests/corpus/4064-cycle-classes-after-init/item.ts",
"<repo>/tests/corpus/4064-cycle-classes-after-init/shelf.ts",
"<repo>/tests/corpus/4064-cycle-classes-after-init/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4065-cycle-init-throws/main.ts": {
"order": [
"<repo>/tests/corpus/4065-cycle-init-throws/rules.ts",
"<repo>/tests/corpus/4065-cycle-init-throws/ledger.ts",
"<repo>/tests/corpus/4065-cycle-init-throws/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4066-cycle-top-level-tdz/main.ts": {
"order": [
"<repo>/tests/corpus/4066-cycle-top-level-tdz/margin.ts",
"<repo>/tests/corpus/4066-cycle-top-level-tdz/frame.ts",
"<repo>/tests/corpus/4066-cycle-top-level-tdz/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts": {
"order": [
"<repo>/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts"
],
"diags": []
},
"<repo>/tests/diagnostics/cycle-class-early/main.ts": {
"order": [
"<repo>/tests/diagnostics/cycle-class-early/dogs.ts",
"<repo>/tests/diagnostics/cycle-class-early/pets.ts",
"<repo>/tests/diagnostics/cycle-class-early/main.ts"
],
"diags": []
},
"<repo>/tests/diagnostics/cycle-default-export/main.ts": {
"order": [
"<repo>/tests/diagnostics/cycle-default-export/b.ts",
"<repo>/tests/diagnostics/cycle-default-export/a.ts",
"<repo>/tests/diagnostics/cycle-default-export/main.ts"
],
"diags": [
{
"code": "SC1016",
"message": "circular imports (<repo>/tests/diagnostics/cycle-default-export/a.ts → <repo>/tests/diagnostics/cycle-default-export/b.ts → <repo>/tests/diagnostics/cycle-default-export/a.ts; the default export expression at <repo>/tests/diagnostics/cycle-default-export/a.ts:2 has no initialization state inside the cycle) are not supported yet",
"loc": {
"file": "<repo>/tests/diagnostics/cycle-default-export/a.ts",
"start": 0,
"end": 0
},
"milestone": "later",
"hint": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge"
}
]
},
"<repo>/tests/diagnostics/cycle-namespace-import/main.ts": {
"order": [
"<repo>/tests/diagnostics/cycle-namespace-import/b.ts",
"<repo>/tests/diagnostics/cycle-namespace-import/a.ts",
"<repo>/tests/diagnostics/cycle-namespace-import/main.ts"
],
"diags": [
{
"code": "SC1016",
"message": "circular imports (<repo>/tests/diagnostics/cycle-namespace-import/a.ts → <repo>/tests/diagnostics/cycle-namespace-import/b.ts → <repo>/tests/diagnostics/cycle-namespace-import/a.ts; the namespace import 'helpers' at <repo>/tests/diagnostics/cycle-namespace-import/a.ts:1 names a module of the cycle) are not supported yet",
"loc": {
"file": "<repo>/tests/diagnostics/cycle-namespace-import/a.ts",
"start": 0,
"end": 0
},
"milestone": "later",
"hint": "ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge"
}
]
},
"<repo>/tests/diagnostics/cycle-var-early/main.ts": {
"order": [
"<repo>/tests/diagnostics/cycle-var-early/b.ts",
"<repo>/tests/diagnostics/cycle-var-early/a.ts",
"<repo>/tests/diagnostics/cycle-var-early/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/cycle-generations-long-lived.ts": {
"order": [
"<repo>/tests/corpus/cycle-generations-long-lived.ts"
],
"diags": []
},
"<repo>/tests/corpus/4045-optional-element-projections.ts": {
"order": [
"<repo>/tests/corpus/4045-optional-element-projections.ts"
],
"diags": []
},
"<repo>/tests/corpus/4046-captured-parameter-values.ts": {
"order": [
"<repo>/tests/corpus/4046-captured-parameter-values.ts"
],
"diags": []
},
"<repo>/tests/corpus/4047-captured-parameter-rebinding.ts": {
"order": [
"<repo>/tests/corpus/4047-captured-parameter-rebinding.ts"
],
"diags": []
},
"<repo>/tests/corpus/3138-array-large-storage.ts": {
"order": [
"<repo>/tests/corpus/3138-array-large-storage.ts"
+250 -146
View File
@@ -39,12 +39,17 @@
* from it drags in every node below, and none of it is ever garbage.
*
* So the collector is generational, in the shape CPython uses for this
* same algorithm. Each header carries a `gen`; a pass names the oldest
* generation it will walk and SKIPS every object above it, exactly as it
* skips NULL and immortals. Objects that survive a pass are promoted, so
* the next nursery pass never walks them again: the cost of a nursery pass
* is proportional to what has been allocated since the last one, not to
* the live heap.
* same algorithm: nursery, mature and old. Each header carries a `gen`; a
* pass names the oldest generation it will walk and SKIPS every object above
* it, exactly as it skips NULL and immortals. Objects that survive a pass are
* promoted one generation, so the next pass at a lower level never walks
* them again: the cost of a nursery pass is proportional to what has been
* allocated since the last one, and the cost of a mature pass to what has
* survived a nursery pass since the last mature one — not to the live heap.
* The third generation is what keeps a long-lived structure out of the walk
* that reclaims medium-lived garbage: objects that die after surviving one
* pass (scratch structures built for a phase of work, say) are reclaimed by
* mature passes that never touch the data that has been live for a while.
*
* Restricting the walk stays sound because trial deletion is already
* conservative in the right direction. An edge from a skipped (older)
@@ -53,41 +58,57 @@
* answer the collector gives for a reference held by the stack. Edges the
* other way are simply not followed; the older object was not a candidate
* for freeing in this pass. What this gives up is cycles that SPAN
* generations: those are invisible to a nursery pass and are found by the
* full pass, which walks every generation and so is exactly the old
* algorithm.
* generations: those are invisible to a restricted pass and are found by
* the full pass, which walks every generation and so is exactly the
* single-generation algorithm.
*
* Scheduling. One counter drives the release path: candidates buffered
* since the last pass. When it trips, the pass runs at the nursery level
* unless either full-pass condition holds:
* heap growth the live cycle-headered heap is a fraction past its size
* after the last full pass — the standard mature-generation
* rule, which catches garbage made from fresh allocation;
* mature backlog the mature candidate buffer has reached a fraction of the
* since the last pass. When it trips, the pass runs at the highest level
* whose condition holds:
* full, growth the live cycle-headered heap is a fraction past its size
* after the last full pass. The fraction starts at 1/4 and
* ADAPTS to what full passes find: a growth-triggered full
* pass that reclaims few OLD objects relative to the growth
* that triggered it, twice in a row, doubles the fraction
* (up to 1/2), and one that does reclaim them restores 1/4.
* Garbage younger than the old generation does not count:
* the mature level reclaims it without walking the old heap.
* This is what stops a large retained structure from being
* re-walked at every quarter of growth while it is building
* or while the garbage is all young.
* full, backlog the old candidate buffer has reached a fraction of the
* live heap. This one is not redundant: live data that is
* unlinked INTO a dead cycle grows no counter at all (it
* was tallied when it was allocated) and is invisible to a
* nursery pass, so without it a program that churns its
* restricted pass, so without it a program that churns its
* long-lived structures would never collect anything. But
* a mature object is also buffered whenever a temporary
* an old object is also buffered whenever a temporary
* reference to it is dropped, so merely READING a live
* structure (walking a tree, say) refills the buffer with
* objects that are not garbage. A full pass that frees
* almost nothing therefore doubles this fraction, and a
* productive one restores it.
* scheduled age a mature candidate has waited through a nursery's worth
* of event-loop checkpoints. Candidate COUNT cannot bound
* the garbage behind one root, and an idle heap does not
* trip growth, so this keeps a sparse mature backlog from
* objects that are not garbage. A pass that frees almost
* nothing therefore doubles this fraction, and a productive
* one restores it.
* mature the same two rules one level down, with a fixed 1/4
* growth fraction measured from the last mature-or-full
* pass and a backlog rule over the mature buffer.
* scheduled age a mature or old candidate has waited through a nursery's
* worth of event-loop checkpoints. Candidate COUNT cannot
* bound the garbage behind one root, and an idle heap does
* not trip growth, so this keeps a sparse backlog from
* floating forever without putting a full walk on every
* turn.
* The first two bound collection work to a constant factor of mutator work —
* one heap-sized walk per live/N candidates — while the scheduled age is the
* liveness backstop, limiting its forced full walks to one per nursery's worth
* of checkpoints while mature roots wait. That is the trade: a fixed root
* threshold bounds floating garbage tightly and pays unbounded time for it;
* this bounds ordinary mutator-triggered work and lets garbage float in
* proportion, but not forever.
* The growth and backlog rules bound collection work to a constant factor of
* mutator work — one heap-sized walk per live/N candidates or per growth of
* live/N — while the scheduled age is the liveness backstop, limiting its
* forced full walks to one per nursery's worth of checkpoints while older
* roots wait. That is the trade: a fixed root threshold bounds floating
* garbage tightly and pays unbounded time for it; this bounds ordinary
* mutator-triggered work and lets garbage float in proportion, but not
* forever. Peak memory stays bounded the same way: young garbage floats by
* at most a quarter of the heap before a mature pass, and old garbage by at
* most half the heap's size after the last full pass, and by more than a
* quarter only once two full passes in a row have shown that little of the
* growth was old garbage.
*/
#include "scr_runtime.h"
@@ -154,10 +175,11 @@ void scr_rc_destroy(void *obj, void (*destroy)(void *)) {
}
}
/* Live cycle-headered objects — what the full-pass trigger watches.
* Exported: the inline allocation paths (scr_runtime.h, llvm/alloc.ts)
* maintain it too. */
/* Live cycle-headered objects and the running count of OLD objects freed.
* The inline allocation/free paths (scr_runtime.h, llvm/alloc.ts) maintain
* both counters so full passes can measure old-generation reclamation. */
SCR_TL size_t scr_cyc_live = 0;
SCR_TL size_t scr_cyc_old_freed = 0;
void *scr_cyc_alloc(size_t size, ScrTraceFn trace, ScrCycFreeFn free_fn) {
return scr_cyc_alloc_inline(size, trace, free_fn);
@@ -213,23 +235,46 @@ static SCR_TL ScrVec scr_white;
static SCR_TL ScrVec scr_xgen;
/* Objects above this generation are invisible to the pass in flight. */
static SCR_TL unsigned scr_gen_limit = SCR_CYC_MATURE;
static SCR_TL unsigned scr_gen_limit = SCR_CYC_OLD;
static size_t scr_cyc_pass(unsigned gen_limit);
/* ── when to collect ──────────────────────────────────────────────────── */
#define SCR_CYC_NURSERY_CANDIDATES 256 /* nursery trigger */
#define SCR_CYC_FULL_GROWTH_DIV 4 /* full pass per +1/4 of live heap */
#define SCR_CYC_FULL_FLOOR 4096 /* ...but not below this many objects */
/* Live count as of the end of the last full pass. */
/* Scheduling decisions run once per pass, never per object, so they are
* kept out of line and built for size: every executable that can release a
* cycle-capable value links them, including ones that never form a cycle. */
#define SCR_CYC_SCHEDULING __attribute__((cold, noinline, minsize))
/* Per-pass and cross-generation bookkeeping: work that is proportional to
* candidates, dead objects or skipped edges and dominated by the calls it
* makes, never the per-edge graph walk. Built for size for the same reason. */
#define SCR_CYC_COMPACT __attribute__((noinline, minsize))
#define SCR_CYC_GROWTH_DIV 4 /* growth pass per +1/4 of live heap */
#define SCR_CYC_GROWTH_FLOOR 4096 /* ...but not below this many objects */
/* Live count as of the end of the last full pass, and of the last pass that
* walked the mature generation (mature or full). */
static SCR_TL size_t scr_cyc_live_after_full = 0;
static SCR_TL size_t scr_cyc_live_after_mature = 0;
/* Doublings of the mature-backlog threshold earned by consecutive full passes
* that found almost nothing to free (see scr_cyc_mature_threshold). */
/* Doublings of the full-pass growth fraction earned by consecutive
* growth-triggered full passes that reclaimed little of the old generation
* (see scr_cyc_scheduled_pass). Capped at a fraction of 1/2: the heap may grow
* at most by half past its size after the last full pass before the next.
* The streak counts those unproductive passes since the last productive one;
* the fraction only grows once two have run in a row. */
#define SCR_CYC_GROWTH_MAX_SHIFT 1
static SCR_TL unsigned scr_cyc_growth_shift = 0;
static SCR_TL unsigned scr_cyc_growth_streak = 0;
/* Doublings of each generation's backlog threshold earned by consecutive
* passes that found almost nothing to free (see scr_cyc_backlog_threshold).
* Only the mature and old entries are used. */
#define SCR_CYC_BACKLOG_MAX_SHIFT 8
static SCR_TL unsigned scr_cyc_backlog_shift = 0;
static SCR_TL unsigned scr_cyc_backlog_shift[SCR_CYC_NGENS];
static size_t scr_cyc_nursery_threshold(void) {
static SCR_TL size_t cached = 0;
@@ -241,81 +286,128 @@ static size_t scr_cyc_nursery_threshold(void) {
return cached;
}
/* Buffered candidates across both generations, and the count at which the
/* Buffered candidates across every generation, and the count at which the
* release path next collects. Keeping a running total (rather than summing
* the buffers) is what holds the hot path to one load and one compare, as
* it was when there was a single buffer. The trigger is re-armed at the end
* of every pass to "whatever survived, plus a nursery's worth": without that
* hysteresis a large mature buffer — which a nursery pass cannot drain —
* hysteresis a large older buffer — which a nursery pass cannot drain —
* would re-arm on every single release. Both start at zero, so the first
* release runs one trivial pass that arms them properly. */
static SCR_TL size_t scr_cyc_nbuffered = 0;
static SCR_TL size_t scr_cyc_trigger = 0;
/* Scheduled nursery passes for which at least one mature root was waiting.
* A full pass resets the age; with no mature backlog there is nothing to age. */
/* Scheduled nursery passes for which at least one mature or old root was
* waiting. A full pass resets the age; with no such backlog there is nothing
* to age. */
static SCR_TL size_t scr_cyc_scheduled_mature_age = 0;
static size_t scr_cyc_buffered(void) {
return scr_roots[SCR_CYC_NURSERY].n + scr_roots[SCR_CYC_MATURE].n;
return scr_roots[SCR_CYC_NURSERY].n + scr_roots[SCR_CYC_MATURE].n
+ scr_roots[SCR_CYC_OLD].n;
}
/* A full pass is due once the live heap has grown a fraction past what it
* was when the last one finished. Since a pass resets the baseline to the
* live count it leaves behind, an unproductive full pass cannot re-trigger
* itself — the next one waits for another fraction of growth. */
static bool scr_cyc_full_due(void) {
size_t base = scr_cyc_live_after_full;
if (base < SCR_CYC_FULL_FLOOR) base = SCR_CYC_FULL_FLOOR;
return scr_cyc_live > base + base / SCR_CYC_FULL_GROWTH_DIV;
static bool scr_cyc_older_backlog(void) {
return scr_roots[SCR_CYC_MATURE].n != 0 || scr_roots[SCR_CYC_OLD].n != 0;
}
/* Cold half of the release path: pick the generation and collect.
static size_t scr_cyc_growth_base(size_t live_after) {
return live_after < SCR_CYC_GROWTH_FLOOR ? SCR_CYC_GROWTH_FLOOR : live_after;
}
/* A pass is due once the live heap has grown a fraction past what it was
* when the last pass at that level finished. Since a pass resets its
* baseline to the live count it leaves behind, an unproductive pass cannot
* re-trigger itself — the next one waits for another fraction of growth. */
static bool scr_cyc_grown(size_t live_after, unsigned shift) {
size_t base = scr_cyc_growth_base(live_after);
return scr_cyc_live > base + ((base / SCR_CYC_GROWTH_DIV) << shift);
}
/* The backlog rule for one generation's candidate buffer.
*
* Heap growth is not the only thing that owes a full pass. Live data that
* TURNS INTO garbage — a mature structure unlinked into a dead cycle —
* grows no counter at all: those objects were already tallied in
* scr_cyc_live when they were allocated, so scr_cyc_full_due stays false
* forever, and a nursery pass cannot see them because they are mature. Left
* at that, a program that churns its long-lived structures without
* allocating would never collect anything. So the mature buffer's own size
* is the second full-pass trigger, at a fraction of the live heap: that
* bounds uncollected mature candidates proportionally and keeps the
* amortized cost linear (one heap-sized walk per live/N candidates).
* Heap growth is not the only thing that owes a pass. Live data that TURNS
* INTO garbage — an older structure unlinked into a dead cycle — grows no
* counter at all: those objects were already tallied in scr_cyc_live when
* they were allocated, so the growth rules stay false forever, and a
* restricted pass cannot see them because they sit above its level. Left at
* that, a program that churns its long-lived structures without allocating
* would never collect anything. So a buffer's own size is the second trigger
* for its level, at a fraction of the live heap: that bounds uncollected
* candidates proportionally and keeps the amortized cost linear (one walk
* per live/N candidates).
*
* Re-buffered live objects cannot be told apart from new garbage until a
* pass walks them, and a program that keeps traversing one live structure
* buffers it again after every pass. Each full pass that frees almost none
* of its candidates doubles the threshold, so repeated unproductive walks
* thin out geometrically; once it exceeds the mature population this
* trigger rests, leaving heap growth, the scheduled age and exit to collect.
* That only delays garbage made from existing data: replacing it requires
* allocation, which grows the heap and trips the growth trigger. The first
* productive full pass restores the original fraction. */
static size_t scr_cyc_mature_threshold(void) {
size_t t = scr_cyc_live / SCR_CYC_FULL_GROWTH_DIV;
* buffers it again after every pass. Each backlog-level pass that frees
* almost none of its candidates doubles the threshold, so repeated
* unproductive walks thin out geometrically; once it exceeds the population
* this trigger rests, leaving heap growth, the scheduled age and exit to
* collect. That only delays garbage made from existing data: replacing it
* requires allocation, which grows the heap and trips a growth trigger. The
* first productive pass at that level restores the original fraction. */
static size_t scr_cyc_backlog_threshold(unsigned gen) {
size_t t = scr_cyc_live / SCR_CYC_GROWTH_DIV;
if (t < SCR_CYC_NURSERY_CANDIDATES) t = SCR_CYC_NURSERY_CANDIDATES;
return t > (SIZE_MAX >> scr_cyc_backlog_shift) ? SIZE_MAX : t << scr_cyc_backlog_shift;
unsigned shift = scr_cyc_backlog_shift[gen];
return t > (SIZE_MAX >> shift) ? SIZE_MAX : t << shift;
}
/* A scheduled full pass, adapting the backlog threshold to its yield. The
* explicit sweep (scr_collect_cycles) is not a scheduling decision and
* leaves the threshold alone. Productive means at least one object freed
* per FULL_GROWTH_DIV candidates; a pass with no candidates says nothing. */
static void scr_cyc_full_pass(void) {
size_t freed = scr_cyc_pass(SCR_CYC_MATURE);
if (scr_cands.n == 0) return;
if (freed * SCR_CYC_FULL_GROWTH_DIV >= scr_cands.n)
scr_cyc_backlog_shift = 0;
else if (scr_cyc_backlog_shift < SCR_CYC_BACKLOG_MAX_SHIFT)
scr_cyc_backlog_shift++;
/* One scheduled pass at `gen`. A mature or old pass adapts that level's
* backlog threshold to its yield; the explicit sweep (scr_collect_cycles) is
* not a scheduling decision and leaves the thresholds alone. Productive means
* at least one object freed per GROWTH_DIV candidates; a pass with no
* candidates says nothing.
*
* A full pass that heap growth triggered also adapts the growth fraction to
* what it reclaimed from the OLD generation — the one part of its work no
* restricted pass could have done. Weighing that against the growth since the
* last full pass keeps full passes frequent while old structures are being
* discarded (their garbage is what floats until the next full pass) and
* spaces them out while the heap is growing with live or young data, where
* each walk of the retained heap buys almost nothing. Backing off only after
* two unproductive passes in a row keeps a program that discards old
* structures at a steady rate on the base schedule: its passes alternate
* between finding the garbage just aged into the old generation and finding
* it still younger, and a single miss says little.
*
* Every level shares this one body, so a program that links the collector
* carries a single copy of the scheduling logic. */
static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_growth) {
size_t base = scr_cyc_growth_base(scr_cyc_live_after_full);
size_t growth = scr_cyc_live > base ? scr_cyc_live - base : 0;
size_t old_freed_before = scr_cyc_old_freed;
size_t freed = scr_cyc_pass(gen);
if (gen == SCR_CYC_NURSERY) return;
if (scr_cands.n != 0) {
if (freed * SCR_CYC_GROWTH_DIV >= scr_cands.n)
scr_cyc_backlog_shift[gen] = 0;
else if (scr_cyc_backlog_shift[gen] < SCR_CYC_BACKLOG_MAX_SHIFT)
scr_cyc_backlog_shift[gen]++;
}
if (!by_growth) return;
size_t reclaimed = scr_cyc_old_freed - old_freed_before;
if (reclaimed * SCR_CYC_GROWTH_DIV >= growth) {
scr_cyc_growth_shift = 0;
scr_cyc_growth_streak = 0;
} else if (++scr_cyc_growth_streak >= 2
&& scr_cyc_growth_shift < SCR_CYC_GROWTH_MAX_SHIFT) {
scr_cyc_growth_shift++;
}
}
static void scr_cyc_collect_due(void) {
if (scr_cyc_full_due() || scr_roots[SCR_CYC_MATURE].n >= scr_cyc_mature_threshold())
scr_cyc_full_pass();
else
scr_cyc_pass(SCR_CYC_NURSERY);
/* Cold half of the release path: pick the level and collect. It runs once
* per pass, so it is kept out of line and built for size, which leaves the
* hot buffering path in scr_cyc_on_release small. */
static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) {
unsigned gen = SCR_CYC_NURSERY;
bool by_growth = scr_cyc_grown(scr_cyc_live_after_full, scr_cyc_growth_shift);
if (by_growth || scr_roots[SCR_CYC_OLD].n >= scr_cyc_backlog_threshold(SCR_CYC_OLD))
gen = SCR_CYC_OLD;
else if (scr_cyc_grown(scr_cyc_live_after_mature, 0)
|| scr_roots[SCR_CYC_MATURE].n >= scr_cyc_backlog_threshold(SCR_CYC_MATURE))
gen = SCR_CYC_MATURE;
scr_cyc_scheduled_pass(gen, by_growth);
}
/* One scheduled pass, for callers that reach a natural collection point
@@ -328,11 +420,11 @@ void scr_cyc_collect_scheduled(void) {
scr_cyc_scheduled_mature_age = 0;
return;
}
if (scr_roots[SCR_CYC_MATURE].n == 0) {
if (!scr_cyc_older_backlog()) {
scr_cyc_scheduled_mature_age = 0;
} else if (++scr_cyc_scheduled_mature_age
>= scr_cyc_nursery_threshold()) {
scr_cyc_full_pass();
scr_cyc_scheduled_pass(SCR_CYC_OLD, false);
return;
}
scr_cyc_collect_due();
@@ -353,7 +445,7 @@ void scr_cyc_on_dead(void *obj) {
scr_cyc_nbuffered--;
/* A death must not consume room reserved for a future candidate. */
scr_cyc_trigger--;
if (h->gen == SCR_CYC_MATURE && b->n == 0)
if (h->gen != SCR_CYC_NURSERY && !scr_cyc_older_backlog())
scr_cyc_scheduled_mature_age = 0;
}
@@ -417,7 +509,7 @@ static void scr_sb_visit(void *child, void *ctx) {
static void scr_scan_black(void *obj, uintptr_t depth) {
ScrCycHdr *h = scr_cyc_hdr(obj);
h->color = SCR_CYC_BLACK;
if (h->gen < SCR_CYC_MATURE) scr_cyc_push(&scr_promote, obj);
if (h->gen < SCR_CYC_OLD) scr_cyc_push(&scr_promote, obj);
if (depth == SCR_CYC_WALK_DEPTH) {
scr_cyc_push(&scr_restore_pending, obj);
return;
@@ -483,7 +575,7 @@ static void scr_scan(void *obj, uintptr_t depth) {
* edges were never trial-deleted, so anything it references kept that count
* and was scan-blacked rather than whitened — the same conservatism that
* makes restricting the walk sound in the first place. */
static void scr_xg_pin_visit(void *child, void *ctx) {
static SCR_CYC_COMPACT void scr_xg_pin_visit(void *child, void *ctx) {
(void)ctx;
if (child == NULL || SCR_RC(child) == SIZE_MAX) return;
if (scr_cyc_hdr(child)->gen <= scr_gen_limit) return; /* markGray had it */
@@ -509,7 +601,7 @@ static void scr_xg_destroy(void *obj) {
scr_xg_freed++;
}
static void scr_xg_release(void *obj) {
static SCR_CYC_COMPACT void scr_xg_release(void *obj) {
if (SCR_RC(obj) > 1) {
SCR_RC(obj) -= 1;
scr_cyc_on_release(obj); /* lost a reference: a possible cycle root */
@@ -522,43 +614,12 @@ static void scr_xg_release(void *obj) {
/* One pass over every candidate at or below `gen_limit`; returns how many
* objects it freed. */
static size_t scr_cyc_pass(unsigned gen_limit) {
if (scr_collecting) return 0;
scr_collecting = true;
scr_gen_limit = gen_limit;
/* markRoots: keep live candidates (still purple), drop the rest — an
* object re-retained since buffering is black. Drain the buffers before
* walking: tracing only adjusts counts and worklists, so nothing can
* re-buffer underneath us. Candidates ABOVE the limit keep their slots
* and wait for a full pass. */
scr_cands.n = 0;
for (unsigned g = 0; g <= gen_limit; g++) {
for (size_t i = 0; i < scr_roots[g].n; i++) {
void *obj = scr_roots[g].v[i];
ScrCycHdr *h = scr_cyc_hdr(obj);
h->buffered = 0;
if (h->color == SCR_CYC_PURPLE)
scr_cyc_push(&scr_cands, obj);
}
scr_roots[g].n = 0;
}
for (size_t i = 0; i < scr_cands.n; i++) scr_mark_gray(scr_cands.v[i], 0);
while (scr_pending.n) {
void *obj = scr_pending.v[--scr_pending.n];
scr_cyc_hdr(obj)->trace(obj, scr_mg_visit, NULL);
}
scr_white.n = 0;
for (size_t i = 0; i < scr_cands.n; i++) scr_scan(scr_cands.v[i], 0);
while (scr_pending.n) {
void *obj = scr_pending.v[--scr_pending.n];
/* A later outside root may have restored this deferred white node and
* its descendants already. Never scan a black node's edges again. */
if (scr_cyc_hdr(obj)->color == SCR_CYC_WHITE)
scr_cyc_hdr(obj)->trace(obj, scr_scan_visit, NULL);
}
/* The second half of a pass: settle the white set, promote, re-buffer,
* tear down and re-arm. Its loops run once per candidate or dead object and
* spend their time in the trace and teardown calls they make, so this half is
* built for size; the graph walk above it stays fully optimized. Returns how
* many objects the pass freed. */
static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit) {
size_t dead = 0;
for (size_t i = 0; i < scr_white.n; i++) {
void *obj = scr_white.v[i];
@@ -573,7 +634,7 @@ static size_t scr_cyc_pass(unsigned gen_limit) {
* `gen` still holds the values the walk filtered on (promotion below is
* what changes them). A full pass skips no edge, so it pins nothing. */
scr_xgen.n = 0;
if (gen_limit < SCR_CYC_MATURE) {
if (gen_limit < SCR_CYC_OLD) {
for (size_t i = 0; i < scr_white.n; i++) {
void *obj = scr_white.v[i];
scr_cyc_hdr(obj)->trace(obj, scr_xg_pin_visit, NULL);
@@ -581,10 +642,12 @@ static size_t scr_cyc_pass(unsigned gen_limit) {
}
/* Every phase that consults SCR_CYC_SKIP has run, so the recorded
* survivors can graduate now. They are externally referenced, so none of
* them is in the white set about to be freed. */
for (size_t i = 0; i < scr_promote.n; i++)
scr_cyc_hdr(scr_promote.v[i])->gen = SCR_CYC_MATURE;
* survivors can graduate now, one generation each. They are externally
* referenced, so none of them is in the white set about to be freed. */
for (size_t i = 0; i < scr_promote.n; i++) {
ScrCycHdr *h = scr_cyc_hdr(scr_promote.v[i]);
if (h->gen < SCR_CYC_OLD) h->gen++;
}
scr_promote.n = 0;
/* A restricted pass may have spared a candidate only because the edge
@@ -595,13 +658,14 @@ static size_t scr_cyc_pass(unsigned gen_limit) {
* root, and a dead cycle whose members all got spared that way would
* never be walked again by any pass, full ones included. So a restricted
* pass hands its survivors back as candidates in the generation they were
* just promoted into, where a full pass — which skips nothing, and so
* judges them on real reference counts — will settle them. A full pass
* just promoted into, where a later pass at that level — and in the end a
* full pass, which skips nothing and so judges them on real reference
* counts — will settle them. A full pass
* needs none of this: it walked every edge, so rc > 0 there means a
* genuine outside reference and Bacon-Rajan's own reasoning retires the
* candidate. Re-buffering costs a walk, never correctness: an object that
* is truly live gets re-blackened by the next retain and dropped then. */
if (gen_limit < SCR_CYC_MATURE) {
if (gen_limit < SCR_CYC_OLD) {
for (size_t i = 0; i < scr_cands.n; i++) {
void *obj = scr_cands.v[i];
ScrCycHdr *h = scr_cyc_hdr(obj);
@@ -640,7 +704,8 @@ static size_t scr_cyc_pass(unsigned gen_limit) {
scr_xgen.n = 0;
freed += scr_xg_freed;
if (gen_limit >= SCR_CYC_MATURE) {
if (gen_limit >= SCR_CYC_MATURE) scr_cyc_live_after_mature = scr_cyc_live;
if (gen_limit >= SCR_CYC_OLD) {
scr_cyc_live_after_full = scr_cyc_live;
scr_cyc_scheduled_mature_age = 0;
}
@@ -652,6 +717,46 @@ static size_t scr_cyc_pass(unsigned gen_limit) {
return freed;
}
static size_t scr_cyc_pass(unsigned gen_limit) {
if (scr_collecting) return 0;
scr_collecting = true;
scr_gen_limit = gen_limit;
/* markRoots: keep live candidates (still purple), drop the rest — an
* object re-retained since buffering is black. Drain the buffers before
* walking: tracing only adjusts counts and worklists, so nothing can
* re-buffer underneath us. Candidates ABOVE the limit keep their slots
* and wait for a pass at their own level. */
scr_cands.n = 0;
for (unsigned g = 0; g <= gen_limit; g++) {
for (size_t i = 0; i < scr_roots[g].n; i++) {
void *obj = scr_roots[g].v[i];
ScrCycHdr *h = scr_cyc_hdr(obj);
h->buffered = 0;
if (h->color == SCR_CYC_PURPLE)
scr_cyc_push(&scr_cands, obj);
}
scr_roots[g].n = 0;
}
for (size_t i = 0; i < scr_cands.n; i++) scr_mark_gray(scr_cands.v[i], 0);
while (scr_pending.n) {
void *obj = scr_pending.v[--scr_pending.n];
scr_cyc_hdr(obj)->trace(obj, scr_mg_visit, NULL);
}
scr_white.n = 0;
for (size_t i = 0; i < scr_cands.n; i++) scr_scan(scr_cands.v[i], 0);
while (scr_pending.n) {
void *obj = scr_pending.v[--scr_pending.n];
/* A later outside root may have restored this deferred white node and
* its descendants already. Never scan a black node's edges again. */
if (scr_cyc_hdr(obj)->color == SCR_CYC_WHITE)
scr_cyc_hdr(obj)->trace(obj, scr_scan_visit, NULL);
}
return scr_cyc_settle(gen_limit);
}
void scr_collect_cycles(void) {
/* The full pass, run to a fixpoint. A teardown's releases can drop the
* last reference to a further cycle, so one pass does not always drain
@@ -661,8 +766,7 @@ void scr_collect_cycles(void) {
* can have buffered anything new, so a pass that comes up empty ends
* this; and since each repeat needs a strictly smaller live heap to
* continue, it terminates. */
while (scr_cyc_pass(SCR_CYC_MATURE)
&& (scr_roots[SCR_CYC_NURSERY].n || scr_roots[SCR_CYC_MATURE].n)) {
while (scr_cyc_pass(SCR_CYC_OLD) && scr_cyc_buffered()) {
}
}
@@ -671,7 +775,7 @@ void scr_collect_cycles(void) {
void scr_cyc_context_cleanup(void) {
scr_collect_cycles();
ScrVec *vectors[] = {&scr_roots[SCR_CYC_NURSERY], &scr_roots[SCR_CYC_MATURE],
&scr_cands, &scr_promote, &scr_pending, &scr_restore_pending, &scr_white, &scr_xgen};
&scr_roots[SCR_CYC_OLD], &scr_cands, &scr_promote, &scr_pending, &scr_restore_pending, &scr_white, &scr_xgen};
for (size_t i = 0; i < sizeof vectors / sizeof vectors[0]; i++) {
free(vectors[i]->v);
*vectors[i] = (ScrVec){0};
+3 -3
View File
@@ -250,8 +250,8 @@ static void shared_property_keys(void) {
static void checked_leaf_cycles(void) {
/* Object properties omit primitives from trial deletion, while arrays and
* captured unknown values still trace them. Exercise shared leaves through
* both edge kinds, with and without an external owner, in either generation. */
for (int old = 0; old < 2; old++) {
* both edge kinds, with and without an external owner, in every generation. */
for (uint16_t gen = SCR_CYC_NURSERY; gen < SCR_CYC_NGENS; gen++) {
for (int external = 0; external < 2; external++) {
#ifdef SCR_RC_AUDIT
long before_dyns = scr_dyn_live_count();
@@ -260,7 +260,7 @@ static void checked_leaf_cycles(void) {
ScrStr *text = scr_str_new("survives either edge", 20);
ScrDyn *leaf = scr_dyn_new_str(text);
scr_str_release(text);
if (old) scr_cyc_hdr(leaf)->gen = SCR_CYC_MATURE;
scr_cyc_hdr(leaf)->gen = gen;
ScrDyn *first = scr_dyn_new_obj(), *second = scr_dyn_new_obj();
ScrDyn *array = scr_dyn_new_arr();
ScrClosure *closure = scr_closure_new(NULL, 1);
+17 -9
View File
@@ -336,12 +336,13 @@ char **scr_worker_argv(void);
* scan: restore externally-referenced subgraphs; gather: free the
* dead cycle members, releasing only edges that LEAVE the white set).
* It is GENERATIONAL: each header carries a generation, a pass names the
* oldest one it will walk, and objects that survive a pass are promoted out
* of the nursery so later nursery passes never re-walk them. That is what
* keeps a pass proportional to recent allocation rather than to the whole
* live heap — see the generation note in scr_cycle.c for the soundness
* argument and the schedule. Collection points: program exit (before the RC
* audit), event-loop quiescence, and the per-generation triggers.
* oldest one it will walk, and objects that survive a pass are promoted one
* generation (nursery, mature, old) so later restricted passes never re-walk
* them. That is what keeps a pass proportional to recent allocation rather
* than to the whole live heap — see the generation note in scr_cycle.c for
* the soundness argument and the schedule. Collection points: program
* exit (before the RC audit), event-loop quiescence, and the per-generation
* triggers.
* SCR_CYCLE_THRESHOLD pins the nursery trigger to a fixed candidate count.
* There is no concurrent or incremental collection.
*
@@ -367,8 +368,9 @@ enum {
};
/* Generations. A candidate sits in the buffer named by its own `gen`, and a
* pass walks only objects at or below the generation it collects. */
enum { SCR_CYC_NURSERY = 0, SCR_CYC_MATURE = 1, SCR_CYC_NGENS = 2 };
* pass walks only objects at or below the generation it collects; a pass at
* SCR_CYC_OLD is a full pass. */
enum { SCR_CYC_NURSERY = 0, SCR_CYC_MATURE = 1, SCR_CYC_OLD = 2, SCR_CYC_NGENS = 3 };
typedef struct ScrCycHdr {
ScrTraceFn trace;
@@ -380,7 +382,7 @@ typedef struct ScrCycHdr {
#endif
uint32_t color; /* SCR_CYC_* */
uint16_t buffered; /* 1 = sitting in its generation's candidate buffer */
uint16_t gen; /* SCR_CYC_NURSERY..SCR_CYC_MATURE (the walk filter) */
uint16_t gen; /* SCR_CYC_NURSERY..SCR_CYC_OLD (the walk filter) */
size_t buf_index; /* position there (O(1) removal when rc hits 0) */
} ScrCycHdr;
@@ -400,6 +402,8 @@ typedef struct ScrCycHdr {
_Static_assert(sizeof(ScrCycHdr) == 32, "LLVM backend expects a 32-byte cycle header");
_Static_assert(offsetof(ScrCycHdr, color) == 16,
"LLVM backend's inlined mark-live stores i32 0 at obj-16");
_Static_assert(offsetof(ScrCycHdr, gen) == 22,
"LLVM backend's inline free reads the generation at header+22");
#elif UINTPTR_MAX == UINT32_MAX
_Static_assert(sizeof(ScrCycHdr) == 24, "wasm32 cycle payloads require an aligned 24-byte header");
_Static_assert(offsetof(ScrCycHdr, color) == 12,
@@ -561,6 +565,7 @@ static inline void scr_mem_free(void *p) { free(p); }
* count, block. Every other header word starts zero (SCR_CYC_BLACK,
* SCR_CYC_NURSERY, unbuffered). */
extern SCR_TL size_t scr_cyc_live;
extern SCR_TL size_t scr_cyc_old_freed;
extern SCR_TL void (*scr_weak_dispose_hook)(void *);
static inline void *scr_cyc_alloc_inline(size_t size, ScrTraceFn trace, ScrCycFreeFn free_fn) {
if (size > SIZE_MAX - sizeof(ScrCycHdr)) scr_trap("scriptc: out of memory\n");
@@ -574,9 +579,12 @@ static inline void *scr_cyc_alloc_inline(size_t size, ScrTraceFn trace, ScrCycFr
static inline void scr_cyc_free_inline(void *obj) {
if (scr_weak_dispose_hook) scr_weak_dispose_hook(obj);
scr_cyc_live--;
if (scr_cyc_hdr(obj)->gen == SCR_CYC_OLD) scr_cyc_old_freed++;
scr_mem_free(scr_cyc_hdr(obj));
}
_Static_assert(SCR_CYC_NURSERY == 0, "zeroed cycle headers start in the nursery");
_Static_assert(sizeof(((ScrCycHdr *)0)->gen) == 2 && SCR_CYC_OLD == 2,
"LLVM backend's inline free compares an i16 generation against 2");
/* Dispose an object whose reference count already reached zero. The caller
* removes its cycle candidate first. Nested disposals keep bounded stack
+104
View File
@@ -244,6 +244,107 @@ static void check_unproductive_backlog_backs_off(void) {
}
}
/* Survive two passes: nursery to mature, then mature to old. */
static void age_to_old(Node *node) {
for (int i = 0; i < 2; i++) {
read_live(node);
scr_collect_cycles();
}
check(scr_cyc_hdr(node)->gen == SCR_CYC_OLD, "survivor did not reach the old generation");
}
/* Fresh live leaves, each released once so the release path keeps running
* its passes, until `target` objects have been freed or `limit` leaves have
* been made. Returns how many it made. */
static size_t grow_until(Node **keep, size_t limit, size_t target) {
size_t n = 0;
while (freed < target && n < limit) {
keep[n] = make_leaf();
read_live(keep[n]);
n++;
}
return n;
}
static void drop_leaves(Node **keep, size_t n) {
for (size_t i = 0; i < n; i++) {
keep[i]->rc--;
scr_cyc_on_dead(keep[i]);
node_free(keep[i]);
}
}
/* A dead cycle that has aged into the old generation is reachable only by a
* full pass. While the heap grows with live data, full passes find nothing
* and back off, but the dead cycle must still be reclaimed before the heap
* has grown by half past its size after the last full pass. Runs first, so
* the live heap is below the growth floor and that bound is known. */
static void check_old_garbage_reclaimed_while_growing(void) {
enum { RING = 64, FLOOR = 4096, LIMIT = 4 * FLOOR };
static Node *keep[LIMIT];
size_t before = freed;
Node *ring = make_ring(RING);
age_to_old(ring);
release_live(ring); /* last outside owner gone: a dead old cycle */
size_t grown = grow_until(keep, LIMIT, before + RING);
check(freed == before + RING, "dead old cycle was never reclaimed while the heap grew");
check(grown <= FLOOR + FLOOR / 2, "dead old cycle outlived the growth bound");
drop_leaves(keep, grown);
scr_collect_cycles();
}
/* Dead rings that survived at least one pass, each holding a traced edge
* into an old ring: whichever level reclaims them must pay those edges off,
* and must do so without an explicit sweep. */
static void check_older_rings_into_old(void) {
enum { OLD_RING = 2048, RING = 8, RINGS = 1024, LIMIT = 4 * (OLD_RING + RING * RINGS) };
static Node *keep[LIMIT];
Node *old = make_ring(OLD_RING);
age_to_old(old);
size_t before = freed;
Node *rings[RINGS];
for (size_t i = 0; i < RINGS; i++) {
rings[i] = make_ring(RING);
rings[i]->other = old;
old->rc++;
read_live(rings[i]);
}
scr_cyc_collect_scheduled(); /* every ring survives at least one pass */
for (size_t i = 0; i < RINGS; i++) {
check(scr_cyc_hdr(rings[i])->gen != SCR_CYC_NURSERY, "ring was not promoted");
release_live(rings[i]);
}
size_t grown = grow_until(keep, LIMIT, before + RINGS * RING);
check(freed == before + RINGS * RING, "dead older rings were never reclaimed");
check(old->rc == 2, "edges into the old generation were not paid off");
drop_leaves(keep, grown);
release_live(old);
scr_collect_cycles();
}
/* A dead cycle spanning the mature and old generations is spared by mature
* passes; promotion and re-buffering must still hand it to a full pass. */
static void check_cycle_spanning_mature_and_old(void) {
enum { LIMIT = 4 * 4096 };
static Node *keep[LIMIT];
Node *old = make_ring(4);
age_to_old(old);
Node *young = make_ring(4);
young->other = old;
old->rc++;
old->other = young;
young->rc++;
read_live(young);
scr_cyc_collect_scheduled(); /* young survives a pass and is promoted */
size_t before = freed;
release_live(old);
release_live(young); /* only the cycle's own edges remain */
size_t grown = grow_until(keep, LIMIT, before + 8);
check(freed == before + 8, "cycle spanning generations was never reclaimed");
drop_leaves(keep, grown);
scr_collect_cycles();
}
static void check_deep_ring(void) {
enum { DEPTH = 100000 };
size_t before = freed;
@@ -415,6 +516,9 @@ static void check_deferred_white_restoration(void) {
}
int main(void) {
check_old_garbage_reclaimed_while_growing();
check_older_rings_into_old();
check_cycle_spanning_mature_and_old();
check_unproductive_backlog_backs_off();
check_sparse_mature_backlog(1);
check_sparse_mature_backlog(2);
@@ -0,0 +1,100 @@
// Element reads of class arrays compared, tested, and chained directly.
// Holes, explicit undefined, out-of-range indices, operand order, and
// mutation during later operands keep JavaScript's results.
class Part {
readonly label: string;
weight: number;
constructor(label: string, weight: number) {
this.label = label;
this.weight = weight;
}
heavier(limit: number): boolean {
return this.weight > limit;
}
}
const bolt = new Part("bolt", 2);
const gear = new Part("gear", 7);
const nut = new Part("nut", 1);
const parts: Part[] = [bolt, gear, nut];
const order = [2, 0, 1, 5, -1, 1.5];
function positions(list: Part[], probe: Part): string {
const found: string[] = [];
for (let j = 0; j < order.length; j++) {
if (list[order[j]] === probe) found.push(`same@${j}`);
if (list[order[j]] !== probe) found.push(`diff@${j}`);
if (list[order[j]] === undefined) found.push(`missing@${j}`);
}
return found.join(" ");
}
console.log(positions(parts, gear));
function weights(list: Part[]): string {
const out: string[] = [];
for (let j = 0; j < order.length; j++) {
out.push(String(list[order[j]]?.weight ?? "none"));
out.push(list[order[j]]?.label ?? "-");
}
return out.join(",");
}
console.log(weights(parts));
function looped(list: Part[], probe: Part): number {
let hits = 0;
for (let i = 0; i < list.length; i++) {
const item = list[i];
if (item === probe) hits += 10;
if (item !== undefined && item.heavier(1)) hits++;
if (!item) hits += 100;
}
return hits;
}
console.log(looped(parts, bolt));
// Holes and explicit undefined values stored behind a bare element type.
const sparse: Part[] = [bolt];
sparse[3] = nut;
const unknownSlot: Part[] = [];
sparse[2] = unknownSlot[9];
console.log(sparse.length, looped(sparse, nut), 1 in sparse, 2 in sparse);
console.log(sparse[1] === sparse[2], sparse[1] === undefined, sparse[2] === undefined);
console.log(sparse[1]?.label ?? "hole", sparse[2]?.label ?? "explicit");
// The left operand is read before the right operand mutates the array.
const queue: Part[] = [bolt, gear];
console.log(queue[0] === queue.shift(), queue.length);
console.log(queue[0] === (queue[0] = nut), queue[0].label);
let reads = 0;
function at(list: Part[], index: number): number {
reads++;
return index;
}
console.log(parts[at(parts, 1)] === parts[at(parts, 1)], reads);
console.log(parts[at(parts, 9)] === parts[at(parts, 8)], reads);
// Reads inside callbacks and nested functions use the same rules.
const probes = [bolt, nut, new Part("bolt", 2)];
const matches = probes.map((probe) => {
let count = 0;
for (let i = 0; i <= parts.length; i++) {
const item = parts[i];
if (item === probe) count++;
count += item?.weight ?? 0;
}
return count;
});
console.log(matches.join(" "));
// Failed member reads still throw Node's TypeError.
try {
const shelf: Part[] = [bolt];
console.log(shelf[order[3]].heavier(0));
} catch (error) {
console.log((error as Error).name, (error as Error).message);
}
// Identity survives repeated reads, and a mutated element is observed.
const first = parts[0];
parts[0].weight = 11;
console.log(first === parts[0], first.weight, parts[0] === bolt, parts.indexOf(bolt));
@@ -0,0 +1,97 @@
// Parameters captured by nested functions but never rebound. Closures see
// the entry value and later changes to the referenced object, on early
// returns, escaping callbacks, loops, nesting, and exceptions.
interface Filter {
key: number;
skip: boolean;
label: string;
}
const values = [3, 1, 4, 1, 5, 9, 2, 6];
function matching(filter: Filter): number {
if (filter.skip) return -1;
return values.filter((value) => value % 3 === filter.key).length;
}
console.log(matching({ key: 1, skip: false, label: "a" }), matching({ key: 0, skip: true, label: "b" }));
function makeReaders(filter: Filter, scale: number, tag: string, enabled: boolean): (() => string)[] {
const readers: (() => string)[] = [];
if (!enabled) return readers;
for (let i = 0; i < 3; i++) {
readers.push(() => `${tag}:${filter.label}:${filter.key * scale + i}`);
}
readers.push(() => (enabled ? tag.toUpperCase() : "off"));
return readers;
}
const shared: Filter = { key: 2, skip: false, label: "first" };
const readers = makeReaders(shared, 10, "r", true);
shared.label = "renamed";
shared.key = 5;
console.log(readers.map((read) => read()).join(" "), makeReaders(shared, 1, "x", false).length);
function nested(base: number, word: string): () => () => string {
return () => {
const inner = () => `${word}${base}`;
return inner;
};
}
const outer = nested(7, "n");
console.log(outer()(), outer()(), nested(1, "m")()());
function guarded(limit: number, items: number[]): number {
if (items.length === 0) throw new Error(`empty for ${limit}`);
const picked = items.filter((item) => item < limit);
if (picked.length === 0) throw new Error(`none below ${limit}`);
return picked.reduce((sum, item) => sum + item + limit, 0);
}
for (const input of [[], [8, 9], [1, 2, 9]]) {
try {
console.log(guarded(5, input));
} catch (error) {
console.log("caught", (error as Error).message);
}
}
function throwsInside(code: string): string {
try {
[1, 2].forEach((n) => {
if (n === 2) throw new Error(`${code}-${n}`);
});
return "unreachable";
} catch (error) {
return `${(error as Error).message} ${code}`;
}
}
console.log(throwsInside("q"));
// Recursive and repeated creation keeps each call's own value.
function countdown(n: number): string[] {
if (n === 0) return [];
const here = () => `step${n}`;
return [here(), ...countdown(n - 1), here()];
}
console.log(countdown(3).join(","));
class Counter {
total = 0;
addAll(amount: number, times: number): number {
const add = () => {
this.total += amount;
};
for (let i = 0; i < times; i++) add();
return this.total;
}
}
const counter = new Counter();
console.log(counter.addAll(2, 3), counter.addAll(5, 1));
function delayed(message: string, order: string[]): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
order.push(message);
resolve();
}, 0);
});
}
const order: string[] = [];
Promise.all([delayed("later", order), delayed("last", order)]).then(() => console.log(order.join(" ")));
@@ -0,0 +1,81 @@
// Captured parameters that are rebound anywhere keep one shared binding:
// the body, nested closures, compound and destructuring assignments, and
// loop headers all update the value every closure observes.
function reassignedAfter(count: number): () => number {
const read = () => count;
count = count * 2;
return read;
}
console.log(reassignedAfter(4)());
function reassignedInside(start: number): [() => number, () => void] {
return [() => start, () => {
start += 10;
}];
}
const [get, bump] = reassignedInside(1);
bump();
bump();
console.log(get());
function deeplyReassigned(name: string): () => string {
const outer = () => {
const inner = () => {
name = name + "!";
};
inner();
return name;
};
outer();
return () => name;
}
console.log(deeplyReassigned("hey")());
function compound(total: number, flag: boolean, text: string): () => string {
const show = () => `${total}/${flag}/${text}`;
total++;
total -= 3;
flag = !flag;
text ??= "unused";
text += "+";
return show;
}
console.log(compound(10, false, "t")());
function destructured(left: number, right: number): () => string {
const show = () => `${left},${right}`;
[left, right] = [right, left];
({ value: left } = { value: left * 100 });
return show;
}
console.log(destructured(1, 2)());
function loopTarget(item: string, list: string[]): () => string {
const show = () => item;
for (item of list) {
if (item === "stop") break;
}
return show;
}
console.log(loopTarget("none", ["a", "stop", "b"])());
function conditional(level: number, raise: boolean): (() => number)[] {
const before = () => level;
if (raise) level = level + 1;
const after = () => level * 10;
return [before, after];
}
console.log(conditional(1, true).map((f) => f()), conditional(1, false).map((f) => f()));
function defaulted(size = 3, unit = "cm"): () => string {
return () => `${size}${unit}`;
}
console.log(defaulted()(), defaulted(5)(), defaulted(undefined, "mm")());
function earlyAndLate(value: number, early: boolean): () => number {
if (early) return () => value;
const read = () => value;
value = -value;
return read;
}
console.log(earlyAndLate(3, true)(), earlyAndLate(3, false)());
@@ -0,0 +1,17 @@
import { summary } from "./report.ts";
export class Inventory {
items: Map<string, number> = new Map();
add(name: string, qty: number): void {
this.items.set(name, (this.items.get(name) ?? 0) + qty);
}
count(): number {
let total = 0;
for (const qty of this.items.values()) total += qty;
return total;
}
}
export const stock = new Inventory();
stock.add("nut", 10);
console.log("inventory ready:", summary());
@@ -0,0 +1,9 @@
// A shared instance exported from one member of an import cycle and read
// through a function of the other member. inventory.ts evaluates after
// report.ts, so report's function only runs once `stock` initialized.
import { stock } from "./inventory.ts";
import { summary } from "./report.ts";
stock.add("bolt", 4);
console.log(summary());
console.log(stock.count());
@@ -0,0 +1,7 @@
import { stock } from "./inventory.ts";
export function summary(): string {
const parts: string[] = [];
for (const [name, qty] of stock.items) parts.push(`${name}=${qty}`);
return parts.join(", ") + ` (total ${stock.count()})`;
}
@@ -0,0 +1,6 @@
import { south } from "./south.ts";
export function east(n: number): string {
return n <= 0 ? "E" : "e" + south(n - 1);
}
console.log("east body", east(1));
@@ -0,0 +1,9 @@
// Side effects across a three-module cycle run in ES evaluation order:
// the depth-first walk from main reaches north → east → south, and south's
// import of north is answered from the module cache, so the bodies run
// south, east, north, then main. Each body calls into the others.
import { north, log } from "./north.ts";
log("main body");
console.log(north(3));
console.log(log("done"));
@@ -0,0 +1,12 @@
import { east } from "./east.ts";
const lines: string[] = [];
export function log(line: string): number {
lines.push(line);
console.log(`[${lines.length}] ${line}`);
return lines.length;
}
export function north(n: number): string {
return n <= 0 ? "N" : "n" + east(n - 1);
}
log("north body " + east(2));
@@ -0,0 +1,6 @@
import { north } from "./north.ts";
export function south(n: number): string {
return n <= 0 ? "S" : "s" + north(n - 1);
}
console.log("south body", south(0), typeof north);
@@ -0,0 +1,16 @@
import { probe, spend, clear } from "./probe.ts";
console.log("before:", probe());
console.log("spend before:", spend(1));
console.log("clear before:", clear());
export const limit: number = 8;
export let used = 0;
export function record(n: number): void {
used += n;
}
export function reset(): void {
used = 0;
}
console.log("after:", probe());
console.log("spend after:", spend(3));
export { probe };
+6
View File
@@ -0,0 +1,6 @@
// Reading or writing a let/const binding of an import cycle before its
// declaration ran throws ReferenceError, exactly as Node reports it; the
// same reads succeed once the declaration ran.
import { limit, used, probe } from "./limits.ts";
console.log("main", limit, used, probe());
+28
View File
@@ -0,0 +1,28 @@
import { limit, used, record, reset } from "./limits.ts";
export function probe(): string {
try {
return `${used}/${limit}`;
} catch (e) {
const err = e as Error;
return `${err.name}: ${err.message} (${e instanceof ReferenceError})`;
}
}
export function spend(n: number): string {
try {
record(n);
return "ok " + used;
} catch (e) {
return (e as Error).message;
}
}
export function clear(): string {
try {
reset();
return "cleared";
} catch (e) {
return (e as Error).name;
}
}
@@ -0,0 +1,11 @@
import { isOdd } from "./odd.ts";
export function isEven(n: number): boolean {
return n === 0 ? true : isOdd(n - 1);
}
export function parity(n: number): string {
return `${n} is ${labels[isEven(n) ? 0 : 1]}`;
}
console.log("even body", isEven(4), isOdd(4));
const labels = ["even", "odd"];
console.log("even ready", parity(2));
@@ -0,0 +1,8 @@
// Function declarations of an import cycle are initialized before any
// module body runs: each member's top level calls functions of the other
// member while the cycle is still evaluating, including mutual recursion.
import { isEven, parity } from "./even.ts";
import { isOdd } from "./odd.ts";
console.log(isEven(10), isOdd(7), isEven(3));
console.log(parity(5), parity(8));
@@ -0,0 +1,6 @@
import { isEven } from "./even.ts";
export function isOdd(n: number): boolean {
return n === 0 ? false : isEven(n - 1);
}
console.log("odd body", typeof isEven, isOdd(1));
@@ -0,0 +1,15 @@
import { Shelf } from "./shelf.ts";
export class Item {
static created = 0;
readonly name: string;
readonly shelf: Shelf | null;
constructor(name: string, shelf: Shelf | null) {
this.name = name;
this.shelf = shelf;
Item.created++;
}
placed(): string {
return this.shelf instanceof Shelf ? `${this.name}@${this.shelf.label}` : `${this.name}@none`;
}
}
@@ -0,0 +1,12 @@
// Classes of two cycle members reference each other (construction,
// instanceof, statics) from method bodies that run after both class
// declarations evaluated; a shared default instance is built at load.
import { Shelf, defaultShelf } from "./shelf.ts";
import { Item } from "./item.ts";
const shelf = new Shelf("garage");
shelf.add("rake");
shelf.add("hose");
console.log(shelf.describe());
console.log(defaultShelf.describe());
console.log(new Item("loose", null).placed(), Item.created, Shelf.count);
@@ -0,0 +1,23 @@
import { Item } from "./item.ts";
export class Shelf {
static count = 0;
items: Item[] = [];
readonly label: string;
constructor(label: string) {
this.label = label;
Shelf.count++;
}
add(name: string): Item {
const item = new Item(name, this);
this.items.push(item);
return item;
}
describe(): string {
return `${this.label}: ${this.items.map((i) => i.placed()).join(", ")}`;
}
}
export const defaultShelf = new Shelf("default");
defaultShelf.add("box");
console.log("shelf ready", Item.created);
@@ -0,0 +1,5 @@
import { validate } from "./rules.ts";
console.log("ledger body");
export const total: number = validate([3, 4, -1]);
console.log("ledger never finishes", total);
@@ -0,0 +1,7 @@
// @exit: 1
// A module body of an import cycle throws during initialization: the
// error propagates out of the whole graph, so the rest of the throwing
// body, its importers and main never run (stdout and exit code match).
import { total } from "./ledger.ts";
console.log("main never runs", total);
@@ -0,0 +1,11 @@
import { total } from "./ledger.ts";
console.log("rules body");
export function validate(entries: number[]): number {
let sum = 0;
for (const e of entries) {
if (e < 0) throw new RangeError(`negative entry ${e}`);
sum += e;
}
return sum + total;
}
@@ -0,0 +1,4 @@
import { margin } from "./margin.ts";
console.log("frame body");
export const width: number = margin * 2;
@@ -0,0 +1,7 @@
// @exit: 1
// A cycle member's top level reads a const of the module that imported
// it before that module's body ran: Node throws an uncaught
// ReferenceError there, after the earlier output.
import { width } from "./frame.ts";
console.log("main never runs", width);
@@ -0,0 +1,4 @@
import { width } from "./frame.ts";
console.log("margin body");
export const margin: number = width / 10;
@@ -2,6 +2,6 @@ import { b } from "./b.ts";
export const a: string = "A";
export function useB(): string {
return b;
export function label(): string {
return a + b;
}
@@ -1,3 +1,4 @@
import { a } from "./a.ts";
console.log("b body");
export const b: string = a + "B";
@@ -0,0 +1,14 @@
// @dynamic
// import() of a module whose cycle partner reads a const of it at top
// level: the partner evaluates first, its read throws ReferenceError, and
// the import() promise rejects with that error.
async function main(): Promise<void> {
try {
const ns = await import("./a.ts");
console.log("loaded", ns.label());
} catch (e) {
const err = e as Error;
console.log("rejected:", err.name, err.message);
}
}
main();
@@ -0,0 +1,115 @@
// Long-lived cyclic structures next to garbage of every age.
//
// Warehouses keep bins with parent back-pointers and a name index for the
// whole run. Each restock builds scratch pick lists (doubly linked, pointing
// into the bins) that live through many collector passes before they are
// dropped, and every few rounds a whole warehouse is retired after it has
// been live for a long time. Every dead structure must be reclaimed without
// touching the live ones: the sanitized lane's RC audit checks that nothing
// is left over at exit, and the checksums check that nothing live was freed.
class Bin {
label: string;
parent: Bin | null = null;
children: Bin[] = [];
stock: number;
constructor(label: string, stock: number) {
this.label = label;
this.stock = stock;
}
}
class Warehouse {
name: string;
root: Bin;
index: Map<string, Bin> = new Map();
constructor(name: string) {
this.name = name;
this.root = new Bin(`${name}/root`, 0);
this.index.set(this.root.label, this.root);
}
add(parent: Bin, label: string, stock: number): Bin {
const bin = new Bin(`${this.name}/${label}`, stock);
bin.parent = parent;
parent.children.push(bin);
this.index.set(bin.label, bin);
return bin;
}
}
class Pick {
bin: Bin;
qty: number;
prev: Pick | null = null;
next: Pick | null = null;
constructor(bin: Bin, qty: number) {
this.bin = bin;
this.qty = qty;
}
}
function stockWarehouse(name: string, bins: number): Warehouse {
const w = new Warehouse(name);
const all: Bin[] = [w.root];
for (let i = 1; i < bins; i++) all.push(w.add(all[(i - 1) >> 2], `b${i}`, (i * 7) % 13));
return w;
}
function pickList(w: Warehouse, round: number, length: number): Pick | null {
let head: Pick | null = null;
let tail: Pick | null = null;
for (let i = 0; i < length; i++) {
const bin = w.index.get(`${w.name}/b${1 + ((i * 31 + round) % (w.index.size - 1))}`);
if (bin === undefined) continue;
const p = new Pick(bin, 1 + (i % 3));
if (tail === null) head = p;
else {
tail.next = p;
p.prev = tail;
}
tail = p;
}
return head;
}
function depth(bin: Bin): number {
let d = 0;
for (let b: Bin | null = bin.parent; b !== null; b = b.parent) d++;
return d;
}
const warehouses: Warehouse[] = [];
for (let i = 0; i < 4; i++) warehouses.push(stockWarehouse(`w${i}`, 1500));
let pending: (Pick | null)[] = [];
let picked = 0;
let retired = 0;
for (let round = 0; round < 24; round++) {
const w = warehouses[round % warehouses.length];
pending.push(pickList(w, round, 400));
// Lists wait a few rounds before they are fulfilled and dropped.
if (pending.length > 3) {
for (const head of pending) {
for (let p = head; p !== null; p = p.next) picked += p.qty * (1 + depth(p.bin));
}
pending = [];
}
// Retire the oldest warehouse every eight rounds and open a fresh one.
if (round % 8 === 7) {
const gone = warehouses.shift();
if (gone !== undefined) retired += gone.index.size;
warehouses.push(stockWarehouse(`w${4 + round}`, 1500));
}
}
let bins = 0;
let stock = 0;
for (const w of warehouses) {
bins += w.index.size;
for (const bin of w.index.values()) {
stock += bin.stock;
if (bin.parent !== null && !bin.parent.children.includes(bin)) console.log("broken link", bin.label);
}
}
console.log(`warehouses ${warehouses.map((w) => w.name).join(",")}`);
console.log(`bins ${bins} stock ${stock} retired ${retired}`);
console.log(`picked ${picked} pending ${pending.length}`);
+148
View File
@@ -0,0 +1,148 @@
// Map, Set and array iterators returned through methods annotated
// IterableIterator<T> or Iterator<T>, forwarded through further methods
// and functions, and stored in variables. Iteration stays lazy and live:
// entries added while a loop runs are visited, and an exhausted iterator
// stays exhausted.
interface Slot {
n: number;
}
class Inventory {
private readonly counts = new Map<string, number>();
private readonly tags = new Set<string>();
readonly slots = new Map<string, Slot>();
readonly history: string[] = [];
add(name: string, n: number): void {
this.counts.set(name, n);
this.tags.add(name.toUpperCase());
this.history.push(name);
}
entries(): IterableIterator<[string, number]> {
return this.counts.entries();
}
names(): IterableIterator<string> {
return this.counts.keys();
}
amounts(): Iterator<number> {
return this.counts.values();
}
tagList(): IterableIterator<string> {
return this.tags.values();
}
recent(): IterableIterator<string> {
return this.history.values();
}
positions(): IterableIterator<[number, string]> {
return this.history.entries();
}
slotEntries(): IterableIterator<[string, Slot]> {
return this.slots.entries();
}
}
class Store {
readonly stock = new Inventory();
entries(): IterableIterator<[string, number]> {
return this.stock.entries();
}
}
function forward(store: Store): IterableIterator<[string, number]> {
return store.entries();
}
function* steps(): Generator<number, void, undefined> {
yield 1;
yield 2;
}
function stepsAsIterator(): IterableIterator<number> {
return steps();
}
const store = new Store();
store.stock.add("bolt", 3);
store.stock.add("nut", 5);
for (const [name, n] of forward(store)) console.log("entry", name, n);
let grew = false;
for (const name of store.stock.names()) {
console.log("name", name);
if (!grew) {
store.stock.add("washer", 9);
grew = true;
}
}
const amounts = store.stock.amounts();
let step = amounts.next();
while (!step.done) {
console.log("amount", step.value);
step = amounts.next();
}
console.log("after", amounts.next().done);
const stored: IterableIterator<[string, number]> = store.entries();
for (const [name] of stored) {
console.log("first only", name);
break;
}
for (const [name] of stored) console.log("rest", name);
console.log("spread", [...store.stock.tagList()]);
console.log("from", Array.from(store.stock.names()));
console.log("positions", [...store.stock.positions()]);
let total = 0;
for (const v of store.stock.recent()) {
total += v.length;
if (v === "bolt") store.stock.history.push("pin");
}
console.log("total", total, store.stock.history.length);
store.stock.slots.set("a", { n: 1 });
for (const [, slot] of store.stock.slotEntries()) slot.n += 10;
console.log("slot", store.stock.slots.get("a")!.n);
const counted = stepsAsIterator();
console.log("generator", counted.next().value, [...counted]);
const empty = new Inventory();
console.log("empty", [...empty.entries()], empty.amounts().next().done);
// Leaving a loop early closes a generator (its finally block runs) but
// leaves a native iterator open, so a later loop resumes where it stopped.
function* guarded(): Generator<number, void, undefined> {
try {
yield 1;
yield 2;
yield 3;
} finally {
console.log("guarded closed");
}
}
function guardedSteps(): IterableIterator<number> {
return guarded();
}
const closing = guardedSteps();
for (const n of closing) {
console.log("guarded", n);
if (n === 2) break;
}
console.log("guarded after", closing.next().done);
const resumable = store.entries();
for (const [name] of resumable) {
console.log("before throw", name);
break;
}
try {
for (const [name] of resumable) {
console.log("throwing at", name);
throw new Error("stop");
}
} catch (e) {
if (e instanceof Error) console.log("caught", e.message);
}
for (const [name] of resumable) console.log("resumed", name);
+102
View File
@@ -0,0 +1,102 @@
// A call to a function returning `never` can stand wherever a value is
// expected: it still runs (and throws), and the surrounding expression
// never needs its value. Each case runs both its normal path and its
// throwing path.
class Point {
x: number;
constructor(x: number) {
this.x = x;
}
}
function fail(message: string): never {
throw new Error(message);
}
function rangeFail(): never {
throw new RangeError("out of range");
}
function lengthOf(text: string): number {
if (text.length > 0) return text.length;
return fail("empty input");
}
function checked(text: string): number {
const n: number = text.length > 0 ? text.length : fail("no length");
return n;
}
function labelOf(value: string | undefined): string {
return value ?? fail("missing label");
}
function pointOf(value: Point | undefined): Point {
return value ?? fail("missing point");
}
const makePoint = (ok: boolean): Point => (ok ? new Point(3) : rangeFail());
const count: () => number = () => fail("no count");
function listOf(ok: boolean): string[] {
if (ok) return ["a"];
const items: string[] = fail("no list");
return items;
}
function maybe(flag: boolean): number | undefined {
if (flag) return 1;
return fail("no maybe");
}
function twice(n: number): number {
return n * 2;
}
function same<T>(value: T): T {
return value;
}
class Reader {
pos = 0;
error(message: string): never {
throw new SyntaxError(message + " at " + this.pos);
}
digit(c: string): number {
return c >= "0" && c <= "9" ? c.charCodeAt(0) - 48 : this.error("bad digit " + c);
}
}
function attempt(label: string, run: () => unknown): void {
try {
console.log(label, run());
} catch (e) {
if (e instanceof Error) console.log(label, "caught", e.name, e.message);
}
}
attempt("length", () => lengthOf("abc"));
attempt("length empty", () => lengthOf(""));
attempt("checked", () => checked("ab"));
attempt("checked empty", () => checked(""));
attempt("label", () => labelOf("tag"));
attempt("label missing", () => labelOf(undefined));
attempt("point", () => pointOf(new Point(5)).x);
attempt("point missing", () => pointOf(undefined).x);
attempt("arrow", () => makePoint(true).x);
attempt("arrow fails", () => makePoint(false).x);
attempt("count", () => count());
attempt("list", () => listOf(true).length);
attempt("list fails", () => listOf(false).length);
attempt("maybe", () => maybe(true));
attempt("maybe fails", () => maybe(false));
attempt("argument", () => twice(fail("as argument")));
let total = 0;
attempt("generic", () => same<number>(total > 0 ? total : fail("generic")));
attempt("assigned", () => {
total = fail("assigned");
return total;
});
attempt("digit", () => new Reader().digit("7"));
attempt("digit fails", () => new Reader().digit("q"));
async function load(ok: boolean): Promise<number> {
if (ok) return 7;
return fail("async failure");
}
load(true).then((v) => console.log("async", v));
load(false).catch((e) => {
if (e instanceof Error) console.log("async caught", e.message);
});
+84
View File
@@ -0,0 +1,84 @@
// Unchecked element reads are undefined past the end of an array even
// though the element type omits undefined. Slots whose types admit
// undefined receive that value as their undefined state; present
// elements keep their ordinary values.
function attempt(label: string, run: () => unknown): void {
try {
console.log(label, run());
} catch (e) {
if (e instanceof Error) console.log(label, "threw", e.name);
}
}
const noNames: string[] = [];
const names = ["ada", "bo"];
const scores = [7];
function firstName(preferred: string | undefined, list: string[]): string | undefined {
return preferred ?? list[0];
}
function thirdScore(list: number[]): number | undefined {
return list[2];
}
function describe(value: string | undefined): string {
return value === undefined ? "none" : value;
}
function pickOr(flag: boolean, list: string[]): string | undefined {
return flag ? list[0] : "fixed";
}
function eitherName(list: string[]): string | undefined {
return list[1] || list[0];
}
function stored(preferred: string | undefined, list: string[]): string | undefined {
const chosen = preferred ?? list[0];
return chosen;
}
function fromPattern(preferred: string | undefined, list: string[]): string | undefined {
const [head] = list;
return preferred ?? head;
}
function laterRead(preferred: string | undefined, list: string[]): () => string | undefined {
return () => preferred ?? list[0];
}
function orFallback<T>(value: T | undefined, fallback: T): T {
return value === undefined ? fallback : value;
}
class Shelf {
items: string[] = [];
label: string | undefined;
top(): string | undefined {
return this.label ?? this.items[this.items.length - 1];
}
}
attempt("first empty", () => firstName(undefined, noNames));
attempt("first named", () => firstName(undefined, names));
attempt("first preferred", () => firstName("cy", noNames));
attempt("third", () => thirdScore(scores));
attempt("describe", () => describe(noNames[0]));
attempt("describe present", () => describe(names[1]));
attempt("ternary", () => pickOr(true, noNames));
attempt("or", () => eitherName(noNames));
attempt("or present", () => eitherName(names));
attempt("stored", () => stored(undefined, noNames));
attempt("pattern", () => fromPattern(undefined, noNames));
attempt("pattern present", () => fromPattern(undefined, names));
attempt("capture", () => laterRead(undefined, noNames)());
attempt("generic", () => orFallback(noNames[0] ?? undefined, "fallback"));
attempt("explicit generic", () => orFallback<string | undefined>(noNames[3], "unused"));
attempt("length", () => (firstName(undefined, names) ?? "").length);
let missing: string | undefined;
attempt("concat", () => (missing ?? noNames[0]) + "!");
attempt("template", () => `${firstName(undefined, noNames)}`);
const shelf = new Shelf();
attempt("shelf empty", () => shelf.top());
shelf.items.push("box", "jar");
attempt("shelf top", () => shelf.top());
shelf.label = "label";
attempt("shelf label", () => shelf.top());
// A member read needs the present value: undefined still throws there.
attempt("member", () => (missing ?? noNames[0]).length);
@@ -0,0 +1,5 @@
import { Pet } from "./pets.ts";
export class Dog extends Pet {}
export function makeDog(name: string): Pet {
return new Dog(name);
}
@@ -0,0 +1,2 @@
import { first } from "./pets.ts";
console.log(first.speak());
@@ -0,0 +1,11 @@
import { makeDog } from "./dogs.ts";
export class Pet {
name: string;
constructor(name: string) {
this.name = name;
}
speak(): string {
return this.name;
}
}
export const first = makeDog("rex");
@@ -0,0 +1,2 @@
import { scale } from "./b.ts";
export default scale(4);
@@ -0,0 +1,7 @@
import size from "./a.ts";
export function scale(n: number): number {
return n * 2;
}
export function current(): number {
return size;
}
@@ -0,0 +1,2 @@
import size from "./a.ts";
console.log(size);
@@ -0,0 +1,3 @@
import * as helpers from "./b.ts";
export const base = 2;
export const total: number = helpers.double(base);
@@ -0,0 +1,4 @@
import * as a from "./a.ts";
export function double(n: number): number {
return n * a.base;
}
@@ -0,0 +1,2 @@
import { total } from "./a.ts";
console.log(total);
-5
View File
@@ -1,5 +0,0 @@
import { b } from "./b.ts";
export const a: string = "A";
export function useB(): string {
return b;
}
-2
View File
@@ -1,2 +0,0 @@
import { a } from "./a.ts";
export const b: string = a + "B";
-2
View File
@@ -1,2 +0,0 @@
import { useB } from "./a.ts";
console.log(useB());
+4
View File
@@ -0,0 +1,4 @@
import { read } from "./b.ts";
console.log(read());
export var level = 3;
export { read };
+4
View File
@@ -0,0 +1,4 @@
import { level } from "./a.ts";
export function read(): number {
return level;
}
@@ -0,0 +1,2 @@
import { read } from "./a.ts";
console.log(read());
-5
View File
@@ -1,5 +0,0 @@
import { bee } from "./b.ts";
export function aye(n: number): number {
return n <= 0 ? 0 : bee(n - 1) + 1;
}
console.log("a-init", aye(2));
-4
View File
@@ -1,4 +0,0 @@
import { aye } from "./a.ts";
export function bee(n: number): number {
return n <= 0 ? 0 : aye(n - 1) + 1;
}
@@ -1,2 +0,0 @@
import { aye } from "./a.ts";
console.log(aye(5));
-2
View File
@@ -1,2 +0,0 @@
import { b } from "./b.ts";
export const a: number = b + 1;
-2
View File
@@ -1,2 +0,0 @@
import { a } from "./a.ts";
export const b: number = a + 1;
-2
View File
@@ -1,2 +0,0 @@
import { a } from "./a.ts";
console.log(a);
@@ -1,12 +0,0 @@
// @dynamic
// The REFUSED shape through the --dynamic subgraph: the cycle behind this
// import() reads a cycle-crossing binding at a module's top level, which
// in Node observes the partially-initialized exporter (TDZ
// ReferenceError). The dynamic-subgraph walk fences it with the same
// narrowed SC1016 preflight's static walk mints, naming the binding and
// the offending line.
async function main(): Promise<void> {
const ns = await import("./a.ts");
console.log(ns.useB());
}
main();
+21
View File
@@ -0,0 +1,21 @@
// Iterator-typed slots hold generators and built-in collection iterators.
// A class implementing the iterator protocol stays a pointed fence there,
// and Iterable<T> annotations have no lowering yet.
class Countdown implements Iterator<number> {
n = 2;
next(): IteratorResult<number> {
return this.n > 0 ? { value: this.n--, done: false } : { value: undefined, done: true };
}
}
function firstOf(it: Iterator<number>): void {
console.log(it.next().value);
}
firstOf(new Countdown());
function total(values: Iterable<number>): number {
let sum = 0;
for (const v of values) sum += v;
return sum;
}
console.log(total([1, 2]));
+1 -1
View File
@@ -38,7 +38,7 @@ const sinhMethod = Math.sinh; // fixed-arity Math function values now lower
// (n-ary Math.min/max LOWER now — the variadic battery lives in the
// corpus; the mixed spread/positional list is the form that stays fenced.)
const clamped = Math.min(1, ...[2, 3]);
const entries = [1, 2].entries();
const entries = [1, 2].entries(); // array iterators lower now
const arrAt = [1, 2].at(0);
const norm = "abc".normalize();
const limited = "a,b,c".split(Math.random() ? "," : /,/, 2); // union separators stay fenced; limits lower
@@ -4,4 +4,4 @@ cjs-require-cycle/a.js:1:1 - error SC1016: circular imports (cjs-require-cycle/a
| ^
2 |
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window
hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge
@@ -0,0 +1,8 @@
cycle-class-early/dogs.ts:2:26 - error SC1016: circular imports (the class 'Pet' declared in cycle-class-early/pets.ts can be accessed before its declaration runs) are not supported yet
1 | import { Pet } from "./pets.ts";
2 | export class Dog extends Pet {}
| ^~~
3 | export function makeDog(name: string): Pet {
hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge
@@ -0,0 +1,7 @@
cycle-default-export/a.ts:1:1 - error SC1016: circular imports (cycle-default-export/a.ts → cycle-default-export/b.ts → cycle-default-export/a.ts; the default export expression at cycle-default-export/a.ts:2 has no initialization state inside the cycle) are not supported yet
1 | import { scale } from "./b.ts";
| ^
2 | export default scale(4);
hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge
@@ -0,0 +1,7 @@
cycle-namespace-import/a.ts:1:1 - error SC1016: circular imports (cycle-namespace-import/a.ts → cycle-namespace-import/b.ts → cycle-namespace-import/a.ts; the namespace import 'helpers' at cycle-namespace-import/a.ts:1 names a module of the cycle) are not supported yet
1 | import * as helpers from "./b.ts";
| ^
2 | export const base = 2;
hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge
@@ -1,7 +0,0 @@
cycle-tdz-read/a.ts:1:1 - error SC1016: circular imports (cycle-tdz-read/a.ts → cycle-tdz-read/b.ts → cycle-tdz-read/a.ts; the cycle-crossing binding 'a' is read at cycle-tdz-read/b.ts:2, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet
1 | import { b } from "./b.ts";
| ^
2 | export const a: string = "A";
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window
@@ -0,0 +1,8 @@
cycle-var-early/b.ts:3:10 - error SC1016: circular imports (the var binding 'level' declared in cycle-var-early/a.ts can be accessed before its declaration runs, where Node reads undefined) are not supported yet
2 | export function read(): number {
3 | return level;
| ^~~~~
4 | }
hint: ES-module cycles of let/const/function bindings compile as-is; rewrite the named construct (a named import instead of a namespace, a const instead of a default-export expression or var, a class declared before the code that can reach it) or break the named edge
@@ -1,7 +0,0 @@
cycle-window-call/a.ts:1:1 - error SC1016: circular imports (cycle-window-call/a.ts → cycle-window-call/b.ts → cycle-window-call/a.ts; top-level code at cycle-window-call/a.ts:5 can run user code during the cycle's init window — only declaration-only module bodies are admitted) are not supported yet
1 | import { bee } from "./b.ts";
| ^
2 | export function aye(n: number): number {
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window

Some files were not shown because too many files have changed in this diff Show More