diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ac018c222..e7ae6a934 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4648,18 +4648,37 @@ export class LlEmitter { B.line(`call void @scr_box_set_ref(ptr ${box}, ptr ${cell})`); } + /** The payload a TDZ box holds before its declaration runs, when that is + * not NULL. A nullable union's value is its arm pointer with NULL as a + * unit arm (nullable-unions.ts), so a stored `null`/`undefined` would + * read back as the temporal dead zone; such boxes start at the module's + * immortal NULLABLE_ABSENT sentinel instead, which no value of the + * binding can hold and every RC entry point and the collector skip. */ + private tdzEmptySentinel(t: IrType): string | null { + if (!this.nullableUnions.of(t)) return null; + this.needsNullableAbsent = true; + this.immortalValues.add(NULLABLE_ABSENT); + return NULLABLE_ABSENT; + } + /** The TDZ-guarded read of a boxed binding: an empty payload slot is * the temporal dead zone — throw Node's exact catchable ReferenceError * (exprs.ts's varRef guard). Scalars then peek the one-element * array cell; ref kinds read the box normally (+1). */ - private checkTdz(box: string, name: string): string { + private checkTdz(box: string, t: IrType, name: string): string { const B = this.B; const slotp = B.tmp(); const slotv = B.tmp(); const empty = B.tmp(); B.line(`${slotp} = getelementptr inbounds %ScrBox, ptr ${box}, i64 0, i32 5`); - B.line(`${slotv} = load i64, ptr ${slotp}`); - B.line(`${empty} = icmp eq i64 ${slotv}, 0`); + const sentinel = this.tdzEmptySentinel(t); + if (sentinel !== null) { + B.line(`${slotv} = load ptr, ptr ${slotp}`); + B.line(`${empty} = icmp eq ptr ${slotv}, ${sentinel}`); + } else { + B.line(`${slotv} = load i64, ptr ${slotp}`); + B.line(`${empty} = icmp eq i64 ${slotv}, 0`); + } this.throwIfUninitialized(empty, name); return slotv; } @@ -4699,7 +4718,7 @@ export class LlEmitter { } tdzBoxRead(box: string, t: IrType, name: string): string { - const slotv = this.checkTdz(box, name); + const slotv = this.checkTdz(box, t, name); const B = this.B; const acc = boxAccess(t); if (acc === "ref") return this.boxGet(box, t); @@ -4727,7 +4746,7 @@ export class LlEmitter { ): void { if (local.tdz) { const first = initializes || !local.mutable; - const slotv = first ? null : this.checkTdz(box, local.name); + const slotv = first ? null : this.checkTdz(box, local.type, local.name); const acc = boxAccess(local.type); if (acc !== "ref") { if (first) this.tdzScalarInit(box, local.type, value); @@ -5447,7 +5466,13 @@ export class LlEmitter { B.line(`${box} = ${boxNew} ; let ${b.local!.name} (boxed)`); B.line(`store ptr ${box}, ptr ${b.slot}`); this.scopes[this.scopes.length - 1]!.push({ slot: b.slot, type: b.type, boxed: true }); - if (s.init === null) break; + if (s.init === null) { + // An empty TDZ box whose payload can legitimately be NULL + // starts at its distinct sentinel (tdzEmptySentinel). + const sentinel = b.local!.tdz === true ? this.tdzEmptySentinel(b.type) : null; + if (sentinel !== null) B.line(`store ptr ${sentinel}, ptr ${this.boxSlot(box)}`); + break; + } const v = this.emitExpr(s.init); if (isRefCounted(v.type)) this.moveTemp(v); // the box takes ownership if (b.local!.tdz === true && boxAccess(b.type) !== "ref") { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 3114f4770..28ef1f431 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1592,7 +1592,10 @@ export interface IrLocal { * empty throws JS's catchable ReferenceError ("Cannot access 'name' * before initialization"), exactly Node's temporal dead zone. Always * paired with `boxed`; scalar payloads use a one-element array cell so - * the NULL slot remains the TDZ sentinel. Capture entries inherit the flag. */ + * the NULL slot remains the TDZ sentinel. Backends whose values of the + * binding's type can themselves be NULL (the LLVM backend's nullable + * unions, where NULL is `null`/`undefined`) mark the empty box with a + * distinct sentinel instead. Capture entries inherit the flag. */ tdz?: true; } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 63e87957a..7bf630618 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15736,6 +15736,12 @@ "/tests/corpus/4597-callback-field-method-name.ts" ], "diags": [] + }, + "/tests/corpus/4596-tdz-nullable-forward-capture.ts": { + "order": [ + "/tests/corpus/4596-tdz-nullable-forward-capture.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/4596-tdz-nullable-forward-capture.ts b/tests/corpus/4596-tdz-nullable-forward-capture.ts new file mode 100644 index 000000000..f11edc1b2 --- /dev/null +++ b/tests/corpus/4596-tdz-nullable-forward-capture.ts @@ -0,0 +1,197 @@ +export {}; + +// A closure created above a `let`/`const` it captures holds the binding in +// its temporal dead zone until the declaration runs. A binding of a +// reference type joined with `null` or `undefined` may legitimately hold +// that unit value afterwards; it must read as the value, not as the TDZ. + +function attempt(label: string, action: () => void): void { + try { + action(); + console.log(label, "ok"); + } catch (error) { + if (error instanceof Error) console.log(label, error.name, error.message); + } +} + +class Node2 { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} + +// The lowering shape that tripped the self-hosted compiler: a host object +// whose callback memoizes into later-declared bindings. +interface Host { + keys(): Set; +} +class Owner { + private readonly host: Host; + constructor(host: Host) { + this.host = host; + } + count(): number { + return this.host.keys().size; + } +} +function memoized(): void { + let computed = 0; + const owner = new Owner({ keys: () => (cache ??= compute()) }); + let cache: Set | null = null; + const compute = (): Set => { + computed++; + return new Set(["a", "b"]); + }; + console.log("memoized", owner.count(), owner.count(), computed); +} +memoized(); + +function setArm(): void { + const read = (): string => (value === null ? "null" : `size ${value.size}`); + const write = (): void => { + value = new Set([1, 2, 3]); + }; + const clear = (): void => { + value = null; + }; + attempt("set read before", () => console.log(read())); + attempt("set write before", write); + let value: Set | null = null; + console.log("set initial", read()); + write(); + console.log("set written", read()); + clear(); + console.log("set cleared", read()); +} +setArm(); + +function stringArm(): void { + const fill = (): string => (value ??= "filled"); + const read = (): string => String(value); + attempt("string fill before", () => console.log(fill())); + let value: string | undefined = undefined; + console.log("string initial", read()); + console.log("string fill", fill(), fill()); + value = ""; + console.log("string empty or", value || "fallback", JSON.stringify(read())); + value ||= "or-assigned"; + console.log("string or-assign", read()); +} +stringArm(); + +function arrayArm(): void { + const push = (n: number): number => (value ??= []).push(n); + const length = (): number => value?.length ?? -1; + const joined = (): string => value?.join(",") ?? "none"; + attempt("array length before", () => console.log(length())); + let value: number[] | undefined = undefined; + console.log("array initial", length(), joined()); + push(4); + push(5); + console.log("array pushed", length(), joined()); + value = undefined; + console.log("array reset", length()); +} +arrayArm(); + +function mapArm(): void { + const get = (k: string): string => value?.get(k) ?? "missing"; + const ensure = (): Map => (value ??= new Map([["k", "v"]])); + let value: Map | null = null; + console.log("map initial", get("k")); + ensure().set("j", "w"); + console.log("map ensured", get("k"), get("j"), ensure().size); +} +mapArm(); + +function classArm(): void { + const name = (): string => value?.name ?? "none"; + const swap = (next: Node2 | null): string => { + const before = name(); + value = next; + return `${before}->${name()}`; + }; + attempt("class swap before", () => console.log(swap(new Node2("early")))); + let value: Node2 | null = null; + console.log("class", swap(new Node2("first")), swap(null), swap(new Node2("second"))); +} +classArm(); + +function recordArm(): void { + const label = (): string => (value === undefined ? "undefined" : value.label); + const point = { label: "p", x: 1 }; + let value: { label: string; x: number } | undefined = undefined; + console.log("record initial", label()); + value = point; + console.log("record set", label(), value.x); +} +recordArm(); + +// `null` and `undefined` are distinct values of the same binding. +function bothUnits(): void { + const describe = (): string => + value === undefined ? "undefined" : value === null ? "null" : value.name; + const set = (next: Node2 | null | undefined): void => { + value = next; + }; + attempt("both read before", () => console.log(describe())); + let value: Node2 | null | undefined = undefined; + console.log("both", describe()); + set(null); + console.log("both", describe()); + set(new Node2("present")); + console.log("both", describe()); + set(undefined); + console.log("both", describe()); +} +bothUnits(); + +function constBinding(): void { + const read = (): string => (value === null ? "const null" : value.join("+")); + attempt("const read before", () => console.log(read())); + const value: string[] | null = Math.random() < 2 ? null : ["never"]; + console.log(read()); +} +constBinding(); + +// Every iteration has its own binding and its own temporal dead zone. +function loop(): void { + const readers: (() => string)[] = []; + for (let i = 0; i < 3; i++) { + const reader = (): string => (value === null ? `null@${i}` : value); + readers.push(reader); + if (i === 1) attempt("loop read before", () => console.log(reader())); + let value: string | null = i === 2 ? "two" : null; + } + console.log("loop", readers.map((reader) => reader()).join(" ")); +} +loop(); + +// A forward-captured binding inside a reference cycle (closure -> box -> +// instance -> closure) is reclaimed without touching the empty sentinel. +class Holder { + callback: () => string = () => "unset"; +} +function cycle(): string { + const holder = new Holder(); + holder.callback = () => + value === null ? "cycle null" : value.callback === holder.callback ? "cycle self" : "cycle other"; + let value: Holder | null = null; + const first = holder.callback(); + value = holder; + return `${first}, ${holder.callback()}`; +} +for (let i = 0; i < 3; i++) console.log(cycle()); + +// The empty box is released when its scope unwinds before initialization; +// a closure that escaped the frame still sees the temporal dead zone. +let escapedRead: () => number = () => -1; +function abandoned(): void { + escapedRead = () => value?.length ?? 0; + if (Math.random() < 2) throw new Error("unwound before declaration"); + let value: number[] | null = null; + console.log("abandoned unreachable", escapedRead()); +} +attempt("abandoned", abandoned); +attempt("escaped read", () => console.log(escapedRead()));