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 ILPretty fixture is the state machine from the issue's assembly with
its external types stubbed out, so the fix stays guarded where the legacy
compiler is unavailable. The Pretty fixture for dynamic await now runs
the pre-Roslyn configurations as well, which is the end-to-end guard on
Windows; its expected output records that the compiler copies an awaited
dynamic value into a local first and emits no debug name for it. The
dead stores an optimized build leaves before a try block containing an
await are not specific to dynamic - a plain await on a Task produces the
same three - so they sit behind an EXPECTED_OUTPUT-only block.
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