From c00ca313acf8b155a5c994069c9067a352469af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 01:22:37 +0200 Subject: [PATCH] fix: preserve loop lexical TDZ and retained closure cells --- changelog.d/10235-loop-lexical-tdz.md | 15 ++ crates/perry-codegen/src/stmt/mod.rs | 46 +++-- .../src/stmt/prealloc_tdz_path_tests.rs | 94 ++++++++++ crates/perry-hir/src/ir/stmt.rs | 3 + crates/perry-hir/src/lower/expr_function.rs | 1 + .../perry-hir/src/lower/lowering_context.rs | 2 + crates/perry-hir/src/lower/stmt.rs | 19 +- crates/perry-hir/src/lower_decl/block.rs | 51 ++++-- crates/perry-hir/src/lower_decl/body_stmt.rs | 21 ++- crates/perry-hir/tests/loop_lexical_tdz.rs | 102 +++++++++++ crates/perry/tests/loop_lexical_tdz.rs | 20 +++ scripts/test-loop-lexical-tdz.mjs | 54 ++++++ test-files/test_gap_10051_loop_lexical_tdz.ts | 167 ++++++++++++++++++ 13 files changed, 560 insertions(+), 35 deletions(-) create mode 100644 changelog.d/10235-loop-lexical-tdz.md create mode 100644 crates/perry-codegen/src/stmt/prealloc_tdz_path_tests.rs create mode 100644 crates/perry-hir/tests/loop_lexical_tdz.rs create mode 100644 crates/perry/tests/loop_lexical_tdz.rs create mode 100644 scripts/test-loop-lexical-tdz.mjs create mode 100644 test-files/test_gap_10051_loop_lexical_tdz.ts diff --git a/changelog.d/10235-loop-lexical-tdz.md b/changelog.d/10235-loop-lexical-tdz.md new file mode 100644 index 0000000000..3226ca698a --- /dev/null +++ b/changelog.d/10235-loop-lexical-tdz.md @@ -0,0 +1,15 @@ +Captured forward `let` and `const` bindings now receive fresh TDZ cells at their +own block entry. Repeated loop entries throw `ReferenceError` before each +declaration, uninitialized `let` declarations end that entry's TDZ with +`undefined`, and retained callbacks keep their original iteration's binding. +Function-scoped `var` bindings continue to share one cell. + +Switch cases allocate one shared lexical environment after the discriminant, +and TDZ cells precede hoisted block-function closures. Code generation also +allocates a fresh cell in every emitted copy of a `finally` block, preserving +the shared stack slot across its normal and exceptional paths. + +Adds HIR and LLVM regressions plus a bounded byte-for-byte Node/native suite +covering script and module contexts at O0/Os/Oz with default and compact GC +configurations, retained callbacks, recursion, skipped declarations, and +exceptional `finally` paths. diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index cc27d26763..73e8ecaf05 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -33,6 +33,8 @@ mod loops; mod masked_window_region; #[cfg(test)] mod prealloc_module_global_tests; +#[cfg(test)] +mod prealloc_tdz_path_tests; pub(crate) mod stable_packed_accumulator; pub(crate) mod stable_packed_loop; mod stable_packed_typed_array; @@ -690,14 +692,10 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result if ctx.module_globals.contains_key(id) { continue; } - if ctx.locals.contains_key(id) { - // A previous PreallocateBoxes (or an unusual nesting) - // already set this up -- skip to keep the existing slot. + if !tdz && ctx.locals.contains_key(id) { + // Ordinary preallocation preserves a shared function-scoped cell. ctx.prealloc_boxes.insert(*id); ctx.boxed_vars.insert(*id); - if tdz { - ctx.tdz_boxes.insert(*id); - } continue; } let is_i32_control = crate::expr::is_compiler_private_async_i32_control_local(ctx, *id); @@ -740,19 +738,29 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result "jsvalue_box_cell", ) }; - let slot = ctx.func.alloca_entry(crate::types::I64); - // perry#4926: PreallocateBoxes can sit nested inside an If/Try/Labeled - // body (e.g. the async state-machine wrapper), so this block's - // box-pointer store doesn't necessarily dominate every load of the - // slot. Entry-init the slot to TAG_UNDEFINED so paths that bypass this - // statement read a defined sentinel instead of `undef` (see the boxed - // `Stmt::Let` arm in let_stmt.rs). The slot holds a *box pointer*, not - // the value, so it is TAG_UNDEFINED-initialized in both the TDZ and - // non-TDZ cases -- the TAG_TDZ sentinel lives in the box cell, not the - // slot. - let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); - ctx.func - .entry_allocas_push_store(crate::types::I64, &undef_bits, &slot); + // #10051: a TDZ statement creates this entry's lexical environment. + // Emit its allocation even when an earlier COPY of the statement was + // lowered already (normal/exceptional finally paths, for example). + // Reuse the stack slot, but never the previous entry's heap cell: + // retained closures must keep their original binding and value. + let slot = if let Some(slot) = ctx.locals.get(id) { + slot.clone() + } else { + let slot = ctx.func.alloca_entry(crate::types::I64); + // perry#4926: PreallocateBoxes can sit nested inside an If/Try/Labeled + // body (e.g. the async state-machine wrapper), so this block's + // box-pointer store doesn't necessarily dominate every load of the + // slot. Entry-init the slot to TAG_UNDEFINED so paths that bypass this + // statement read a defined sentinel instead of `undef` (see the boxed + // `Stmt::Let` arm in let_stmt.rs). The slot holds a *box pointer*, not + // the value, so it is TAG_UNDEFINED-initialized in both the TDZ and + // non-TDZ cases -- the TAG_TDZ sentinel lives in the box cell, not the + // slot. + let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); + ctx.func + .entry_allocas_push_store(crate::types::I64, &undef_bits, &slot); + slot + }; ctx.block().store(crate::types::I64, &box_ptr, &slot); record_boxed_slot_js_value_bits(ctx, *id, &box_ptr, "preallocate_boxes.box_ptr_slot"); if cell_note != "jsvalue_box_cell" { diff --git a/crates/perry-codegen/src/stmt/prealloc_tdz_path_tests.rs b/crates/perry-codegen/src/stmt/prealloc_tdz_path_tests.rs new file mode 100644 index 0000000000..145fe4bee5 --- /dev/null +++ b/crates/perry-codegen/src/stmt/prealloc_tdz_path_tests.rs @@ -0,0 +1,94 @@ +//! #10051: copies of a lexical scope must each execute their TDZ allocation. +use perry_hir::{types::Type, Expr, Function, Module, Stmt}; + +fn emit(body: Vec) -> String { + let mut module = Module::new("tdz_paths.ts"); + module.functions.push(Function { + id: 1, + name: "test".into(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: true, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + String::from_utf8( + crate::compile_module(&module, super::prealloc_module_global_tests::ir_opts()).unwrap(), + ) + .unwrap() +} + +fn allocated_slots(ir: &str, seed: &str) -> Vec { + ir.lines() + .filter_map(|line| { + if !line.contains(&format!("call i64 @js_box_alloc_bits(i64 {seed})")) { + return None; + } + let value = line.trim().split(" = ").next().unwrap(); + let prefix = format!("store i64 {value}, ptr "); + Some( + ir.lines() + .find_map(|store| store.trim().strip_prefix(&prefix).map(str::to_string)) + .expect("each allocated box is stored"), + ) + }) + .collect() +} + +#[test] +fn tdz_finally_allocates_on_normal_and_exception_paths() { + let ir = emit(vec![Stmt::Try { + body: vec![Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(99)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + })], + catch: None, + finally: Some(vec![ + Stmt::PreallocateTdzBoxes(vec![10]), + Stmt::Let { + id: 10, + name: "value".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(42)), + }, + ]), + }]); + let slots = allocated_slots(&ir, crate::nanbox::TAG_TDZ_I64); + assert_eq!( + slots.len(), + 2, + "both finally paths need fresh TDZ cells:\n{ir}" + ); + assert_eq!(slots[0], slots[1], "path copies share one stack slot"); + assert!( + ir.contains(&format!( + "store i64 {}, ptr {}", + crate::nanbox::TAG_UNDEFINED_I64, + slots[0] + )), + "slot must be entry-initialized" + ); +} + +#[test] +fn ordinary_preallocation_still_preserves_an_existing_cell() { + let ir = emit(vec![ + Stmt::PreallocateBoxes(vec![10]), + Stmt::PreallocateBoxes(vec![10]), + ]); + assert_eq!( + allocated_slots(&ir, crate::nanbox::TAG_UNDEFINED_I64).len(), + 1, + "ordinary function-scoped cells must not be freshened:\n{ir}" + ); +} diff --git a/crates/perry-hir/src/ir/stmt.rs b/crates/perry-hir/src/ir/stmt.rs index 2ce2a54c33..cdd77f4163 100644 --- a/crates/perry-hir/src/ir/stmt.rs +++ b/crates/perry-hir/src/ir/stmt.rs @@ -72,6 +72,9 @@ pub enum Stmt { /// read of such a box before its `Stmt::Let` runs throws a spec /// ReferenceError; the `Stmt::Let` (or `let x;` with no init) overwrites /// the sentinel with the real value / `undefined`, ending the dead zone. + /// Nested lexical scopes emit this at block entry: every execution must + /// allocate a fresh cell, even when codegen emits multiple copies of that + /// block (such as a finally body on normal and exceptional paths). PreallocateTdzBoxes(Vec), /// Hand the heap box cells behind a set of boxed LocalIds to the async /// activation lifetime tracker (#7933 / #8213). A cell no closure captures diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index ab3173ac7d..03a1798a94 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -1211,6 +1211,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul &combined, &hoisted_id_set, ); + prealloc.retain(|id| !ctx.nested_forward_scope_ids.contains(id)); for id in &forward_boxed_ids { if !prealloc.contains(id) { prealloc.push(*id); diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 6e0584d0ef..688e9b8e6e 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -589,6 +589,8 @@ pub struct LoweringContext { /// enclosing scope). Without this, a same-named `let` in a sibling block /// was skipped (deduped by name) and any post-block reference of the name /// resolved to the block's box instead of the outer binding. + /// Their TDZ cells are also allocated at block entry, rather than function + /// entry, to preserve per-entry binding identity and the TDZ in loops. pub(crate) nested_forward_scope_ids: HashSet, /// Shadow index: function name -> index in `functions` Vec (last entry for shadowing) pub(crate) functions_index: HashMap, diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index b9b3a3276a..7159c9469d 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1856,7 +1856,7 @@ pub(crate) fn lower_stmt( module.init.push(Stmt::Throw(expr)); } ast::Stmt::Switch(switch_stmt) => { - let discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; + let mut discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; let mut cases = Vec::new(); let switch_scope_mark = ctx.push_block_scope(); // Case statement-lists share the switch's block scope without @@ -1868,8 +1868,9 @@ pub(crate) fn lower_stmt( // one shared scope key: a second case re-declaring the name is a // redeclaration, not a shadow. let mut saved_class_renames = Vec::new(); + let mut tdz_boxes = Vec::new(); for case in &switch_stmt.cases { - rebind_nested_forward_scope_lets(ctx, &case.cons); + tdz_boxes.extend(rebind_nested_forward_scope_lets(ctx, &case.cons)); saved_class_renames.extend(enter_class_rename_scope( ctx, switch_stmt.span.lo.0, @@ -1891,6 +1892,20 @@ pub(crate) fn lower_stmt( exit_class_rename_scope(ctx, saved_class_renames); ctx.pop_block_scope(switch_scope_mark); + if !tdz_boxes.is_empty() { + // Evaluate the discriminant before entering the shared case + // environment; fallthrough must not allocate a second cell. + let id = ctx.fresh_local(); + module.init.push(Stmt::Let { + id, + name: "__switch_discriminant".into(), + ty: Type::Any, + mutable: false, + init: Some(discriminant), + }); + module.init.push(Stmt::PreallocateTdzBoxes(tdz_boxes)); + discriminant = Expr::LocalGet(id); + } module.init.push(Stmt::Switch { discriminant, cases, diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index b20d746865..5707e41ea1 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -19,7 +19,7 @@ pub(crate) use var_names::{ }; pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Result> { - rebind_nested_forward_scope_lets(ctx, &block.stmts); + let tdz_boxes = rebind_nested_forward_scope_lets(ctx, &block.stmts); // #9466: `class` is block-scoped, so a `class X` here is a DISTINCT class // from any enclosing/prior `class X` and needs its own registration key. // This is the funnel every `{}`-shaped scope shares — bare block, `if` / @@ -35,7 +35,12 @@ pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Re let saved_class_renames = enter_class_rename_scope(ctx, block.span.lo.0, &block.stmts); let lowered = lower_stmts_using_aware(ctx, &block.stmts); exit_class_rename_scope(ctx, saved_class_renames); - lowered + lowered.map(|mut body| { + if !tdz_boxes.is_empty() { + body.insert(0, Stmt::PreallocateTdzBoxes(tdz_boxes)); + } + body + }) } /// Make the forward-captured `let`/`const` bindings that @@ -48,15 +53,22 @@ pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Re /// unwinds, so the binding is visible exactly within its block — a same-named /// `let` in a sibling block gets its own id/box, and references after the /// block resolve to the outer binding (or stay global) as in Node. +/// Returns the cells to allocate at this scope's runtime entry. In particular, +/// a loop must allocate NEW cells on every entry, both to restart the TDZ and +/// to leave callbacks from previous iterations attached to their original cells. /// /// Called from [`lower_block_stmt`] (every `{}`-shaped scope: block, `try` / /// `catch` / `finally`, block-bodied `if` / loop / labeled bodies) and from /// the two switch-case lowering arms (`lower/stmt.rs`, `lower_decl/ /// body_stmt.rs`), whose case statement-lists share the switch's block scope /// without being a `BlockStmt`. -pub(crate) fn rebind_nested_forward_scope_lets(ctx: &mut LoweringContext, stmts: &[ast::Stmt]) { +pub(crate) fn rebind_nested_forward_scope_lets( + ctx: &mut LoweringContext, + stmts: &[ast::Stmt], +) -> Vec { + let mut tdz_boxes = Vec::new(); if ctx.lexical_forward_decls.is_empty() { - return; + return tdz_boxes; } for stmt in stmts { let ast::Stmt::Decl(ast::Decl::Var(var_decl)) = stmt else { @@ -75,11 +87,13 @@ pub(crate) fn rebind_nested_forward_scope_lets(ctx: &mut LoweringContext, stmts: if let Some(&id) = ctx.lexical_forward_decls.get(&span_lo) { if ctx.nested_forward_scope_ids.contains(&id) { ctx.locals.push((name, id, Type::Any)); + tdz_boxes.push(id); } } } } } + tdz_boxes } /// Collect identifier names referenced INSIDE any closure (arrow / function @@ -106,7 +120,9 @@ pub(crate) fn rebind_nested_forward_scope_lets(ctx: &mut LoweringContext, stmts: /// scope local now (so the earlier closure resolves it to the local and /// captures the live box) and span-keyed in `lexical_forward_decls` so the /// declaration — including a destructuring leaf — reuses the same id. Returns -/// the pre-registered ids so the caller can prealloc their boxes at entry. +/// the function-scoped ids so the caller can prealloc their boxes at function +/// entry. Nested lexical ids are allocated at their own scope's entry by +/// `rebind_nested_forward_scope_lets`'s callers. /// /// `body_entry_locals_len` is `ctx.locals.len()` captured before any of this /// body's own locals were defined — anything at or above it is in THIS scope, @@ -131,9 +147,9 @@ pub(crate) fn pre_register_forward_captured_lets( // `try { let cb = () => x; let x = …; cb() }` (esbuild `__esm` streaming // closures in the compiled query async-generator) fell through to // `js_global_get_or_throw_unresolved` → `ReferenceError: x is not - // defined`. Forward-captured boxes from any depth still preallocate at - // function entry (Phase 4/5) and each declaration reuses its id by span - // (`lexical_forward_decls`). + // defined`. Function-scoped boxes preallocate at function entry (Phase + // 4/5); nested lexical boxes at their own block entry. Each declaration + // reuses its id by span (`lexical_forward_decls`). // // The bool is `is_nested`: only the function-body top level (front entry) // defines its pre-registrations as name-visible function-scope locals. @@ -218,7 +234,6 @@ pub(crate) fn pre_register_forward_captured_lets( ctx.var_hoisted_ids.insert(id); ctx.tdz_forward_ids.insert(id); ctx.nested_forward_scope_ids.insert(id); - forward_boxed_ids.push(id); ctx.lexical_forward_decls.insert(span_lo, id); registered_here.insert(name); } else { @@ -754,6 +769,7 @@ pub fn lower_fn_body_block_stmt( // the box before the declaration assigns through it. let combined: Vec = hoisted_lets.iter().chain(other.iter()).cloned().collect(); let mut prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_id_set); + prealloc.retain(|id| !ctx.nested_forward_scope_ids.contains(id)); for id in forward_boxed_ids { if !prealloc.contains(&id) { prealloc.push(id); @@ -1075,7 +1091,7 @@ fn lower_strict_block_fn_decls( ) -> Result> { use std::collections::HashSet; - rebind_nested_forward_scope_lets(ctx, &block.stmts); + let tdz_boxes = rebind_nested_forward_scope_lets(ctx, &block.stmts); let mut hoisted_ids = HashSet::new(); for stmt in &block.stmts { @@ -1092,7 +1108,11 @@ fn lower_strict_block_fn_decls( hoisted_ids.insert(id); } if hoisted_ids.is_empty() { - return lower_stmts_using_aware(ctx, &block.stmts); + let mut body = lower_stmts_using_aware(ctx, &block.stmts)?; + if !tdz_boxes.is_empty() { + body.insert(0, Stmt::PreallocateTdzBoxes(tdz_boxes)); + } + return Ok(body); } // Lower in source order first: a declaration body may capture lexical @@ -1115,8 +1135,15 @@ fn lower_strict_block_fn_decls( } let combined: Vec<_> = hoisted.iter().chain(other.iter()).cloned().collect(); - let prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_ids); + let mut prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_ids); + // A hoisted closure may capture a forward lexical from this block. Its + // TDZ cell is already allocated here; never replace it with an ordinary + // undefined-seeded cell or hoist a nested block's cell into this scope. + prealloc.retain(|id| !ctx.nested_forward_scope_ids.contains(id)); let mut result = Vec::new(); + if !tdz_boxes.is_empty() { + result.push(Stmt::PreallocateTdzBoxes(tdz_boxes)); + } if !prealloc.is_empty() { result.push(Stmt::PreallocateBoxes(prealloc)); } diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 5c675a03a3..d339267582 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1012,7 +1012,7 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { - let discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; + let mut discriminant = lower_expr(ctx, &switch_stmt.discriminant)?; let mut cases = Vec::new(); let switch_scope_mark = ctx.push_block_scope(); // Case statement-lists share the switch's block scope without @@ -1024,8 +1024,11 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Vec { + let parsed = parse_typescript(source, "loop-tdz.ts").expect("parse"); + lower_module(&parsed, "test", "loop-tdz.ts") + .expect("lower") + .functions + .into_iter() + .find(|f| f.name == "test") + .expect("test function") + .body +} + +fn local(stmts: &[Stmt], name: &str) -> LocalId { + stmts + .iter() + .find_map(|s| match s { + Stmt::Let { id, name: n, .. } if n == name => Some(*id), + _ => None, + }) + .expect("local declaration") +} + +#[test] +fn loop_lexicals_allocate_at_block_entry_but_vars_at_function_entry() { + for directive in ["", "'use strict';"] { + let stmts = body(&format!( + r#" + function test() {{ + {directive} + for (let i = 0; i < 2; i++) {{ + const read = () => [value, shared]; + let value; + var shared = i; + read(); + }} + }} + "# + )); + let loop_body = stmts + .iter() + .find_map(|s| match s { + Stmt::For { body, .. } => Some(body), + _ => None, + }) + .expect("loop"); + let value = local(loop_body, "value"); + let shared = local(&stmts, "shared"); + assert!( + matches!(loop_body.first(), Some(Stmt::PreallocateTdzBoxes(ids)) if ids.contains(&value)), + "lexical cell must be created on each loop entry: {stmts:?}" + ); + assert!( + !stmts + .iter() + .any(|s| matches!(s, Stmt::PreallocateTdzBoxes(ids) if ids.contains(&value))), + "nested lexical cell must not be allocated at function entry" + ); + assert!( + stmts + .iter() + .any(|s| matches!(s, Stmt::PreallocateBoxes(ids) if ids.contains(&shared))), + "var keeps one function-scoped cell" + ); + } +} + +#[test] +fn strict_hoisted_closure_captures_the_block_entry_tdz_cell() { + let stmts = body( + r#" + function test() { + 'use strict'; + while (true) { + read(); + function read() { return value; } + let value = 1; + break; + } + } + "#, + ); + let loop_body = stmts + .iter() + .find_map(|s| match s { + Stmt::While { body, .. } => Some(body), + _ => None, + }) + .expect("loop"); + let value = local(loop_body, "value"); + assert!( + matches!(loop_body.first(), Some(Stmt::PreallocateTdzBoxes(ids)) if ids.contains(&value)), + "TDZ cell must precede hoisted closures: {stmts:?}" + ); + assert!( + !loop_body + .iter() + .any(|s| matches!(s, Stmt::PreallocateBoxes(ids) if ids.contains(&value))), + "ordinary closure preallocation must not duplicate the TDZ cell" + ); +} diff --git a/crates/perry/tests/loop_lexical_tdz.rs b/crates/perry/tests/loop_lexical_tdz.rs new file mode 100644 index 0000000000..8b7c812d96 --- /dev/null +++ b/crates/perry/tests/loop_lexical_tdz.rs @@ -0,0 +1,20 @@ +//! CI-visible entry point for the bounded Node/native regression. +use std::{path::Path, process::Command}; + +#[test] +fn standalone_regression() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("node") + .arg(root.join("scripts/test-loop-lexical-tdz.mjs")) + .env("PERRY_BIN", env!("CARGO_BIN_EXE_perry")) + .env("PERRY_WORKSPACE_ROOT", &root) + .current_dir(&root) + .output() + .expect("run bounded Node regression driver"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/scripts/test-loop-lexical-tdz.mjs b/scripts/test-loop-lexical-tdz.mjs new file mode 100644 index 0000000000..c714ccf670 --- /dev/null +++ b/scripts/test-loop-lexical-tdz.mjs @@ -0,0 +1,54 @@ +// #10051: independent Node/native oracle, both lexical-scope lowering paths. +// PERRY_BIN and PERRY_RUNTIME_DIR must name one coherent compiler/runtime build. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-loop-tdz-')); +let passed = false; +try { + for (const type of ['commonjs', 'module']) { + const dir = path.join(work, type); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type })); + const source = path.join(dir, 'main.ts'); + fs.copyFileSync(path.join(root, 'test-files/test_gap_10051_loop_lexical_tdz.ts'), source); + const oracle = spawnSync(process.execPath, [source], { cwd: dir, timeout: 15_000 }); + if (oracle.status !== 0) throw new Error(`Node ${type} oracle failed: ${oracle.stderr}`); + fs.writeFileSync(path.join(dir, 'node.stdout'), oracle.stdout); + for (const gc of ['default', 'compact']) { + for (const opt of ['0', 's', 'z']) { + const label = `${type}-${gc}-O${opt}`; + const output = path.join(dir, label); + const env = { ...process.env, PERRY_LL_OPT_LEVEL: opt }; + for (const flag of ['PERRY_RS4GC', 'PERRY_SHADOW_STACK', + 'PERRY_INLINE_SHADOW_SLOT', 'PERRY_FULL_OUTLINE_IC']) delete env[flag]; + if (gc === 'compact') Object.assign(env, { + PERRY_RS4GC: '0', PERRY_SHADOW_STACK: '1', + PERRY_INLINE_SHADOW_SLOT: '0', PERRY_FULL_OUTLINE_IC: '1', + }); + const compile = spawnSync(compiler, ['compile', source, '-o', output, + '--no-cache', '--no-auto-optimize', '--no-codegen', '--no-color'], + { cwd: dir, env, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }); + fs.writeFileSync(output + '.compile.log', Buffer.concat([ + compile.stdout ?? Buffer.alloc(0), compile.stderr ?? Buffer.alloc(0)])); + if (compile.status !== 0) throw new Error(`${label} compile failed: ${compile.error ?? compile.status}`); + const run = spawnSync(output, [], { cwd: dir, env, timeout: 15_000 }); + fs.writeFileSync(output + '.stdout', run.stdout ?? Buffer.alloc(0)); + fs.writeFileSync(output + '.stderr', run.stderr ?? Buffer.alloc(0)); + if (run.status !== 0 || !run.stdout?.equals(oracle.stdout)) { + throw new Error(`${label} native/Node mismatch: ${run.error ?? run.status}\n${run.stdout ?? ''}${run.stderr ?? ''}`); + } + console.log(`PASS ${label}`); + } + } + } + passed = true; +} finally { + if (passed) fs.rmSync(work, { recursive: true }); + else console.error(`Retained regression diagnostics: ${work}`); +} diff --git a/test-files/test_gap_10051_loop_lexical_tdz.ts b/test-files/test_gap_10051_loop_lexical_tdz.ts new file mode 100644 index 0000000000..bcc548c833 --- /dev/null +++ b/test-files/test_gap_10051_loop_lexical_tdz.ts @@ -0,0 +1,167 @@ +// #10051: each block entry creates fresh captured lexical cells, including TDZ. +function test() { + const results: string[] = []; + for (let index = 0; index < 2; index++) { + let read = () => value; + try { read(); results.push('missing-tdz'); } + catch (error) { results.push(error instanceof ReferenceError ? 'tdz' : 'wrong-error'); } + let value: number; + results.push(String(read())); + value = index; + results.push(String(read())); + let recurse = (n: number): number => n === 0 ? value : recurse(n - 1); + results.push(String(recurse(2))); + } + return results.join(','); +} +console.log(test()); + +function probe(read: () => unknown): string { + let result: string; + try { result = String(read()); } + catch (error) { result = error instanceof ReferenceError ? 'tdz' : 'wrong-error'; } + return result; +} + +function retained() { + 'use strict'; + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 3; index++) { + const uninitializedRead = () => uninitialized; + const initializedRead = () => initialized; + const constantRead = () => constant; + results.push(probe(uninitializedRead), probe(initializedRead), probe(constantRead)); + let uninitialized: number; + results.push(probe(uninitializedRead)); + uninitialized = index; + let initialized = index + 10; + const constant = index + 20; + const recurse = (n: number): number => n === 0 ? initialized : recurse(n - 1); + reads.push(uninitializedRead, initializedRead, constantRead, () => recurse(2)); + initialized += 100; + } + console.log('retained-tdz:' + results.join(',')); + console.log('retained-values:' + reads.map(probe).join(',')); +} +retained(); + +function sharedVar() { + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 3; index++) { + const read = () => value; + results.push(probe(read)); + var value = index; + reads.push(read); + } + console.log('var-before:' + results.join(',')); + console.log('var-retained:' + reads.map(probe).join(',')); +} +sharedVar(); + +function otherBlocks() { + 'use strict'; + const reads: (() => unknown)[] = []; + const results: string[] = []; + let index = 0; + while (index < 2) { + { + const read = () => value; + results.push(probe(read)); + const value = index; + reads.push(read); + } + try { + const read = () => value; + results.push(probe(read)); + let value = index + 10; + reads.push(read); + } finally { + const read = () => value; + results.push(probe(read)); + let value = index + 20; + reads.push(read); + } + switch (index) { + case 0: + default: + const read = () => value; + results.push(probe(read)); + let value = index + 30; + reads.push(read); + case 99: + // Fallthrough stays in the same lexical environment. + results.push(probe(read)); + } + index++; + } + do { + const read = () => value; + results.push(probe(read)); + const value = index + 40; + reads.push(read); + index--; + } while (index > 0); + console.log('blocks-tdz:' + results.join(',')); + console.log('blocks-retained:' + reads.map(probe).join(',')); +} +otherBlocks(); + +function hoistedBlockFunctions() { + 'use strict'; + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 2; index++) { + results.push(probe(read)); + function read() { return value; } + let value = index; + reads.push(read); + } + console.log('hoisted-tdz:' + results.join(',')); + console.log('hoisted-retained:' + reads.map(probe).join(',')); +} +hoistedBlockFunctions(); + +// Function expressions use a separate function-body lowering path. +const expression = function () { + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 2; index++) { + const read = () => value; + results.push(probe(read)); + let value = index; + reads.push(read); + } + return results.join(',') + ':' + reads.map(probe).join(','); +}; +console.log('expression:' + expression()); + +function abruptEntries() { + const reads: (() => unknown)[] = []; + const results: string[] = []; + for (let index = 0; index < 3; index++) { + const read = () => value; + reads.push(read); + if (index === 1) continue; + let value = index; + } + console.log('skipped-declaration:' + reads.map(probe).join(',')); + for (let index = 0; index < 3; index++) { + try { + try { + if (index === 1) throw new Error('body'); + } finally { + const read = () => value; + results.push(probe(read)); + let value = index + 10; + reads.push(read); + } + } catch (error) { + results.push('caught'); + } + } + console.log('finally-paths:' + results.join(',')); + console.log('abrupt-retained:' + reads.map(probe).join(',')); +} +abruptEntries();