Skip to content

Add an async/await pattern test suite and fix the await operand conversions it found - #4021

Open
christophwille wants to merge 5 commits into
masterfrom
tests/async-await-patterns
Open

Add an async/await pattern test suite and fix the await operand conversions it found#4021
christophwille wants to merge 5 commits into
masterfrom
tests/async-await-patterns

Conversation

@christophwille

@christophwille christophwille commented Aug 16, 2026

Copy link
Copy Markdown
Member

Adds a pretty/correctness test suite for ExpressionBuilder.VisitAwait, and fixes the await-operand conversions the suite turned up.

The conversion in front of an await operand

VisitAwait used to finish with ConvertTo(expectedType, allowImplicitConversion: true). There is no target type the C# compiler could convert an await operand to -- the await pattern is resolved by member lookup on the operand itself -- so any conversion that is merely implicit was being dropped, taking load-bearing casts with it. That is CS1929 for an explicit interface implementation, CS4001 for await (Task)null, and pointer-shaped nonsense for an in-receiver extension awaiter.

The replacement asks the question that actually decides it: does the operand, written plainly, bind the same GetAwaiter the IL calls (CallBuilder.CheckSimpleCall)? If not, the cast stays and is emitted explicitly.

That needs one wrinkle for boxing. A struct receiver reaching an extension GetAwaiter declared on an interface is boxed, so the translated operand is typed object and the lookup finds nothing -- which would put a redundant (IAwaitableMarker) in front of every such await. C# boxes an await operand implicitly, so the box need not survive; the lookup therefore looks through the boxing ConversionResolveResult, and the box is only detached once the answer is known. UnwrapChild mutates the AST, so running it speculatively leaves a cast with no child behind and the whole method falls back to printing its raw state machine.

The Box stays in the ILAst either way, so Await's argument still matches the stored GetAwaiterMethod.

Tests

  • TestCases/Pretty/AsyncAwaitPatterns.cs -- await contexts (statement position, operators, member access, try/finally, await using/await foreach, async iterators), operand shapes (ternary, coalesce, cast, null literal, field/property/indexer, struct element), and receiver shapes (instance, inherited, interface, explicit interface implementation, user-defined conversion, extension on class/struct/primitive/delegate/tuple/in receiver, generic, ConfigureAwait). The shapes where the cast in front of the operand is load-bearing live here too, so they stay covered as regression tests.
  • TestCases/Correctness/AsyncAwaitPatterns.cs -- what the awaits have to mean: copy semantics of struct awaitables (mutable field vs. readonly field vs. property) and evaluation order around the suspension point.

Known-bad shapes filed instead of pinned

Three await shapes still decompile to code that does not compile. They are unrelated to the operand conversion and have no correct output to pin yet, so they are tracked rather than committed as a red test:

default(Task) was also on that list and is not a defect: it compiles to the same ldnull as (Task)null, so the two are indistinguishable in IL and the cast is a correct decompilation.

siegfriedpammer and others added 5 commits August 16, 2026 10:03
The await surface had almost no fixture coverage beyond Task/ValueTask: every
GetAwaiter in the corpus was an instance method on the awaited type itself, so
the conversion VisitAwait applies to the operand was never exercised for an
inherited, interface-typed or extension-method awaiter. Probing that surface
turned up eight defects, all of which produce C# that does not compile.

AsyncAwaitPatterns pins the shapes that do round-trip, along the three axes the
translation actually depends on: the GetAwaiter receiver, the operand
expression, and the context the await sits in. Its Correctness twin pins what
Pretty cannot see - copy semantics of struct awaitables and the evaluation
order around the suspension point.

AsyncAwaitPatternsBugs is the spec for the defects, written as the C# that
ought to come out, with the current wrong output named per member. It fails
today; that is the point, and fixing a defect is meant to delete a comment
rather than edit an expectation.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
…e's no target type that the C# compiler could convert to.

Instead, use `IsAppropriateCallTarget` to detect whether an explicit cast is necessary for calling the correct `GetAwaiter` method.
An operand boxed for the GetAwaiter call is typed 'object', so the member
lookup that decides whether the await needs a cast finds nothing and a
redundant cast to the receiver type reaches the output. C# inserts that boxing
conversion implicitly, so the box may be dropped -- but only after the lookup
confirms the unboxed operand still binds the same GetAwaiter, and only via the
resolve result: UnwrapChild detaches the operand from the AST, so running it
speculatively leaves a cast with no child behind and decompilation of the whole
method falls back to the raw state machine.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The fixture was written as a spec of nine await shapes that decompiled to code
that does not compile. Six no longer do. Of the rest, default(Task) was never a
defect -- it compiles to the same ldnull as (Task)null, so the two are
indistinguishable in IL and the cast is a correct decompilation. The three real
ones are unrelated to the await conversion and have no correct output to pin
yet, so they move to #4017, #4018 and #4019; what stays behind is a regression
test for the shapes where the cast in front of the operand is load-bearing.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
They were split out only because they were failing; there is no reason to keep
a second fixture now that they pass. Folding them in also widens their coverage
from roslyn4OrNewer to every defaultOptions config -- legacy csc, Roslyn 1.3.2
onwards and the net40 targets -- with the 'in'-receiver extension gated on CS72
because that one needs C# 7.2. IAwaitable and ClassAwaitable were declared
identically in both files and collapse into one declaration.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
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.

3 participants