Skip to content

fix(async): async-generator yield* return() forwarding + non-object next() (#5592) - #5751

Merged
proggeramlug merged 1 commit into
mainfrom
fix/async-gen-yield-star-return-5592
Jun 28, 2026
Merged

fix(async): async-generator yield* return() forwarding + non-object next() (#5592)#5751
proggeramlug merged 1 commit into
mainfrom
fix/async-gen-yield-star-return-5592

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Subcluster: async-generator yield * delegation (#5592)

The class-tail ticket's single largest root is the async-generator yield *
family — 40 cases across async-gen-method{,-static} and
async-gen-private-method{,-static} (statements + expressions), all failing
because Perry only implemented the next() direction of yield * delegation.
This PR makes three spec steps correct and fixes 24 of those 40.

What was wrong

  1. Non-object iterator result — after Await(? Call(next, …)) the spec
    requires If Type(innerResult) is not Object, throw a TypeError. Perry read
    .done/.value off the primitive, so gen.next() fulfilled where it must
    reject. (yield-star-next-non-object-ignores-then)

  2. return() not forwardedgen.return(v) while suspended inside a
    yield * must forward to the delegated iterator's return method
    (yield * step 6.c): complete with v when there is no return, re-yield
    on {done:false}, or complete with the result's value on {done:true}.
    Perry completed the outer generator directly and never touched the inner
    iterator, so the test logs stayed empty.
    (yield-star-{sync,async}-return)

  3. AsyncFromSync return/throw double-read
    %AsyncFromSyncIteratorPrototype%.{return,throw} do GetMethod once then
    Call the captured value. Perry re-dispatched by name, firing a
    get return / get throw accessor twice and diverging from Node's
    operation order. (the sync-iterator half of the return cases)

Fix

  • perry-transform linearize — enforce the Object-check on every awaited
    delegated pull (async generators only; sync yield * reads .done/.value
    via GetV and never throws), and record each yield * suspend-state interval.
  • perry-transform lower/abrupt — in an async generator's .return()
    closure, when suspended inside a recorded yield * interval, forward to the
    delegated iterator's return (await + Object-check + done dispatch / re-yield);
    otherwise fall through to the existing completion path. No-op for sync
    generators and for generators with no yield *.
  • perry-runtimeasync_from_sync_call_raw now invokes the
    already-fetched return/throw method value (this = iter) instead of
    re-dispatching by name.

Before / after (8 yield-star-* class dirs)

before after
non-object fail ×8 pass ×8
sync-return fail ×8 pass ×8
async-return fail ×8 pass ×8
sync-throw fail ×8 fail ×8 (remaining)
async-throw fail ×8 fail ×8 (remaining)

rt-fail = 0, compile-fail = 0 across all 8 dirs; non-class async-generator
and for await…of slices show no new runtime/compile failures.

What remains

The 16 *-throw cases need throw() forwarding. Its {done:true} branch must
resume the outer body after the yield *, which requires the
async-generator dispatch-loop continuation that sync generators already have
(#4374) — async .throw()/.return() closures don't run the dispatch loop, so
this is a larger state-machine change left as follow-up.

Related

Refs #5592, #5745, #5591.

Summary by CodeRabbit

  • New Features

    • Improved support for delegated async generator flows, including better forwarding for return() during yield* handling.
    • Added more reliable handling when iterator methods are accessed by name versus from a cached value.
  • Bug Fixes

    • Fixed cases where delegated iterator results were not validated consistently, preventing incorrect continuation on invalid results.
    • Improved behavior when a delegated iterator method is unavailable, so generator completion now follows the expected path.

…() + non-object next() (#5592)

Async-generator `yield *` only implemented the `next()` direction. Three
spec steps were missing, so test262's `yield-star-*` class cases failed:

1. **non-object iter-result** — after `Await(? Call(next, ...))` the spec
   requires `If Type(innerResult) is not Object, throw a TypeError`. Perry
   read `.done`/`.value` off a primitive instead, so `gen.next()` fulfilled
   where it must reject (`yield-star-next-non-object-ignores-then`).

2. **return() forwarding** — `gen.return(v)` while suspended inside a
   `yield *` must forward to the delegated iterator's `return` method
   (spec `yield *` step 6.c): complete with `v` when there is no `return`,
   re-yield on a `{done:false}` result, or complete with the result value
   on `{done:true}`. Perry completed the outer generator directly, never
   touching the inner iterator (`yield-star-{sync,async}-return`).

3. **AsyncFromSync return/throw double-read** — `%AsyncFromSyncIterator
   Prototype%.{return,throw}` do `GetMethod` once then `Call` the captured
   value; Perry re-dispatched by name, firing a `get return`/`get throw`
   accessor twice and diverging from Node's operation order.

Implementation:

- linearize: enforce the Object-check on every awaited delegated pull
  (async generators only — sync `yield *` reads `.done`/`.value` via
  `GetV` and never throws), and record each `yield *` suspend-state
  interval so the `.return()` closure can find the delegated iterator.
- lower/abrupt: in an async generator's `.return()` closure, forward to
  the delegated iterator's `return` (await + Object-check + done dispatch)
  when suspended in a `yield *` interval; otherwise fall through to the
  existing completion path. No-op for sync generators / generators
  without a `yield *`.
- runtime: `async_from_sync_call_raw` invokes the already-fetched
  return/throw method value (with `this = iter`) instead of re-dispatching
  by name.

Fixes 24 of 40 class `yield-star-*` cases (non-object 8, sync-return 8,
async-return 8) across async-gen-method{,-static} and async-gen-private-
method{,-static} (statements + expressions). The remaining `*-throw`
cases need `throw()` forwarding, whose `{done:true}` branch must resume
the outer body after the `yield *` (async-generator dispatch-loop
continuation, analogous to #4374 for sync generators) — left as
follow-up.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Implements spec-compliant gen.return(v) forwarding through yield* delegated iterators in async generators. The transform pipeline now records suspension-state intervals during linearization and emits per-interval routing code in the .return closure. Additionally fixes async-from-sync iterator dispatch to use callable-value invocation vs. by-name method dispatch for built-in iterators.

Changes

Async yield* return routing and async-from-sync dispatch

Layer / File(s) Summary
Async-from-sync iterator method dispatch fix
crates/perry-runtime/src/array/iterator.rs
Splits dispatch into js_native_call_value for callable method values vs. js_native_call_method for built-in iterators where next is unreadable; adds conditional IMPLICIT_THIS restoration and Ok(None) early-return for undefined non-next methods.
DelegationRoute struct and thread-local accumulator
crates/perry-transform/src/generator/linearize.rs, crates/perry-transform/src/generator/mod.rs
Introduces LINEARIZE_DELEGATIONS thread-local, DelegationRoute struct with suspend-state interval bounds and iter_id, and reset_delegation_routes / take_delegation_routes APIs.
Object-type guards and route recording in emit_yield_star_loop
crates/perry-transform/src/generator/linearize.rs
Emits TypeError guards for non-object delegated results (async only), captures deleg_lo/deleg_hi suspend-state bounds, and pushes DelegationRoute entries into the thread-local accumulator.
build_yield_star_return_routes emitter
crates/perry-transform/src/generator/lower/abrupt.rs
Adds not_object_condition helper and build_yield_star_return_routes, which generates per-DelegationRoute if-state-in-interval blocks: reads delegated return method, completes outer generator when absent, otherwise awaits, validates result object, then completes or re-yields.
Wiring into generator lowering
crates/perry-transform/src/generator/lower.rs
Resets delegation state before linearize_body, captures routes after, re-exports build_yield_star_return_routes, and extends the .return closure resume body with the emitted routing statements.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐇 Hoppity-hop through the yield* maze,
The rabbit routes returns through delegated ways.
State intervals captured, TypeError thrown right,
Built-in iterators dispatched with delight.
No more falling through — the spec shines bright! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: async-generator yield* return forwarding and non-object next handling.
Description check ✅ Passed The description is detailed and covers summary, changes, related issues, and verification, though it doesn't follow the repository template exactly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/async-gen-yield-star-return-5592

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/array/iterator.rs`:
- Around line 345-357: The async-from-sync iterator method lookup in iterator.rs
treats only TAG_UNDEFINED as “absent,” so TAG_NULL currently falls into the
non-callable error path instead of matching GetMethod behavior. Update the
method handling in the async-from-sync iterator logic around the `callable`
decision to treat `crate::value::TAG_NULL` the same as undefined, and keep the
builtin `next` fallback inside that same absent-method branch so `return: null`
and `throw: null` resolve correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d03b6e52-e628-4172-8e47-e4aef5886240

📥 Commits

Reviewing files that changed from the base of the PR and between 7573d18 and 7de69ed.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/array/iterator.rs
  • crates/perry-transform/src/generator/linearize.rs
  • crates/perry-transform/src/generator/lower.rs
  • crates/perry-transform/src/generator/lower/abrupt.rs
  • crates/perry-transform/src/generator/mod.rs

Comment on lines +345 to 357
let callable = if method_value.to_bits() == crate::value::TAG_UNDEFINED {
let raw = crate::value::js_nanbox_get_pointer(iter) as usize;
if method != b"next" || !is_builtin_iterator_class_id(raw) {
return Ok(None);
}
// A builtin iterator that exposes no readable `next` property: fall back
// to method dispatch (string/typed-array iterators tower their `next`
// through the class-id method table).
false
} else if !is_callable_value(method_value) {
return Err(async_from_sync_type_error(
b"Async-from-sync iterator method is not callable",
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the runtime's null tag/helper name and nearby async-from-sync callers.
rg -n "TAG_NULL|is_null\\(|async_from_sync_(return|throw)|async_from_sync_call\\(" crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- iterator.rs around the changed branch ---'
sed -n '320,390p' crates/perry-runtime/src/array/iterator.rs

echo
echo '--- search for async-from-sync / iterator method handling in runtime ---'
rg -n "async_from_sync|GetMethod|TAG_NULL|TAG_UNDEFINED|is_callable_value|method_value.to_bits\\(\\)" \
  crates/perry-runtime/src/array crates/perry-runtime/src/object crates/perry-runtime/src/value.rs

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- iterator.rs: @@asyncIterator GetMethod handling ---'
sed -n '590,610p' crates/perry-runtime/src/array/iterator.rs

echo
echo '--- iterator.rs: nearby cached-method branch ---'
sed -n '738,752p' crates/perry-runtime/src/array/iterator.rs

echo
echo '--- iterator.rs: async-from-sync branch under review ---'
sed -n '336,360p' crates/perry-runtime/src/array/iterator.rs

Repository: PerryTS/perry

Length of output: 3651


Treat TAG_NULL as absent here too. GetMethod treats null the same as undefined, so return: null / throw: null currently hits the non-callable TypeError path instead of the absent-method path. Add crate::value::TAG_NULL to the check and keep the builtin next fallback inside that branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/array/iterator.rs` around lines 345 - 357, The
async-from-sync iterator method lookup in iterator.rs treats only TAG_UNDEFINED
as “absent,” so TAG_NULL currently falls into the non-callable error path
instead of matching GetMethod behavior. Update the method handling in the
async-from-sync iterator logic around the `callable` decision to treat
`crate::value::TAG_NULL` the same as undefined, and keep the builtin `next`
fallback inside that same absent-method branch so `return: null` and `throw:
null` resolve correctly.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Verification

Build: cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static — clean.
cargo fmt --all -- --check — clean. Commit is code-only (5 source files; no
Cargo.toml/Cargo.lock/CLAUDE.md/CHANGELOG).

All 8 yield-star-* class dirs (async-gen-method{,-static},
async-gen-private-method{,-static} × statements/expressions):

pass 680  diff 16  runtime-fail 0  compile-fail 0

diff is exactly the 16 remaining *-throw cases (8 async-throw + 8 sync-throw).
Delta vs the #5592 snapshot for these dirs: 40 failing → 16 failing (−24)
non-object ×8, sync-return ×8, async-return ×8 now pass.

Regression surface (the change only executes on the async-generator yield *
path + the AsyncFromSyncIterator return/throw runtime helper):
language/{expressions,statements}/async-generator + language/statements/for-await-of
ran clean with no new runtime-fail / compile-fail (the one pre-existing
async-generator/default-proto runtime-fail is unrelated AsyncGenerator.prototype
setup, untouched here). The for await…of dir specifically exercises the
AsyncFromSync close (return) path that the runtime change modifies — rt-fail=0
there.

Note: the full language/{statements,expressions}/class slice (~thousands of
cases) did not complete in the constrained build host (worker kills under
load); the change is surgically scoped to async-generator yield * lowering and
the AsyncFromSync helper, so no non-async-generator class case exercises the
modified code paths.

@proggeramlug
proggeramlug merged commit d8b450f into main Jun 28, 2026
15 checks passed
@proggeramlug
proggeramlug deleted the fix/async-gen-yield-star-return-5592 branch June 28, 2026 08:57
proggeramlug added a commit that referenced this pull request Jun 28, 2026
…me (#5761)

* fix(async): #5745 — async-generator yield* .throw() delegation + resume

When an async generator is suspended inside a `yield *` and `gen.throw(e)`
is called, spec `yield *` step 6.b requires forwarding the error to the
delegated iterator's `throw` method. Perry already forwarded `.return()`
(#5751) but `.throw()` fell through to the outer generator's own catch
routing, so the inner iterator's `throw` never fired, the post-resume
yielded value was dropped, and the delegation loop could not continue.

Unlike `return`, a `throw` whose inner result is `done` does NOT complete
the outer generator — it resumes execution *after* the `yield *` (which may
yield again or run to completion). This needs the async-generator
abrupt-resume continuation machinery that sync generators have (#4374) but
the async path deliberately omitted.

Implementation:
- DelegationRoute records the drive loop's iter-result local (`result_id`)
  and its condition-check state (`resume_state` = the `while` cond state).
- build_yield_star_throw_routes (async only): GetMethod(iterator,"throw");
  if undefined, AsyncIteratorClose the inner iterator then throw a TypeError;
  else await throw.call(iter, e), enforce the Object result, store it into
  `result_id`, set state to `resume_state`, and re-drive a clone of the
  state-dispatch loop. The condition state then reads `result.done` and
  either exits the loop (resuming the outer body past the `yield *`) or
  re-yields `result.value` — reproducing spec operation order exactly.
- Injected into the `.throw()` closure before the catch-routing fallback,
  so it only fires while suspended in a delegation.

Strictly additive and async-only: `delegations` is recorded solely under
`linearize_async_generator()`, so the routes are empty (a no-op) for sync
generators and for async generators without a `yield *`.

test262 language/{expressions,statements}/class/async-gen-method-static:
96 pass / 1 diff each (was ~10 failing); the remaining diff
(`yield-star-sync-next`, a `[Symbol.iterator]` thisValue gap) is unrelated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(async): route delegated yield* throw-protocol errors to outer catch

Address CodeRabbit review on #5761: when the delegation protocol itself
completes abruptly — `iterator.throw` rejecting, a non-object inner result,
or the `throw`-undefined TypeError — the error occurs at the `yield *` site,
so an outer `try/catch` around the delegation must be able to handle it
(spec `?`/ReturnIfAbrupt semantics).

Wrap the protocol work (read `throw`, close-on-undefined, await call, object
check) in a generated `try/catch`. The handler routes the caught error into
the enclosing try's linearized catch states via build_abrupt_routing, then
re-drives the dispatch loop so a `yield` inside that catch suspends; when no
catch matches it runs pending non-yielding finallys and re-throws to reject
the generator. `state` is still the delegation suspend state in the handler
(the success-path `state = resume_state` runs last, inside the try), so it
falls inside the outer try's protected interval and matches correctly.

Validated: inner `throw` that throws is now caught by an outer
`try { yield* inner } catch (e) { yield ... }`; a missing inner `throw`
closes the inner iterator via `return` and the TypeError is likewise caught.
No test262 regressions across language/{expressions,statements}/{class/
async-gen-method-static,async-generator} + AsyncFromSyncIteratorPrototype.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: note async yielding-finally limitation in yield* throw fallback

Clarify (CodeRabbit minor) that the no-catch fallback running only
non-yielding finallys before rethrowing matches build_async_throw_body's
own async fallback. Routing into a yielding finally needs the
pending-completion re-raise machinery gated !is_async_generator; doing so
without it would swallow the error. Out of scope; comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant