Fix #3320: detect dynamic await in pre-Roslyn state machines - #4132
Open
siegfriedpammer wants to merge 1 commit into
Open
Fix #3320: detect dynamic await in pre-Roslyn state machines#4132siegfriedpammer wants to merge 1 commit into
siegfriedpammer wants to merge 1 commit into
Conversation
siegfriedpammer
commented
Sep 14, 2026
| { | ||
| for (int i = block.Instructions.Count - 1; i >= 0; i--) | ||
| { | ||
| if (block.Instructions[i] is StLoc { Variable: { Kind: VariableKind.Local, IsSingleDefinition: true, LoadCount: 1, AddressCount: 0 } copy, Value: LdLoc } store |
Member
Author
There was a problem hiding this comment.
Suggested change
| if (block.Instructions[i] is StLoc { Variable: { Kind: VariableKind.Local, IsSingleDefinition: true, LoadCount: 1, AddressCount: 0 } copy, Value: LdLoc } store | |
| if (block.Instructions[i] is StLoc { Variable: { Kind: VariableKind.Local, IsSingleDefinition: true, LoadCount: 1 } copy, Value: LdLoc } store |
IsSingleDefinition implies AddressCount == 0
Reviewed with Stampeded!
The C# 5 compiler loads the awaiter into a fresh local before every dynamic call site and before the ICriticalNotifyCompletion type test, and the early non-aggressive inlining refuses to fold those copies. Every await matcher identifies an await by the identity of its awaiter variable, so each copy hid the awaiter from the matcher and the whole await stayed undetected. Each such copy has one store and one load, and after the dynamic call sites are collapsed that load sits in the next instruction, which is the case ILInlining.InlineOne already handles, including the check that the source is not overwritten first. Folding is restricted to the IsCompleted and GetResult call sites and the completion-interface type tests, so dynamic calls in user code keep their locals: a blanket fold retypes unrelated locals of the surrounding method (an enum local decompiled as int plus a cast). Two smaller divergences from the Roslyn shape sat behind that one: the merge block of the AwaitOnCompleted/AwaitUnsafeOnCompleted diamond clears doFinallyBodies before its leave, and the awaiter is restored from its object-typed field with unbox.any rather than castclass. The regression test is the state machine from the issue's assembly with its external types stubbed out, so it runs where the legacy compiler does not. Checked against real legacy csc output (/o- and /o+): dynamic await in plain code, loops, try/catch, try/finally and using. Assisted-by: Claude:claude-opus-5[1m]:Claude Code
siegfriedpammer
force-pushed
the
fix/3320-legacy-dynamic-await
branch
from
September 14, 2026 04:21
821923b to
96456e9
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.
An
awaiton adynamicvalue in an assembly built by the C# 5 compiler wasleft undecompiled: the state machine struct and its call-site container leaked
into the output as
<GetAsync>d__d<T>and<GetAsync>o__SiteContainer6<T>.The C# 5 compiler loads the awaiter into a fresh local before every dynamic call
site and before the
ICriticalNotifyCompletiontype test, and the earlynon-aggressive inlining refuses to fold those copies. Every await matcher
identifies an await by the identity of its awaiter variable, so each copy hid
the awaiter from the matcher.
Each such copy has one store and one load, and once the dynamic call sites are
collapsed that load sits in the next instruction - the case
ILInlining.InlineOnealready handles, including the check that the source isnot overwritten first. Folding is restricted to the
IsCompleted/GetResultcall sites and the completion-interface type tests, so dynamic calls in user
code keep their locals; a blanket fold retypes unrelated locals of the
surrounding method (an enum local decompiled as
intplus a cast).Two smaller divergences from the Roslyn shape sat behind that one:
AwaitOnCompleted/AwaitUnsafeOnCompleteddiamondclears
doFinallyBodiesbefore its leave, where Roslyn's is a bare leave;object-typed field withunbox.anyratherthan
castclass.The engine change is +60/-13 in
AsyncAwaitDecompiler.cs; the rest of the diffis the ildasm fixture.
Verification
external types stubbed out, so it runs where the legacy compiler does not.
/o-and/o+): dynamic await inplain code, loops,
try/catch,try/finallyandusingall decompile.InlineAwaiterCopiescall sites were tested for redundancy by removingeach in turn; either removal leaves the state machine undecompiled. The first
has to run before detection in
AnalyzeStateMachine, the second only becomespossible once coalescing has put the call site and its awaiter copy in one
block.
Known coverage gap
The
doFinallyBodiesmerge-block handling is required for dynamic await insidetry/finallyorusing- verified against real legacy csc - but the committedfixture does not exercise that path, so there is no red test behind those lines.
Reviewers may want a second fixture for it.
This pull request was prepared by an AI agent (Claude Opus 5 via Claude Code)
on behalf of the repository owner.