Interpreter: rewrite async generator as a lazy coroutine (#690/#717/#752/#734)#773
Merged
Conversation
/#734) Replace the interpreter's eager-drain SharpTSAsyncGenerator with a lazy, main-thread C# async coroutine plus an ECMA-262 §27.6.3 request queue. The body now runs as an ordinary interpreter async execution (ExecuteBlockAsync) on the single event-loop thread and suspends at each yield by handing the value to the driving request and awaiting the next one. A worker thread (as the synchronous generator uses) is impossible here: the interpreter is single-threaded and a worker would race the event loop on the shared interpreter environment. Reusing the real async execution path fixes several issues at once: - #690: two next() calls before the first settles are serviced FIFO via the request queue instead of racing a half-populated drain. - #717: a for await...of INSIDE an async generator drives the async-iterator protocol natively instead of throwing "Cannot iterate over non-iterable". - #752: an await nested in a yielded expression (yield f(await x)) preserves the ambient environment like any async function, so the caller's bindings no longer read back undefined. - #734: an async generator function expression that closes over a block-scoped binding runs natively (new SharpTSAsyncArrowGeneratorFunction; the GeneratorArrowLifter leaves it in place). The compiler still reports a clean "Yield not supported in this context" error. Mechanism: new Interpreter.CurrentAsyncGenerator identifies the active generator; Expr.Yield in the async path routes through EvaluateYieldAsync -> OnYieldAsync. AwaitPreservingEnvironment now also saves/restores CurrentAsyncGenerator so interleaved generators never see each other's binding. for await...of early exit (break / return / throw) calls CloseAsyncIteratorOnEarlyExit (AsyncIteratorClose) so a suspended generator runs its finally blocks. return()/throw() reuse the synchronous generator's GeneratorResume / GeneratorReturnException injection, so finally blocks run. A re-entrant next() during the body's initial synchronous segment rejects with a catchable TypeError instead of deadlocking (#542 analog). Promotes 7 previously CompiledOnly async-generator tests to run in the interpreter too, and adds #734 coverage. Full suite: 13200 passing. Gaps filed: #770 (labeled loop containing await/yield has no async handler -- pre-existing, exposed here), #771 (deep re-entrancy stall).
…fo[]) origin/main currently does not compile: the two instance/private-method call sites of EmitGeneratorFunctionDCInit (ILCompiler.AsyncGenerators.cs and ILCompiler.Generators.cs) pass methodBuilder.GetParameters() — a ParameterInfo[] — to a parameter declared as Type[]?. This is a latent mismatch from the interaction of the #720 (async/generator private methods, which deliberately box from the method's ACTUAL IL signature) and #724/#725 (function display-class seeding) changes after both landed. Accept ParameterInfo[]? and box value types via .ParameterType, leaving a private method's all-object slots unboxed. Behavior is unchanged; this only makes the tree compile. Unrelated to the async-generator coroutine rewrite in the preceding commit — committed separately so it is easy to drop if main is fixed independently.
9021132 to
3564041
Compare
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.
Summary
Rewrites the interpreter's async generator from the eager full-body drain model to a lazy, main-thread C# async coroutine backed by an ECMA-262 §27.6.3 request queue. This single rewrite closes four open interpreter issues that all stemmed from the eager-drain model, and removes a class of environment-leak workarounds.
Why a coroutine, not a worker thread
The synchronous generator (
SharpTSGenerator) suspends a background thread at eachyield. That isn't viable for async generators: the interpreter is single-threaded — a customSynchronizationContextroutes every async continuation back to one event-loop thread — and a worker thread would race the event loop on the shared_interpreter.Environment. So the async generator body now runs as an ordinary interpreter async execution (ExecuteBlockAsync) on that event-loop thread and suspends at eachyieldby handing the value to the driving request and awaiting the next one.Reusing the real async execution path is what fixes #717 and #752 essentially for free — a
for awaitinside the body and anawaitnested in a yielded expression are handled by the same code that handles them in any async function.Issues fixed
next()Runtime Error: Only instances and objects have properties1 false 2 falsefor await…ofinside an async generatorRuntime Error: Cannot iterate over non-iterable valueyield f(await x)closure leakundefined(undefined 2)T 2)Error: Undefined variable 'n'How it works
SharpTSAsyncGenerator(full rewrite): statesSuspendedStart/SuspendedYield/Executing/Completed; aRequestqueue ofnext/return/throw(each carrying aTaskCompletionSource). The body is started lazily on the firstnext()(synchronously up to its first suspension, matching the spec) and suspends at eachyieldviaSuspendAtYieldAsync.return()/throw()reuse the synchronous generator'sGeneratorResume/GeneratorReturnExceptioninjection, sofinallyblocks run.Interpreter.CurrentAsyncGeneratoridentifies the active generator.Expr.Yieldin the async path routes through the newEvaluateYieldAsync→OnYieldAsync(the yielded expression is evaluated asynchronously, soyield await xworks).AwaitPreservingEnvironmentnow also saves/restoresCurrentAsyncGenerator, so interleaved async generators never observe each other's binding.for await…ofearly exit (break / return / throw out of the body) now callsCloseAsyncIteratorOnEarlyExit— ECMA-262AsyncIteratorClose— so a suspended generator runs itsfinallyblocks before the loop leaves (a lazy generator is otherwise abandoned at itsyield). Integrated with the labeled-loop adoption from Labeledfor await...ofis iterated synchronously (both interpreter and compiled) #728.EvaluateArrowFunctiondispatchesIsAsync && IsGeneratorto a newSharpTSAsyncArrowGeneratorFunction;GeneratorArrowLifterdrops the!IsAsyncguard so a block-capturing async generator expression is left in place (the compiler reports the same clean "Yield not supported in this context" error as the sync Generator function expression capturing a block-scoped / loop variable is lifted out of scope #678 case).next()/return()/throw()issued while the body advances itself synchronously rejects with a catchableTypeError: Async generator is already runningrather than deadlocking (the interpreter analog of the compiled Compiled async generator re-entrant next() stack-overflows (needs request-queue semantics, not the sync 'already running' guard) #542 guard).Tests
main.CompiledOnlyasync-generator tests to run in the interpreter too (Compiled async generator: resumed yield (and yield* completion) evaluates to null instead of undefined #481 resumed-yield / yield* completion, Compiled async generator re-entrant next() stack-overflows (needs request-queue semantics, not the sync 'already running' guard) #542 re-entrancy, Compiled: for await…of INSIDE an async generator still blocks on a genuinely-async source (AsyncGeneratorMoveNextEmitter.EmitForAwaitOf) #697 for-await-inside-generator suspend / break / try-finally).#734region inGeneratorTests(interpreter-native + compiler-rejects-clearly) and a direct-next()Interpreter: async generator leaks its closure when an await is nested inside a delegated expression (yield f(await x)) #752 regression inAsyncGeneratorTests.Notes for the reviewer
main's build. When I rebased onto currentmain, the tree did not compile: the two instance/private-method call sites ofEmitGeneratorFunctionDCInitpassmethodBuilder.GetParameters()(aParameterInfo[]) to a parameter declaredType[]?— a latent mismatch from Compiled mode: async/generator private methods (async #m,*#m) emit invalid IL / unsupported #720 (which deliberately boxes from the method's actual IL signature) meeting Compiled: arrow inside an INSTANCE generator method that writes a captured variable (function DC not wired) #724/Compiled: arrow inside an ASYNC generator body that writes a captured variable (full function-DC fix) #725 after both landed. The fix acceptsParameterInfo[]?and boxes via.ParameterType; behavior is unchanged. It is a separate commit so it is trivial to drop ifmainis fixed independently.await/yield) is resolved — it turned out to be addressed by the combination of Labeledfor await...ofis iterated synchronously (both interpreter and compiled) #728 (which added an async labeled-statement handler and label adoption in the async loops) and this rewrite (which makes ayieldin such a loop suspend). Closed.next()issued after a genuinely-pending await isn't guarded → silent stall, not a hang) remains open as a pathological self-referential limitation.