Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.d/10049-generator-continuation-preallocation.md
Original file line number Diff line number Diff line change
@@ -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.
127 changes: 127 additions & 0 deletions crates/perry-codegen/src/stmt/boxed_continuation_tests.rs
Original file line number Diff line number Diff line change
@@ -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<Stmt> {
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);
}
42 changes: 42 additions & 0 deletions crates/perry-codegen/src/stmt/boxed_local_init.rs
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/stmt/let_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/stmt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions test-files/test_gap_generator_delegated_local_capture.ts
Original file line number Diff line number Diff line change
@@ -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); });
79 changes: 79 additions & 0 deletions test-files/test_gap_generator_preallocated_capture_controls.ts
Original file line number Diff line number Diff line change
@@ -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<number>) {
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); });
Loading