diff --git a/changelog.d/8667-regexp-repeat-matcher.md b/changelog.d/8667-regexp-repeat-matcher.md new file mode 100644 index 0000000000..d84dccab52 --- /dev/null +++ b/changelog.d/8667-regexp-repeat-matcher.md @@ -0,0 +1,8 @@ +Fixed RegExp backtracking behavior for quantified capture groups, nullable +iterations, and captures referenced across lookaround assertions. Perry now +uses an ECMAScript matcher for the affected patterns while retaining the +existing linear-time engines for the common path. + +Regular-expression replacement also now matches JavaScript strings containing +lone surrogates as UTF-16 code units without losing their WTF-8 representation. +Together these changes make all 89 test262 cases tracked by #5897 pass. diff --git a/changelog.d/8707-await-in-catch-blockwait.md b/changelog.d/8707-await-in-catch-blockwait.md new file mode 100644 index 0000000000..03627d075d --- /dev/null +++ b/changelog.d/8707-await-in-catch-blockwait.md @@ -0,0 +1 @@ +Fix a deadlock where an `await` inside a `catch` block of an async function or closure compiled to a blocking busy-wait instead of an async suspend. Reached re-entrantly from inside the async-step / async-generator pull cascade (a rejected awaited promise in a `try` routing to a `catch` that itself awaits — a common stream retry/cleanup shape), it monopolised the runtime thread and hung — the natively-compiled Claude Code `-p` streaming path being the motivating case. The async-step throw handler now routes the delivered error into the catch's already-linearized dispatch states (and lets a `throw` raised inside the catch escape correctly) instead of re-inlining the catch body as blocking awaits. (#8681) diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index de9861a150..3ac80518dd 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -261,7 +261,7 @@ libc.workspace = true gimli = { version = "0.34", default-features = false, features = ["read"] } rand = "0.10" regex = { workspace = true, optional = true } -regress = { workspace = true, optional = true } +regress = { workspace = true, features = ["utf16"], optional = true } # Taffy — flexbox / grid layout engine for the perry/tui module # (#358 Phase 3). Same crate Bevy and Dioxus use; pure Rust, no FFI. taffy = { version = "0.13", default-features = false, features = ["std", "flexbox", "taffy_tree"] } diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 324fbb3e5e..964098fbeb 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -636,11 +636,16 @@ pub fn is_registered_regex(addr: usize) -> bool { /// Internal helper: Get string data from StringHeader pub(crate) fn string_as_str<'a>(s: *const StringHeader) -> &'a str { + unsafe { std::str::from_utf8_unchecked(string_as_bytes(s)) } +} + +/// Internal helper: get the byte payload without assuming it is Unicode +/// scalar UTF-8. JavaScript strings containing lone surrogates use WTF-8. +pub(crate) fn string_as_bytes<'a>(s: *const StringHeader) -> &'a [u8] { unsafe { let len = (*s).byte_len as usize; let data = (s as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data, len); - std::str::from_utf8_unchecked(bytes) + std::slice::from_raw_parts(data, len) } } @@ -1397,19 +1402,39 @@ pub extern "C" fn js_string_replace_regex( return js_string_from_str(""); } - let str_data = string_as_str(s); - let repl_str = if is_valid_ptr(replacement) { - string_as_str(replacement) - } else { - "undefined" - }; - if !is_valid_regex_ptr(re) { // If regex is null, return original string return copy_replace_source(s); } unsafe { + // The Rust string engines require scalar-value UTF-8, while Perry + // stores lone JavaScript surrogates as WTF-8. Match those subjects as + // UTF-16 code units with the ECMAScript engine and rebuild the result + // through the WTF-8-aware string builder. + if (*s).flags & crate::string::STRING_FLAG_HAS_LONE_SURROGATES != 0 { + let replacement_bytes = if is_valid_ptr(replacement) { + string_as_bytes(replacement) + } else { + b"undefined" + }; + if let Some(result) = repeat_matcher::replace_wtf8_subject( + re, + string_as_bytes(s), + replacement_bytes, + (*re).global, + ) { + return finish_replace_bytes(&result); + } + } + + let str_data = string_as_str(s); + let repl_str = if is_valid_ptr(replacement) { + string_as_str(replacement) + } else { + "undefined" + }; + if let Some(repeat_matcher) = lookup_repeat_matcher(re) { let result = repeat_matcher.replace(str_data, repl_str, (*re).global); return finish_replace_bytes(result.as_bytes()); diff --git a/crates/perry-runtime/src/regex/repeat_matcher.rs b/crates/perry-runtime/src/regex/repeat_matcher.rs index 5ce703b71f..e962c0e181 100644 --- a/crates/perry-runtime/src/regex/repeat_matcher.rs +++ b/crates/perry-runtime/src/regex/repeat_matcher.rs @@ -185,6 +185,7 @@ impl RepeatMatcherRegex { #[derive(Clone, Copy)] struct GroupFrame { captures_before: usize, + negative_lookaround: bool, } fn named_capture_end(bytes: &[u8], open: usize) -> Option { @@ -204,6 +205,12 @@ fn is_capturing_group(bytes: &[u8], open: usize) -> bool { bytes.get(open + 1) != Some(&b'?') || named_capture_end(bytes, open).is_some() } +fn is_negative_lookaround(bytes: &[u8], open: usize) -> bool { + bytes.get(open + 1) == Some(&b'?') + && (bytes.get(open + 2) == Some(&b'!') + || (bytes.get(open + 2) == Some(&b'<') && bytes.get(open + 3) == Some(&b'!'))) +} + fn has_braced_quantifier(bytes: &[u8], mut index: usize) -> bool { if bytes.get(index) != Some(&b'{') { return false; @@ -230,10 +237,10 @@ fn quantifier_follows(bytes: &[u8], index: usize) -> bool { || has_braced_quantifier(bytes, index) } -/// Return the capture-name layout when a pattern has a capture inside a -/// quantified group. That is precisely the shape for which the linear engine's -/// leftmost-first result can expose stale captures or stop after the wrong -/// nullable iteration. +/// Return the capture-name layout when a pattern needs ECMAScript backtracking +/// capture semantics. Besides quantified captures, this includes captures in a +/// negative lookaround: after a successful negative assertion those captures +/// are unmatched, so a later backreference must match the empty string. fn quantified_capture_layout(pattern: &str) -> Option>> { let bytes = pattern.as_bytes(); let mut captures = Vec::new(); @@ -260,7 +267,10 @@ fn quantified_capture_layout(pattern: &str) -> Option>> { .map(|end| pattern[index + 3..end].to_string()); captures.push(name); } - groups.push(GroupFrame { captures_before }); + groups.push(GroupFrame { + captures_before, + negative_lookaround: is_negative_lookaround(bytes, index), + }); index += 1; } b')' if !in_class => { @@ -268,7 +278,9 @@ fn quantified_capture_layout(pattern: &str) -> Option>> { index += 1; continue; }; - if captures.len() > group.captures_before && quantifier_follows(bytes, index + 1) { + if captures.len() > group.captures_before + && (quantifier_follows(bytes, index + 1) || group.negative_lookaround) + { needs_repeat_matcher = true; } index += 1; @@ -288,6 +300,169 @@ pub(super) fn compile(pattern: &str, flags: &str) -> Option }) } +fn source_and_flags(re: *const super::RegExpHeader) -> (String, String) { + if let Some(source) = + super::REGEX_SOURCE_TABLE.with(|table| table.borrow().get(&(re as usize)).cloned()) + { + return source; + } + unsafe { + ( + super::string_as_str((*re).pattern_ptr).to_string(), + super::string_as_str((*re).flags_ptr).to_string(), + ) + } +} + +fn decode_wtf8_units(bytes: &[u8]) -> Vec { + let mut units = Vec::new(); + let mut offset = 0usize; + while offset < bytes.len() { + let (advance, utf16_units, code_point) = crate::string::wtf8_step(bytes, offset); + if utf16_units == 2 && code_point >= 0x10000 { + let astral = code_point - 0x10000; + units.push(0xD800 + (astral >> 10) as u16); + units.push(0xDC00 + (astral & 0x3FF) as u16); + } else if utf16_units == 1 { + units.push(code_point as u16); + } + offset = (offset + advance).min(bytes.len()); + } + units +} + +fn append_wtf8_unit(out: &mut Vec, unit: u16) { + if let Some(ch) = char::from_u32(unit as u32) { + let mut encoded = [0u8; 3]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } else { + out.extend_from_slice(&[ + 0xE0 | ((unit >> 12) as u8), + 0x80 | (((unit >> 6) & 0x3F) as u8), + 0x80 | ((unit & 0x3F) as u8), + ]); + } +} + +fn append_unit_range(out: &mut Vec, units: &[u16], range: std::ops::Range) { + for &unit in &units[range] { + append_wtf8_unit(out, unit); + } +} + +fn append_replacement( + out: &mut Vec, + replacement: &[u8], + units: &[u16], + matched: ®ress::Match, +) { + let group_count = matched.groups().len(); + let has_named_groups = matched.named_groups().next().is_some(); + let mut index = 0usize; + while index < replacement.len() { + if replacement[index] != b'$' || index + 1 == replacement.len() { + out.push(replacement[index]); + index += 1; + continue; + } + match replacement[index + 1] { + b'$' => { + out.push(b'$'); + index += 2; + } + b'&' => { + append_unit_range(out, units, matched.range()); + index += 2; + } + b'`' => { + append_unit_range(out, units, 0..matched.start()); + index += 2; + } + b'\'' => { + append_unit_range(out, units, matched.end()..units.len()); + index += 2; + } + b'0'..=b'9' => { + let first = (replacement[index + 1] - b'0') as usize; + let (group, consumed) = + if index + 2 < replacement.len() && replacement[index + 2].is_ascii_digit() { + let two = first * 10 + (replacement[index + 2] - b'0') as usize; + if (1..group_count).contains(&two) { + (Some(two), 2) + } else if (1..group_count).contains(&first) { + (Some(first), 1) + } else { + (None, 0) + } + } else if (1..group_count).contains(&first) { + (Some(first), 1) + } else { + (None, 0) + }; + if let Some(group) = group { + if let Some(range) = matched.group(group) { + append_unit_range(out, units, range); + } + index += 1 + consumed; + } else { + out.push(b'$'); + index += 1; + } + } + b'<' if has_named_groups => { + if let Some(relative_end) = replacement[index + 2..] + .iter() + .position(|&byte| byte == b'>') + { + let name = + std::str::from_utf8(&replacement[index + 2..index + 2 + relative_end]) + .unwrap_or_default(); + if let Some(range) = matched.named_group(name) { + append_unit_range(out, units, range); + } + index += 3 + relative_end; + } else { + out.push(b'$'); + index += 1; + } + } + _ => { + out.push(b'$'); + index += 1; + } + } + } +} + +/// Replace on a WTF-8 subject by exposing its exact JavaScript UTF-16 code +/// units to the ECMAScript matcher. The returned bytes remain WTF-8 and are +/// canonicalized by the caller's string builder. +pub(super) fn replace_wtf8_subject( + re: *const super::RegExpHeader, + subject: &[u8], + replacement: &[u8], + global: bool, +) -> Option> { + let (source, flags) = source_and_flags(re); + let regex = regress::Regex::with_flags(&source, flags.as_str()).ok()?; + let units = decode_wtf8_units(subject); + let matches: Vec = if flags.contains('u') || flags.contains('v') { + regex.find_from_utf16(&units, 0).collect() + } else { + regex.find_from_ucs2(&units, 0).collect() + }; + + let mut out = Vec::with_capacity(subject.len().saturating_add(replacement.len())); + let mut last_end = 0usize; + for matched in matches.iter().take(if global { usize::MAX } else { 1 }) { + append_unit_range(&mut out, &units, last_end..matched.start()); + append_replacement(&mut out, replacement, &units, matched); + last_end = matched.end(); + } + append_unit_range(&mut out, &units, last_end..units.len()); + Some(out) +} + #[cfg(test)] mod tests { use super::*; @@ -296,6 +471,8 @@ mod tests { fn detects_only_quantified_groups_with_captures() { assert!(quantified_capture_layout(r"(a?b??)*").is_some()); assert!(quantified_capture_layout(r"(?:(?=(abc))){0,1}a").is_some()); + assert!(quantified_capture_layout(r"(?!(a)b)\1").is_some()); + assert!(quantified_capture_layout(r"(? bool { } #[cfg(test)] -mod computed_and_field_async_tests { - use super::*; - - // The minimal shape the collect scan matches: `async () => { await 1 }` - // — `Expr::Closure { is_async, !is_generator }` whose body has an Await. - fn async_closure_with_await(func_id: perry_hir::types::FuncId) -> Expr { - Expr::Closure { - func_id, - params: Vec::new(), - return_type: Type::Any, - body: vec![Stmt::Expr(Expr::Await(Box::new(Expr::Integer(1))))], - captures: Vec::new(), - mutable_captures: Vec::new(), - captures_this: false, - captures_new_target: false, - enclosing_class: None, - is_arrow: true, - is_async: true, - is_generator: false, - is_strict: false, - } - } - - fn empty_fn(id: perry_hir::types::FuncId, body: Vec) -> Function { - Function { - id, - name: String::new(), - type_params: Vec::new(), - params: Vec::new(), - return_type: Type::Any, - body, - is_async: false, - is_generator: false, - is_strict: false, - is_exported: false, - captures: Vec::new(), - decorators: Vec::new(), - was_plain_async: false, - was_unrolled: false, - } - } - - fn empty_class(name: &str) -> Class { - Class { - id: 1, - name: name.to_string(), - type_params: Vec::new(), - extends: None, - extends_name: None, - native_extends: None, - extends_expr: None, - heritage_lexically_shadowed: false, - fields: Vec::new(), - constructor: None, - methods: Vec::new(), - getters: Vec::new(), - setters: Vec::new(), - static_accessor_names: Vec::new(), - static_accessor_fn_ids: Vec::new(), - computed_members: Vec::new(), - static_fields: Vec::new(), - static_methods: Vec::new(), - decorators: Vec::new(), - is_exported: false, - aliases: Vec::new(), - is_nested: false, - alloc_width_hint: 0, - specialized_from: None, - } - } - - fn field_with_init(name: &str, init: Expr) -> ClassField { - ClassField { - name: name.to_string(), - key_expr: None, - ty: Type::Any, - init: Some(init), - is_private: false, - is_readonly: false, - decorators: Vec::new(), - } - } - - // `h = async () => await 1` as an INSTANCE field: before #5854's collect - // extension the scan skipped `class.fields`, so this closure's FuncId never - // entered `async_step_closures`, the rewrite pass (which filters on that - // set) skipped it, and it stayed a raw block-waiting async fn. It must now - // be BOTH collected and CPS-rewritten to a generator. - #[test] - fn async_closure_in_instance_field_is_collected_and_rewritten() { - let mut module = Module::new("test"); - let mut class = empty_class("C"); - class - .fields - .push(field_with_init("h", async_closure_with_await(50))); - module.classes.push(class); - - transform_async_to_generator(&mut module); - - assert!( - module.async_step_closures.contains(&50), - "instance-field async closure FuncId must be collected" - ); - // An async CLOSURE with awaits is rewritten in place: its body becomes a - // state machine (via transform_plain_async_closure_body) and `is_async` - // is cleared. (Unlike a top-level async fn, it is NOT re-flagged as a - // generator — the transformed body IS the driver.) A cleared `is_async` - // is the definitive signal the rewrite fired rather than falling back to - // raw block-wait. - match &module.classes[0].fields[0].init { - Some(Expr::Closure { is_async, .. }) => assert!( - !*is_async, - "field async closure must be CPS-rewritten (is_async cleared)" - ), - other => panic!("field init should still be a Closure, got {other:?}"), - } - } - - // Companion: a STATIC field initializer is a separate container the collect - // scan also skipped pre-#5854. - #[test] - fn async_closure_in_static_field_is_collected() { - let mut module = Module::new("test"); - let mut class = empty_class("C"); - class - .static_fields - .push(field_with_init("h", async_closure_with_await(60))); - module.classes.push(class); - - transform_async_to_generator(&mut module); - - assert!( - module.async_step_closures.contains(&60), - "static-field async closure FuncId must be collected" - ); - } - - // A computed-key member body (`[0]() { async () => await 1 }`). The rewrite - // loop already walked `computed_members` (commit f80652ad0) but the collect - // scan did not, so the id set it filters on never listed the closure and the - // walk was dead. With both sides covering computed_members it works. - #[test] - fn async_closure_in_computed_member_body_is_collected() { - let mut module = Module::new("test"); - let mut class = empty_class("C"); - class.computed_members.push(ClassComputedMember { - key_expr: Expr::Integer(0), - function: empty_fn(2, vec![Stmt::Expr(async_closure_with_await(70))]), - is_static: false, - kind: ClassComputedMemberKind::Method, - source_order: 0, - }); - module.classes.push(class); - - transform_async_to_generator(&mut module); - - assert!( - module.async_step_closures.contains(&70), - "computed-member-body async closure FuncId must be collected" - ); - } -} +#[path = "async_to_generator_tests.rs"] +mod computed_and_field_async_tests; diff --git a/crates/perry-transform/src/async_to_generator_tests.rs b/crates/perry-transform/src/async_to_generator_tests.rs new file mode 100644 index 0000000000..7bb4b8759b --- /dev/null +++ b/crates/perry-transform/src/async_to_generator_tests.rs @@ -0,0 +1,924 @@ +//! Async-to-generator tests for computed-key and field-position awaits. +//! +//! Split out of `async_to_generator.rs` (2000-line-per-file cap). Pure +//! relocation; declared with `#[path]` from the parent so the module path +//! -- and therefore every test name -- is unchanged. + +use super::*; + +use super::*; + +// The minimal shape the collect scan matches: `async () => { await 1 }` +// — `Expr::Closure { is_async, !is_generator }` whose body has an Await. +fn async_closure_with_await(func_id: perry_hir::types::FuncId) -> Expr { + Expr::Closure { + func_id, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Expr(Expr::Await(Box::new(Expr::Integer(1))))], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: true, + is_generator: false, + is_strict: false, + } +} + +fn empty_fn(id: perry_hir::types::FuncId, body: Vec) -> Function { + Function { + id, + name: String::new(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn empty_class(name: &str) -> Class { + Class { + id: 1, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn field_with_init(name: &str, init: Expr) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Any, + init: Some(init), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +// `h = async () => await 1` as an INSTANCE field: before #5854's collect +// extension the scan skipped `class.fields`, so this closure's FuncId never +// entered `async_step_closures`, the rewrite pass (which filters on that +// set) skipped it, and it stayed a raw block-waiting async fn. It must now +// be BOTH collected and CPS-rewritten to a generator. +#[test] +fn async_closure_in_instance_field_is_collected_and_rewritten() { + let mut module = Module::new("test"); + let mut class = empty_class("C"); + class + .fields + .push(field_with_init("h", async_closure_with_await(50))); + module.classes.push(class); + + transform_async_to_generator(&mut module); + + assert!( + module.async_step_closures.contains(&50), + "instance-field async closure FuncId must be collected" + ); + // An async CLOSURE with awaits is rewritten in place: its body becomes a + // state machine (via transform_plain_async_closure_body) and `is_async` + // is cleared. (Unlike a top-level async fn, it is NOT re-flagged as a + // generator — the transformed body IS the driver.) A cleared `is_async` + // is the definitive signal the rewrite fired rather than falling back to + // raw block-wait. + match &module.classes[0].fields[0].init { + Some(Expr::Closure { is_async, .. }) => assert!( + !*is_async, + "field async closure must be CPS-rewritten (is_async cleared)" + ), + other => panic!("field init should still be a Closure, got {other:?}"), + } +} + +// Companion: a STATIC field initializer is a separate container the collect +// scan also skipped pre-#5854. +#[test] +fn async_closure_in_static_field_is_collected() { + let mut module = Module::new("test"); + let mut class = empty_class("C"); + class + .static_fields + .push(field_with_init("h", async_closure_with_await(60))); + module.classes.push(class); + + transform_async_to_generator(&mut module); + + assert!( + module.async_step_closures.contains(&60), + "static-field async closure FuncId must be collected" + ); +} + +// ── Differential await-position audit (#8681 -p streaming hang) ────────── +// +// The `-p` streaming deadlock traced to an async CLOSURE whose `await` +// reached codegen as a raw `Expr::Await` (the `fs_await.rs` blocking +// busy-wait) instead of a suspend point — i.e. `transform_async_to_generator` +// did not rewrite it. A raw block-wait entered from inside the async-step / +// microtask cascade (the SSE async-generator pull chain) monopolises the +// single runtime thread and self-deadlocks. +// +// For every syntactic position an `await` can sit in, an async closure that +// contains one MUST be (a) collected into `async_step_closures` and (b) +// CPS-rewritten so `is_async` is cleared. A cleared `is_async` is the +// definitive "rewrite fired, will suspend" signal; a still-set `is_async` on +// a closure that has an await is exactly the block-wait escape. This test +// sweeps the positions so a future edit to the walker / rewrite that drops +// one is caught here instead of in a 30-minute bundle compile. +fn await_(inner: Expr) -> Expr { + Expr::Await(Box::new(inner)) +} + +/// An async arrow whose body is `stmts`, at `func_id`. +fn async_closure_body(func_id: perry_hir::types::FuncId, stmts: Vec) -> Expr { + Expr::Closure { + func_id, + params: Vec::new(), + return_type: Type::Any, + body: stmts, + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: true, + is_generator: false, + is_strict: false, + } +} + +#[test] +fn async_closure_await_in_every_position_is_rewritten() { + // (label, body containing exactly one `await` in the named position) + let cases: Vec<(&str, Vec)> = vec![ + ( + "ternary-then", + vec![Stmt::Expr(Expr::Conditional { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(await_(Expr::Integer(1))), + else_expr: Box::new(Expr::Integer(0)), + })], + ), + ( + "logical-and-rhs", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Bool(true)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "logical-coalesce-rhs", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::Coalesce, + left: Box::new(Expr::Null), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "sequence", + vec![Stmt::Expr(Expr::Sequence(vec![ + Expr::Integer(0), + await_(Expr::Integer(1)), + ]))], + ), + ( + "switch-discriminant", + vec![Stmt::Switch { + discriminant: await_(Expr::Integer(1)), + cases: vec![], + }], + ), + ( + "switch-case-body", + vec![Stmt::Switch { + discriminant: Expr::Integer(0), + cases: vec![SwitchCase { + test: Some(Expr::Integer(0)), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + }], + ), + ( + "try-body", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + catch: None, + finally: None, + }], + ), + ( + "catch-body", + vec![Stmt::Try { + body: vec![], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: None, + }], + ), + ( + "finally-body", + vec![Stmt::Try { + body: vec![], + catch: None, + finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]), + }], + ), + ( + "array-element", + vec![Stmt::Expr(Expr::Array(vec![await_(Expr::Integer(1))]))], + ), + ( + "object-value", + vec![Stmt::Expr(Expr::Object(vec![( + "k".to_string(), + await_(Expr::Integer(1)), + )]))], + ), + ( + "call-arg", + vec![Stmt::Expr(Expr::Call { + callee: Box::new(Expr::Undefined), + args: vec![await_(Expr::Integer(1))], + type_args: vec![], + byte_offset: 0, + })], + ), + ( + "index", + vec![Stmt::Expr(Expr::IndexGet { + object: Box::new(Expr::Array(vec![])), + index: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "binary-rhs", + vec![Stmt::Expr(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::Integer(1)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "return-await", + vec![Stmt::Return(Some(await_(Expr::Integer(1))))], + ), + ("throw-await", vec![Stmt::Throw(await_(Expr::Integer(1)))]), + ( + "if-condition", + vec![Stmt::If { + condition: await_(Expr::Bool(true)), + then_branch: vec![], + else_branch: None, + }], + ), + ( + "while-body", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + ), + ( + "for-of-iterable", + vec![Stmt::Expr(Expr::ForOfToArray(Box::new(await_( + Expr::Array(vec![]), + ))))], + ), + ]; + + // One class per case: the closure sits in an instance-field initializer + // (a stable container that stays an `Expr::Closure` after transform, so + // its `is_async` is directly inspectable — see the #5854 test above). + let mut module = Module::new("test"); + let base: perry_hir::types::FuncId = 1000; + for (i, (_label, body)) in cases.iter().enumerate() { + let id = base + i as perry_hir::types::FuncId; + let mut class = empty_class("C"); + class + .fields + .push(field_with_init("h", async_closure_body(id, body.clone()))); + module.classes.push(class); + } + + transform_async_to_generator(&mut module); + + let mut escaped: Vec = Vec::new(); + for (i, (label, _body)) in cases.iter().enumerate() { + let id = base + i as perry_hir::types::FuncId; + let collected = module.async_step_closures.contains(&id); + let rewritten = matches!( + &module.classes[i].fields[0].init, + Some(Expr::Closure { + is_async: false, + .. + }) + ); + if !collected || !rewritten { + escaped.push(format!( + "{label}: collected={collected} rewritten={rewritten}" + )); + } + } + assert!( + escaped.is_empty(), + "async closures with an await in these positions escaped the \ + async->generator transform (would block-wait at runtime): {escaped:#?}" + ); +} + +// #8681 (-p streaming hang): an async-generator CLASS METHOD +// (`async *[Symbol.asyncIterator]()` — the Anthropic SDK `Stream` shape) must +// be recorded in `module.async_generator_funcs` just like a top-level +// `async function* g(){}`, or codegen never builds its async-generator driver +// wrapper and the method runs as a plain SYNC generator: its linearized +// awaits fall back to the blocking busy-wait (`fs_await.rs`), which +// self-deadlocks when driven from inside the async-step/microtask cascade. +fn async_gen_fn(id: perry_hir::types::FuncId) -> Function { + let mut f = empty_fn( + id, + vec![Stmt::Expr(Expr::Yield { + value: Some(Box::new(Expr::Await(Box::new(Expr::Integer(1))))), + delegate: false, + })], + ); + f.is_async = true; + f.is_generator = true; + f +} + +#[test] +fn async_generator_class_methods_are_recorded_like_top_level() { + use crate::generator::transform_generators; + + let mut module = Module::new("test"); + + // (a) baseline: a top-level `async function* g(){}` — known-recorded. + module.functions.push(async_gen_fn(100)); + + // (b) an async-generator INSTANCE method, (c) STATIC method, + // (d) COMPUTED-key member — the three class containers. + let mut class = empty_class("Stream"); + class.methods.push(async_gen_fn(200)); + class.static_methods.push(async_gen_fn(300)); + class.computed_members.push(ClassComputedMember { + key_expr: Expr::Integer(0), + function: async_gen_fn(400), + is_static: false, + kind: ClassComputedMemberKind::Method, + source_order: 0, + }); + module.classes.push(class); + + // The async-step pre-pass runs first in the real pipeline, then the + // generator transform records async-generator func ids. + transform_async_to_generator(&mut module); + transform_generators(&mut module); + + let recorded = &module.async_generator_funcs; + assert!( + recorded.contains(&100), + "top-level async generator must be recorded (baseline)" + ); + let mut missing: Vec<(&str, perry_hir::types::FuncId)> = Vec::new(); + for (label, id) in [ + ("instance-method", 200), + ("static-method", 300), + ("computed-member", 400), + ] { + if !recorded.contains(&id) { + missing.push((label, id)); + } + } + assert!( + missing.is_empty(), + "async-generator class methods NOT recorded in async_generator_funcs \ + (they will run as sync generators and block-wait): {missing:?}" + ); +} + +// ── Async-generator linearizer residual-await audit (#8681) ────────────── +// +// After the full async pipeline (`transform_async_to_generator` + +// `transform_generators`), NO raw `Expr::Await` may survive anywhere: every +// await is either linearized into a generator suspend or CPS-rewritten in a +// nested async closure. A surviving raw `Expr::Await` is compiled by +// `fs_await.rs` into the blocking busy-wait — the exact `-p` deadlock when it +// fires from inside the async-step / async-generator pull chain. The prior +// fix in this family (pi #6728) was an `await` inside `if`/loop/`try` in an +// async generator that never suspended; this sweep guards the whole matrix. +fn count_raw_awaits_stmts(stmts: &[Stmt]) -> usize { + stmts.iter().map(count_raw_awaits_stmt).sum() +} +fn count_raw_awaits_stmt(s: &Stmt) -> usize { + match s { + Stmt::Let { init: Some(e), .. } + | Stmt::Expr(e) + | Stmt::Throw(e) + | Stmt::Return(Some(e)) => count_raw_awaits_expr(e), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + count_raw_awaits_expr(condition) + + count_raw_awaits_stmts(then_branch) + + else_branch + .as_ref() + .map_or(0, |b| count_raw_awaits_stmts(b)) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + count_raw_awaits_expr(condition) + count_raw_awaits_stmts(body) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_ref().map_or(0, |i| count_raw_awaits_stmt(i)) + + condition.as_ref().map_or(0, |c| count_raw_awaits_expr(c)) + + update.as_ref().map_or(0, |u| count_raw_awaits_expr(u)) + + count_raw_awaits_stmts(body) + } + Stmt::Try { + body, + catch, + finally, + } => { + count_raw_awaits_stmts(body) + + catch + .as_ref() + .map_or(0, |c| count_raw_awaits_stmts(&c.body)) + + finally.as_ref().map_or(0, |f| count_raw_awaits_stmts(f)) + } + Stmt::Switch { + discriminant, + cases, + } => { + count_raw_awaits_expr(discriminant) + + cases + .iter() + .map(|c| { + c.test.as_ref().map_or(0, count_raw_awaits_expr) + + count_raw_awaits_stmts(&c.body) + }) + .sum::() + } + Stmt::Labeled { body, .. } => count_raw_awaits_stmt(body), + _ => 0, + } +} +fn count_raw_awaits_expr(e: &Expr) -> usize { + let mut n = if matches!(e, Expr::Await(_)) { 1 } else { 0 }; + // Descend into a nested closure body too: after the pipeline an async + // closure is a state machine, so a raw await there is equally a bug. + if let Expr::Closure { body, .. } = e { + n += count_raw_awaits_stmts(body); + } + perry_hir::walker::walk_expr_children(e, &mut |c| n += count_raw_awaits_expr(c)); + n +} + +fn async_gen_with_body(id: perry_hir::types::FuncId, body: Vec) -> Function { + let mut f = empty_fn(id, body); + f.is_async = true; + f.is_generator = true; + f +} + +#[test] +fn async_generator_linearizes_every_await_position() { + use crate::generator::transform_generators; + + let y = |v: Expr| { + Stmt::Expr(Expr::Yield { + value: Some(Box::new(v)), + delegate: false, + }) + }; + let cases: Vec<(&str, Vec)> = vec![ + ("yield-await", vec![y(await_(Expr::Integer(1)))]), + ( + "await-in-if-body", + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + else_branch: None, + }], + ), + ( + "await-in-while-body", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + }], + ), + ( + "await-in-for-body", + vec![Stmt::For { + init: None, + condition: Some(Expr::Bool(false)), + update: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + }], + ), + ( + "await-in-try-body", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + catch: None, + finally: None, + }], + ), + ( + "await-in-catch", + vec![Stmt::Try { + body: vec![y(Expr::Integer(0))], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: None, + }], + ), + // NOTE: `await` inside a `finally` of a REAL async generator + // (`async function*`) is a SEPARATE, pre-existing gap in the + // `#4438` B2-finally lowering — the yielding finally's states are + // built with a raw `Expr::Await` instead of an async suspend, so it + // block-waits the same way. It is NOT addressed by this PR (which + // fixes the `was_plain_async` catch path); the closure test + // `async_closure_rewrite_leaves_no_residual_await` DOES cover + // `in-finally` for the `was_plain_async` path, which is clean. + // Tracked separately in #8715; omitted here so this test asserts + // only what this change fixes. + ( + "await-in-if-inside-try-inside-loop", + // The pi #6728 shape: await buried in nested control flow. + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Try { + body: vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1)))], + else_branch: None, + }], + catch: None, + finally: None, + }], + }], + ), + ( + "await-in-switch-case", + vec![Stmt::Switch { + discriminant: Expr::Integer(0), + cases: vec![SwitchCase { + test: Some(Expr::Integer(0)), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + }], + ), + ( + "await-in-ternary", + vec![Stmt::Expr(Expr::Conditional { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(await_(Expr::Integer(1))), + else_expr: Box::new(Expr::Integer(0)), + })], + ), + ( + "await-in-logical", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Bool(true)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "await-then-yield-await", + vec![ + Stmt::Expr(await_(Expr::Integer(1))), + y(await_(Expr::Integer(2))), + ], + ), + ]; + + let mut escaped: Vec = Vec::new(); + for (label, body) in &cases { + let mut module = Module::new("test"); + module + .functions + .push(async_gen_with_body(500, body.clone())); + transform_async_to_generator(&mut module); + transform_generators(&mut module); + // Scan every function the pipeline produced (the original plus the + // synthesized step closures / bodies). + let residual: usize = module + .functions + .iter() + .map(|f| count_raw_awaits_stmts(&f.body)) + .sum::() + + module.init.iter().map(count_raw_awaits_stmt).sum::(); + if residual > 0 { + escaped.push(format!("{label}: {residual} raw await(s) survived")); + } + } + assert!( + escaped.is_empty(), + "raw Expr::Await survived async-generator linearization (would \ + block-wait at runtime): {escaped:#?}" + ); +} + +// #8681 (THE crash frame `perry_closure __85891`): a plain async CLOSURE +// rewritten to the async-step driver must leave NO raw `Expr::Await` in its +// body — every await must become an async-step suspend. A residual raw await +// is compiled by fs_await.rs (with `ctx.is_async_fn == false`, since the +// rewrite cleared `is_async`) into the blocking busy-wait + top-level-await +// exit — exactly the symbols the crash-frame closure calls +// (`js_wait_for_event`, `js_unsettled_top_level_await_exit`, ×7 sites). The +// earlier `..._await_in_every_position_is_rewritten` test only checked that +// `is_async` was cleared; it never checked for leftover awaits. This does. +#[test] +fn async_closure_rewrite_leaves_no_residual_await() { + let cases: Vec<(&str, Vec)> = vec![ + ("top-level", vec![Stmt::Expr(await_(Expr::Integer(1)))]), + ( + "in-if", + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1)))], + else_branch: None, + }], + ), + ( + "in-while", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + ), + ( + "in-for", + vec![Stmt::For { + init: None, + condition: Some(Expr::Bool(false)), + update: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + ), + ( + "in-try", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + catch: None, + finally: None, + }], + ), + ( + "try-await-and-catch-await", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(0)))], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: None, + }], + ), + ( + "in-catch", + vec![Stmt::Try { + body: vec![Stmt::Expr(Expr::Integer(0))], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: None, + }], + ), + ( + "in-finally", + vec![Stmt::Try { + body: vec![Stmt::Expr(Expr::Integer(0))], + catch: None, + finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]), + }], + ), + ( + "in-if-in-try-in-while", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Try { + body: vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1)))], + else_branch: None, + }], + catch: None, + finally: None, + }], + }], + ), + ( + "in-ternary", + vec![Stmt::Expr(Expr::Conditional { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(await_(Expr::Integer(1))), + else_expr: Box::new(Expr::Integer(0)), + })], + ), + ( + "in-logical", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Bool(true)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "in-switch-case", + vec![Stmt::Switch { + discriminant: Expr::Integer(0), + cases: vec![SwitchCase { + test: Some(Expr::Integer(0)), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + }], + ), + ]; + + let mut module = Module::new("test"); + let base: perry_hir::types::FuncId = 2000; + for (i, (_label, body)) in cases.iter().enumerate() { + let id = base + i as perry_hir::types::FuncId; + let mut class = empty_class("C"); + class + .fields + .push(field_with_init("h", async_closure_body(id, body.clone()))); + module.classes.push(class); + } + + transform_async_to_generator(&mut module); + + let mut residual: Vec = Vec::new(); + for (i, (label, _body)) in cases.iter().enumerate() { + if let Some(init) = &module.classes[i].fields[0].init { + let n = count_raw_awaits_expr(init); + if n > 0 { + residual.push(format!("{label}: {n} raw await(s) survived")); + } + } + } + assert!( + residual.is_empty(), + "async-closure async-step rewrite left raw Expr::Await (would \ + block-wait at runtime — the `__85891` crash shape): {residual:#?}" + ); +} + +// #8681: async-generator CLOSURE EXPRESSIONS (`const g = async function*(){ +// await x; yield y }`) go through `transform_generator_closures_in_stmts`, a +// different path than named async-gen functions. The `-p` crash frame is a +// `perry_closure` — an inline closure — so this path is the closest match. +// After the pipeline no raw `Expr::Await` may survive in the closure or the +// synthesized bodies the transform lifts into `module.functions`. +#[test] +fn async_generator_closure_expressions_linearize_awaits() { + use crate::generator::transform_generators; + + let async_gen_closure = |id: perry_hir::types::FuncId, body: Vec| Expr::Closure { + func_id: id, + params: Vec::new(), + return_type: Type::Any, + body, + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: true, + is_generator: true, + is_strict: false, + }; + let y = |v: Expr| { + Stmt::Expr(Expr::Yield { + value: Some(Box::new(v)), + delegate: false, + }) + }; + + let bodies: Vec<(&str, Vec)> = vec![ + ("yield-await", vec![y(await_(Expr::Integer(1)))]), + ( + "await-in-loop-in-try", + vec![Stmt::Try { + body: vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + }], + catch: None, + finally: None, + }], + ), + ( + "await-in-if", + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + else_branch: None, + }], + ), + ]; + + let mut escaped: Vec = Vec::new(); + for (label, body) in &bodies { + let mut module = Module::new("test"); + // `const g = async function*(){...}` at module scope. + module.init.push(Stmt::Let { + id: 0, + name: "g".to_string(), + ty: Type::Any, + mutable: false, + init: Some(async_gen_closure(600, body.clone())), + }); + transform_async_to_generator(&mut module); + transform_generators(&mut module); + let residual: usize = module + .functions + .iter() + .map(|f| count_raw_awaits_stmts(&f.body)) + .sum::() + + module.init.iter().map(count_raw_awaits_stmt).sum::(); + if residual > 0 { + escaped.push(format!("{label}: {residual} raw await(s) survived")); + } + } + assert!( + escaped.is_empty(), + "raw Expr::Await survived async-generator CLOSURE linearization \ + (would block-wait at runtime): {escaped:#?}" + ); +} + +// A computed-key member body (`[0]() { async () => await 1 }`). The rewrite +// loop already walked `computed_members` (commit f80652ad0) but the collect +// scan did not, so the id set it filters on never listed the closure and the +// walk was dead. With both sides covering computed_members it works. +#[test] +fn async_closure_in_computed_member_body_is_collected() { + let mut module = Module::new("test"); + let mut class = empty_class("C"); + class.computed_members.push(ClassComputedMember { + key_expr: Expr::Integer(0), + function: empty_fn(2, vec![Stmt::Expr(async_closure_with_await(70))]), + is_static: false, + kind: ClassComputedMemberKind::Method, + source_order: 0, + }); + module.classes.push(class); + + transform_async_to_generator(&mut module); + + assert!( + module.async_step_closures.contains(&70), + "computed-member-body async closure FuncId must be collected" + ); +} diff --git a/crates/perry-transform/src/generator/lower/async_step.rs b/crates/perry-transform/src/generator/lower/async_step.rs index 9dbc446b13..30a4e1793d 100644 --- a/crates/perry-transform/src/generator/lower/async_step.rs +++ b/crates/perry-transform/src/generator/lower/async_step.rs @@ -16,7 +16,15 @@ pub(crate) fn build_async_throw_body_direct( let mut fallback = vec![Stmt::Throw(Expr::LocalGet(throw_param_id))]; for route in catches.into_iter().rev() { - let condition = catch_route_condition(&route, state_id, false, false); + // #8681: a LINEARIZED catch (its body became real dispatch states) must + // use the state-based upper bound (`protected_end_state`, which EXCLUDES + // the catch's own states) so an error raised *inside* the catch — e.g. + // `catch (e) { await x; throw wrap(e); }` — ESCAPES to an enclosing + // handler instead of re-matching this same route and re-entering the + // catch. The legacy inline path (catch_entry_state == None) keeps the + // async `post_catch_state` upper bound it always used. + let state_based = route.catch_entry_state.is_some(); + let condition = catch_route_condition(&route, state_id, state_based, false); let then_branch = build_async_catch_route_body_direct( route, state_id, @@ -49,6 +57,32 @@ pub(crate) fn build_async_catch_route_body_direct( ))); } + // #8681: when the catch body was linearized into its own dispatch states + // (`catch_entry_state`), route the delivered error INTO those states — + // bind the catch param (above), set `state = catch_entry_state`, and fall + // through to the step's `while (true)` dispatch loop — exactly as the sync + // path does in `build_abrupt_routing`. The old behavior inlined a raw copy + // of the catch body here and ran `rewrite_yield_to_await_in_stmts` over it, + // turning every `await` inside the catch into a BLOCKING busy-wait + // (`fs_await.rs`: `js_wait_for_event` + `js_unsettled_top_level_await_exit`). + // Reached re-entrantly from inside the async-step / async-generator pull + // cascade (a rejected awaited promise in a `try` routing to a `catch` that + // itself awaits — the Anthropic SDK stream error path), that blocking wait + // monopolises the single runtime thread and self-deadlocks: the + // `perry_closure __85891` `-p` hang. The linearized catch states suspend via + // the async-step driver (`AsyncStepChain`) like any other await, so the + // driver keeps making progress. + if let Some(catch_entry_state) = route.catch_entry_state { + body.push(Stmt::Expr(Expr::LocalSet( + state_id, + Box::new(Expr::Number(catch_entry_state as f64)), + ))); + return body; + } + + // Legacy fallback: the catch body was NOT linearized (no await/yield inside + // it, so there is nothing to suspend on) — inline it directly. A yield-free + // catch has no `await` to turn into a block-wait, so this stays correct. let mut rewritten = route.body; rewrite_hoisted_lets_in_stmts(&mut rewritten, hoisted_ids); rewrite_yield_to_await_in_stmts(&mut rewritten); diff --git a/crates/perry-transform/src/generator/mod.rs b/crates/perry-transform/src/generator/mod.rs index 6432b496ae..5ac3f27a4e 100644 --- a/crates/perry-transform/src/generator/mod.rs +++ b/crates/perry-transform/src/generator/mod.rs @@ -164,6 +164,13 @@ pub fn transform_generators(module: &mut Module) { } for m in &mut class.methods { if m.is_generator { + // #8681: an `async *m(){}` method is an async generator — record + // it (before the transform clears `is_async`) exactly like the + // top-level `async function*` loop above, so codegen has the same + // ground truth for methods as for functions/closures. + if m.is_async { + record_async_generator_func(m.id); + } transform_generator_function_with_extra_captures( m, &mut next_local_id, @@ -181,6 +188,10 @@ pub fn transform_generators(module: &mut Module) { } for m in &mut class.static_methods { if m.is_generator { + // #8681: `static async *m(){}` — see the instance-method note. + if m.is_async { + record_async_generator_func(m.id); + } transform_generator_function(m, &mut next_local_id, &mut next_func_id); } let mut b = std::mem::take(&mut m.body); @@ -193,6 +204,11 @@ pub fn transform_generators(module: &mut Module) { for member in &mut class.computed_members { let m = &mut member.function; if m.is_generator { + // #8681: `async *[Symbol.asyncIterator](){}` — the Anthropic SDK + // `Stream` shape; record it as an async generator like the rest. + if m.is_async { + record_async_generator_func(m.id); + } transform_generator_function_with_extra_captures( m, &mut next_local_id,