From 5d0b38ef879f2a8192c7e0b4f30b97f4c1cc3a28 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 12:03:37 -0700 Subject: [PATCH] Keep null distinct from the TDZ in forward-captured nullable bindings A closure created above the `let`/`const` it captures holds the binding in a TDZ box whose NULL payload slot marks "not yet initialized". A union of one reference arm with `null` and/or `undefined` (`Set | null`, `string | undefined`, `C | null`) is represented as the bare arm pointer with NULL standing for the unit arm, so storing that unit value made the box indistinguishable from the temporal dead zone: every later read or write threw "Cannot access 'x' before initialization" although the declaration had run. The self-hosted compiler trips on exactly this shape in lowerer.ts (`let dynamicViews: Set | null = null`, read by a callback created earlier), which made scriptc 0.2.8 fail to build tsc-ts's relater with SC3004. Node evaluates the source correctly; the native code was wrong. TDZ boxes of nullable-union bindings now start at the module's immortal NULLABLE_ABSENT sentinel and the TDZ test compares against it. No value of the binding can hold that sentinel, and every RC entry point and the cycle collector already skip it, so box release, trace and the initializing store need no other change. Corpus: 4596-tdz-nullable-forward-capture. --- packages/compiler/src/backend/llvm/emitter.ts | 37 +++- packages/compiler/src/ir/ir.ts | 5 +- .../test/ts7/baselines/order-parity.json | 6 + .../4596-tdz-nullable-forward-capture.ts | 197 ++++++++++++++++++ 4 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 tests/corpus/4596-tdz-nullable-forward-capture.ts 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()));