fix: yield* async-delegation test262 parity (next-capture + object-literal thenable await) - #4783
Merged
Merged
Conversation
…al thenable await
Spec-aligns `yield *` async/sync delegation and fixes a core async/await
gap that blocked the delegated async-iterator protocol.
test262 (vs origin/async-generator-parity, 0 regressions):
- language/{expressions,statements}/async-generator: 820 -> 838 pass (92.3% -> 94.4%)
- built-ins/Promise: 347 -> 357 pass (60.5% -> 62.2%) [thenable fix, bonus]
- language/{expressions,statements}/generators: no change, 0 regressions
Root causes addressed:
1. [[NextMethod]] captured once (perry-transform/generator/linearize.rs).
The `yield*` desugar re-read `del_iter.next` on every loop iteration,
re-running the iterator's `get next` accessor and an extra property read
per step. Spec (GetIterator) reads `next` exactly once and reuses the
captured method. Factored the 3 near-identical desugar sites (statement
`yield* e`, `return yield* e`, `let x = yield* e`) into one
`emit_yield_star_loop` helper that captures `__del_next = __del_iter.next`
once and invokes `__del_next.call(__del_iter, arg)` (reads
Function.prototype.call, not the iterator's getters; binds `this` for
builtin/inherited `next` thunks). Falls back to method dispatch when the
captured value isn't callable (string/typed-array iterators expose no
readable `next`).
2. First inner pull passes an explicit `undefined`, not argless. Spec inits
`received = NormalCompletion(undefined)`, so every inner `next()` — including
the first — gets one argument (test262 yield-star-*-next assert
`next args.length === 1`).
3. async-from-sync wrapper caches the sync `[[NextMethod]]`
(perry-runtime/array/iterator.rs). CreateAsyncFromSyncIterator reads `next`
once; the wrapper re-read (and re-ran the getter) on every `next()` call.
4. Object-literal thenables now assimilate in `await`/`Promise.resolve`
(perry-runtime/promise/combinators.rs). `js_assimilate_thenable` returned
early for class_id==0 objects (plain `{ then() {} }` / `{ get then() {} }`),
so `await { then(r){r(v)} }` resolved with the thenable itself. Route those
through the existing property-based fallback (spec PromiseResolveThenableJob:
Get(value,"then") + IsCallable, independent of storage). Also pass the
resolving functions to user `then` as NaN-boxed function values so
`typeof onFulfilled === "function"` holds.
The delegated yielded value is intentionally NOT awaited: current
AsyncGeneratorYield does not await its operand (only the `next()` result is
awaited), so a delegated promise value flows through un-unwrapped
(yield-star-promise-not-unwrapped).
This was referenced Jun 8, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #4777 (now merged). Spec-aligns
yield *async/sync delegation and fixes a coreawait/Promise.resolvegap that gated the delegated async-iterator protocol.Results (test262, vs main @ #4777, 0 regressions)
yield-star tests newly passing:
yield-star-async-next,yield-star-next-then-get-abrupt,yield-star-next-then-returns-abrupt,yield-star-getiter-async-returns-null-throw(+named-variants). Failure-set diff (mine − base) is empty for all three dirs.Root causes fixed
[[NextMethod]]captured once (generator/linearize.rs). The desugar re-readdel_iter.nextevery loop iteration, re-running the iterator'sget nextaccessor + an extra property read per step; spec (GetIterator) readsnextonce and reuses it. Factored the 3 near-identical desugar sites (yield* e,return yield* e,let x = yield* e) into oneemit_yield_star_loopthat captures__del_next = __del_iter.nextonce and calls__del_next.call(__del_iter, arg)(bindsthisfor inherited/builtinnextthunks), falling back to method dispatch when the captured value isn't callable (string/typed-array iterators expose no readablenext).undefined(not argless). Spec initsreceived = NormalCompletion(undefined), so every innernext()— including the first — gets one arg (next args.length === 1).[[NextMethod]](array/iterator.rs). CreateAsyncFromSyncIterator readsnextonce; the wrapper re-read (and re-ran the getter) on everynext().await/Promise.resolve(promise/combinators.rs).js_assimilate_thenablereturned early forclass_id == 0objects ({ then(){} }/{ get then(){} }), soawait { then(r){r(v)} }resolved with the thenable itself. Routed through the existing property-based fallback (PromiseResolveThenableJob:Get(value,"then")+ IsCallable), and pass the resolving functions to userthenas NaN-boxed function values sotypeof onFulfilled === "function"holds. Also fixes +10built-ins/Promisetests.The delegated yielded value is intentionally not awaited — current
AsyncGeneratorYielddoes not await its operand (only thenext()result is), so a delegated promise flows through un-unwrapped (yield-star-promise-not-unwrapped).Regression check
cargo test -p perry-transform -p perry-runtime -p perry-hir -p perry-codegen: all real tests pass. Two perry-runtime unit tests (closure_name_and_length_ignore_plain_assignment,stream_constructors_expose_static_method_values) are pre-existing parallel-isolation flakes — both pass in isolation and on base; the same binary passes/fails depending only on whether a sibling test'stest_clear_closure_side_tables()runs concurrently. Not caused by this change.yield*smoke (delegation through array / Set / generator / custom[Symbol.iterator]/ two-waynext(v)) matches Node.Remaining yield* failures (out of scope; orthogonal pre-existing gaps)
yield-star-{async,sync}-{return,throw},getiter-async-{return,throw}-method-is-null,*-notdone-iter-value-throws): the suspendedyield*must dispatch the consumer's.return()/.throw()to the delegate (close + re-yield), needing resume-mode plumbing through the async-generator state machine (generator/lower.rs+object/async_generator_queue.rs).[Symbol.iterator]this-binding (yield-star-sync-next): calling the (getter-returned)[Symbol.iterator]binds the wrongthis. Separate subsystem.yield-star-next-{call-value-get-abrupt,non-object-ignores-then,not-callable-number-throw,then-non-callable-number-fulfillpromise}): IteratorNext result must be validated as Object (spec step iv) + abrupt propagation.