From 2e7bfbd7d07674d45bdc92655021959376826d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 08:56:16 +0200 Subject: [PATCH 1/4] fix(codegen): initialize preallocated cells on every continuation --- crates/perry-codegen/src/stmt/mod.rs | 30 ++--- .../src/stmt/prealloc_continuation_tests.rs | 111 ++++++++++++++++++ .../src/stmt/prealloc_module_global_tests.rs | 2 +- ...t_gap_generator_delegated_local_capture.ts | 19 +++ ...generator_preallocated_capture_controls.ts | 80 +++++++++++++ 5 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 crates/perry-codegen/src/stmt/prealloc_continuation_tests.rs create mode 100644 test-files/test_gap_generator_delegated_local_capture.ts create mode 100644 test-files/test_gap_generator_preallocated_capture_controls.ts diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 6550f09bab..dbf3a7841e 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -28,6 +28,8 @@ mod let_stmt_facts; mod loops; mod masked_window_region; #[cfg(test)] +mod prealloc_continuation_tests; +#[cfg(test)] mod prealloc_module_global_tests; pub(crate) mod stable_packed_accumulator; pub(crate) mod stable_packed_loop; @@ -671,16 +673,11 @@ 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. - ctx.prealloc_boxes.insert(*id); - ctx.boxed_vars.insert(*id); - if tdz { - ctx.tdz_boxes.insert(*id); - } - continue; - } + // #10048: `locals` describes emitted storage, not which initializers + // dominate this path. Generator lowering can clone a scope into + // mutually exclusive continuations. Each executed scope entry needs + // its own fresh cell, even when an earlier emitted copy owns the slot. + // Reuse only the alloca below, never omit this path's allocation. let is_i32_control = crate::expr::is_compiler_private_async_i32_control_local(ctx, *id); let is_i1_control = crate::expr::is_compiler_private_async_i1_control_local(ctx, *id); let blk = ctx.block(); @@ -721,7 +718,6 @@ 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 @@ -731,9 +727,15 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result // 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); + let slot = if let Some(slot) = ctx.locals.get(id) { + slot.clone() + } else { + let slot = ctx.func.alloca_entry(crate::types::I64); + 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_continuation_tests.rs b/crates/perry-codegen/src/stmt/prealloc_continuation_tests.rs new file mode 100644 index 0000000000..aedf024eee --- /dev/null +++ b/crates/perry-codegen/src/stmt/prealloc_continuation_tests.rs @@ -0,0 +1,111 @@ +//! #10048: emitting an earlier branch does not initialize a sibling branch. +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, Param, Stmt}; + +fn branch(tdz: bool) -> Vec { + vec![ + if tdz { + Stmt::PreallocateTdzBoxes(vec![101]) + } else { + Stmt::PreallocateBoxes(vec![101]) + }, + Stmt::Let { + id: 101, + name: "callback".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(40)), + }, + Stmt::Return(Some(Expr::LocalGet(101))), + ] +} + +fn assert_each_continuation_allocates(tdz: bool) { + let mut module = Module::new("prealloc_continuation.ts"); + module.functions.push(Function { + id: 1, + name: "resume".into(), + type_params: Vec::new(), + params: vec![Param { + id: 100, + name: "state".into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Any, + body: vec![Stmt::If { + condition: Expr::LocalGet(100), + then_branch: branch(tdz), + else_branch: Some(branch(tdz)), + }], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let ir = String::from_utf8( + crate::compile_module(&module, super::prealloc_module_global_tests::ir_opts()).unwrap(), + ) + .unwrap(); + let allocations: Vec<_> = ir + .lines() + .filter(|line| line.contains("call i64 @js_box_alloc_bits(")) + .collect(); + // Both mutually exclusive copies initialize the same lexical binding. + // The pre-fix ctx.locals check only emitted the first allocation. + assert_eq!( + allocations.len(), + 2, + "each continuation must allocate:\n{ir}" + ); + let seed = if tdz { + crate::nanbox::TAG_TDZ_I64 + } else { + crate::nanbox::TAG_UNDEFINED_I64 + }; + let mut slots = Vec::new(); + for allocation in allocations { + assert!( + allocation.contains(seed), + "wrong initial cell value: {allocation}" + ); + let result = allocation.trim().split(" = ").next().unwrap(); + let store = ir + .lines() + .find(|line| { + line.trim() + .starts_with(&format!("store i64 {result}, ptr ")) + }) + .expect("each allocated box must initialize its pointer slot"); + slots.push(store.trim().split(", ptr ").nth(1).unwrap()); + } + assert_eq!( + slots[0], slots[1], + "continuations must share the lexical slot" + ); + assert!( + ir.contains(&format!( + "store i64 {}, ptr {}", + crate::nanbox::TAG_UNDEFINED_I64, + slots[0] + )), + "bypassed declarations still need the entry sentinel" + ); +} + +#[test] +fn each_plain_continuation_allocates_its_cell() { + assert_each_continuation_allocates(false); +} + +#[test] +fn each_tdz_continuation_allocates_its_cell() { + assert_each_continuation_allocates(true); +} diff --git a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs index 5443cc155f..3b990bd6ff 100644 --- a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs +++ b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs @@ -39,7 +39,7 @@ use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::Type; use perry_hir::{Expr, Module, ModuleInitKind, Param, Stmt}; -fn ir_opts() -> CompileOptions { +pub(super) fn ir_opts() -> CompileOptions { CompileOptions { target: None, is_entry_module: true, diff --git a/test-files/test_gap_generator_delegated_local_capture.ts b/test-files/test_gap_generator_delegated_local_capture.ts new file mode 100644 index 0000000000..a3632d937e --- /dev/null +++ b/test-files/test_gap_generator_delegated_local_capture.ts @@ -0,0 +1,19 @@ +// #10048: a delegated loop emits multiple copies of the resumed scope. +// The callback's preallocated cell must be initialized in every copy. +async function* delegate(value: number) { yield value; } +async function* outer() { + let base = 40; + for (let index = 0; index < 2; index++) { + yield* delegate(index); + let getter = () => base + index; + let wrapper = () => getter(); + yield wrapper(); + } +} +async function main() { + const values: number[] = []; + for await (const value of outer()) values.push(value); + if (values.join(',') !== '0,40,1,41') throw new Error(values.join(',')); + console.log('PASS: delegated loop captures local callable'); +} +main().catch(error => { console.error(error); process.exit(1); }); diff --git a/test-files/test_gap_generator_preallocated_capture_controls.ts b/test-files/test_gap_generator_preallocated_capture_controls.ts new file mode 100644 index 0000000000..c0883f09bf --- /dev/null +++ b/test-files/test_gap_generator_preallocated_capture_controls.ts @@ -0,0 +1,80 @@ +// #10048 controls: fresh iteration cells, empty delegates, TDZ, var identity, +// recursive closures, and captures made after ordinary yield/await. +function check(label: string, actual: string, expected: string) { + if (actual !== expected) throw new Error(label + ': ' + actual + ' != ' + expected); + console.log(label + ': ' + actual); +} +async function* emptyDelegate() { if (false) yield -1; } +async function* nonemptyDelegate(value: number) { yield value; } +async function* emptyLoop() { + for (let index = 0; index < 2; index++) { + yield* emptyDelegate(); + let getter = () => index + 40; + let wrapper = () => getter(); + yield wrapper(); + } +} +async function* yieldControl() { + yield 1; + let getter = () => 42; + let wrapper = () => getter(); + yield wrapper(); +} +async function awaitControl() { + await Promise.resolve(1); + let getter = () => 42; + let wrapper = () => getter(); + return wrapper(); +} +async function* retained(callbacks: Array<() => number>) { + for (let index = 0; index < 3; index++) { + yield* nonemptyDelegate(index); + let value = index + 10; + let getter = () => value; + let wrapper = () => getter(); + callbacks.push(wrapper); + value += 100; + yield wrapper(); + } +} +function tdzAndRecursion() { + 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(','); +} +function hoistedVar() { + const callbacks: Array<() => number> = []; + for (let index = 0; index < 3; index++) { + callbacks.push(read); + var value = index; + function read() { return value; } + } + return callbacks.map(callback => callback()).join(','); +} +async function collect(iterator: AsyncIterable) { + const values: number[] = []; + for await (const value of iterator) values.push(value); + return values.join(','); +} +async function main() { + check('empty delegate', await collect(emptyLoop()), '40,41'); + check('ordinary yield', await collect(yieldControl()), '1,42'); + check('ordinary await', String(await awaitControl()), '42'); + const callbacks: Array<() => number> = []; + check('retained yields', await collect(retained(callbacks)), '0,110,1,111,2,112'); + check('retained callbacks', callbacks.map(callback => callback()).join(','), '110,111,112'); + check('TDZ and recursion', tdzAndRecursion(), 'tdz,undefined,0,0,tdz,undefined,1,1'); + check('hoisted var', hoistedVar(), '2,2,2'); + console.log('PASS: preallocated capture controls'); +} +main().catch(error => { console.error(error); process.exit(1); }); From eb52a581ed3dc3853b9e7d4abbbdef4f1f23a5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 08:57:09 +0200 Subject: [PATCH 2/4] docs: record generator continuation preallocation fix --- .../10049-generator-continuation-preallocation.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog.d/10049-generator-continuation-preallocation.md diff --git a/changelog.d/10049-generator-continuation-preallocation.md b/changelog.d/10049-generator-continuation-preallocation.md new file mode 100644 index 0000000000..4fa6e126f3 --- /dev/null +++ b/changelog.d/10049-generator-continuation-preallocation.md @@ -0,0 +1,11 @@ +Fix captured local callbacks becoming undefined after `yield*` inside an async +generator loop (#10048). Each emitted preallocation directive now initializes +its own control-flow path, reusing an existing local slot without confusing +emitted storage with an executed initialization. Scope re-entry gets a fresh +cell while retained closures keep their original cells. Module-global +precedence, TDZ initialization, and specialized async control cells are kept. + +Adds fail-before/pass-after codegen coverage for ordinary and TDZ continuation +copies, plus independent native parity fixtures for delegated loops, retained +iteration callbacks, empty delegates, ordinary yield/await, recursion, TDZ, +and shared hoisted-var bindings. From 81b47b47cace5b150edde32059ff272a3a19eca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 09:21:55 +0200 Subject: [PATCH 3/4] fix(codegen): materialize missing cells in reused boxed declarations --- ...49-generator-continuation-preallocation.md | 12 ++-- ...n_tests.rs => boxed_continuation_tests.rs} | 58 ++++++++++++------- .../src/stmt/boxed_local_init.rs | 40 +++++++++++++ crates/perry-codegen/src/stmt/let_stmt.rs | 6 ++ crates/perry-codegen/src/stmt/mod.rs | 33 ++++++----- 5 files changed, 106 insertions(+), 43 deletions(-) rename crates/perry-codegen/src/stmt/{prealloc_continuation_tests.rs => boxed_continuation_tests.rs} (65%) create mode 100644 crates/perry-codegen/src/stmt/boxed_local_init.rs diff --git a/changelog.d/10049-generator-continuation-preallocation.md b/changelog.d/10049-generator-continuation-preallocation.md index 4fa6e126f3..43b4f8b865 100644 --- a/changelog.d/10049-generator-continuation-preallocation.md +++ b/changelog.d/10049-generator-continuation-preallocation.md @@ -1,11 +1,11 @@ Fix captured local callbacks becoming undefined after `yield*` inside an async -generator loop (#10048). Each emitted preallocation directive now initializes -its own control-flow path, reusing an existing local slot without confusing -emitted storage with an executed initialization. Scope re-entry gets a fresh -cell while retained closures keep their original cells. Module-global -precedence, TDZ initialization, and specialized async control cells are kept. +generator loop (#10048). The reused boxed-declaration path now initializes a +missing cell before evaluating its initializer, without confusing an existing +stack slot with an executed box allocation. Existing live cells are retained, +preserving shared hoisted-var bindings. Module globals and preallocated cells +remain on their existing paths; specialized async control cell types are kept. -Adds fail-before/pass-after codegen coverage for ordinary and TDZ continuation +Adds codegen coverage for initialized and uninitialized boxed declaration copies, plus independent native parity fixtures for delegated loops, retained iteration callbacks, empty delegates, ordinary yield/await, recursion, TDZ, and shared hoisted-var bindings. diff --git a/crates/perry-codegen/src/stmt/prealloc_continuation_tests.rs b/crates/perry-codegen/src/stmt/boxed_continuation_tests.rs similarity index 65% rename from crates/perry-codegen/src/stmt/prealloc_continuation_tests.rs rename to crates/perry-codegen/src/stmt/boxed_continuation_tests.rs index aedf024eee..6a594b3120 100644 --- a/crates/perry-codegen/src/stmt/prealloc_continuation_tests.rs +++ b/crates/perry-codegen/src/stmt/boxed_continuation_tests.rs @@ -1,27 +1,43 @@ -//! #10048: emitting an earlier branch does not initialize a sibling branch. +//! #10048: a boxed Let's earlier emitted copy may never execute on this path. use perry_hir::types::Type; use perry_hir::{Expr, Function, Module, Param, Stmt}; -fn branch(tdz: bool) -> Vec { +fn branch(initialized: bool) -> Vec { vec![ - if tdz { - Stmt::PreallocateTdzBoxes(vec![101]) - } else { - Stmt::PreallocateBoxes(vec![101]) - }, Stmt::Let { id: 101, name: "callback".into(), ty: Type::Any, mutable: true, - init: Some(Expr::Integer(40)), + init: initialized.then_some(Expr::Integer(40)), + }, + Stmt::Let { + id: 102, + name: "writer".into(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Closure { + func_id: 2, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Expr(Expr::LocalSet(101, Box::new(Expr::Integer(42))))], + captures: vec![101], + mutable_captures: vec![101], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }), }, Stmt::Return(Some(Expr::LocalGet(101))), ] } -fn assert_each_continuation_allocates(tdz: bool) { - let mut module = Module::new("prealloc_continuation.ts"); +fn assert_each_continuation_allocates(initialized: bool) { + let mut module = Module::new("boxed_continuation.ts"); module.functions.push(Function { id: 1, name: "resume".into(), @@ -38,8 +54,8 @@ fn assert_each_continuation_allocates(tdz: bool) { return_type: Type::Any, body: vec![Stmt::If { condition: Expr::LocalGet(100), - then_branch: branch(tdz), - else_branch: Some(branch(tdz)), + then_branch: branch(initialized), + else_branch: Some(branch(initialized)), }], is_async: false, is_generator: false, @@ -65,11 +81,11 @@ fn assert_each_continuation_allocates(tdz: bool) { 2, "each continuation must allocate:\n{ir}" ); - let seed = if tdz { - crate::nanbox::TAG_TDZ_I64 - } else { - crate::nanbox::TAG_UNDEFINED_I64 - }; + let seed = crate::nanbox::TAG_UNDEFINED_I64; + assert!( + ir.contains("boxed.reuse.allocate"), + "missing-cell allocation must be conditional" + ); let mut slots = Vec::new(); for allocation in allocations { assert!( @@ -101,11 +117,11 @@ fn assert_each_continuation_allocates(tdz: bool) { } #[test] -fn each_plain_continuation_allocates_its_cell() { - assert_each_continuation_allocates(false); +fn initialized_boxed_let_materializes_a_missing_cell() { + assert_each_continuation_allocates(true); } #[test] -fn each_tdz_continuation_allocates_its_cell() { - assert_each_continuation_allocates(true); +fn uninitialized_boxed_let_materializes_a_missing_cell() { + assert_each_continuation_allocates(false); } diff --git a/crates/perry-codegen/src/stmt/boxed_local_init.rs b/crates/perry-codegen/src/stmt/boxed_local_init.rs new file mode 100644 index 0000000000..75da961819 --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_local_init.rs @@ -0,0 +1,40 @@ +//! A reused stack slot is not proof its boxed declaration executed (#10048). +use crate::expr::FnCtx; +use crate::types::{I32, I64}; + +pub(super) fn ensure_reused_box_is_initialized(ctx: &mut FnCtx<'_>, id: u32) { + if !ctx.boxed_vars.contains(&id) + || ctx.prealloc_boxes.contains(&id) + || ctx.module_globals.contains_key(&id) + { + return; + } + let Some(slot) = ctx.locals.get(&id).cloned() else { + return; + }; + let pointer = ctx.block().load(I64, &slot); + let missing = ctx + .block() + .icmp_eq(I64, &pointer, crate::nanbox::TAG_UNDEFINED_I64); + let allocate = ctx.new_block("boxed.reuse.allocate"); + let ready = ctx.new_block("boxed.reuse.ready"); + let allocate_label = ctx.block_label(allocate); + let ready_label = ctx.block_label(ready); + ctx.block().cond_br(&missing, &allocate_label, &ready_label); + ctx.current_block = allocate; + let cell = if crate::expr::is_compiler_private_async_i32_control_local(ctx, id) { + ctx.block().call(I64, "js_i32_box_alloc", &[(I32, "0")]) + } else if crate::expr::is_compiler_private_async_i1_control_local(ctx, id) { + ctx.block().call(I64, "js_bool_box_alloc", &[(I32, "0")]) + } else { + ctx.block().call( + I64, + "js_box_alloc_bits", + &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], + ) + }; + ctx.block().store(I64, &cell, &slot); + super::record_boxed_slot_js_value_bits(ctx, id, &cell, "boxed_let.reused_missing_box"); + ctx.block().br(&ready_label); + ctx.current_block = ready; +} diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index fabc412735..c8056e1f95 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -386,6 +386,12 @@ pub(crate) fn lower_let( // reuse guard must consider the rep map too, or a redeclaration would // re-run the allocation path and leave the local with two slots. if ctx.locals.contains_key(&id) || ctx.local_slot_reps.contains_key(&id) { + // #10048: a previous *emitted* boxed Let may belong to another + // generator continuation. Its entry-initialized pointer slot exists, + // but the allocation did not execute on this resumed path. Preserve + // live var cells while lazily materializing a missing declaration cell + // BEFORE evaluating the initializer (which may capture itself). + super::boxed_local_init::ensure_reused_box_is_initialized(ctx, id); if let Some(init_expr) = init { // The binding's OWN declaration ends its Temporal Dead Zone: the // reused-slot write below (plain, unchecked) overwrites any TAG_TDZ diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index dbf3a7841e..95dbf94c9d 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -11,6 +11,9 @@ use crate::expr::{lower_expr, lower_expr_value, materialize_js_value, FnCtx}; use crate::native_value::{LoweredValue, MaterializationReason}; use crate::types::DOUBLE; +#[cfg(test)] +mod boxed_continuation_tests; +mod boxed_local_init; #[cfg(test)] mod boxed_slot_no_root_tests; mod cached_field_index_return; @@ -28,8 +31,6 @@ mod let_stmt_facts; mod loops; mod masked_window_region; #[cfg(test)] -mod prealloc_continuation_tests; -#[cfg(test)] mod prealloc_module_global_tests; pub(crate) mod stable_packed_accumulator; pub(crate) mod stable_packed_loop; @@ -673,11 +674,16 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result if ctx.module_globals.contains_key(id) { continue; } - // #10048: `locals` describes emitted storage, not which initializers - // dominate this path. Generator lowering can clone a scope into - // mutually exclusive continuations. Each executed scope entry needs - // its own fresh cell, even when an earlier emitted copy owns the slot. - // Reuse only the alloca below, never omit this path's allocation. + if ctx.locals.contains_key(id) { + // A previous PreallocateBoxes (or an unusual nesting) + // already set this up -- skip to keep the existing slot. + 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); let is_i1_control = crate::expr::is_compiler_private_async_i1_control_local(ctx, *id); let blk = ctx.block(); @@ -718,6 +724,7 @@ 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 @@ -727,15 +734,9 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result // 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 slot = if let Some(slot) = ctx.locals.get(id) { - slot.clone() - } else { - let slot = ctx.func.alloca_entry(crate::types::I64); - let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); - ctx.func - .entry_allocas_push_store(crate::types::I64, &undef_bits, &slot); - slot - }; + let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); + ctx.func + .entry_allocas_push_store(crate::types::I64, &undef_bits, &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" { From f56ef0f9638e333be994db6de8bdac00f90b587e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 09:50:55 +0200 Subject: [PATCH 4/4] test(codegen): isolate existing loop TDZ gap from generator controls --- .../src/stmt/boxed_local_init.rs | 2 ++ crates/perry-codegen/src/stmt/let_stmt.rs | 6 +---- ...generator_preallocated_capture_controls.ts | 25 +++++++++---------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/perry-codegen/src/stmt/boxed_local_init.rs b/crates/perry-codegen/src/stmt/boxed_local_init.rs index 75da961819..418f7f2377 100644 --- a/crates/perry-codegen/src/stmt/boxed_local_init.rs +++ b/crates/perry-codegen/src/stmt/boxed_local_init.rs @@ -1,4 +1,6 @@ //! A reused stack slot is not proof its boxed declaration executed (#10048). +//! Generator continuations can emit the declaration on mutually exclusive paths. +//! Preserve live var cells, but create a missing cell before a self-capturing init. use crate::expr::FnCtx; use crate::types::{I32, I64}; diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index c8056e1f95..f9bdad3bc5 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -386,11 +386,7 @@ pub(crate) fn lower_let( // reuse guard must consider the rep map too, or a redeclaration would // re-run the allocation path and leave the local with two slots. if ctx.locals.contains_key(&id) || ctx.local_slot_reps.contains_key(&id) { - // #10048: a previous *emitted* boxed Let may belong to another - // generator continuation. Its entry-initialized pointer slot exists, - // but the allocation did not execute on this resumed path. Preserve - // live var cells while lazily materializing a missing declaration cell - // BEFORE evaluating the initializer (which may capture itself). + // #10048: materialize a missing continuation cell before its initializer. super::boxed_local_init::ensure_reused_box_is_initialized(ctx, id); if let Some(init_expr) = init { // The binding's OWN declaration ends its Temporal Dead Zone: the diff --git a/test-files/test_gap_generator_preallocated_capture_controls.ts b/test-files/test_gap_generator_preallocated_capture_controls.ts index c0883f09bf..fc3cc7f951 100644 --- a/test-files/test_gap_generator_preallocated_capture_controls.ts +++ b/test-files/test_gap_generator_preallocated_capture_controls.ts @@ -37,19 +37,18 @@ async function* retained(callbacks: Array<() => number>) { yield wrapper(); } } -function tdzAndRecursion() { +// Separate invocations: repeated-block TDZ reset already fails on main (#10051). +function tdzAndRecursion(index: number) { 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))); - } + 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(','); } function hoistedVar() { @@ -73,7 +72,7 @@ async function main() { const callbacks: Array<() => number> = []; check('retained yields', await collect(retained(callbacks)), '0,110,1,111,2,112'); check('retained callbacks', callbacks.map(callback => callback()).join(','), '110,111,112'); - check('TDZ and recursion', tdzAndRecursion(), 'tdz,undefined,0,0,tdz,undefined,1,1'); + check('TDZ and recursion', tdzAndRecursion(0) + ',' + tdzAndRecursion(1), 'tdz,undefined,0,0,tdz,undefined,1,1'); check('hoisted var', hoistedVar(), '2,2,2'); console.log('PASS: preallocated capture controls'); }