From 990c7ebd37146dddaaf666c3f69e0d4e79e1247f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 03:24:37 +0200 Subject: [PATCH 1/2] fix: preserve forward closure initializers and TDZ names --- changelog.d/9721-forward-const-tdz.md | 2 + crates/perry-codegen/src/codegen/mod.rs | 2 + crates/perry-codegen/src/codegen/tdz_names.rs | 184 ++++++++++++++++++ .../src/codegen/trusted_box_callback_tests.rs | 26 +++ .../perry-codegen/src/expr/literals_vars.rs | 62 +++--- crates/perry-codegen/src/gc_call_effects.rs | 7 +- .../src/runtime_decls/strings.rs | 2 + crates/perry-codegen/src/strings.rs | 4 + crates/perry-runtime/src/box.rs | 32 ++- crates/perry-runtime/src/error.rs | 9 +- .../src/closure_local_inline.rs | 60 ++++++ ...t_gap_9721_forward_const_initialization.ts | 28 +++ test-files/test_gap_9721_tdz_binding_names.ts | 45 +++++ 13 files changed, 433 insertions(+), 30 deletions(-) create mode 100644 changelog.d/9721-forward-const-tdz.md create mode 100644 crates/perry-codegen/src/codegen/tdz_names.rs create mode 100644 test-files/test_gap_9721_forward_const_initialization.ts create mode 100644 test-files/test_gap_9721_tdz_binding_names.ts diff --git a/changelog.d/9721-forward-const-tdz.md b/changelog.d/9721-forward-const-tdz.md new file mode 100644 index 0000000000..0b6175860b --- /dev/null +++ b/changelog.d/9721-forward-const-tdz.md @@ -0,0 +1,2 @@ +### Fixes +- Preserve closure initializers that earlier closures capture, fixing false temporal-dead-zone errors in mutually recursive `const` functions. Genuine TDZ errors now name the source binding, including captured reads and updates. Fixes #9721. diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ba4899a9ef..83a006dfd8 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -205,6 +205,7 @@ mod hoisted_callback_method_tests; mod index_method_clone_tests; mod indexed_method_artifacts; mod ordinary_method_artifacts; +mod tdz_names; // `pub(crate)` so `crate::linker` can read the inline-hot-small policy // (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`). pub(crate) mod helpers; @@ -475,6 +476,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // becomes part of every emitted global so multi-module programs // don't collide on `.str.0.handle`. let mut strings = StringPool::with_prefix(module_prefix.clone()); + strings.tdz_binding_names = tdz_names::collect(hir); // #5247: install per-module source-location context for the dynamic // call-dispatch throw path, but only under `--debug-symbols` (which sets // `opts.debug_locations` + `opts.module_source`). Off by default — no diff --git a/crates/perry-codegen/src/codegen/tdz_names.rs b/crates/perry-codegen/src/codegen/tdz_names.rs new file mode 100644 index 0000000000..b640a83f37 --- /dev/null +++ b/crates/perry-codegen/src/codegen/tdz_names.rs @@ -0,0 +1,184 @@ +//! Preserve source names for checked reads of forward lexical boxes. +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Expr, Function, Module, Stmt}; + +#[derive(Default)] +struct Names { + bindings: HashMap, + tdz: HashSet, +} + +pub(super) fn collect(module: &Module) -> HashMap { + let mut names = Names::default(); + names.stmts(&module.init); + for function in &module.functions { + names.function(function); + } + for class in &module.classes { + for function in class + .methods + .iter() + .chain(&class.static_methods) + .chain(class.getters.iter().map(|(_, f)| f)) + .chain(class.setters.iter().map(|(_, f)| f)) + .chain(class.constructor.iter()) + .chain(class.computed_members.iter().map(|member| &member.function)) + { + names.function(function); + } + for field in class.fields.iter().chain(&class.static_fields) { + for expr in field.init.iter().chain(&field.key_expr) { + names.expr(expr); + } + } + } + for global in &module.globals { + if let Some(init) = &global.init { + names.expr(init); + } + } + names.bindings.retain(|id, _| names.tdz.contains(id)); + names.bindings +} + +impl Names { + fn function(&mut self, function: &Function) { + self.stmts(&function.body); + for param in &function.params { + if let Some(default) = ¶m.default { + self.expr(default); + } + } + } + + fn expr(&mut self, expr: &Expr) { + if let Expr::Closure { body, .. } = expr { + self.stmts(body); + } + perry_hir::walker::walk_expr_children(expr, &mut |child| self.expr(child)); + } + + fn stmts(&mut self, stmts: &[Stmt]) { + for stmt in stmts { + match stmt { + Stmt::Let { id, name, init, .. } => { + self.bindings.insert(*id, name.clone()); + if let Some(init) = init { + self.expr(init); + } + } + Stmt::PreallocateTdzBoxes(ids) => self.tdz.extend(ids), + Stmt::Expr(expr) | Stmt::Throw(expr) => self.expr(expr), + Stmt::Return(expr) => { + if let Some(expr) = expr { + self.expr(expr); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.expr(condition); + self.stmts(then_branch); + if let Some(branch) = else_branch { + self.stmts(branch); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + self.expr(condition); + self.stmts(body); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + self.stmts(std::slice::from_ref(init)); + } + for expr in condition.iter().chain(update) { + self.expr(expr); + } + self.stmts(body); + } + Stmt::Labeled { body, .. } => self.stmts(std::slice::from_ref(body)), + Stmt::Try { + body, + catch, + finally, + } => { + self.stmts(body); + if let Some(catch) = catch { + self.stmts(&catch.body); + } + if let Some(finally) = finally { + self.stmts(finally); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.expr(discriminant); + for case in cases { + if let Some(test) = &case.test { + self.expr(test); + } + self.stmts(&case.body); + } + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + + #[test] + fn collects_nested_lexical_names_without_naming_ordinary_boxes() { + let local = |id, name: &str| Stmt::Let { + id, + name: name.into(), + ty: Type::Any, + mutable: true, + init: None, + }; + let mut hir = Module::new("names"); + hir.init = vec![ + Stmt::PreallocateBoxes(vec![0]), + Stmt::PreallocateTdzBoxes(vec![1]), + local(0, "ordinary"), + local(1, "later"), + Stmt::Expr(Expr::Closure { + func_id: 0, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::PreallocateTdzBoxes(vec![2]), local(2, "nested")], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }), + ]; + let mut names = super::collect(&hir).into_values().collect::>(); + names.sort(); + assert_eq!(names, ["later", "nested"]); + } +} diff --git a/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs b/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs index 06700bf2e6..03bbccdb48 100644 --- a/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs +++ b/crates/perry-codegen/src/codegen/trusted_box_callback_tests.rs @@ -186,9 +186,18 @@ fn select(closures: Vec<(u32, Expr)>, direct: impl IntoIterator) -> } fn emit(direct_literal: bool) -> String { + emit_with_tdz(direct_literal, false) +} + +fn emit_with_tdz(direct_literal: bool, tdz: bool) -> String { let mut module = Module::new("trusted_box_callback.ts"); module.init_kind = ModuleInitKind::Eager; module.functions = vec![consume_function(), outer_function(direct_literal)]; + if tdz { + module.functions[1] + .body + .insert(0, Stmt::PreallocateTdzBoxes(vec![COUNT])); + } module.init.push(Stmt::Expr(Expr::Call { callee: Box::new(Expr::FuncRef(3)), args: Vec::new(), @@ -260,6 +269,23 @@ fn named_block_body<'a>(function: &'a str, prefix: &str) -> String { .join("\n") } +#[test] +fn named_tdz_reads_reach_public_and_trusted_callbacks() { + let ir = emit_with_tdz(true, true); + let public = function_body(&ir, "perry_closure_trusted_box_callback_ts__99"); + let trusted = function_body( + &ir, + "perry_closure_trusted_box_callback_ts__99$trusted_boxes", + ); + assert!(public.contains("@js_box_get_bits_named("), "{public}"); + let cold = named_block_body(&trusted, "trusted_box.tdz"); + assert!(cold.contains("@js_box_get_bits_trusted_named("), "{cold}"); + assert!( + ir.contains("c\"count\\00\""), + "binding name must be in the string pool" + ); +} + #[test] fn direct_arrow_gets_a_private_body_but_keeps_the_public_validation_path() { let ir = emit(true); diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index eea5d38544..a1d125ec12 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -24,6 +24,29 @@ use super::{ TrustedBoxCapturePtr, }; +/// Only TDZ-capable source bindings need a named accessor. Ordinary boxes +/// retain their existing ABI; trusted inline loads pass the name only on +/// their cold TDZ arm. Names come from permanent, GC-rooted string globals. +fn emit_box_read(ctx: &mut FnCtx<'_>, id: u32, ptr: &str, trusted: bool) -> String { + let base = if trusted { + "js_box_get_bits_trusted" + } else { + "js_box_get_bits" + }; + if let Some(name) = ctx.strings.tdz_binding_names.get(&id).cloned() { + let index = ctx.strings.intern(&name); + let global = format!("@{}", ctx.strings.entry(index).handle_global); + let name = ctx.block().load(DOUBLE, &global); + ctx.block().call( + I64, + &format!("{base}_named"), + &[(I64, ptr), (DOUBLE, &name)], + ) + } else { + ctx.block().call(I64, base, &[(I64, ptr)]) + } +} + /// Load the current value from a compiler-proven raw box capture. /// /// The exact-arrow resolver has already validated `capture.ptr`, so the hot @@ -31,7 +54,11 @@ use super::{ /// to the existing trusted accessor only for the reserved sentinel; that /// helper owns both ReferenceError construction and Perry's internal TDZ /// suppression window semantics. -fn load_trusted_box_capture_bits(ctx: &mut FnCtx<'_>, capture: &TrustedBoxCapturePtr) -> String { +fn load_trusted_box_capture_bits( + ctx: &mut FnCtx<'_>, + id: u32, + capture: &TrustedBoxCapturePtr, +) -> String { let bits = ctx.block().load(I64, &capture.ptr); let is_tdz = ctx.block().icmp_eq(I64, &bits, crate::nanbox::TAG_TDZ_I64); let slow_idx = ctx.new_block("trusted_box.tdz"); @@ -47,9 +74,7 @@ fn load_trusted_box_capture_bits(ctx: &mut FnCtx<'_>, capture: &TrustedBoxCaptur // before entering that observable cold arm, just like a PIC miss or // dynamic `+` fallback. crate::expr::emit_versioned_loop_callback_deopt(ctx); - let slow_bits = ctx - .block() - .call(I64, "js_box_get_bits_trusted", &[(I64, &capture.bits)]); + let slow_bits = emit_box_read(ctx, id, &capture.bits, true); let slow_end = ctx.block().label.clone(); ctx.block().br(&merge_label); @@ -474,22 +499,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // and deref via js_box_get_bits. if ctx.boxed_vars.contains(id) { if let Some(capture) = ctx.trusted_box_capture_ptrs.get(id).cloned() { - let bits = load_trusted_box_capture_bits(ctx, &capture); + let bits = load_trusted_box_capture_bits(ctx, *id, &capture); let value = ctx.block().bitcast_i64_to_double(&bits); demote_extracted_string_binding(ctx, *id, &value); return Ok(value); } let closure_ptr = super::current_closure_ptr_value(ctx, "captured boxed local")?; - let getter = if ctx.trusted_box_captures { - "js_box_get_bits_trusted" - } else { - "js_box_get_bits" - }; let box_ptr = load_closure_capture_bits_inline(ctx, &closure_ptr, capture_idx); - let blk = ctx.block(); - let bits = blk.call(I64, getter, &[(I64, &box_ptr)]); - let value = blk.bitcast_i64_to_double(&bits); + let bits = emit_box_read(ctx, *id, &box_ptr, ctx.trusted_box_captures); + let value = ctx.block().bitcast_i64_to_double(&bits); demote_extracted_string_binding(ctx, *id, &value); return Ok(value); } @@ -519,8 +538,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(slot) = ctx.locals.get(id).cloned() { let blk = ctx.block(); let box_ptr = blk.load(I64, &slot); - let bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]); - let value = blk.bitcast_i64_to_double(&bits); + let bits = emit_box_read(ctx, *id, &box_ptr, false); + let value = ctx.block().bitcast_i64_to_double(&bits); demote_extracted_string_binding(ctx, *id, &value); return Ok(value); } @@ -959,7 +978,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // nested user frame `coerce_old`/`step_new` may enter. if ctx.boxed_vars.contains(id) { if let Some(capture) = ctx.trusted_box_capture_ptrs.get(id).cloned() { - let old_bits = load_trusted_box_capture_bits(ctx, &capture); + let old_bits = load_trusted_box_capture_bits(ctx, *id, &capture); let old = ctx.block().bitcast_i64_to_double(&old_bits); if needs_numeric_coerce && ctx.versioned_loop_deopt_context.is_some() { let is_number = crate::stmt::emit_js_value_is_number(ctx, &old); @@ -1016,11 +1035,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } let closure_ptr = super::current_closure_ptr_value(ctx, "captured boxed local update")?; - let getter = if ctx.trusted_box_captures { - "js_box_get_bits_trusted" - } else { - "js_box_get_bits" - }; let setter = if ctx.trusted_box_captures { "js_box_set_bits_trusted_no_barrier" } else { @@ -1032,7 +1046,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_closure_get_capture_bits", &[(I64, &closure_ptr), (I32, &idx_str)], ); - let old_bits = blk.call(I64, getter, &[(I64, &box_ptr)]); + let old_bits = emit_box_read(ctx, *id, &box_ptr, ctx.trusted_box_captures); + let blk = ctx.block(); let old = blk.bitcast_i64_to_double(&old_bits); let old = coerce_old(blk, &old); let new = step_new(blk, &old); @@ -1086,7 +1101,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(slot) = ctx.locals.get(id).cloned() { let blk = ctx.block(); let box_ptr = blk.load(I64, &slot); - let old_bits = blk.call(I64, "js_box_get_bits", &[(I64, &box_ptr)]); + let old_bits = emit_box_read(ctx, *id, &box_ptr, false); + let blk = ctx.block(); let old = blk.bitcast_i64_to_double(&old_bits); let old = coerce_old(blk, &old); let new = step_new(blk, &old); diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 9e0e2948c6..d2d4508d7b 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -872,7 +872,12 @@ mod tests { /// symbol can be admitted; this one cannot. #[test] fn the_tdz_capable_box_getter_stays_a_safepoint() { - for name in ["js_box_get_bits", "js_box_get_bits_trusted"] { + for name in [ + "js_box_get_bits", + "js_box_get_bits_trusted", + "js_box_get_bits_named", + "js_box_get_bits_trusted_named", + ] { assert_eq!( classify_direct_callee(name), GcCallEffect::Unknown, diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index b101f9cbf9..0c009c6dc4 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1079,8 +1079,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // both inc() and get() in a returned object literal). module.declare_function("js_box_alloc_bits", I64, &[I64]); module.declare_function("js_box_get_bits", I64, &[I64]); + module.declare_function("js_box_get_bits_named", I64, &[I64, DOUBLE]); module.declare_function("js_box_set_bits", VOID, &[I64, I64]); module.declare_function("js_box_get_bits_trusted", I64, &[I64]); + module.declare_function("js_box_get_bits_trusted_named", I64, &[I64, DOUBLE]); module.declare_function("js_box_set_bits_trusted_no_barrier", VOID, &[I64, I64]); module.declare_function("js_box_alloc", I64, &[DOUBLE]); module.declare_function("js_box_get", DOUBLE, &[I64]); diff --git a/crates/perry-codegen/src/strings.rs b/crates/perry-codegen/src/strings.rs index 20467e44a3..532e7b1393 100644 --- a/crates/perry-codegen/src/strings.rs +++ b/crates/perry-codegen/src/strings.rs @@ -81,6 +81,9 @@ pub struct StringPool { /// Ordered list of unique entries; the index in this Vec is the /// interned index referenced by `interned`. entries: Vec, + /// Module-wide names for TDZ-capable bindings, including outer bindings + /// read from closure bodies. Kept separately from per-function aliases. + pub(crate) tdz_binding_names: HashMap, /// #5247: source-location context for the dynamic call-dispatch throw /// path. Set once per module after construction (only when the CLI /// `--debug-symbols` flag is on). `None` in the default build so codegen @@ -141,6 +144,7 @@ impl StringPool { module_prefix, interned: HashMap::new(), entries: Vec::new(), + tdz_binding_names: HashMap::new(), debug_location_ctx: None, debug_source_line_offset: 0, pending_call_offset: std::cell::Cell::new(0), diff --git a/crates/perry-runtime/src/box.rs b/crates/perry-runtime/src/box.rs index 802d6fbd24..dc65e4f791 100644 --- a/crates/perry-runtime/src/box.rs +++ b/crates/perry-runtime/src/box.rs @@ -970,6 +970,18 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { /// rather than dereferencing. See perry#393 for the failure mode. #[no_mangle] pub extern "C" fn js_box_get_bits(ptr: *mut Box) -> i64 { + box_get_bits_named(ptr, f64::from_bits(crate::value::TAG_UNDEFINED)) +} + +/// Checked lexical read with the source binding name supplied by codegen. +/// The name is consumed only on the TDZ error path, before any GC allocation. +#[no_mangle] +pub extern "C" fn js_box_get_bits_named(ptr: *mut Box, name: f64) -> i64 { + box_get_bits_named(ptr, name) +} + +#[inline] +fn box_get_bits_named(ptr: *mut Box, name: f64) -> i64 { unsafe { if !is_registered_box_ptr(ptr) { // perry#924: production services see these in tight bursts of @@ -1020,7 +1032,7 @@ pub extern "C" fn js_box_get_bits(ptr: *mut Box) -> i64 { if TDZ_SUPPRESS_DEPTH.with(|d| d.get()) > 0 { return crate::value::TAG_UNDEFINED as i64; } - crate::error::js_throw_reference_error_tdz(f64::from_bits(crate::value::TAG_UNDEFINED)); + crate::error::js_throw_reference_error_tdz(name); } bits as i64 } @@ -1073,12 +1085,21 @@ pub extern "C" fn js_box_capture_cell_ptr(bits: i64) -> i64 { #[no_mangle] pub unsafe extern "C" fn js_box_get_bits_trusted(ptr: *mut Box) -> i64 { + unsafe { js_box_get_bits_trusted_named(ptr, f64::from_bits(crate::value::TAG_UNDEFINED)) } +} + +/// Named counterpart of `js_box_get_bits_trusted`. +/// +/// # Safety +/// `ptr` must be a live box cell, as for `js_box_get_bits_trusted`. +#[no_mangle] +pub unsafe extern "C" fn js_box_get_bits_trusted_named(ptr: *mut Box, name: f64) -> i64 { let bits = unsafe { (*ptr).value }; if bits == crate::value::TAG_TDZ { if TDZ_SUPPRESS_DEPTH.with(|d| d.get()) > 0 { return crate::value::TAG_UNDEFINED as i64; } - crate::error::js_throw_reference_error_tdz(f64::from_bits(crate::value::TAG_UNDEFINED)); + crate::error::js_throw_reference_error_tdz(name); } bits as i64 } @@ -1415,6 +1436,13 @@ static KEEP_JS_BOX_GET_BITS_TRUSTED: unsafe extern "C" fn(*mut Box) -> i64 = js_box_get_bits_trusted; #[cfg(feature = "keepalive-anchors")] #[used] +static KEEP_JS_BOX_GET_BITS_NAMED: extern "C" fn(*mut Box, f64) -> i64 = js_box_get_bits_named; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_BOX_GET_BITS_TRUSTED_NAMED: unsafe extern "C" fn(*mut Box, f64) -> i64 = + js_box_get_bits_trusted_named; +#[cfg(feature = "keepalive-anchors")] +#[used] static KEEP_JS_BOX_SET_BITS_TRUSTED_NO_BARRIER: unsafe extern "C" fn(*mut Box, i64) = js_box_set_bits_trusted_no_barrier; #[cfg(feature = "keepalive-anchors")] diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index f3592bae06..e6a55d645f 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -1081,16 +1081,17 @@ fn throw_reference_error_message(message: &'static [u8]) -> ! { /// `class` binding is read, `typeof`-d, or compound-assigned before its /// declaration has been evaluated — i.e. while its box still holds the /// `TAG_TDZ` sentinel. `name` is the NaN-boxed binding name (or `undefined` -/// when codegen could not thread a name through, e.g. a captured box read). -/// Message matches V8/Node byte-for-byte: `Cannot access x before +/// for legacy unnamed box reads). +/// Message matches V8/Node byte-for-byte: `Cannot access 'x' before /// initialization`. #[no_mangle] pub extern "C" fn js_throw_reference_error_tdz(name: f64) -> f64 { + let unnamed = name.to_bits() == crate::value::TAG_UNDEFINED; let name = value_to_lossy_string(name); - let msg = if name.is_empty() { + let msg = if unnamed || name.is_empty() { "Cannot access uninitialized variable before initialization".to_string() } else { - format!("Cannot access {} before initialization", name) + format!("Cannot access '{}' before initialization", name) }; let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); let err_ptr = js_referenceerror_new(msg_str); diff --git a/crates/perry-transform/src/closure_local_inline.rs b/crates/perry-transform/src/closure_local_inline.rs index 51611dab92..2b84d5cf1e 100644 --- a/crates/perry-transform/src/closure_local_inline.rs +++ b/crates/perry-transform/src/closure_local_inline.rs @@ -109,6 +109,18 @@ fn process_stmts(stmts: &mut Vec, next_local_id: &mut LocalId) { // (`let exists' = exists`); follow such copies so the calls through // them count as calls of the closure. let set = collect_aliases(&stmts[i + 1..], id); + // Forward captures and reads can precede the declaration. Removing + // its initializer leaves those live boxes uninitialized even though + // every later use is an inlineable call (#9721). Earlier calls must + // also retain their original TDZ behavior. + let mut earlier_uses = Uses::default(); + for s in &stmts[..i] { + collect_uses_in_stmt(s, &set, &mut earlier_uses); + } + if earlier_uses.other || earlier_uses.calls != 0 { + i += 1; + continue; + } let mut uses = Uses::default(); for s in &stmts[i + 1..] { collect_uses_in_stmt(s, &set, &mut uses); @@ -887,6 +899,54 @@ mod tests { assert_eq!(format!("{stmts:?}"), format!("{before:?}")); } + #[test] + fn a_forward_capture_keeps_the_later_initializer() { + let mut earlier = arrow(2, Vec::new(), call_local(F, vec![Expr::Integer(1)]), false); + if let Expr::Closure { + captures, + mutable_captures, + .. + } = &mut earlier + { + captures.push(F); + mutable_captures.push(F); + } + let mut stmts = vec![ + Stmt::PreallocateTdzBoxes(vec![F]), + Stmt::Expr(earlier), + Stmt::Let { + id: F, + name: "later".into(), + ty: Type::Any, + mutable: false, + init: Some(arrow(1, vec![param(P, "value")], Expr::LocalGet(P), false)), + }, + Stmt::Expr(call_local(F, vec![Expr::Integer(2)])), + ]; + let before = format!("{stmts:?}"); + process_stmts(&mut stmts, &mut 100); + assert_eq!(format!("{stmts:?}"), before); + } + + #[test] + fn a_call_before_initialization_keeps_its_tdz_and_later_initializer() { + let mut stmts = vec![ + Stmt::PreallocateTdzBoxes(vec![F]), + Stmt::Expr(call_local(F, vec![Expr::Integer(1)])), + Stmt::Let { + id: F, + name: "later".into(), + ty: Type::Any, + mutable: false, + init: Some(arrow(1, vec![param(P, "value")], Expr::LocalGet(P), false)), + }, + Stmt::Expr(call_local(F, vec![Expr::Integer(2)])), + ]; + let before = format!("{stmts:?}"); + process_stmts(&mut stmts, &mut 100); + assert_eq!(format!("{stmts:?}"), before); + } + #[test] fn a_non_trivial_argument_or_a_capture_by_a_nested_closure_is_declined() { // Non-trivial argument: the arrow would duplicate or reorder effects. diff --git a/test-files/test_gap_9721_forward_const_initialization.ts b/test-files/test_gap_9721_forward_const_initialization.ts new file mode 100644 index 0000000000..6c293944ae --- /dev/null +++ b/test-files/test_gap_9721_forward_const_initialization.ts @@ -0,0 +1,28 @@ +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(); diff --git a/test-files/test_gap_9721_tdz_binding_names.ts b/test-files/test_gap_9721_tdz_binding_names.ts new file mode 100644 index 0000000000..111684f713 --- /dev/null +++ b/test-files/test_gap_9721_tdz_binding_names.ts @@ -0,0 +1,45 @@ +// Genuine dead-zone reads must name the source binding, including captures. +function captures(): void { + const read = () => later(); + try { read(); } catch (error) { console.log(error.name, error.message); } + const later = () => "initialized"; + console.log(read(), later()); +} + +function localAndCaptured(): void { + const read = () => value; + try { console.log(value); } catch (error) { console.log(error.name, error.message); } + try { read(); } catch (error) { console.log(error.name, error.message); } + let value = 41; + value++; + console.log(read()); +} + +function typeOfAndUpdate(): void { + const type = () => typeof count; + const update = () => count++; + try { type(); } catch (error) { console.log(error.name, error.message); } + try { update(); } catch (error) { console.log(error.name, error.message); } + let count = 10; + console.log(type(), update(), count); +} + +function nestedNames(): void { + const value = "outer"; + { + const read = () => value; + try { read(); } catch (error) { console.log(error.name, error.message); } + const value = "inner"; + console.log(read()); + } + console.log(value); + const readUnicode = () => café; + try { readUnicode(); } catch (error) { console.log(error.name, error.message); } + const café = "ready"; + console.log(readUnicode()); +} + +captures(); +localAndCaptured(); +typeOfAndUpdate(); +nestedNames(); From 5aec53064bbdc1ed5666009da4b1d7befe26ded8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 03:25:33 +0200 Subject: [PATCH 2/2] docs: key changelog fragment to PR 9762 --- .../{9721-forward-const-tdz.md => 9762-forward-const-tdz.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9721-forward-const-tdz.md => 9762-forward-const-tdz.md} (100%) diff --git a/changelog.d/9721-forward-const-tdz.md b/changelog.d/9762-forward-const-tdz.md similarity index 100% rename from changelog.d/9721-forward-const-tdz.md rename to changelog.d/9762-forward-const-tdz.md