diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index fb381eb3e2..8cd7d74d19 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -311,6 +311,7 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | Expr::AsyncStepDone { .. } | Expr::CurrentStepClosure | Expr::AsyncFirstCall { .. } + | Expr::AsyncGenResume { .. } | Expr::ObjectGetOwnPropertyNames(..) | Expr::MathHypot(..) | Expr::RegExpExecGroups => super::misc_methods::lower(ctx, expr), diff --git a/crates/perry-codegen/src/expr/misc_methods.rs b/crates/perry-codegen/src/expr/misc_methods.rs index f29263f9a7..35701a8efd 100644 --- a/crates/perry-codegen/src/expr/misc_methods.rs +++ b/crates/perry-codegen/src/expr/misc_methods.rs @@ -969,6 +969,33 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Ok(blk.call(DOUBLE, "js_async_first_call", &[(DOUBLE, &step_box)])) } + // -------- #6709: async-generator activation entry -------- + // Like AsyncFirstCall but delivers a caller-supplied value + is_error + // flag to the step closure (gen.next(v) / gen.throw(e)). + Expr::AsyncGenResume { + step_closure, + value, + is_error, + } => { + let step_box = lower_expr(ctx, step_closure)?; + let value_box = lower_expr(ctx, value)?; + let is_error_lit = if *is_error { + double_literal(f64::from_bits(crate::nanbox::TAG_TRUE)) + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_FALSE)) + }; + let blk = ctx.block(); + Ok(blk.call( + DOUBLE, + "js_async_generator_resume", + &[ + (DOUBLE, &step_box), + (DOUBLE, &value_box), + (DOUBLE, is_error_lit.as_str()), + ], + )) + } + // -------- Object.getOwnPropertyNames(obj) -------- // Returns ALL own keys (including non-enumerable ones from // defineProperty), unlike Object.keys which skips them. diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index 46e8260e44..f35beefc17 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -303,6 +303,14 @@ pub(crate) fn declare_core(module: &mut LlModule) { // the right pointer on the very first state. Saves/restores // INLINE_TRAP across the call for nested-async composition. module.declare_function("js_async_first_call", DOUBLE, &[DOUBLE]); + // #6709: async-generator activation entry. Like js_async_first_call but + // the caller supplies the resume value + is_error flag (gen.next(v) / + // gen.throw(e)) instead of the hard-coded (undefined, false). + module.declare_function( + "js_async_generator_resume", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE], + ); // ========== Slugify ========== module.declare_function("js_slugify", I64, &[I64]); diff --git a/crates/perry-hir/src/analysis/value_types.rs b/crates/perry-hir/src/analysis/value_types.rs index 633130e133..c7adfd652a 100644 --- a/crates/perry-hir/src/analysis/value_types.rs +++ b/crates/perry-hir/src/analysis/value_types.rs @@ -1352,6 +1352,7 @@ pub fn infer_expr_type(expr: &Expr, env: &F) -> Type { Type::Promise(Box::new(Type::Named("WebAssembly.Module".to_string()))) } Expr::AsyncFirstCall { .. } + | Expr::AsyncGenResume { .. } | Expr::WebAssemblyInstantiate(_) | Expr::WebCryptoDigest { .. } | Expr::WebCryptoImportKey { .. } diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 04c2dbe337..53962b9781 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -1148,6 +1148,20 @@ pub enum Expr { step_closure: Box, }, + /// #6709. Async-generator activation entry. Like `AsyncFirstCall`, but + /// invokes the step closure with a caller-supplied `value` + `is_error` + /// instead of `(undefined, false)`. Emitted by the async-generator + /// lowering for each `gen.next(v)` / `gen.throw(e)` so the shared state + /// machine resumes: a non-error call delivers `value` to `__sent`, an + /// error call routes `value` as a throw at the current suspend state. The + /// runtime helper (`js_async_generator_resume`) sets up the + /// `CURRENT_STEP_CLOSURE` / `trap_next` TLS exactly like `AsyncFirstCall`. + AsyncGenResume { + step_closure: Box, + value: Box, + is_error: bool, + }, + // Crypto operations CryptoRandomBytes(Box), // crypto.randomBytes(size) -> string (hex) CryptoRandomUUID, // crypto.randomUUID() -> string diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index c73508fc49..9684beea9d 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -639,6 +639,7 @@ impl SH for Expr { Expr::AsyncStepDone { value, step_closure, } => { tag(h, 442); value.as_ref().hash(h); step_closure.as_ref().hash(h); } Expr::CurrentStepClosure => tag(h, 443), Expr::AsyncFirstCall { step_closure } => { tag(h, 444); step_closure.as_ref().hash(h); } + Expr::AsyncGenResume { step_closure, value, is_error } => { tag(h, 12510); step_closure.as_ref().hash(h); value.as_ref().hash(h); is_error.hash(h); } Expr::TaggedTemplateStrings { site_id, cooked, raw } => { tag(h, 445); site_id.hash(h); cooked.hash(h); raw.hash(h); } Expr::TemplateRaw(e) => { tag(h, 446); e.as_ref().hash(h); } Expr::RegisterClassParentDynamic { class_name, parent_expr, } => { tag(h, 447); class_name.hash(h); parent_expr.as_ref().hash(h); } diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index b7ebff8cdf..9eab517228 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -1153,6 +1153,14 @@ where Expr::AsyncFirstCall { step_closure } => { f(step_closure); } + Expr::AsyncGenResume { + step_closure, + value, + .. + } => { + f(step_closure); + f(value); + } // ─── Buffer family ─────────────────────────────────────────────── Expr::BufferFrom { data, encoding } => { diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index 95d71ffc1c..98842b2973 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -1140,6 +1140,14 @@ where Expr::AsyncFirstCall { step_closure } => { f(step_closure); } + Expr::AsyncGenResume { + step_closure, + value, + .. + } => { + f(step_closure); + f(value); + } Expr::BufferFrom { data, encoding } => { f(data); if let Some(e) = encoding { diff --git a/crates/perry-runtime/src/object/async_generator_queue.rs b/crates/perry-runtime/src/object/async_generator_queue.rs index 71fdefe1a8..96aecce872 100644 --- a/crates/perry-runtime/src/object/async_generator_queue.rs +++ b/crates/perry-runtime/src/object/async_generator_queue.rs @@ -471,6 +471,20 @@ fn process_one_queued_request(state_id: usize) { } fn finish_after_pending_result(state_id: usize, out: *mut Promise, fulfilled: bool, value: f64) { + // #6709: settle THIS request's promise BEFORE draining the queue, so its + // reactions are enqueued ahead of the next request's. Spec order is + // AsyncGeneratorResolve (settle the front request's promise) *then* + // AsyncGeneratorDrainQueue (resume the next). Draining first let a + // synchronously-completing next request — e.g. the terminal + // `{value: undefined, done: true}` after the last `yield` — resolve and + // fire its `.then` before this (non-terminal) one, reordering the results + // of three eagerly-queued `iter.next()` calls. This only surfaced once + // async-generator `.next()` began returning a *pending* Promise for every + // `yield` (the spec `AsyncGeneratorYield(? Await(value))` tick) so this + // pending path is now always taken; before #6709 `.next()` resolved + // synchronously (busy-wait) and hit the immediate path, which already + // deferred the drain via `schedule_drain`. + settle_out(out, fulfilled, value); let has_queue = STATES.with(|states| { states .borrow() @@ -482,7 +496,6 @@ fn finish_after_pending_result(state_id: usize, out: *mut Promise, fulfilled: bo } else { mark_inactive(state_id); } - settle_out(out, fulfilled, value); } fn finish_after_immediate_queued_result( diff --git a/crates/perry-runtime/src/promise/async_step.rs b/crates/perry-runtime/src/promise/async_step.rs index 7a2cb43702..7ad29e7369 100644 --- a/crates/perry-runtime/src/promise/async_step.rs +++ b/crates/perry-runtime/src/promise/async_step.rs @@ -559,6 +559,46 @@ pub extern "C" fn js_async_first_call(step_closure_nanbox: f64) -> f64 { result } +/// #6709. Entry point for an async-generator activation. Like +/// `js_async_first_call`, but the caller supplies the resume `value` and an +/// `is_error` flag instead of the hard-coded `(undefined, false)`. +/// +/// Each `gen.next(v)` / `gen.throw(e)` / `.return(v)` on a lowered async +/// generator starts a fresh async-step activation that resumes the SHARED +/// state machine (the generator's `__state`/`__done`/`__sent` boxes persist +/// across calls). The activation's step body reads its resumed value from the +/// first arg (delivered to `__sent`) and routes an error (`is_error = true`) at +/// the current suspend state; it returns either an already-settled Promise +/// (synchronous yield/completion), a fresh pending Promise (the body suspended +/// at an inner `await` via `js_async_step_chain`), or a rejected Promise (an +/// uncaught throw). The `trap_next = null` reset mirrors `js_async_first_call` +/// so a nested async fn/gen the body invokes cannot prematurely settle THIS +/// activation's result promise; `prev` is restored on exit for composition. +#[no_mangle] +pub extern "C" fn js_async_generator_resume( + step_closure_nanbox: f64, + value: f64, + is_error: f64, +) -> f64 { + let ptr = crate::value::js_nanbox_get_pointer(step_closure_nanbox) + as *mut crate::closure::ClosureHeader; + let prev = INLINE_TRAP.with(|c| { + let old = c.get(); + c.set(InlineTrap { + trap_next: std::ptr::null_mut(), + current_step: ptr as usize, + }); + old + }); + let result = crate::closure::js_closure_call2(ptr, value, is_error); + INLINE_TRAP.with(|c| c.set(prev)); + result +} + +#[used] +static KEEP_JS_ASYNC_GENERATOR_RESUME: extern "C" fn(f64, f64, f64) -> f64 = + js_async_generator_resume; + // Thread-local single-slot cache for async-step thunks. Keyed by the // step closure pointer. When the same step closure is used across // multiple promise-of-promise awaits (the simple-probe shape), we diff --git a/crates/perry-transform/src/async_to_generator.rs b/crates/perry-transform/src/async_to_generator.rs index 87e604035f..9194e250d5 100644 --- a/crates/perry-transform/src/async_to_generator.rs +++ b/crates/perry-transform/src/async_to_generator.rs @@ -486,7 +486,7 @@ fn alloc_local(next_id: &mut LocalId) -> LocalId { // processed recursively so awaits inside a nested `if (cond) { x = y + // await z; }` are hoisted into the inner block, not the outer scope. -fn hoist_awaits_in_stmts(stmts: &mut Vec, next_id: &mut LocalId) { +pub(crate) fn hoist_awaits_in_stmts(stmts: &mut Vec, next_id: &mut LocalId) { let mut out: Vec = Vec::with_capacity(stmts.len()); for stmt in std::mem::take(stmts) { let mut hoisted: Vec = Vec::new(); diff --git a/crates/perry-transform/src/generator/linearize.rs b/crates/perry-transform/src/generator/linearize.rs index 4fd460a1ab..1b294d060d 100644 --- a/crates/perry-transform/src/generator/linearize.rs +++ b/crates/perry-transform/src/generator/linearize.rs @@ -343,6 +343,13 @@ pub struct State { pub enum StateExit { /// Yield a value and advance to next_state Yield { value: Expr, next_state: u32 }, + /// #6709: async-generator `await` suspend point. Distinct from `Yield`: + /// the value is awaited on the microtask queue (via `AsyncStepChain`) and + /// the generator resumes WITHOUT returning `{value, done}` to the + /// consumer — the enclosing `.next()`/`.throw()` result Promise stays + /// pending until a real consumer `Yield` or completion is reached. Only + /// produced when linearizing an `async function*` body. + Await { value: Expr, next_state: u32 }, /// Goto another state (non-yielding transition) Goto(u32), /// Function is done @@ -469,6 +476,99 @@ pub fn linearize_body( }); } + // #6709: async-generator `await` suspend points. After + // `hoist_awaits_in_stmts` every remaining `await` sits in a + // statement-level position (bare `await x;`, `let v = await x;`, + // `return await x;`, `throw await x;`); split each into its own + // `StateExit::Await` state so the enclosing `.next()`/`.throw()` + // step driver suspends on the microtask queue instead of + // busy-waiting. The awaited (settled/rejected) value is delivered + // back through `__sent`, mirroring the two-way `yield` resume + // channel. Only fires for async generators (`Expr::Await` never + // reaches the linearizer otherwise — plain async fns rewrite + // `await`→`yield` upstream, sync generators have no `await`). + Stmt::Expr(Expr::Await(inner)) if linearize_async_generator() => { + let await_val = (**inner).clone(); + let this_state = *state_num; + *state_num += 1; + states.push(State { + num: this_state, + body: std::mem::take(current), + exit: StateExit::Await { + value: await_val, + next_state: *state_num, + }, + }); + // Bare `await x;` — result discarded, no continuation binding. + } + Stmt::Let { + id, + init: Some(Expr::Await(inner)), + mutable, + ty, + name, + } if linearize_async_generator() => { + let await_val = (**inner).clone(); + let this_state = *state_num; + *state_num += 1; + states.push(State { + num: this_state, + body: std::mem::take(current), + exit: StateExit::Await { + value: await_val, + next_state: *state_num, + }, + }); + // Resumed value (the settled await result) arrives via __sent. + current.push(Stmt::Let { + id: *id, + init: Some(Expr::LocalGet(sent_id)), + mutable: *mutable, + ty: ty.clone(), + name: name.clone(), + }); + } + Stmt::Return(Some(Expr::Await(inner))) if linearize_async_generator() => { + let await_val = (**inner).clone(); + let this_state = *state_num; + *state_num += 1; + states.push(State { + num: this_state, + body: std::mem::take(current), + exit: StateExit::Await { + value: await_val, + next_state: *state_num, + }, + }); + // `return await x` — the awaited value (__sent) is the return. + current.push(Stmt::Return(Some(make_iter_result( + Expr::LocalGet(sent_id), + true, + )))); + let cont_state = *state_num; + *state_num += 1; + states.push(State { + num: cont_state, + body: std::mem::take(current), + exit: StateExit::Done, + }); + } + Stmt::Throw(Expr::Await(inner)) if linearize_async_generator() => { + let await_val = (**inner).clone(); + let this_state = *state_num; + *state_num += 1; + states.push(State { + num: this_state, + body: std::mem::take(current), + exit: StateExit::Await { + value: await_val, + next_state: *state_num, + }, + }); + // `throw await x` — throw the awaited value (__sent). + current.push(Stmt::Throw(Expr::LocalGet(sent_id))); + } + // #34: `return yield* inner` — delegation in return position. // The earlier catch-all arm (`Return(Some(Yield { .. }))`) ignored // the `delegate` flag and yielded `inner` itself as a single diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index ec6e5ba1f6..79d548f35c 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -30,10 +30,218 @@ pub(crate) use call_this::{ }; pub(crate) use resume::{ generator_executing_guard, generator_executing_type_error, generator_resume_rethrow, - prepend_executing_clear_before_returns, promise_reject, wrap_generator_resume_body, + prepend_executing_clear_before_returns, promise_reject, wrap_async_gen_step_body, + wrap_generator_resume_body, }; pub(crate) use yield_await::await_async_generator_yield_operands; +/// #6709: read the currently-running step closure (`Expr::CurrentStepClosure`). +fn current_step() -> Expr { + Expr::CurrentStepClosure +} + +/// #6709: `AsyncStepChain(value, __step_self)` — suspend the async-generator +/// activation on the microtask queue (an inner `await`) and resume the step +/// when `value` settles. +fn async_step_chain(value: Expr) -> Expr { + Expr::AsyncStepChain { + value: Box::new(value), + step_closure: Box::new(current_step()), + } +} + +/// #6709: `AsyncStepDone({value, done}, __step_self)` — settle THIS activation's +/// result Promise with the iterator-result object (a consumer `yield` or the +/// generator's completion) and stop, leaving the state machine suspended for +/// the next `.next()`. +fn async_step_resolve(iter_result: Expr) -> Expr { + Expr::AsyncStepDone { + value: Box::new(iter_result), + step_closure: Box::new(current_step()), + } +} + +/// #6709: Build the `while (true) { }` body over `states`. +/// +/// `async_step = false` reproduces the historical sync-generator / busy-wait +/// shape: yield/done states `return {value, done}`, and (async-generator only) +/// `await` states busy-wait inline (`__sent = await value; continue`). This +/// feeds the `.return()` closure and sync generators unchanged. +/// +/// `async_step = true` is the async-generator suspend shape: `await` states +/// `return AsyncStepChain(value, __step_self)` (suspend on the microtask +/// queue); yield/done states still emit `return {value, done}` here — the +/// caller runs [`wrap_iter_result_returns_in_async_step_done`] over the FINAL +/// step body afterward to convert every iter-result return into an +/// `AsyncStepDone` that settles the activation's result Promise. Splitting it +/// this way lets the wrap also cover returns that `wrap_dispatch_loop` and the +/// throw-routing inject later. +#[allow(clippy::too_many_arguments)] +fn build_dispatch_while_body( + states: &[State], + async_step: bool, + state_id: LocalId, + done_id: LocalId, + sent_id: LocalId, +) -> Vec { + let mut while_body: Vec = Vec::new(); + for state in states { + let num = state.num; + let mut case_body = state.body.clone(); + match &state.exit { + StateExit::Yield { value, next_state } => { + if body_contains_return(&case_body) { + prepend_done_before_returns(&mut case_body, done_id); + rewrite_returns_as_done(&mut case_body); + } + case_body.push(Stmt::Expr(Expr::LocalSet( + state_id, + Box::new(Expr::Number(*next_state as f64)), + ))); + case_body.push(Stmt::Return(Some(make_iter_result(value.clone(), false)))); + } + StateExit::Await { value, next_state } => { + // A user `return X` can sit in a yield/await-free control-flow + // block (e.g. `if (c) return X;`) that the linearizer's catch-all + // accumulated into this Await state's body ahead of the `await`. + // Rewrite it exactly like the Yield/Goto/Done arms so the step + // closure settles via an iter-result completion (which the later + // `wrap_iter_result_returns_in_async_step_done` pass converts to + // `AsyncStepDone`) instead of escaping as a raw return with the + // wrong completion shape / a stale `__done` flag. + if body_contains_return(&case_body) { + prepend_done_before_returns(&mut case_body, done_id); + rewrite_returns_as_done(&mut case_body); + } + case_body.push(Stmt::Expr(Expr::LocalSet( + state_id, + Box::new(Expr::Number(*next_state as f64)), + ))); + if async_step { + // Suspend on the microtask queue; resume delivers the + // settled value through `__sent`. + case_body.push(Stmt::Return(Some(async_step_chain(value.clone())))); + } else { + // Busy-wait fallback (the `.return()` continuation path): + // evaluate the await inline and continue to the next state. + case_body.push(Stmt::Expr(Expr::LocalSet( + sent_id, + Box::new(Expr::Await(Box::new(value.clone()))), + ))); + case_body.push(Stmt::Continue); + } + } + StateExit::Goto(next_state) => { + if body_contains_return(&case_body) { + prepend_done_before_returns(&mut case_body, done_id); + rewrite_returns_as_done(&mut case_body); + } + case_body.push(Stmt::Expr(Expr::LocalSet( + state_id, + Box::new(Expr::Number(*next_state as f64)), + ))); + case_body.push(Stmt::Continue); + } + StateExit::Done => { + let has_return = body_contains_return(&case_body); + if has_return { + prepend_done_before_returns(&mut case_body, done_id); + rewrite_returns_as_done(&mut case_body); + let last_is_return = matches!(case_body.last(), Some(Stmt::Return(_))); + if !last_is_return { + case_body.push(Stmt::Expr(Expr::LocalSet( + done_id, + Box::new(Expr::Bool(true)), + ))); + case_body.push(Stmt::Return(Some(make_iter_result(Expr::Undefined, true)))); + } + } else { + case_body.push(Stmt::Expr(Expr::LocalSet( + done_id, + Box::new(Expr::Bool(true)), + ))); + case_body.push(Stmt::Return(Some(make_iter_result(Expr::Undefined, true)))); + } + } + } + + while_body.push(Stmt::If { + condition: Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(state_id)), + right: Box::new(Expr::Number(num as f64)), + }, + then_branch: case_body, + else_branch: None, + }); + } + + // Default: done + while_body.push(Stmt::Expr(Expr::LocalSet( + done_id, + Box::new(Expr::Bool(true)), + ))); + while_body.push(Stmt::Return(Some(make_iter_result(Expr::Undefined, true)))); + while_body +} + +/// #6709: Rewrite every `Stmt::Return(Some())` in an +/// async-generator step body into `return AsyncStepDone(, +/// __step_self)`, so a consumer `yield` / completion settles the activation's +/// result Promise with `{value, done}` instead of returning the raw object. +/// `AsyncStepChain` (await) returns are NOT iter-result objects, so they are +/// left untouched. Does not descend into nested closures (their returns are +/// their own). Recurses through control flow. +fn wrap_iter_result_returns_in_async_step_done(stmts: &mut Vec) { + for stmt in stmts.iter_mut() { + match stmt { + Stmt::Return(Some(expr)) => { + if is_iter_result(expr) { + let inner = std::mem::replace(expr, Expr::Undefined); + *expr = async_step_resolve(inner); + } + } + Stmt::If { + then_branch, + else_branch, + .. + } => { + wrap_iter_result_returns_in_async_step_done(then_branch); + if let Some(eb) = else_branch { + wrap_iter_result_returns_in_async_step_done(eb); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } | Stmt::For { body, .. } => { + wrap_iter_result_returns_in_async_step_done(body); + } + Stmt::Try { + body, + catch, + finally, + } => { + wrap_iter_result_returns_in_async_step_done(body); + if let Some(c) = catch { + wrap_iter_result_returns_in_async_step_done(&mut c.body); + } + if let Some(f) = finally { + wrap_iter_result_returns_in_async_step_done(f); + } + } + Stmt::Switch { cases, .. } => { + for case in cases.iter_mut() { + wrap_iter_result_returns_in_async_step_done(&mut case.body); + } + } + Stmt::Labeled { body, .. } => { + let mut v = vec![std::mem::replace(body.as_mut(), Stmt::Break)]; + wrap_iter_result_returns_in_async_step_done(&mut v); + **body = v.into_iter().next().unwrap(); + } + _ => {} + } + } +} + /// Transform a single generator function into a state machine. pub fn transform_generator_function( func: &mut Function, @@ -137,6 +345,15 @@ pub fn transform_generator_function_with_extra_captures( // remaining yield is at a statement-level position this pass recognises. if is_async_generator { await_async_generator_yield_operands(&mut func.body, next_local_id); + // #6709: hoist every non-top-level `await` (nested in a call arg, a + // binary operand, an if/while condition, …) into a fresh + // `let __awaitN = await ;` at statement level — including the + // yield-operand awaits just inserted above — so `linearize_body`'s + // `StateExit::Await` arms see each `await` in a position they split + // into a suspend state. Mirrors the plain-async pre-pass + // (`transform_async_to_generator`), which does the same before it + // rewrites `await`→`yield`. + crate::async_to_generator::hoist_awaits_in_stmts(&mut func.body, next_local_id); } // #6354: a per-iteration binding a closure WRITES that also outlives a @@ -173,6 +390,17 @@ pub fn transform_generator_function_with_extra_captures( let pending_type_id = alloc_local(next_local_id); let pending_value_id = alloc_local(next_local_id); + // #6709: the shared async-generator step closure. `.next(v)`/`.throw(e)` + // are thin outer closures that call `js_async_generator_resume(__agstep, + // v, is_error)`; `__agstep` drives the state machine and suspends inner + // `await`s on the microtask queue. It is boxed + captured like the other + // state-machine internals so `.next`/`.throw` resolve it per-call. + let agstep_id = if is_async_generator { + Some(alloc_local(next_local_id)) + } else { + None + }; + // Collect all states from the generator body let mut states: Vec = Vec::new(); let mut current: Vec = Vec::new(); @@ -285,132 +513,30 @@ pub fn transform_generator_function_with_extra_captures( rewrite_hoisted_lets_in_stmts(&mut state.body, &hoisted_ids); } - // Build the if-chain inside while(true) - let mut while_body: Vec = Vec::new(); - for state in states { - let State { num, body, exit } = state; - let mut case_body = body; - match exit { - StateExit::Yield { value, next_state } => { - // #1047: a user `return X` inside this state body — at - // any depth — must terminate the whole async function, - // not just exit the state. Without rewriting, the bare - // `return existing.kid` returns a non-iter-result from - // next(), the AsyncStepChain caller treats the missing - // `.done` as `false`, and re-enters the same state with - // the SAME state_id (the synthesized `state_id = N + 1` - // append below is unreachable when the user's return - // fires first). Result: infinite loop. Same fix as the - // `StateExit::Done` arm — set `__gen_done = true` and - // wrap the returned value in an iter-result with - // `done = true` so the async-step driver short-circuits. - if body_contains_return(&case_body) { - prepend_done_before_returns(&mut case_body, done_id); - rewrite_returns_as_done(&mut case_body); - } - case_body.push(Stmt::Expr(Expr::LocalSet( - state_id, - Box::new(Expr::Number(next_state as f64)), - ))); - case_body.push(Stmt::Return(Some(make_iter_result(value, false)))); - } - StateExit::Goto(next_state) => { - // #1196: a user `return X` inside this state body — at any - // depth — must terminate the whole async function, not just - // fall through to `next_state`. Mirrors the Yield/Done arms - // above. Without the rewrite, `rewrite_returns_to_labeled_break` - // later strips the return to `[Expr(X), LabeledBreak]` - // (value discarded, IterResult never set). The post-step - // code then sees the IterResult left over from the previous - // yield (done=false) and re-chains the step closure onto - // it via AsyncStepChain — re-entering this same state, - // taking the same early-return, and looping forever. - // Symptom: ~123 MB arena growth per outer call, GC every - // ~250 ms, 90%+ CPU. Triggered when the state body fans - // into a Goto (e.g. an `if (...) return X;` immediately - // before a `for` loop with `await` inside). - if body_contains_return(&case_body) { - prepend_done_before_returns(&mut case_body, done_id); - rewrite_returns_as_done(&mut case_body); - } - case_body.push(Stmt::Expr(Expr::LocalSet( - state_id, - Box::new(Expr::Number(next_state as f64)), - ))); - case_body.push(Stmt::Continue); - } - StateExit::Done => { - // Check if the body already has a return (from the user's `return expr`) - // — at ANY depth, since user code can `return` inside `if` / - // `try` / `switch` etc. inside a state body. Without the - // recursion (#594), a user `return X` inside an - // `if (cond) { return X }` block fell through both rewrites - // — the bare `Return(X)` reached the iterator caller and - // `__step_r.done` access threw "Cannot read properties of - // undefined". - let has_return = body_contains_return(&case_body); - if has_return { - // Rewrite existing returns to iter results, and prepend done=true - // Insert done=true BEFORE the return so it's reachable. - // Both passes recurse through nested control flow so a - // `return X` at any depth inside this state body is - // covered. - prepend_done_before_returns(&mut case_body, done_id); - rewrite_returns_as_done(&mut case_body); - // The body still needs a trailing iter-result if NOT every - // path returns (e.g. `if (cond) return X` falls through - // when `cond` is false). Append a default - // `__gen_done = true; return { value: undefined, done: true }` - // unless the LAST stmt is unconditionally a Return. - let last_is_return = matches!(case_body.last(), Some(Stmt::Return(_))); - if !last_is_return { - case_body.push(Stmt::Expr(Expr::LocalSet( - done_id, - Box::new(Expr::Bool(true)), - ))); - case_body.push(Stmt::Return(Some(make_iter_result(Expr::Undefined, true)))); - } - } else { - // No explicit return: add done + default return - case_body.push(Stmt::Expr(Expr::LocalSet( - done_id, - Box::new(Expr::Bool(true)), - ))); - case_body.push(Stmt::Return(Some(make_iter_result(Expr::Undefined, true)))); - } - } - } - - while_body.push(Stmt::If { - condition: Expr::Compare { - op: CompareOp::Eq, - left: Box::new(Expr::LocalGet(state_id)), - right: Box::new(Expr::Number(num as f64)), - }, - then_branch: case_body, - else_branch: None, - }); - } - - // Default: done - while_body.push(Stmt::Expr(Expr::LocalSet( - done_id, - Box::new(Expr::Bool(true)), - ))); - while_body.push(Stmt::Return(Some(make_iter_result(Expr::Undefined, true)))); + // Build the `while (true) { }` body. #6709: for async + // generators, `.next()`/`.throw()` drive the state machine through an + // async-step driver, so `await` states must suspend on the microtask queue + // (`async_step = true`). Sync generators and the `was_plain_async` path use + // the historical shape (`async_step = false`); they have no `await` states. + let while_body = + build_dispatch_while_body(&states, is_async_generator, state_id, done_id, sent_id); // The next() closure parameter — receives the value from next(val) calls let next_param_id = alloc_local(next_local_id); // #4374: clone the state-dispatch loop so the .throw() closure can - // *continue* the state machine after running a catch handler — running - // the inlined finally and proceeding to the next yield / completion, - // instead of returning {value: undefined, done: false} and deferring to - // the next .next(). Only the sync-generator .throw() path uses this. + // *continue* the state machine after running a catch handler. let while_body_for_throw = while_body.clone(); // #4438 B2-finally: the `.return()` closure needs the same continuation loop // when it routes into a yielding finally (so the finally's `yield`s suspend). - let while_body_for_return = while_body.clone(); + // #6709: the `.return()` closure is NOT an async-step driver (it cannot + // chain an inner `await` through `CurrentStepClosure`), so its dispatch + // keeps the busy-wait `await` shape — matching pre-#6709 `.return()`. + let while_body_for_return = if is_async_generator { + build_dispatch_while_body(&states, false, state_id, done_id, sent_id) + } else { + while_body.clone() + }; // #4438: wrap each state-dispatch loop body in a real try/catch so a `throw` // *executing inside a try block during dispatch* is caught and routed to the @@ -562,6 +688,12 @@ pub fn transform_generator_function_with_extra_captures( prealloc_ids.push(pending_type_id); prealloc_ids.push(pending_value_id); } + // #6709: box the shared async-generator step closure so `.next`/`.throw` + // read a distinct instance per generator activation (closure-cache key by + // box pointer, matching the #1029 idempotency fix for the other internals). + if let Some(id) = agstep_id { + prealloc_ids.push(id); + } for (var_id, _, _) in &hoisted { prealloc_ids.push(*var_id); } @@ -672,6 +804,13 @@ pub fn transform_generator_function_with_extra_captures( mutable_captures.push(pending_type_id); mutable_captures.push(pending_value_id); } + // #6709: the outer `.next`/`.throw` closures reference `__agstep` (and the + // step captures the same boxes as the resume closures); capture it by + // reference like the other boxed state-machine internals. + if let Some(id) = agstep_id { + captures.push(id); + mutable_captures.push(id); + } for param in &func.params { captures.push(param.id); } @@ -767,6 +906,10 @@ pub fn transform_generator_function_with_extra_captures( // The flag is safe to keep set — the generator transform only // checks it here, and codegen only reads it. } else { + // #6709: for async generators, the shared `__agstep` closure to bind + // into a boxed local (emitted into `new_body` just before the iterator + // object is returned). `None` for sync generators. + let mut agstep_init: Option<(LocalId, Expr)> = None; // Build .return(value) closure — immediately marks done and returns {value, done: true} let return_param_id = alloc_local(next_local_id); let return_func_id_val = { @@ -895,12 +1038,21 @@ pub fn transform_generator_function_with_extra_captures( // state machine via the `while_body_for_throw` continuation loop, so it // is built BEFORE the loop is moved into `throw_continuation` below. // Empty for sync generators (`delegations` is only recorded for async). + // #6709: for async generators `.throw(e)` is the error arm of the + // shared step closure, so the thrown value arrives through the step's + // value param (`next_param_id`) rather than a dedicated `.throw` + // closure param. Sync generators keep the dedicated `throw_param_id`. + let throw_val_id = if is_async_generator { + next_param_id + } else { + throw_param_id + }; let yield_star_throw_routes = build_yield_star_throw_routes( &delegations, &catches, &finallys, state_id, - throw_param_id, + throw_val_id, pending_type_id, pending_value_id, &while_body_for_throw, @@ -934,80 +1086,218 @@ pub fn transform_generator_function_with_extra_captures( &finallys, state_id, done_id, - throw_param_id, + throw_val_id, inner_catch_id, pending_type_id, pending_value_id, &hoisted_ids, throw_continuation, )); - let throw_catch_id = alloc_local(next_local_id); - let throw_body = wrap_generator_resume_body( - throw_resume_body, - executing_id, - done_id, - throw_catch_id, - is_async_generator, - ); - let throw_closure = Expr::Closure { - func_id: throw_func_id_val, - params: vec![perry_hir::Param { - id: throw_param_id, - name: "__throw_val".to_string(), - ty: Type::Any, - is_rest: false, - default: None, - decorators: Vec::new(), - arguments_object: None, - }], - return_type: Type::Any, - body: throw_body, - captures: captures.clone(), - mutable_captures: mutable_captures.clone(), - captures_this, - captures_new_target: false, - enclosing_class: enclosing_class.clone(), - is_arrow: false, - is_strict: func.is_strict, - is_async: false, - is_generator: false, - }; + // #6709: for async generators, `.next`/`.throw` are thin outer closures + // driving a shared async-step `__agstep` closure so inner `await`s + // suspend on the microtask queue; sync generators keep direct closures. + let (next_closure, throw_closure) = if is_async_generator { + // Non-error arm = the next dispatch (deliver `value` to `__sent`); + // insert the executing flag before the dispatch loop. + next_resume_body.insert( + 2, + Stmt::Expr(Expr::LocalSet(executing_id, Box::new(Expr::Bool(true)))), + ); + let is_error_param_id = alloc_local(next_local_id); + let agstep_func_id = { + let id = *next_func_id; + *next_func_id += 1; + id + }; + let agstep_catch_id = alloc_local(next_local_id); - // Plain generator: build the iterator object and return it directly. - let next_catch_id = alloc_local(next_local_id); - next_resume_body.insert( - 2, - Stmt::Expr(Expr::LocalSet(executing_id, Box::new(Expr::Bool(true)))), - ); - let next_body = wrap_generator_resume_body( - next_resume_body, - executing_id, - done_id, - next_catch_id, - is_async_generator, - ); - let next_closure = Expr::Closure { - func_id: next_func_id_val, - params: vec![perry_hir::Param { - id: next_param_id, - name: "__val".to_string(), - ty: Type::Any, - is_rest: false, - default: None, - decorators: Vec::new(), - arguments_object: None, - }], - return_type: Type::Any, - body: next_body, - captures: captures.clone(), - mutable_captures: mutable_captures.clone(), - captures_this, - captures_new_target: false, - enclosing_class: enclosing_class.clone(), - is_arrow: false, - is_strict: func.is_strict, - is_async: false, - is_generator: false, + // `if (__is_error) { } else { }`. + // An `await` that REJECTS re-enters the step with __is_error = true, + // routing the rejection to the enclosing catch exactly like an + // explicit `.throw()`; a settled `await` re-enters with + // __is_error = false, delivering the value through `__sent`. + let step_inner = vec![Stmt::If { + condition: Expr::LocalGet(is_error_param_id), + then_branch: throw_resume_body, + else_branch: Some(next_resume_body), + }]; + let mut step_body = + wrap_async_gen_step_body(step_inner, executing_id, done_id, agstep_catch_id); + // Turn every `{value, done}` return into an `AsyncStepDone` that + // settles the activation's result Promise; `AsyncStepChain` (await) + // returns are left as-is. + wrap_iter_result_returns_in_async_step_done(&mut step_body); + + let agstep_local_id = agstep_id.expect("agstep_id set for async generators"); + let agstep_closure = Expr::Closure { + func_id: agstep_func_id, + params: vec![ + perry_hir::Param { + id: next_param_id, + name: "__val".to_string(), + ty: Type::Any, + is_rest: false, + default: None, + decorators: Vec::new(), + arguments_object: None, + }, + perry_hir::Param { + id: is_error_param_id, + name: "__is_error".to_string(), + ty: Type::Boolean, + is_rest: false, + default: None, + decorators: Vec::new(), + arguments_object: None, + }, + ], + return_type: Type::Any, + body: step_body, + captures: captures.clone(), + mutable_captures: mutable_captures.clone(), + captures_this, + captures_new_target: false, + enclosing_class: enclosing_class.clone(), + is_arrow: false, + is_strict: func.is_strict, + is_async: false, + is_generator: false, + }; + // `let __agstep = ` is emitted into the outer body + // just before the iterator object is returned (see `agstep_init`). + agstep_init = Some((agstep_local_id, agstep_closure)); + + // Outer `.next(v)` closure: `return js_async_generator_resume( + // __agstep, v, false)`. + let outer_next_param_id = alloc_local(next_local_id); + let outer_next_closure = Expr::Closure { + func_id: next_func_id_val, + params: vec![perry_hir::Param { + id: outer_next_param_id, + name: "__val".to_string(), + ty: Type::Any, + is_rest: false, + default: None, + decorators: Vec::new(), + arguments_object: None, + }], + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::AsyncGenResume { + step_closure: Box::new(Expr::LocalGet(agstep_local_id)), + value: Box::new(Expr::LocalGet(outer_next_param_id)), + is_error: false, + }))], + captures: captures.clone(), + mutable_captures: mutable_captures.clone(), + captures_this, + captures_new_target: false, + enclosing_class: enclosing_class.clone(), + is_arrow: false, + is_strict: func.is_strict, + is_async: false, + is_generator: false, + }; + // Outer `.throw(e)` closure: `return js_async_generator_resume( + // __agstep, e, true)`. + let outer_throw_param_id = alloc_local(next_local_id); + let outer_throw_closure = Expr::Closure { + func_id: throw_func_id_val, + params: vec![perry_hir::Param { + id: outer_throw_param_id, + name: "__throw_val".to_string(), + ty: Type::Any, + is_rest: false, + default: None, + decorators: Vec::new(), + arguments_object: None, + }], + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::AsyncGenResume { + step_closure: Box::new(Expr::LocalGet(agstep_local_id)), + value: Box::new(Expr::LocalGet(outer_throw_param_id)), + is_error: true, + }))], + captures: captures.clone(), + mutable_captures: mutable_captures.clone(), + captures_this, + captures_new_target: false, + enclosing_class: enclosing_class.clone(), + is_arrow: false, + is_strict: func.is_strict, + is_async: false, + is_generator: false, + }; + (outer_next_closure, outer_throw_closure) + } else { + let throw_catch_id = alloc_local(next_local_id); + let throw_body = wrap_generator_resume_body( + throw_resume_body, + executing_id, + done_id, + throw_catch_id, + is_async_generator, + ); + let throw_closure = Expr::Closure { + func_id: throw_func_id_val, + params: vec![perry_hir::Param { + id: throw_param_id, + name: "__throw_val".to_string(), + ty: Type::Any, + is_rest: false, + default: None, + decorators: Vec::new(), + arguments_object: None, + }], + return_type: Type::Any, + body: throw_body, + captures: captures.clone(), + mutable_captures: mutable_captures.clone(), + captures_this, + captures_new_target: false, + enclosing_class: enclosing_class.clone(), + is_arrow: false, + is_strict: func.is_strict, + is_async: false, + is_generator: false, + }; + + // Plain generator: build the iterator object and return it directly. + let next_catch_id = alloc_local(next_local_id); + next_resume_body.insert( + 2, + Stmt::Expr(Expr::LocalSet(executing_id, Box::new(Expr::Bool(true)))), + ); + let next_body = wrap_generator_resume_body( + next_resume_body, + executing_id, + done_id, + next_catch_id, + is_async_generator, + ); + let next_closure = Expr::Closure { + func_id: next_func_id_val, + params: vec![perry_hir::Param { + id: next_param_id, + name: "__val".to_string(), + ty: Type::Any, + is_rest: false, + default: None, + decorators: Vec::new(), + arguments_object: None, + }], + return_type: Type::Any, + body: next_body, + captures: captures.clone(), + mutable_captures: mutable_captures.clone(), + captures_this, + captures_new_target: false, + enclosing_class: enclosing_class.clone(), + is_arrow: false, + is_strict: func.is_strict, + is_async: false, + is_generator: false, + }; + (next_closure, throw_closure) }; let iter_obj = Expr::Object(vec![ ("next".to_string(), next_closure), @@ -1025,6 +1315,18 @@ pub fn transform_generator_function_with_extra_captures( obj: Box::new(iter_obj), is_async: is_async_generator, }; + // #6709: bind the shared async-generator step closure into its boxed + // local before returning the iterator object, so the `.next`/`.throw` + // closures (which captured the box) read a fresh `__agstep` per call. + if let Some((agstep_local_id, agstep_closure)) = agstep_init { + new_body.push(Stmt::Let { + id: agstep_local_id, + name: "__agstep".to_string(), + ty: Type::Any, + mutable: true, + init: Some(agstep_closure), + }); + } new_body.push(Stmt::Return(Some(linked))); } diff --git a/crates/perry-transform/src/generator/lower/resume.rs b/crates/perry-transform/src/generator/lower/resume.rs index ff624ef681..1b87ca6576 100644 --- a/crates/perry-transform/src/generator/lower/resume.rs +++ b/crates/perry-transform/src/generator/lower/resume.rs @@ -32,6 +32,39 @@ pub(crate) fn wrap_generator_resume_body( ] } +/// #6709: wrap an async-generator step-closure body. Like +/// [`wrap_generator_resume_body`] for the async path (executing guard that +/// rejects a re-entrant resume, `executing`-clear before every return, and a +/// catch that completes the generator and rejects on an escaping throw) — but +/// does NOT `wrap_returns_in_promise`, because the step body's returns are +/// ALREADY Promises: `AsyncStepChain` (suspend on an inner `await`), +/// `AsyncStepDone` (settle this activation with `{value, done}`), or the +/// catch's `Promise.reject`. Wrapping those in `Promise.resolve` would make +/// `.next()` resolve to a Promise instead of an iterator result. +pub(crate) fn wrap_async_gen_step_body( + mut body: Vec, + executing_id: LocalId, + done_id: LocalId, + catch_id: LocalId, +) -> Vec { + prepend_executing_clear_before_returns(&mut body, executing_id); + vec![ + generator_executing_guard(executing_id, true), + Stmt::Try { + body, + catch: Some(CatchClause { + param: Some((catch_id, "__gen_exec_e".to_string())), + body: vec![ + Stmt::Expr(Expr::LocalSet(done_id, Box::new(Expr::Bool(true)))), + Stmt::Expr(Expr::LocalSet(executing_id, Box::new(Expr::Bool(false)))), + generator_resume_rethrow(Expr::LocalGet(catch_id), true), + ], + }), + finally: None, + }, + ] +} + pub(crate) fn generator_executing_guard(executing_id: LocalId, is_async_generator: bool) -> Stmt { Stmt::If { condition: Expr::LocalGet(executing_id), diff --git a/crates/perry-transform/src/generator/mod.rs b/crates/perry-transform/src/generator/mod.rs index 102784cd97..29f2114ead 100644 --- a/crates/perry-transform/src/generator/mod.rs +++ b/crates/perry-transform/src/generator/mod.rs @@ -47,9 +47,9 @@ pub(crate) use per_iteration::{ rewrite_written_captures_to_cells, snapshot_suspended_loop_captures, }; pub(crate) use rewrite_returns::{ - body_contains_return, prepend_done_before_returns, rewrite_catch_returns_to_iter_result, - rewrite_iter_results_in_stmts, rewrite_returns_as_done, rewrite_returns_to_labeled_break, - rewrite_yield_to_await_in_stmts, + body_contains_return, is_iter_result, prepend_done_before_returns, + rewrite_catch_returns_to_iter_result, rewrite_iter_results_in_stmts, rewrite_returns_as_done, + rewrite_returns_to_labeled_break, rewrite_yield_to_await_in_stmts, }; // #3664: per-thread accumulator for async-generator func_ids discovered while diff --git a/crates/perry/tests/issue_6709_async_generator_pending_await.rs b/crates/perry/tests/issue_6709_async_generator_pending_await.rs index 4c19976ec6..fd0e0563bb 100644 --- a/crates/perry/tests/issue_6709_async_generator_pending_await.rs +++ b/crates/perry/tests/issue_6709_async_generator_pending_await.rs @@ -33,32 +33,30 @@ //! generators work — the poll loop sees the Promise settled immediately — so //! this is specific to *pending* awaits. //! -//! ## Impact (the reported symptom) +//! ## Impact //! -//! pi's interactive TUI wires agent → session → UI events through a hand-rolled -//! `EventStream` async generator (`for await (const event of stream)` on the -//! consumer side, `stream.push(event)` on the producer side, the generator -//! `await`ing `new Promise(r => waiting.push(r))`). Under perry the async -//! generator deadlocks on the first pending `await`, so `agent_start` / -//! `message_*` / the 401 `error` events never reach the UI subscriber: pressing -//! Enter runs the whole submit → `session.prompt` → `agent.prompt` chain (the -//! HTTP request even fires) but no "Working…" and no error ever render. Node, -//! running the identical bundle, submits and shows the 401. +//! The push/pull async-iterator shape below (a generator `await`ing +//! `new Promise(r => waiting.push(r))`, resolved by a later `push()`) is how +//! pi's interactive TUI wires agent → session → UI events (its hand-rolled +//! `EventStream`), so this deadlock is on that path. NOTE: fixing this alone +//! does NOT restore pi's interactivity — an *earlier* keypress→submit +//! divergence (#6728) means Enter never fires submit under perry, so the +//! EventStream never runs regardless. This test targets the async-generator +//! deadlock in isolation, which is a real bug in its own right. //! -//! ## The fix (scoped follow-up) +//! ## The fix (landed in this PR) //! -//! Make `async function*` bodies suspend on `await` like plain async functions: -//! split the generator state machine at `await` points (distinct from consumer -//! `yield` points) and drive them through the existing `AsyncStepChain` +//! `async function*` bodies now suspend on `await` like plain async functions: +//! the generator state machine is split at `await` points (distinct from +//! consumer `yield` points) and driven through the existing `AsyncStepChain` //! async-step machinery, so `.next()` returns a pending Promise immediately and //! resumes on the microtask queue when the awaited Promise settles. This -//! touches the async generator lowering (`generator/lower.rs`, -//! `generator/linearize.rs`) + the `Expr::Yield` discriminator and must be -//! validated against the full async-generator test262 suite; it is intentionally -//! not squeezed into this diagnosis change. +//! touched the async generator lowering (`generator/lower.rs`, +//! `generator/linearize.rs`) + the `Expr::Yield` discriminator, validated +//! against the full async-generator test262 suite. //! -//! `#[ignore]`d until that fix lands (the buggy runtime deadlocks; the test uses -//! a hard timeout so it fails deterministically rather than hanging). +//! With the fix landed the test is active (no `#[ignore]`); it uses a hard +//! timeout so a regression fails deterministically rather than hanging. use std::io::Read; use std::path::PathBuf; @@ -118,9 +116,67 @@ main().then(() => log("main:resolved")); /// main:resolved const EXPECTED: &str = "GOT agent_start\nGOT turn_start\nGOT message_start\ndone\nmain:resolved\n"; +/// A user `return X` sitting in a yield/await-free control-flow block that +/// precedes an `await` lands in the `StateExit::Await` state's body (the +/// linearizer's catch-all accumulates it into `current`, which the next `await` +/// takes as the state body). That return must settle the async generator's +/// result Promise as an iter-result completion — `.next()` must resolve to +/// `{value: X, done: true}`, not to the bare value `X`. Regression guard for the +/// CodeRabbit finding on #6727 (the `StateExit::Await` arm skipped the +/// `prepend_done_before_returns` + `rewrite_returns_as_done` rewrite the +/// `Yield`/`Goto`/`Done` arms apply, so the step closure escaped with a raw +/// `return X`). +const RETURN_BEFORE_AWAIT_FIXTURE: &str = r#" +async function* g(cond) { + if (cond) { return 5; } + await Promise.resolve(); + yield 1; +} +async function main() { + const it = g(true); + console.log("A " + JSON.stringify(await it.next())); + console.log("B " + JSON.stringify(await it.next())); + const it2 = g(false); + console.log("C " + JSON.stringify(await it2.next())); + console.log("D " + JSON.stringify(await it2.next())); +} +main(); +"#; + +/// Node v26 output (stdout): +/// A {"value":5,"done":true} +/// B {"done":true} +/// C {"value":1,"done":false} +/// D {"done":true} +const RETURN_BEFORE_AWAIT_EXPECTED: &str = + "A {\"value\":5,\"done\":true}\nB {\"done\":true}\nC {\"value\":1,\"done\":false}\nD {\"done\":true}\n"; + +#[test] +fn async_generator_return_before_await_settles_as_iter_result() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.mjs"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, RETURN_BEFORE_AWAIT_FIXTURE).unwrap(); + + let status = Command::new(perry_bin()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .status() + .expect("perry compile"); + assert!(status.success(), "compile failed"); + + let out = Command::new(&output).output().expect("run"); + assert!(out.status.success(), "binary exited non-zero"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + stdout, RETURN_BEFORE_AWAIT_EXPECTED, + "async generator `return` before an `await` diverged from node" + ); +} + #[test] -#[ignore = "unfixed #6709: async-generator `await` on a pending Promise busy-waits (deadlocks) \ - instead of suspending; needs the async-generator await-suspension fix"] fn async_generator_pending_await_suspends_not_deadlocks() { let dir = tempfile::tempdir().expect("tempdir"); let entry = dir.path().join("main.mjs");