Skip to content

fix: standalone async-generator test262 parity (57.7% → 93.0%) - #4777

Merged
proggeramlug merged 5 commits into
mainfrom
async-generator-parity
Jun 8, 2026
Merged

fix: standalone async-generator test262 parity (57.7% → 93.0%)#4777
proggeramlug merged 5 commits into
mainfrom
async-generator-parity

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Drives standalone async function* (declarations + expressions) test262 parity from 57.7% → 93.0% across language/expressions/async-generator + language/statements/async-generator (pass 512 → 826 / 888, runtime-fail 181 → 21, compile-fail 0). Four root causes, each spec-justified, gated to stay inert outside the affected shapes.

Root causes & fixes

1. Destructuring param + default dropped the binding; destructuring defaults skipped NamedEvaluation (fix(hir))

  • async function*([x,y,z]=[1,2,3]){} (expression form) lowered only the default guard, not the destructuring binding — x/y/z resolved to js_throw_reference_error_unresolved_get. lower_arrow / lower_fn_expr / nested_fn_decl now unwrap Pat::Assign to the inner pattern before is_destructuring_pattern, mirroring the already-correct lower_fn_decl.
  • [fn = function(){}] left fn.name empty instead of "fn". destructuring/pattern_binding now threads the single-name binding into ctx.assignment_inferred_name for anonymous fn/arrow/class defaults at all three default sites.

2. Generator param binding ran on first .next() instead of at call time (fix(transform))

  • Per spec, generator/async-generator parameter binding (FunctionDeclarationInstantiation) runs synchronously when the function is called, before the generator object is created — so an iterator / RequireObjectCoercible / TDZ error during destructuring or default evaluation throws at call time. Perry linearized the param prologue into state 0 of .next(), so a test that only calls f(badIterable) (expecting a synchronous throw) never ran it.
  • Lowering records the prologue length per generator func_id (Module.gen_param_prologue_len); the transform lifts those leading statements into the outer wrapper, boxing their bound locals so the state machine still reads the destructured values. Gated on is_generator (plain async functions keep reject-not-throw semantics) and inert when prologue_len == 0. This fix alone collapsed runtime-fail 181 → 21.

3. yield E didn't Await its operand (fix(transform))

  • Per spec, yield E in an async generator is AsyncGeneratorYield(? Await(E))yield Promise.reject(x) throws x into the generator and yield Promise.resolve(v) yields v. A pre-pass (after hoist_yields) rewrites each statement-level non-delegate yield E into let __ayield = await E; yield __ayield, reusing the existing suspension machinery. yield* delegation untouched.

Verification & zero-regression check

  • All four fixes verified byte-identical to node --experimental-strip-types.
  • Targeted sweep over language/{expressions,statements}/{generators,async-function} (shared state machine): 90.4%, no new crashes — the param-prologue lift is spec-correct for sync generators too.
  • Broad shard 0/12 over built-ins language vs an origin/main baseline (same shard/harness): +33 pass, −10 runtime-fail, compile-fail unchanged (18). The one apparent "regression" (TypedArray/prototype/forEach/returns-undefined.js) is a pre-existing non-deterministic GC/layout-sensitive segfault (passed run 3/3 on a commit-1-only build) surfaced by layout shift, not introduced logic.

Remaining (62, mostly out of scope)

~50 are precise yield* async-delegation tests (exact getter ordering, async-from-sync ticks, return/throw forwarding, abrupt-completion propagation) — a separate AsyncGeneratorYield* reimplementation. ~12 are prototype-chain edge cases.

Ralph Küpper added 5 commits June 8, 2026 08:02
…on parity

Standalone async-generator (and other) expressions dropped two destructuring
behaviors that declarations already had:

1. A param that is both a destructuring pattern AND has a default
   (`async function*([x,y,z]=[1,2,3]){}`) lowered only the default guard, not
   the destructuring binding — x/y/z resolved to
   js_throw_reference_error_unresolved_get. lower_arrow / lower_fn_expr /
   nested_fn_decl now unwrap Pat::Assign to the inner pattern before
   is_destructuring_pattern, mirroring lower_fn_decl.

2. Destructuring defaults skipped NamedEvaluation: `[fn = function(){}]` left
   fn.name empty instead of "fn". pattern_binding now threads the single-name
   binding into ctx.assignment_inferred_name for anonymous fn/arrow/class
   defaults, at all three default sites (array elem, Pat::Assign, object
   shorthand).
Per spec, generator/async-generator parameter binding
(FunctionDeclarationInstantiation) runs synchronously when the function is
called — before the generator object is created — so an iterator /
RequireObjectCoercible / TDZ error during destructuring or default evaluation
throws at call time, not on the first .next(). Perry prepended the param
prologue (default guards + destructuring binding) to the body, which the
generator transform linearized into state 0 of .next(); a test that only calls
f(g) (expecting a synchronous throw) never ran it.

Lowering now records the prologue length per generator func_id
(Module.gen_param_prologue_len); the generator transform lifts those leading
statements out of the state machine and runs them in the outer wrapper, boxing
their bound locals so the state machine still reads the destructured values.
Gated on is_generator (plain async functions keep reject-not-throw semantics)
and inert when there is no destructuring/default param (prologue_len 0).

async-generator dirs: rt-fail 181->21, parity 73.6%->91.7%.
Per spec, `yield E` in an async generator is AsyncGeneratorYield(? Await(E)) —
the operand is awaited before being delivered, so `yield Promise.reject(x)`
throws x into the generator and `yield Promise.resolve(v)` yields v (not the
promise). Perry yielded the raw operand. A pre-pass (after hoist_yields)
rewrites each statement-level non-delegate `yield E` into
`let __ayield = await E; yield __ayield`; the await reuses the existing
suspension machinery. yield* delegation is untouched (it awaits via
delegate_await).
The standalone async-generator parity work (param-prologue lift + per-yield
operand Await) grew generator/lower.rs past the 2000-line CI gate; add it to
the check_file_size.sh allowlist (split tracked under #1435).
A fused method call `o.next(args)` where `next` is an own accessor
(`{ get next() { return fn } }` or Object.defineProperty get) mis-resolved to
undefined — js_native_call_method read the raw field slot (no callable for an
accessor-only property) instead of invoking the getter. The decomposed form
`const f = o.next; f(args)` already worked via the getter-aware property read.
Add an early accessor check: invoke the getter (this=receiver), then call the
resolved function. Gated on the ACCESSORS_IN_USE hot-path flag. Unblocks yield*
over a sync/async iterator whose next/value/done are getters (test262
yield-star-* with `get next()`).

Also add gen_param_prologue_len: HashMap::new() to the Module struct literals in
perry-codegen{,-arkts} test fixtures (new Module field).
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