Skip to content

Forward-captured const stays TDZ-poisoned after initialization (mutual recursion; error also names 'undefined' not the binding) #9721

Description

@proggeramlug

A forward reference between two mutually recursive const arrows throws ReferenceError: Cannot access undefined before initialization at call time, long after both bindings are initialized. Deterministic, 5/5 runs. Not a regression — it reproduces identically on 28c292517 and on that commit plus #9720.

type Off = () => string;
const listeners: (() => string)[] = [];
const ev = { on(cb: () => string): Off { listeners.push(cb); return () => "off-called"; } };

function main(): void {
  const fact = (n: number): number => (n <= 1 ? 1 : n * fact(n - 1));
  console.log("fact=" + fact(5));

  const off = ev.on(() => off());
  console.log("off=" + listeners[0]!());

  const sub = { unsub: (): string => "unsubbed" };
  const sub2 = ((o: { next: () => string }) => { listeners.push(o.next); return sub; })({ next: () => sub2.unsub() });
  console.log("sub2=" + listeners[1]!());

  const a = (): string => b() + "/a",
    b = (): string => "b";
  console.log("multi=" + a());

  const fib = function rec(n: number): number { return n < 2 ? n : rec(n - 1) + rec(n - 2); };
  console.log("fib=" + fib(10));

  // mutual recursion across statements
  const even = (n: number): boolean => (n === 0 ? true : odd(n - 1));
  const odd = (n: number): boolean => (n === 0 ? false : even(n - 1));
  console.log("even10=" + even(10) + " odd7=" + odd(7));
}
main();
node 26.5.1 : fact=120 / off=off-called / sub2=unsubbed / multi=b/a / fib=55 / even10=true odd7=true
perry       : fact=120 / off=off-called / sub2=unsubbed / multi=b/a / fib=55
              ReferenceError: Cannot access undefined before initialization
                  at even (<anonymous>)
                  at main (<anonymous>)

even(10) runs after both even and odd have been initialized, so there is no TDZ window — node returns true. Perry reads odd's forward-declared box and still finds the TDZ sentinel there.

Two defects, one line

  1. The TDZ sentinel is not cleared (or the wrong slot is read) for a forward-captured let/const under this combination, so a legal post-initialization read throws.
  2. The message names undefined instead of the binding. Node says Cannot access 'odd' before initialization. Whatever formats this message is not getting the name, which makes every instance of this error hard to attribute — worth fixing independently of (1).

What it takes to trigger

Deleting the const off = ev.on(() => off()) line makes it pass, so a binding whose own initializer closes over it is necessary. It is not sufficient: off plus the mutual pair alone passes, and so does off plus any single one of fact / fib / the multi-declarator / sub2 plus the pair. The failure needs the fuller combination above — likely a capacity or ordering effect in the per-scope forward-registration bookkeeping (pre_register_forward_captured_lets / lexical_forward_decls / tdz_forward_ids in crates/perry-hir/src/lower_decl/block.rs) rather than any one shape.

Found while validating #9720 (which is a different bug in the same pre-pass — that one is a missing registration, this one is a registration whose sentinel outlives initialization). #9720 neither causes nor fixes this; both arms of its A/B fail identically here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions