Add an async/await pattern test suite and fix the await operand conversions it found - #4021
Open
christophwille wants to merge 5 commits into
Open
Add an async/await pattern test suite and fix the await operand conversions it found#4021christophwille wants to merge 5 commits into
christophwille wants to merge 5 commits into
Conversation
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
dgrunwald
approved these changes
Aug 16, 2026
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.
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
VisitAwaitused to finish withConvertTo(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 forawait (Task)null, and pointer-shaped nonsense for anin-receiver extension awaiter.The replacement asks the question that actually decides it: does the operand, written plainly, bind the same
GetAwaiterthe 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
GetAwaiterdeclared on an interface is boxed, so the translated operand is typedobjectand 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 boxingConversionResolveResult, and the box is only detached once the answer is known.UnwrapChildmutates 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
Boxstays in the ILAst either way, soAwait's argument still matches the storedGetAwaiterMethod.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/inreceiver, 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:
awaiton a type parameter with an interface constraint drops the operand (LdObjIfRefunsupported)awaitin a static dynamic call materializes the call site'stypeofmarker as the receiverwithexpression containing anawaitleaves the raw<Clone>$call in the outputdefault(Task)was also on that list and is not a defect: it compiles to the sameldnullas(Task)null, so the two are indistinguishable in IL and the cast is a correct decompilation.