Skip to content

fix: preserve forward closure initializers and TDZ names - #9762

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9721-forward-const-tdz
Closed

fix: preserve forward closure initializers and TDZ names#9762
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9721-forward-const-tdz

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

A closure-local optimization checked only uses after a declaration. For mutually recursive const functions, it could inline a later call and delete the initializer while an earlier closure still captured that binding's box. The box stayed TDZ-poisoned, so a legal call after initialization threw. The pass now checks earlier uses too and preserves initializers that earlier captures, reads, writes, or calls need.

Genuine TDZ failures now name the source binding. Codegen collects names for TDZ-capable locals and passes their existing string-pool values through named checked/trusted box readers. The trusted inline load keeps its current hot path and passes the name on the cold TDZ arm. Box layout, sentinel representation, and suppression semantics are unchanged; the runtime copies the name before allocating the error.

Validation:

Fixes #9721.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed false temporal dead zone errors in mutually recursive const functions and forward-referenced closures.
    • TDZ ReferenceError messages now identify the affected variable, including captured, updated, nested, and Unicode bindings.
    • Updated error wording to match standard JavaScript behavior, including quoted variable names and clearer handling of unnamed bindings.
  • Tests
    • Added coverage for forward initialization, recursive functions, nested closures, and named TDZ errors.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: c8a99f04-b8c6-4ea4-bd7f-eeb6fc9f5e1a

📥 Commits

Reviewing files that changed from the base of the PR and between 12efed1 and 5aec530.

📒 Files selected for processing (13)
  • changelog.d/9762-forward-const-tdz.md
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/tdz_names.rs
  • crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-codegen/src/gc_call_effects.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-codegen/src/strings.rs
  • crates/perry-runtime/src/box.rs
  • crates/perry-runtime/src/error.rs
  • crates/perry-transform/src/closure_local_inline.rs
  • test-files/test_gap_9721_forward_const_initialization.ts
  • test-files/test_gap_9721_tdz_binding_names.ts
🚧 Files skipped from review as they are similar to previous changes (13)
  • crates/perry-codegen/src/gc_call_effects.rs
  • crates/perry-codegen/src/codegen/tdz_names.rs
  • test-files/test_gap_9721_forward_const_initialization.ts
  • test-files/test_gap_9721_tdz_binding_names.ts
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-codegen/src/strings.rs
  • crates/perry-runtime/src/error.rs
  • crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs
  • crates/perry-runtime/src/box.rs
  • changelog.d/9762-forward-const-tdz.md
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-transform/src/closure_local_inline.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The change preserves forward-captured const initializers and adds source binding names to TDZ box reads. Code generation collects TDZ names, runtime getters forward them to ReferenceError, and tests cover recursive captures, trusted callbacks, and named errors.

Changes

TDZ handling for forward captures

Layer / File(s) Summary
Preserve forward-captured initializers
crates/perry-transform/src/closure_local_inline.rs, test-files/test_gap_9721_forward_const_initialization.ts, changelog.d/9762-forward-const-tdz.md
The closure inlining pass preserves declarations when earlier statements use the binding. Tests cover forward captures, calls before initialization, and mutually recursive const functions.
Collect TDZ binding names
crates/perry-codegen/src/codegen/*, crates/perry-codegen/src/strings.rs
Code generation traverses HIR and stores names for bindings marked by PreallocateTdzBoxes in StringPool.
Emit named TDZ reads
crates/perry-codegen/src/expr/literals_vars.rs, crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs, crates/perry-codegen/src/gc_call_effects.rs
Boxed local, captured, trusted, and update reads use named runtime accessors when a TDZ binding name is available.
Format named TDZ errors
crates/perry-runtime/src/box.rs, crates/perry-runtime/src/error.rs, test-files/test_gap_9721_tdz_binding_names.ts
Runtime box getters forward binding names to TDZ errors. Named errors use quoted identifiers, while unnamed errors retain the generic message.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 5aec5

This change targets forward-captured const initialization and named TDZ errors. No concrete merge-blocking issue remains in the supplied record.

Sequence Diagram(s)

sequenceDiagram
  participant Compiler
  participant GeneratedCode
  participant Runtime
  Compiler->>Compiler: Collect TDZ binding names
  Compiler->>GeneratedCode: Emit named box reads
  GeneratedCode->>Runtime: Call named TDZ getter
  Runtime-->>GeneratedCode: Return value or throw named ReferenceError
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.82% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 34 functions across 12 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes both primary changes: preserving forward closure initializers and reporting TDZ binding names.
Description check ✅ Passed The description explains the defect, implementation, affected runtime and codegen behavior, linked issue, validation results, and known test limitation. It does not use the template headings or includ…
Linked Issues check ✅ Passed The changes satisfy issue [#9721]. The closure-local pass preserves initializers needed by earlier captures and uses, preventing valid post-initialization calls from reading TDZ-poisoned boxes. Named …
Out of Scope Changes check ✅ Passed The changes remain within scope for [#9721]. The code, runtime updates, tests, changelog entry, and validation coverage directly support forward-capture initialization and named TDZ errors.
Full details: Docstring Coverage

Explanation

Docstring coverage is 58.82% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 34 functions across 12 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Holding this one — it regresses a pre-existing codegen regression test.

crates/perry-codegen/tests/native_proof_regressions.rs::tdz_numeric_const_read_is_not_constant_folded asserts "the pre-declaration read must retain the TDZ box check". A/B against the merge base:

  • clean main: passes
  • main + this PR alone (nothing else): fails

No PR in today's queue touches that test file, so this is a behaviour change underneath it rather than a stale expectation. The shape of the assertion suggests a pre-declaration const read is now being constant-folded, which would return the value where the TDZ requires a ReferenceError — so the test looks correct and the fold looks like the bug.

Everything else in the 20-PR train validated together (64/64 gates, five suites), so this is the only thing held back; the other 19 are landing now. Happy to take it as soon as either the fold is gated on the binding being past its TDZ, or you can show the test's expectation is itself wrong — I did not want to assume the latter and update the test out from under it.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Correction — I was wrong above. This is not a regression, and I've fixed the test instead.

I claimed the fold had lost the TDZ check. It hasn't. The emitted IR for that fixture is:

%r5 = call i64 @js_box_get_bits_named(i64 %r3, double %r4)

The read still goes through the box, so the check is intact — this PR routes it through the named variant so the thrown ReferenceError can identify the binding, which is exactly what the PR set out to do and is strictly better. What actually failed is the test's assertion, which hard-codes call i64 @js_box_get_bits(i64 and so reads a better error message as a lost guard.

My A/B was sound (passes on main, fails with this PR alone) but I stopped at attribution and inferred the cause from the assertion's wording instead of reading the IR it printed. The message even said "must retain the TDZ box check" while the IR right below it contained the box call. Apologies for the noise.

Fixed on my side in the train: the assertion now checks the property rather than the spelling — either helper satisfies it — and I added the assertion it was missing, that the read is not folded to the later value:

assert!(
    ir.contains("call i64 @js_box_get_bits(i64 ")
        || ir.contains("call i64 @js_box_get_bits_named(i64 "),
    "the pre-declaration read must retain the TDZ box check:\n{ir}"
);
assert!(
    !ir.contains("double 4.200000e+01"),
    "the pre-declaration read must NOT be constant-folded to its later value:\n{ir}"
);

I sabotage-checked that: with the box call stripped and the fold substituted in, it still fails. So the guarantee is unchanged — only the accepted spelling widened.

This is going into the next train. Nothing needed from you.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9804 (rebase-merged, so your commits keep their authorship). Thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant