Skip to content

Fix #430/#645: compiled for await...of inside an async arrow throws InvalidCastException#677

Merged
nickna merged 1 commit into
mainfrom
wrk/angry-wing-f75d44
Jun 16, 2026
Merged

Fix #430/#645: compiled for await...of inside an async arrow throws InvalidCastException#677
nickna merged 1 commit into
mainfrom
wrk/angry-wing-f75d44

Conversation

@nickna

@nickna nickna commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Fixes #430 and #645 (same root cause).

Problem

In compiled mode, a for await...of over an async generator inside an async arrow function crashed:

System.InvalidCastException: Unable to cast object of type '<g>d__0' to type
'System.Collections.IEnumerable'. at <>c__AsyncArrow_0.MoveNext()
async function* g() { yield 1; yield 2; }
(async () => { for await (const x of g()) console.log(x); })();  // compiled: crash

AsyncArrowMoveNextEmitter had no EmitForOf override, so the loop fell through to the synchronous for-of path in StatementEmitterBase and cast the async-generator state machine to IEnumerable. The interpreter handled the same program correctly, and the async function form already worked.

Fix

The correct async-iterator lowering lived only inside AsyncMoveNextEmitter (with a divergent, more-limited copy in AsyncGeneratorMoveNextEmitter). Rather than add a third copy, I extracted it into StatementEmitterBase.EmitForAwaitOf so all three async state-machine emitters share one implementation:

  • AsyncMoveNextEmitter / AsyncArrowMoveNextEmitter dispatch to the shared base method from their EmitForOf override when f.IsAsync.
  • AsyncGeneratorMoveNextEmitter keeps a protected override (it needs its own error-propagating + strict-done variant for the async-generator-consumer case).

Loop-variable binding now routes through the DeclareLoopVariable / EmitStoreLoopVariable hooks instead of Ctx.Locals directly — the async arrow resolver reads its own private _locals map (via its RegisterLoopLocal override), so a binding registered in Ctx.Locals read back as null in the arrow body.

Validation

  • Compiled output now matches the interpreter for: the $IAsyncGenerator path, custom Symbol.asyncIterator, break/cleanup (iterator.return()), captured-variable accumulation, and nested-arrow (captured-outer-state-machine) contexts.
  • Emitted IL passes ILVerify for every shape.
  • All 12,690 SharpTS.Tests pass (incl. the EmitterSyncTests override guard, updated for the two new overrides).
  • 4 new regression tests in AsyncArrowFunctionTests (both interpreter + compiled).

Out of scope (filed separately)

Note: the local SharpTS.Test262 compiled-baseline host aborts with the known environmental 0x80131506 CLR crash — confirmed identical on clean origin/main (stash comparison), so unrelated to this change.

…InvalidCastException

`AsyncArrowMoveNextEmitter` had no `EmitForOf` override, so a compiled
`for await...of` loop fell through to the synchronous for-of path in
`StatementEmitterBase` and cast the async-generator state machine to
`IEnumerable` — throwing `InvalidCastException` at runtime. The interpreter
handled the same program correctly.

Extract the async-iterator lowering (previously private to
`AsyncMoveNextEmitter`) into `StatementEmitterBase.EmitForAwaitOf` so all
three async state-machine emitters share one implementation instead of
growing a third divergent copy:

- `AsyncMoveNextEmitter` / `AsyncArrowMoveNextEmitter` dispatch to the shared
  base method from their `EmitForOf` override when `f.IsAsync`.
- `AsyncGeneratorMoveNextEmitter` keeps a `protected override` (its own
  error-propagating + strict-done variant for the async-generator consumer).

Loop-variable binding now routes through the `DeclareLoopVariable` /
`EmitStoreLoopVariable` hooks instead of `Ctx.Locals` directly: the async
arrow's resolver reads its own private `_locals` map (via its
`RegisterLoopLocal` override), so a binding registered in `Ctx.Locals` read
back as `null` in the arrow body.

Covers the `Symbol.asyncIterator` protocol, the `$IAsyncGenerator` fast path,
break/cleanup, captured variables, and nested async arrows; emitted IL passes
ILVerify. Adds 4 regression tests (both modes) and registers the two new
emitter overrides in `EmitterSyncTests.AllowedOverrides`.

The residual "async generator whose body awaits a not-yet-settled promise
yields no output" case from #430's comment is the pre-existing blocking-
GetResult event-loop limitation tracked by #631, not the arrow bug. Filed
follow-ups #672 (top-level for-await mis-run as sync) and #673 (captured-var
store in a nested async arrow emits unverifiable IL), both pre-existing.
@nickna nickna merged commit 627f022 into main Jun 16, 2026
3 checks passed
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.

Compiler: for await...of over an async generator inside an async arrow emits synchronous IEnumerable iteration (InvalidCastException)

1 participant