Merge pull request #828 from vercel-labs/fix/lowerer-init-order

Keep null distinct from the TDZ in forward-captured nullable bindings
This commit is contained in:
Chris Tate
2026-10-10 17:20:25 -05:00
committed by GitHub
4 changed files with 238 additions and 7 deletions
+31 -6
View File
@@ -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") {
+4 -1
View File
@@ -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;
}
@@ -15736,6 +15736,12 @@
"<repo>/tests/corpus/4597-callback-field-method-name.ts"
],
"diags": []
},
"<repo>/tests/corpus/4596-tdz-nullable-forward-capture.ts": {
"order": [
"<repo>/tests/corpus/4596-tdz-nullable-forward-capture.ts"
],
"diags": []
}
}
}
@@ -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<string>;
}
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<string> | null = null;
const compute = (): Set<string> => {
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<number> | 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<string, string> => (value ??= new Map([["k", "v"]]));
let value: Map<string, string> | 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()));