diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index ee409a790..92ca91809 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -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"; diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts index 780a25932..d20f6763b 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts @@ -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; diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index 3b752a791..e5553171c 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -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, }, ], diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 44f63fe1d..cbc3da015 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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(); + private readonly lazyStackBoxes = new Map(); private borrowedParameters = new Set(); /** Parameters proven projection-only: callers may pass stack boxes. */ private projectedParameters = new Set(); @@ -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 { diff --git a/packages/compiler/src/backend/llvm/expr-async.ts b/packages/compiler/src/backend/llvm/expr-async.ts index d962ab007..6ece26f22 100644 --- a/packages/compiler/src/backend/llvm/expr-async.ts +++ b/packages/compiler/src/backend/llvm/expr-async.ts @@ -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. diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index b1591749d..860f2af7c 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -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(); diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 4cb88cb51..88b437393 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -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 }); +} diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 70e01cbd4..cef201286 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -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(); diff --git a/packages/compiler/src/backend/llvm/lazy-captures.test.ts b/packages/compiler/src/backend/llvm/lazy-captures.test.ts new file mode 100644 index 000000000..a39104dfb --- /dev/null +++ b/packages/compiler/src/backend/llvm/lazy-captures.test.ts @@ -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 { + 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 { + 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); +}); diff --git a/packages/compiler/src/backend/llvm/lazy-captures.ts b/packages/compiler/src/backend/llvm/lazy-captures.ts new file mode 100644 index 000000000..eb384e72d --- /dev/null +++ b/packages/compiler/src/backend/llvm/lazy-captures.ts @@ -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(); + + constructor(private readonly functions: ReadonlyMap) {} + + parameters(fn: IrFunction): Set { + const result = new Set(); + 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): Set { + const changed = new Set(); + 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; + } +} diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 77dc8271e..08cb21258 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -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. */ diff --git a/packages/compiler/src/backend/llvm/stack-callbacks.ts b/packages/compiler/src/backend/llvm/stack-callbacks.ts index 221e06276..5c404896a 100644 --- a/packages/compiler/src/backend/llvm/stack-callbacks.ts +++ b/packages/compiler/src/backend/llvm/stack-callbacks.ts @@ -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`); diff --git a/packages/compiler/src/backend/llvm/stack-captures.ts b/packages/compiler/src/backend/llvm/stack-captures.ts index dcf9f4f61..f0812b7b0 100644 --- a/packages/compiler/src/backend/llvm/stack-captures.ts +++ b/packages/compiler/src/backend/llvm/stack-captures.ts @@ -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 = 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); } } diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 6a419b78b..317fe05f0 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -142,6 +142,8 @@ export function computeMayThrow(mod: IrModule): { fns: Set; 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 diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 6597ae471..13b38f954 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -197,17 +197,19 @@ export const UNSUPPORTED: Record = { 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" }, diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 833c68acf..921e05fe1 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -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, diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 94c83e4e1..d8716e1db 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -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 diff --git a/packages/compiler/src/frontend/lowering/cycle-initialization.ts b/packages/compiler/src/frontend/lowering/cycle-initialization.ts new file mode 100644 index 000000000..834ee50d2 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/cycle-initialization.ts @@ -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; +} + +/** 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(); + 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(); + 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(); + 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)`, + ), + ); + } + } +} diff --git a/packages/compiler/src/frontend/lowering/iterator-adapters.ts b/packages/compiler/src/frontend/lowering/iterator-adapters.ts new file mode 100644 index 000000000..50272efd0 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/iterator-adapters.ts @@ -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`, `Iterator`, `Generator`). 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` returned through an + * `IterableIterator` 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 }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 6044a55bf..e79133d40 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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. diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 25ab5aaa1..946cb00b0 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -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. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 829163b07..dba8ccdd6 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index eb29e8771..455da4a90 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -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(); + /** 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(); readonly chainNarrowedType = new Map(); readonly chainHandled = new Set(); /** 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(); + /** Import-cycle bindings with an initialization flag, by global id, and + * the top-level statements declaring them (cycle-initialization.ts). */ + readonly cycleInitFlags = new Map(); + readonly cycleInitDeclarations = new Map(); 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(); const optionalReturns = new Set(); const arithmeticReturns = new Map(); @@ -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). */ diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-provenance.ts b/packages/compiler/src/frontend/lowering/runtime-optional-provenance.ts new file mode 100644 index 000000000..bfac8ce0d --- /dev/null +++ b/packages/compiler/src/frontend/lowering/runtime-optional-provenance.ts @@ -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(); + + 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}\``; +} diff --git a/packages/compiler/src/frontend/program.test.ts b/packages/compiler/src/frontend/program.test.ts index 09eb245da..5d689ab3a 100644 --- a/packages/compiler/src/frontend/program.test.ts +++ b/packages/compiler/src/frontend/program.test.ts @@ -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 }); + } +}); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index f2ce119a9..fe32d67a6 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -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(); + // 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(); + // The exact-initialization verdict, same keying (exactCycleRefusal7). + const exactVerdict = new Map(); // 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>(); + +/** 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 { + 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; +} + +/** 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, +): 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(); + const low = new Map(); + const onStack = new Set(); + 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(); + if ( + singleRoot && + !order.some((sf) => isCjsJsFile7(sf, program) || /\.c[jt]s$/.test(sf.fileName)) + ) { + const seen = new Set(); + 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(); + const names = new Set(); + 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(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(); // 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(); @@ -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); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index aef7f2b5d..028f0f2be 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -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, AsyncGenerator (and // the lib's IterableIterator, 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 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); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 991dbd64e..d4c05f6d6 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -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; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 35f0c2c4e..cb876db45 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -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); diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 75645d769..4b1bfe59f 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -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", diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index 782737468..f2e193ced 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -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"); +}); diff --git a/packages/compiler/test/string-and-array-lifetimes.test.ts b/packages/compiler/test/string-and-array-lifetimes.test.ts index de5a85d13..92902341f 100644 --- a/packages/compiler/test/string-and-array-lifetimes.test.ts +++ b/packages/compiler/test/string-and-array-lifetimes.test.ts @@ -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/); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index f42e5d65f..cd5b15ebc 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -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": [] }, - "/tests/diagnostics/cycle-tdz-read/main.ts": { - "order": [ - "/tests/diagnostics/cycle-tdz-read/b.ts", - "/tests/diagnostics/cycle-tdz-read/a.ts", - "/tests/diagnostics/cycle-tdz-read/main.ts" - ], - "diags": [ - { - "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-tdz-read/a.ts → /tests/diagnostics/cycle-tdz-read/b.ts → /tests/diagnostics/cycle-tdz-read/a.ts; the cycle-crossing binding 'a' is read at /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": "/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" - } - ] - }, - "/tests/diagnostics/cycle-window-call/main.ts": { - "order": [ - "/tests/diagnostics/cycle-window-call/b.ts", - "/tests/diagnostics/cycle-window-call/a.ts", - "/tests/diagnostics/cycle-window-call/main.ts" - ], - "diags": [ - { - "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle-window-call/a.ts → /tests/diagnostics/cycle-window-call/b.ts → /tests/diagnostics/cycle-window-call/a.ts; top-level code at /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": "/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" - } - ] - }, - "/tests/diagnostics/cycle/main.ts": { - "order": [ - "/tests/diagnostics/cycle/b.ts", - "/tests/diagnostics/cycle/a.ts", - "/tests/diagnostics/cycle/main.ts" - ], - "diags": [ - { - "code": "SC1016", - "message": "circular imports (/tests/diagnostics/cycle/a.ts → /tests/diagnostics/cycle/b.ts → /tests/diagnostics/cycle/a.ts; the cycle-crossing binding 'a' is read at /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": "/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" - } - ] - }, "/tests/diagnostics/dataview.ts": { "order": [ "/tests/diagnostics/dataview.ts" @@ -11900,12 +11840,6 @@ ], "diags": [] }, - "/tests/diagnostics/dyn-import-cycle-tdz/main.ts": { - "order": [ - "/tests/diagnostics/dyn-import-cycle-tdz/main.ts" - ], - "diags": [] - }, "/tests/diagnostics/dynamic-import-static.ts": { "order": [ "/tests/diagnostics/dynamic-import-static.ts" @@ -14825,6 +14759,173 @@ ], "diags": [] }, + "/tests/corpus/iterator-typed-returns.ts": { + "order": [ + "/tests/corpus/iterator-typed-returns.ts" + ], + "diags": [] + }, + "/tests/corpus/never-returning-calls.ts": { + "order": [ + "/tests/corpus/never-returning-calls.ts" + ], + "diags": [] + }, + "/tests/corpus/optional-element-defaults.ts": { + "order": [ + "/tests/corpus/optional-element-defaults.ts" + ], + "diags": [] + }, + "/tests/diagnostics/iterator-slot-fences.ts": { + "order": [ + "/tests/diagnostics/iterator-slot-fences.ts" + ], + "diags": [] + }, + "/tests/corpus/4060-cycle-shared-instance/main.ts": { + "order": [ + "/tests/corpus/4060-cycle-shared-instance/report.ts", + "/tests/corpus/4060-cycle-shared-instance/inventory.ts", + "/tests/corpus/4060-cycle-shared-instance/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4061-cycle-evaluation-order/main.ts": { + "order": [ + "/tests/corpus/4061-cycle-evaluation-order/south.ts", + "/tests/corpus/4061-cycle-evaluation-order/east.ts", + "/tests/corpus/4061-cycle-evaluation-order/north.ts", + "/tests/corpus/4061-cycle-evaluation-order/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4062-cycle-tdz-read/main.ts": { + "order": [ + "/tests/corpus/4062-cycle-tdz-read/probe.ts", + "/tests/corpus/4062-cycle-tdz-read/limits.ts", + "/tests/corpus/4062-cycle-tdz-read/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4063-cycle-hoisted-calls/main.ts": { + "order": [ + "/tests/corpus/4063-cycle-hoisted-calls/odd.ts", + "/tests/corpus/4063-cycle-hoisted-calls/even.ts", + "/tests/corpus/4063-cycle-hoisted-calls/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4064-cycle-classes-after-init/main.ts": { + "order": [ + "/tests/corpus/4064-cycle-classes-after-init/item.ts", + "/tests/corpus/4064-cycle-classes-after-init/shelf.ts", + "/tests/corpus/4064-cycle-classes-after-init/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4065-cycle-init-throws/main.ts": { + "order": [ + "/tests/corpus/4065-cycle-init-throws/rules.ts", + "/tests/corpus/4065-cycle-init-throws/ledger.ts", + "/tests/corpus/4065-cycle-init-throws/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4066-cycle-top-level-tdz/main.ts": { + "order": [ + "/tests/corpus/4066-cycle-top-level-tdz/margin.ts", + "/tests/corpus/4066-cycle-top-level-tdz/frame.ts", + "/tests/corpus/4066-cycle-top-level-tdz/main.ts" + ], + "diags": [] + }, + "/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts": { + "order": [ + "/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts" + ], + "diags": [] + }, + "/tests/diagnostics/cycle-class-early/main.ts": { + "order": [ + "/tests/diagnostics/cycle-class-early/dogs.ts", + "/tests/diagnostics/cycle-class-early/pets.ts", + "/tests/diagnostics/cycle-class-early/main.ts" + ], + "diags": [] + }, + "/tests/diagnostics/cycle-default-export/main.ts": { + "order": [ + "/tests/diagnostics/cycle-default-export/b.ts", + "/tests/diagnostics/cycle-default-export/a.ts", + "/tests/diagnostics/cycle-default-export/main.ts" + ], + "diags": [ + { + "code": "SC1016", + "message": "circular imports (/tests/diagnostics/cycle-default-export/a.ts → /tests/diagnostics/cycle-default-export/b.ts → /tests/diagnostics/cycle-default-export/a.ts; the default export expression at /tests/diagnostics/cycle-default-export/a.ts:2 has no initialization state inside the cycle) are not supported yet", + "loc": { + "file": "/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" + } + ] + }, + "/tests/diagnostics/cycle-namespace-import/main.ts": { + "order": [ + "/tests/diagnostics/cycle-namespace-import/b.ts", + "/tests/diagnostics/cycle-namespace-import/a.ts", + "/tests/diagnostics/cycle-namespace-import/main.ts" + ], + "diags": [ + { + "code": "SC1016", + "message": "circular imports (/tests/diagnostics/cycle-namespace-import/a.ts → /tests/diagnostics/cycle-namespace-import/b.ts → /tests/diagnostics/cycle-namespace-import/a.ts; the namespace import 'helpers' at /tests/diagnostics/cycle-namespace-import/a.ts:1 names a module of the cycle) are not supported yet", + "loc": { + "file": "/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" + } + ] + }, + "/tests/diagnostics/cycle-var-early/main.ts": { + "order": [ + "/tests/diagnostics/cycle-var-early/b.ts", + "/tests/diagnostics/cycle-var-early/a.ts", + "/tests/diagnostics/cycle-var-early/main.ts" + ], + "diags": [] + }, + "/tests/corpus/cycle-generations-long-lived.ts": { + "order": [ + "/tests/corpus/cycle-generations-long-lived.ts" + ], + "diags": [] + }, + "/tests/corpus/4045-optional-element-projections.ts": { + "order": [ + "/tests/corpus/4045-optional-element-projections.ts" + ], + "diags": [] + }, + "/tests/corpus/4046-captured-parameter-values.ts": { + "order": [ + "/tests/corpus/4046-captured-parameter-values.ts" + ], + "diags": [] + }, + "/tests/corpus/4047-captured-parameter-rebinding.ts": { + "order": [ + "/tests/corpus/4047-captured-parameter-rebinding.ts" + ], + "diags": [] + }, "/tests/corpus/3138-array-large-storage.ts": { "order": [ "/tests/corpus/3138-array-large-storage.ts" diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 1618d7c00..f524a8b7a 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -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}; diff --git a/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index 4ac36de62..4887489f3 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -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); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 789c507be..aa879c10b 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -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 diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index aaa116924..190d79e11 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -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); diff --git a/tests/corpus/4045-optional-element-projections.ts b/tests/corpus/4045-optional-element-projections.ts new file mode 100644 index 000000000..22740d755 --- /dev/null +++ b/tests/corpus/4045-optional-element-projections.ts @@ -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)); diff --git a/tests/corpus/4046-captured-parameter-values.ts b/tests/corpus/4046-captured-parameter-values.ts new file mode 100644 index 000000000..6d6afe997 --- /dev/null +++ b/tests/corpus/4046-captured-parameter-values.ts @@ -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 { + 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(" "))); diff --git a/tests/corpus/4047-captured-parameter-rebinding.ts b/tests/corpus/4047-captured-parameter-rebinding.ts new file mode 100644 index 000000000..529381dc2 --- /dev/null +++ b/tests/corpus/4047-captured-parameter-rebinding.ts @@ -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)()); diff --git a/tests/corpus/4060-cycle-shared-instance/inventory.ts b/tests/corpus/4060-cycle-shared-instance/inventory.ts new file mode 100644 index 000000000..e7f881293 --- /dev/null +++ b/tests/corpus/4060-cycle-shared-instance/inventory.ts @@ -0,0 +1,17 @@ +import { summary } from "./report.ts"; + +export class Inventory { + items: Map = 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()); diff --git a/tests/corpus/4060-cycle-shared-instance/main.ts b/tests/corpus/4060-cycle-shared-instance/main.ts new file mode 100644 index 000000000..6bfcbde7a --- /dev/null +++ b/tests/corpus/4060-cycle-shared-instance/main.ts @@ -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()); diff --git a/tests/corpus/4060-cycle-shared-instance/report.ts b/tests/corpus/4060-cycle-shared-instance/report.ts new file mode 100644 index 000000000..2645ddaaa --- /dev/null +++ b/tests/corpus/4060-cycle-shared-instance/report.ts @@ -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()})`; +} diff --git a/tests/corpus/4061-cycle-evaluation-order/east.ts b/tests/corpus/4061-cycle-evaluation-order/east.ts new file mode 100644 index 000000000..6dcb43b5c --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/east.ts @@ -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)); diff --git a/tests/corpus/4061-cycle-evaluation-order/main.ts b/tests/corpus/4061-cycle-evaluation-order/main.ts new file mode 100644 index 000000000..f23a87135 --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/main.ts @@ -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")); diff --git a/tests/corpus/4061-cycle-evaluation-order/north.ts b/tests/corpus/4061-cycle-evaluation-order/north.ts new file mode 100644 index 000000000..70d5e64e7 --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/north.ts @@ -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)); diff --git a/tests/corpus/4061-cycle-evaluation-order/south.ts b/tests/corpus/4061-cycle-evaluation-order/south.ts new file mode 100644 index 000000000..3cc4bed0b --- /dev/null +++ b/tests/corpus/4061-cycle-evaluation-order/south.ts @@ -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); diff --git a/tests/corpus/4062-cycle-tdz-read/limits.ts b/tests/corpus/4062-cycle-tdz-read/limits.ts new file mode 100644 index 000000000..058006e64 --- /dev/null +++ b/tests/corpus/4062-cycle-tdz-read/limits.ts @@ -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 }; diff --git a/tests/corpus/4062-cycle-tdz-read/main.ts b/tests/corpus/4062-cycle-tdz-read/main.ts new file mode 100644 index 000000000..d41057700 --- /dev/null +++ b/tests/corpus/4062-cycle-tdz-read/main.ts @@ -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()); diff --git a/tests/corpus/4062-cycle-tdz-read/probe.ts b/tests/corpus/4062-cycle-tdz-read/probe.ts new file mode 100644 index 000000000..58bf50b13 --- /dev/null +++ b/tests/corpus/4062-cycle-tdz-read/probe.ts @@ -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; + } +} diff --git a/tests/corpus/4063-cycle-hoisted-calls/even.ts b/tests/corpus/4063-cycle-hoisted-calls/even.ts new file mode 100644 index 000000000..2c360f631 --- /dev/null +++ b/tests/corpus/4063-cycle-hoisted-calls/even.ts @@ -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)); diff --git a/tests/corpus/4063-cycle-hoisted-calls/main.ts b/tests/corpus/4063-cycle-hoisted-calls/main.ts new file mode 100644 index 000000000..0665d9ad6 --- /dev/null +++ b/tests/corpus/4063-cycle-hoisted-calls/main.ts @@ -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)); diff --git a/tests/corpus/4063-cycle-hoisted-calls/odd.ts b/tests/corpus/4063-cycle-hoisted-calls/odd.ts new file mode 100644 index 000000000..cc6b04f09 --- /dev/null +++ b/tests/corpus/4063-cycle-hoisted-calls/odd.ts @@ -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)); diff --git a/tests/corpus/4064-cycle-classes-after-init/item.ts b/tests/corpus/4064-cycle-classes-after-init/item.ts new file mode 100644 index 000000000..85d513195 --- /dev/null +++ b/tests/corpus/4064-cycle-classes-after-init/item.ts @@ -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`; + } +} diff --git a/tests/corpus/4064-cycle-classes-after-init/main.ts b/tests/corpus/4064-cycle-classes-after-init/main.ts new file mode 100644 index 000000000..53ea37c77 --- /dev/null +++ b/tests/corpus/4064-cycle-classes-after-init/main.ts @@ -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); diff --git a/tests/corpus/4064-cycle-classes-after-init/shelf.ts b/tests/corpus/4064-cycle-classes-after-init/shelf.ts new file mode 100644 index 000000000..9dceeefad --- /dev/null +++ b/tests/corpus/4064-cycle-classes-after-init/shelf.ts @@ -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); diff --git a/tests/corpus/4065-cycle-init-throws/ledger.ts b/tests/corpus/4065-cycle-init-throws/ledger.ts new file mode 100644 index 000000000..353991f16 --- /dev/null +++ b/tests/corpus/4065-cycle-init-throws/ledger.ts @@ -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); diff --git a/tests/corpus/4065-cycle-init-throws/main.ts b/tests/corpus/4065-cycle-init-throws/main.ts new file mode 100644 index 000000000..8a018d78d --- /dev/null +++ b/tests/corpus/4065-cycle-init-throws/main.ts @@ -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); diff --git a/tests/corpus/4065-cycle-init-throws/rules.ts b/tests/corpus/4065-cycle-init-throws/rules.ts new file mode 100644 index 000000000..35dc3ffdc --- /dev/null +++ b/tests/corpus/4065-cycle-init-throws/rules.ts @@ -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; +} diff --git a/tests/corpus/4066-cycle-top-level-tdz/frame.ts b/tests/corpus/4066-cycle-top-level-tdz/frame.ts new file mode 100644 index 000000000..9851dc49d --- /dev/null +++ b/tests/corpus/4066-cycle-top-level-tdz/frame.ts @@ -0,0 +1,4 @@ +import { margin } from "./margin.ts"; + +console.log("frame body"); +export const width: number = margin * 2; diff --git a/tests/corpus/4066-cycle-top-level-tdz/main.ts b/tests/corpus/4066-cycle-top-level-tdz/main.ts new file mode 100644 index 000000000..10524674c --- /dev/null +++ b/tests/corpus/4066-cycle-top-level-tdz/main.ts @@ -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); diff --git a/tests/corpus/4066-cycle-top-level-tdz/margin.ts b/tests/corpus/4066-cycle-top-level-tdz/margin.ts new file mode 100644 index 000000000..284302865 --- /dev/null +++ b/tests/corpus/4066-cycle-top-level-tdz/margin.ts @@ -0,0 +1,4 @@ +import { width } from "./frame.ts"; + +console.log("margin body"); +export const margin: number = width / 10; diff --git a/tests/diagnostics/dyn-import-cycle-tdz/a.ts b/tests/corpus/4067-dynamic-import-cycle-tdz/a.ts similarity index 55% rename from tests/diagnostics/dyn-import-cycle-tdz/a.ts rename to tests/corpus/4067-dynamic-import-cycle-tdz/a.ts index 93bc98ee1..6e666f7bb 100644 --- a/tests/diagnostics/dyn-import-cycle-tdz/a.ts +++ b/tests/corpus/4067-dynamic-import-cycle-tdz/a.ts @@ -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; } diff --git a/tests/diagnostics/dyn-import-cycle-tdz/b.ts b/tests/corpus/4067-dynamic-import-cycle-tdz/b.ts similarity index 73% rename from tests/diagnostics/dyn-import-cycle-tdz/b.ts rename to tests/corpus/4067-dynamic-import-cycle-tdz/b.ts index 0aac23620..55ff41170 100644 --- a/tests/diagnostics/dyn-import-cycle-tdz/b.ts +++ b/tests/corpus/4067-dynamic-import-cycle-tdz/b.ts @@ -1,3 +1,4 @@ import { a } from "./a.ts"; +console.log("b body"); export const b: string = a + "B"; diff --git a/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts b/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts new file mode 100644 index 000000000..9ba01e112 --- /dev/null +++ b/tests/corpus/4067-dynamic-import-cycle-tdz/main.ts @@ -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 { + 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(); diff --git a/tests/corpus/cycle-generations-long-lived.ts b/tests/corpus/cycle-generations-long-lived.ts new file mode 100644 index 000000000..08afe7af7 --- /dev/null +++ b/tests/corpus/cycle-generations-long-lived.ts @@ -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 = 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}`); diff --git a/tests/corpus/iterator-typed-returns.ts b/tests/corpus/iterator-typed-returns.ts new file mode 100644 index 000000000..8ccdd85e5 --- /dev/null +++ b/tests/corpus/iterator-typed-returns.ts @@ -0,0 +1,148 @@ +// Map, Set and array iterators returned through methods annotated +// IterableIterator or Iterator, 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(); + private readonly tags = new Set(); + readonly slots = new Map(); + 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 { + return this.counts.keys(); + } + amounts(): Iterator { + return this.counts.values(); + } + tagList(): IterableIterator { + return this.tags.values(); + } + recent(): IterableIterator { + 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 { + yield 1; + yield 2; +} +function stepsAsIterator(): IterableIterator { + 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 { + try { + yield 1; + yield 2; + yield 3; + } finally { + console.log("guarded closed"); + } +} +function guardedSteps(): IterableIterator { + 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); diff --git a/tests/corpus/never-returning-calls.ts b/tests/corpus/never-returning-calls.ts new file mode 100644 index 000000000..2bf90cf92 --- /dev/null +++ b/tests/corpus/never-returning-calls.ts @@ -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(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(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 { + 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); +}); diff --git a/tests/corpus/optional-element-defaults.ts b/tests/corpus/optional-element-defaults.ts new file mode 100644 index 000000000..1b7b45c39 --- /dev/null +++ b/tests/corpus/optional-element-defaults.ts @@ -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(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(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); diff --git a/tests/diagnostics/cycle-class-early/dogs.ts b/tests/diagnostics/cycle-class-early/dogs.ts new file mode 100644 index 000000000..7ca9bf785 --- /dev/null +++ b/tests/diagnostics/cycle-class-early/dogs.ts @@ -0,0 +1,5 @@ +import { Pet } from "./pets.ts"; +export class Dog extends Pet {} +export function makeDog(name: string): Pet { + return new Dog(name); +} diff --git a/tests/diagnostics/cycle-class-early/main.ts b/tests/diagnostics/cycle-class-early/main.ts new file mode 100644 index 000000000..29ba03389 --- /dev/null +++ b/tests/diagnostics/cycle-class-early/main.ts @@ -0,0 +1,2 @@ +import { first } from "./pets.ts"; +console.log(first.speak()); diff --git a/tests/diagnostics/cycle-class-early/pets.ts b/tests/diagnostics/cycle-class-early/pets.ts new file mode 100644 index 000000000..61402d620 --- /dev/null +++ b/tests/diagnostics/cycle-class-early/pets.ts @@ -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"); diff --git a/tests/diagnostics/cycle-default-export/a.ts b/tests/diagnostics/cycle-default-export/a.ts new file mode 100644 index 000000000..58036e73f --- /dev/null +++ b/tests/diagnostics/cycle-default-export/a.ts @@ -0,0 +1,2 @@ +import { scale } from "./b.ts"; +export default scale(4); diff --git a/tests/diagnostics/cycle-default-export/b.ts b/tests/diagnostics/cycle-default-export/b.ts new file mode 100644 index 000000000..e0f1fcd5a --- /dev/null +++ b/tests/diagnostics/cycle-default-export/b.ts @@ -0,0 +1,7 @@ +import size from "./a.ts"; +export function scale(n: number): number { + return n * 2; +} +export function current(): number { + return size; +} diff --git a/tests/diagnostics/cycle-default-export/main.ts b/tests/diagnostics/cycle-default-export/main.ts new file mode 100644 index 000000000..c8e0426b3 --- /dev/null +++ b/tests/diagnostics/cycle-default-export/main.ts @@ -0,0 +1,2 @@ +import size from "./a.ts"; +console.log(size); diff --git a/tests/diagnostics/cycle-namespace-import/a.ts b/tests/diagnostics/cycle-namespace-import/a.ts new file mode 100644 index 000000000..0009745ed --- /dev/null +++ b/tests/diagnostics/cycle-namespace-import/a.ts @@ -0,0 +1,3 @@ +import * as helpers from "./b.ts"; +export const base = 2; +export const total: number = helpers.double(base); diff --git a/tests/diagnostics/cycle-namespace-import/b.ts b/tests/diagnostics/cycle-namespace-import/b.ts new file mode 100644 index 000000000..a96d085cc --- /dev/null +++ b/tests/diagnostics/cycle-namespace-import/b.ts @@ -0,0 +1,4 @@ +import * as a from "./a.ts"; +export function double(n: number): number { + return n * a.base; +} diff --git a/tests/diagnostics/cycle-namespace-import/main.ts b/tests/diagnostics/cycle-namespace-import/main.ts new file mode 100644 index 000000000..351e3cd26 --- /dev/null +++ b/tests/diagnostics/cycle-namespace-import/main.ts @@ -0,0 +1,2 @@ +import { total } from "./a.ts"; +console.log(total); diff --git a/tests/diagnostics/cycle-tdz-read/a.ts b/tests/diagnostics/cycle-tdz-read/a.ts deleted file mode 100644 index b4a07b847..000000000 --- a/tests/diagnostics/cycle-tdz-read/a.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { b } from "./b.ts"; -export const a: string = "A"; -export function useB(): string { - return b; -} diff --git a/tests/diagnostics/cycle-tdz-read/b.ts b/tests/diagnostics/cycle-tdz-read/b.ts deleted file mode 100644 index 38431a367..000000000 --- a/tests/diagnostics/cycle-tdz-read/b.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a.ts"; -export const b: string = a + "B"; diff --git a/tests/diagnostics/cycle-tdz-read/main.ts b/tests/diagnostics/cycle-tdz-read/main.ts deleted file mode 100644 index 3411f476a..000000000 --- a/tests/diagnostics/cycle-tdz-read/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { useB } from "./a.ts"; -console.log(useB()); diff --git a/tests/diagnostics/cycle-var-early/a.ts b/tests/diagnostics/cycle-var-early/a.ts new file mode 100644 index 000000000..b58f743fd --- /dev/null +++ b/tests/diagnostics/cycle-var-early/a.ts @@ -0,0 +1,4 @@ +import { read } from "./b.ts"; +console.log(read()); +export var level = 3; +export { read }; diff --git a/tests/diagnostics/cycle-var-early/b.ts b/tests/diagnostics/cycle-var-early/b.ts new file mode 100644 index 000000000..cbce31a91 --- /dev/null +++ b/tests/diagnostics/cycle-var-early/b.ts @@ -0,0 +1,4 @@ +import { level } from "./a.ts"; +export function read(): number { + return level; +} diff --git a/tests/diagnostics/cycle-var-early/main.ts b/tests/diagnostics/cycle-var-early/main.ts new file mode 100644 index 000000000..da02ae7b6 --- /dev/null +++ b/tests/diagnostics/cycle-var-early/main.ts @@ -0,0 +1,2 @@ +import { read } from "./a.ts"; +console.log(read()); diff --git a/tests/diagnostics/cycle-window-call/a.ts b/tests/diagnostics/cycle-window-call/a.ts deleted file mode 100644 index 65ceac143..000000000 --- a/tests/diagnostics/cycle-window-call/a.ts +++ /dev/null @@ -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)); diff --git a/tests/diagnostics/cycle-window-call/b.ts b/tests/diagnostics/cycle-window-call/b.ts deleted file mode 100644 index ba263cf43..000000000 --- a/tests/diagnostics/cycle-window-call/b.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { aye } from "./a.ts"; -export function bee(n: number): number { - return n <= 0 ? 0 : aye(n - 1) + 1; -} diff --git a/tests/diagnostics/cycle-window-call/main.ts b/tests/diagnostics/cycle-window-call/main.ts deleted file mode 100644 index d88f6d195..000000000 --- a/tests/diagnostics/cycle-window-call/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { aye } from "./a.ts"; -console.log(aye(5)); diff --git a/tests/diagnostics/cycle/a.ts b/tests/diagnostics/cycle/a.ts deleted file mode 100644 index 2464c3596..000000000 --- a/tests/diagnostics/cycle/a.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { b } from "./b.ts"; -export const a: number = b + 1; diff --git a/tests/diagnostics/cycle/b.ts b/tests/diagnostics/cycle/b.ts deleted file mode 100644 index 29732b2f2..000000000 --- a/tests/diagnostics/cycle/b.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a.ts"; -export const b: number = a + 1; diff --git a/tests/diagnostics/cycle/main.ts b/tests/diagnostics/cycle/main.ts deleted file mode 100644 index 631eeb063..000000000 --- a/tests/diagnostics/cycle/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { a } from "./a.ts"; -console.log(a); diff --git a/tests/diagnostics/dyn-import-cycle-tdz/main.ts b/tests/diagnostics/dyn-import-cycle-tdz/main.ts deleted file mode 100644 index 0d6f2d33e..000000000 --- a/tests/diagnostics/dyn-import-cycle-tdz/main.ts +++ /dev/null @@ -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 { - const ns = await import("./a.ts"); - console.log(ns.useB()); -} -main(); diff --git a/tests/diagnostics/iterator-slot-fences.ts b/tests/diagnostics/iterator-slot-fences.ts new file mode 100644 index 000000000..759929615 --- /dev/null +++ b/tests/diagnostics/iterator-slot-fences.ts @@ -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 annotations have no lowering yet. + +class Countdown implements Iterator { + n = 2; + next(): IteratorResult { + return this.n > 0 ? { value: this.n--, done: false } : { value: undefined, done: true }; + } +} +function firstOf(it: Iterator): void { + console.log(it.next().value); +} +firstOf(new Countdown()); + +function total(values: Iterable): number { + let sum = 0; + for (const v of values) sum += v; + return sum; +} +console.log(total([1, 2])); diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index 7293b777e..213a2388d 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -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 diff --git a/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt b/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt index d987d603c..ad55be524 100644 --- a/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt +++ b/tests/harness/__snapshots__/cjs-require-cycle/main.js.txt @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-class-early/main.ts.txt b/tests/harness/__snapshots__/cycle-class-early/main.ts.txt new file mode 100644 index 000000000..3b6b20927 --- /dev/null +++ b/tests/harness/__snapshots__/cycle-class-early/main.ts.txt @@ -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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-default-export/main.ts.txt b/tests/harness/__snapshots__/cycle-default-export/main.ts.txt new file mode 100644 index 000000000..6cf9d4baf --- /dev/null +++ b/tests/harness/__snapshots__/cycle-default-export/main.ts.txt @@ -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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-namespace-import/main.ts.txt b/tests/harness/__snapshots__/cycle-namespace-import/main.ts.txt new file mode 100644 index 000000000..9f81cddf0 --- /dev/null +++ b/tests/harness/__snapshots__/cycle-namespace-import/main.ts.txt @@ -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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-tdz-read/main.ts.txt b/tests/harness/__snapshots__/cycle-tdz-read/main.ts.txt deleted file mode 100644 index eef3a0584..000000000 --- a/tests/harness/__snapshots__/cycle-tdz-read/main.ts.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-var-early/main.ts.txt b/tests/harness/__snapshots__/cycle-var-early/main.ts.txt new file mode 100644 index 000000000..a683dc59f --- /dev/null +++ b/tests/harness/__snapshots__/cycle-var-early/main.ts.txt @@ -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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle-window-call/main.ts.txt b/tests/harness/__snapshots__/cycle-window-call/main.ts.txt deleted file mode 100644 index 1e3ab980b..000000000 --- a/tests/harness/__snapshots__/cycle-window-call/main.ts.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/cycle/main.ts.txt b/tests/harness/__snapshots__/cycle/main.ts.txt deleted file mode 100644 index 5917a95e3..000000000 --- a/tests/harness/__snapshots__/cycle/main.ts.txt +++ /dev/null @@ -1,7 +0,0 @@ -cycle/a.ts:1:1 - error SC1016: circular imports (cycle/a.ts → cycle/b.ts → cycle/a.ts; the cycle-crossing binding 'a' is read at 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 - - 1 | import { b } from "./b.ts"; - | ^ - 2 | export const a: number = b + 1; - - 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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/dyn-import-cycle-tdz/main.ts.txt b/tests/harness/__snapshots__/dyn-import-cycle-tdz/main.ts.txt deleted file mode 100644 index cac6621a5..000000000 --- a/tests/harness/__snapshots__/dyn-import-cycle-tdz/main.ts.txt +++ /dev/null @@ -1,7 +0,0 @@ -dyn-import-cycle-tdz/main.ts:1:1 - error SC1016: circular imports (dyn-import-cycle-tdz/a.ts → dyn-import-cycle-tdz/b.ts → dyn-import-cycle-tdz/a.ts; the cycle-crossing binding 'a' is read at dyn-import-cycle-tdz/b.ts:3, 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 | // @dynamic - | ^ - 2 | // The REFUSED shape through the --dynamic subgraph: the cycle behind this - - 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 \ No newline at end of file diff --git a/tests/harness/__snapshots__/iterator-slot-fences.ts.txt b/tests/harness/__snapshots__/iterator-slot-fences.ts.txt new file mode 100644 index 000000000..fd244e8fd --- /dev/null +++ b/tests/harness/__snapshots__/iterator-slot-fences.ts.txt @@ -0,0 +1,24 @@ +iterator-slot-fences.ts:14:9 - error SC1090: 'Countdown' values in an iterator-typed slot (Iterator and IterableIterator slots hold generators and built-in collection iterators) is not supported yet + + 13 | } + 14 | firstOf(new Countdown()); + | ^~~~~~~~~~~~~~~ + 15 | + +iterator-slot-fences.ts:16:16 - error SC2020: 'Iterable' is part of the standard library types but has no scriptc lowering yet + + 15 | + 16 | function total(values: Iterable): number { + | ^~~~~~ + 17 | let sum = 0; + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +iterator-slot-fences.ts:21:13 - error SC2004: uses of 'total' inherit the blocker on its declaration + + 20 | } + 21 | console.log(total([1, 2])); + | ^~~~~ + 22 | + + hint: the declaration of 'total' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 07d983a61..464c10a5b 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -116,28 +116,10 @@ stdlib-fence.ts:40:17 - error SC2020: 'Math.min' is part of the standard library 39 | // corpus; the mixed spread/positional list is the form that stays fenced.) 40 | const clamped = Math.min(1, ...[2, 3]); | ^~~~~~~~ - 41 | const entries = [1, 2].entries(); + 41 | const entries = [1, 2].entries(); // array iterators lower now hint: Math.min lowers with any number of plain arguments or ONE number[] spread — mixed spread/positional lists don't: spread a single array (Math.min(...xs)) -stdlib-fence.ts:41:7 - error SC2020: 'ArrayIterator<[number, number]>' is part of the standard library types but has no scriptc lowering yet - - 40 | const clamped = Math.min(1, ...[2, 3]); - 41 | const entries = [1, 2].entries(); - | ^~~~~~~ - 42 | const arrAt = [1, 2].at(0); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - -stdlib-fence.ts:41:17 - error SC2020: 'number[].entries' is part of the standard library types but has no scriptc lowering yet - - 40 | const clamped = Math.min(1, ...[2, 3]); - 41 | const entries = [1, 2].entries(); - | ^~~~~~~~~~~~~~ - 42 | const arrAt = [1, 2].at(0); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - stdlib-fence.ts:44:31 - error SC1090: '.split()' on a 'RegExp | string' separator (pass a string, or a regex literal) is not supported yet 43 | const norm = "abc".normalize(); diff --git a/tests/harness/self-hosting-frontend-program.test.ts b/tests/harness/self-hosting-frontend-program.test.ts index ba4e613f0..b3f0a6ce5 100644 --- a/tests/harness/self-hosting-frontend-program.test.ts +++ b/tests/harness/self-hosting-frontend-program.test.ts @@ -68,7 +68,8 @@ function inputs(directory: string): FrontendProgramRequest { }); add("unsafe-cycle", { "main.ts": 'import { first } from "./first.js"; console.log(first);', - "first.ts": 'import { second } from "./second.js"; export const first: number = second + 1;', + "first.ts": + 'import * as peer from "./second.js"; export const first: number = peer.second + 1;', "second.ts": 'import { first } from "./first.js"; export const second: number = first + 1;', }); add(