diff --git a/changelog.d/10049-generator-continuation-preallocation.md b/changelog.d/10049-generator-continuation-preallocation.md new file mode 100644 index 0000000000..43b4f8b865 --- /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). 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 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/boxed_continuation_tests.rs b/crates/perry-codegen/src/stmt/boxed_continuation_tests.rs new file mode 100644 index 0000000000..6a594b3120 --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_continuation_tests.rs @@ -0,0 +1,127 @@ +//! #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(initialized: bool) -> Vec { + vec![ + Stmt::Let { + id: 101, + name: "callback".into(), + ty: Type::Any, + mutable: true, + 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(initialized: bool) { + let mut module = Module::new("boxed_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(initialized), + else_branch: Some(branch(initialized)), + }], + 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 = 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!( + 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 initialized_boxed_let_materializes_a_missing_cell() { + assert_each_continuation_allocates(true); +} + +#[test] +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..418f7f2377 --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_local_init.rs @@ -0,0 +1,42 @@ +//! 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}; + +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..f9bdad3bc5 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -386,6 +386,8 @@ 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: 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 // 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 6550f09bab..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; 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..fc3cc7f951 --- /dev/null +++ b/test-files/test_gap_generator_preallocated_capture_controls.ts @@ -0,0 +1,79 @@ +// #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(); + } +} +// Separate invocations: repeated-block TDZ reset already fails on main (#10051). +function tdzAndRecursion(index: number) { + const results: string[] = []; + 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(0) + ',' + tdzAndRecursion(1), '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); });