Decompile C# 15 runtime-async (/features:runtime-async=on)#3731
Merged
christophwille merged 16 commits intoMay 21, 2026
Conversation
siegfriedpammer
commented
May 7, 2026
siegfriedpammer
commented
May 7, 2026
siegfriedpammer
commented
May 16, 2026
siegfriedpammer
commented
May 17, 2026
siegfriedpammer
commented
May 17, 2026
siegfriedpammer
commented
May 17, 2026
siegfriedpammer
commented
May 17, 2026
057a25b to
5ef96c0
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
Adds decompilation support for Roslyn C# 15 “runtime-async” lowering (/features:runtime-async=on) by recognizing the MethodImplOptions.Async bit and rewriting the helper-based lowering patterns back into canonical IL Await / TryCatch / TryFinally shapes so the existing async pipeline can round-trip to normal async/await source.
Changes:
- Introduces runtime-async-specific IL transforms to collapse manual await patterns and rewrite lowered exception handling back to structured constructs.
- Extends type-system / IL reading to recognize runtime-async methods and compute the correct async return element type for stack typing.
- Adds new PrettyTests that compile existing async sources with runtime-async enabled and validate round-tripping.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| ILSpy/Languages/CSharpLanguage.cs | Adds C# 15.0 to UI language version list. |
| ICSharpCode.Decompiler/TypeSystem/TaskType.cs | Adds UnpackAnyTask helper for runtime-async return element typing. |
| ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs | Optionally hides the runtime-async MethodImpl flag from emitted attributes. |
| ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs | Adds TypeSystemOptions.RuntimeAsync and wires it from settings. |
| ICSharpCode.Decompiler/SRMHacks.cs | Introduces MethodImplAsync constant (0x2000) for SRM metadata access. |
| ICSharpCode.Decompiler/IL/Transforms/EarlyExpressionTransforms.cs | Rewrites AsyncHelpers.Await(x) calls back into IL Await. |
| ICSharpCode.Decompiler/IL/ILReader.cs | Detects runtime-async methods and adjusts return stack typing / AsyncReturnType. |
| ICSharpCode.Decompiler/IL/ControlFlow/RuntimeAsyncManualAwaitTransform.cs | New transform collapsing the manual runtime-async await CFG pattern into Await. |
| ICSharpCode.Decompiler/IL/ControlFlow/RuntimeAsyncExceptionRewriteTransform.cs | New transform rewriting runtime-async lowered try/catch/finally back to structured IL. |
| ICSharpCode.Decompiler/IL/ControlFlow/AwaitInFinallyTransform.cs | Extracts shared helpers for catch-handler matching and dominated-block moves. |
| ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs | Invokes runtime-async transforms when a method is runtime-async but no state machine exists. |
| ICSharpCode.Decompiler/ICSharpCode.Decompiler.csproj | Includes the two new runtime-async transform source files in the build. |
| ICSharpCode.Decompiler/DecompilerSettings.cs | Adds RuntimeAsync setting and wires it into language-version gating/min version logic. |
| ICSharpCode.Decompiler/CSharp/CSharpLanguageVersion.cs | Adds CSharp15_0 and updates Preview to 1500. |
| ICSharpCode.Decompiler.Tests/TestCases/Pretty/Async.cs | Adds new async scenarios (incl. runtime-async-gated early-return tests). |
| ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs | Adds new PrettyTests for runtime-async compilation mode across several suites. |
| ICSharpCode.Decompiler.Tests/Helpers/Tester.cs | Adds compiler flag/suffix and passes /features:runtime-async=on for Roslyn latest. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+176
to
+196
| protected internal override void VisitCall(Call inst) | ||
| { | ||
| base.VisitCall(inst); | ||
| TransformAsyncHelpersAwaitToAwait(inst, context); | ||
| } | ||
|
|
||
| // runtime-async lowering emits `call AsyncHelpers.Await(value)` in place of the IL Await | ||
| // instruction. Convert it back so downstream transforms (UsingTransform's MatchDisposeBlock | ||
| // and friends pattern-match on Await via UnwrapAwait) see the canonical shape that the | ||
| // state-machine async pipeline also produces. | ||
| internal static bool TransformAsyncHelpersAwaitToAwait(Call inst, ILTransformContext context) | ||
| { | ||
| if (!context.Settings.RuntimeAsync) | ||
| return false; | ||
| if (!inst.Method.IsStatic || inst.Arguments.Count != 1 || !IsAsyncHelpersMethod(inst.Method, "Await")) | ||
| return false; | ||
| context.Step("call AsyncHelpers.Await(value) => await(value)", inst); | ||
| var awaitInst = new Await(inst.Arguments[0]).WithILRange(inst); | ||
| awaitInst.GetAwaiterMethod = null; | ||
| awaitInst.GetResultMethod = inst.Method; | ||
| inst.ReplaceWith(awaitInst); |
Comment on lines
+188
to
+189
| if (runtimeAsync) | ||
| return CSharp.LanguageVersion.CSharp15_0; |
Comment on lines
+2212
to
+2213
| [Category("C# 15.0 / VS 202x.yy")] | ||
| [Description("DecompilerSettings.RuntimeAsync")] |
| new LanguageVersion(Decompiler.CSharp.LanguageVersion.CSharp12_0.ToString(), "C# 12.0 / VS 2022.8"), | ||
| new LanguageVersion(Decompiler.CSharp.LanguageVersion.CSharp13_0.ToString(), "C# 13.0 / VS 2022.12"), | ||
| new LanguageVersion(Decompiler.CSharp.LanguageVersion.CSharp14_0.ToString(), "C# 14.0 / VS 2026"), | ||
| new LanguageVersion(Decompiler.CSharp.LanguageVersion.CSharp15_0.ToString(), "C# 15.0 / VS 202x.yy"), |
8162487 to
dd4d6a7
Compare
Detect MethodImplOptions.Async (0x2000) in ILReader and unpack Task/Task<T> return types so the IL Leave value and function.AsyncReturnType match the source signature. Add CSharp15_0 (Preview also bumped to 1500) and a RuntimeAsync setting (default on, gated to >=CSharp15_0), expose it in the Languages dropdown, mask the synthetic MethodImplAsync bit out of the decompiled [MethodImpl], and add a .runtimeasync test suffix.
…ILReader async return-type unpacking. Add TaskType.UnpackAnyTask and use it in ILReader.Init / ReadIL so methodReturnStackType and function.AsyncReturnType agree for runtime-async methods that return ValueTask/ValueTask<T> or any [AsyncMethodBuilder]-attributed custom task type. Previously only Task/Task<T> were unpacked, leaving AsyncReturnType=void while the IL Leave value carried the unpacked element type, which tripped the StackType assert in ExpressionBuilder.Translate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Convert `call System.Runtime.CompilerServices.AsyncHelpers.Await(value)` to the IL Await instruction whenever DecompilerSettings.RuntimeAsync is enabled. The state-machine async pipeline (AsyncAwaitDecompiler) already produces the IL Await for downstream transforms (UsingTransform's MatchDisposeBlock pattern-matches on it via UnwrapAwait); doing the conversion in EarlyExpressionTransforms gives the runtime-async output the same canonical shape before any consumer runs.
Roslyn's runtime-async lowering flattens these into a TryCatch[object] with a captured-rethrow pattern (try-finally) or a TryCatch[T] with a flag-int discriminator and a guarded post-catch body (try-catch). Add a new transform invoked from AsyncAwaitDecompiler when the state-machine matches fail and the method has the runtime-async impl bit; it pattern-matches both shapes and rewrites them back to TryFinally / TryCatch with the original catch body inlined into the handler. The state-machine and runtime-async lowerings of try-finally use the same catch-handler shape and the same dominator-based finally-body extraction, so promote those to internal static helpers (MatchObjectStoreCatchHandler, MoveDominatedBlocksToContainer) on AwaitInFinallyTransform and call them from the new transform. Filter-bearing catches and multi-handler tries are still left to the standard pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Roslyn lowers `catch (T ex) when (filter)` to `catch object when (BlockContainer { isinst T; obj=ex; <filter> })` even when T is `object` (the source-level `catch when (filter)` form). Run a pre-pass over every catch handler that matches the four-block diamond (entry isinst-gate, trueBody with obj-store + user filter, falseBody constant-false, merge leave-with-result), strip the obj-store machinery, retype the handler variable when T is a more specific type than object, and remap reads of the synthesized obj/tmp/typedEx variables back to the handler variable. After that the catch body is the same simple flag-store shape that TryRewriteTryCatch already handles, so the existing match runs unchanged.
Resolves the RethrowDeclaredWithFilter and ComplexCatchBlockWithFilter cases. Multi-handler catches (LoadsToCatch) still fail because they use a multi-valued discriminator that isn't reduced yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…TryCatch. When the user writes multiple catch clauses on a single try, runtime-async lowers each catch's body to "[stloc tmp(ex);] [stloc obj(...);] stloc num(K_i); br continuation" with a unique K_i per handler, and the post-catch flow becomes a switch dispatch on `num` that branches to each user-level catch body. Add a TryRewriteMultiHandlerTryCatch driver that mirrors the single-handler match (using NormalizeRuntimeAsyncFilter for filter cleanup), recognizes the post-catch SwitchInstruction, and uses the existing dominator-based block move to relocate each switch case into the corresponding handler body, remapping that handler's per-handler synthesized variables (and the shared filter obj) back to the catch variable. The shared obj local can no longer be remapped function-wide during filter normalization — that would tag every dispatch idiom with whichever handler ran first — so record the obj per handler in a dictionary and let TryRewriteTryCatch / TryRewriteMultiHandlerTryCatch remap it scoped to each moved catch body. The pre-init "stloc num(0)" is matched by slot index rather than ILVariable identity, since SplitVariables splits the dead pre-init off from the in-handler stores. Resolves the LoadsToCatch case. Filter normalization extends to the typeless `catch when (filter)` form (isinst Object in the filter), recovered as `catch when` in the AST output. Remaining failures in RuntimeAsync are now multi-await expressions, async-in-struct, and a couple of unrelated decompilation issues. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ait. Roslyn's runtime-async lowering uses AsyncHelpers.Await(Task) for Task awaitables (already handled by TransformAsyncHelpersAwaitToAwait in EarlyExpressionTransforms) but emits a manual GetAwaiter / get_IsCompleted / AsyncHelpers.UnsafeAwaitAwaiter / GetResult sequence for non-Task awaitables — YieldAwaitable, ConfiguredCancelableAsyncEnumerable.Enumerator from await foreach, etc. Add a new RuntimeAsyncManualAwaitTransform invoked from AsyncAwaitDecompiler's runtime-async dispatch that recognizes the three-block shape (head with stloc awaiter + IsCompleted check + branch, pause block calling UnsafeAwaitAwaiter, completed block starting with GetResult), strips the suspend machinery, and replaces the GetResult call with an Await IL instruction. When GetAwaiter takes the address of a temporary set in the same block, also drop the temporary store and use the underlying awaitable expression. This collapses the LoadsToCatch await-Task.Yield bodies. AsyncForeach should benefit too (its MoveNextAsync awaits go through this path).
…ct async methods.
For an async method on a value type Roslyn cannot keep a managed reference to the caller's struct alive across an await, so it copies *this into a local at method entry and rewrites every "this.field" access to go through the copy. The decompiler then sees an extra "AsyncInStruct asyncInStruct = *this;" prelude and renders user-level "i++" as "asyncInStruct.i++". State-machine async normally avoids this because TranslateFieldsToLocalAccess already remaps the captured-this field back to the function's own this parameter.
Detect the prelude in runtime-async methods (entry-point stloc V_X(ldobj T(ldloc this)) with the local typed as the containing value type) and rewrite every "ldloc V_X" / "ldloca V_X" to go through the function's this parameter instead, then drop the now-dead copy. The mutation semantics are unchanged — runtime-async struct methods never reflect mutations back to the caller anyway, so re-pointing the access at this is purely a fidelity restoration.
Brings AsyncInStruct.Test back to its source ("i++" / "i + xx.i"). The only remaining failure in RuntimeAsync is Issue2436 (early-return-from-nested-catch encoded as a flag).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a return crosses an enclosing try-finally with await, runtime-async lowers it as: capture the return value, set an int flag to a unique non-zero value, leave the try block normally so the finally runs, then post-finally check "if (flag == K) return capture;". Detect that pattern after my outer try-finally rewrite (or, in optimized builds, the compiler-emitted TryFinally directly) and replace each capture-flag-and-leave site with a direct "leave outer (capture)" — the leave still passes through the TryFinally, so the user's finally body executes before the function returns, which matches the source-level semantics. Handles both the "if (flag == K)" and "if (flag != K)" check forms (the optimizer emits the latter). Closes the last gap in Issue2436 — RuntimeAsync now passes both Optimize and non-Optimize modes; the full RuntimeAsync* sweep is 12/12 green. Also remap reads of the captured-obj local inside the cleaned filter so optimized builds (where Roslyn inlines the typed-cast directly into the user filter expression instead of stashing it in a local) render against the catch variable rather than against "((T)obj)". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… test fixture. Two methods exercise `Task<int>.ConfigureAwait(bool)`: a single false-flag form and a mixed false/true form that combines two awaits in a return expression. Both cases run through the regular state-machine and runtime-async pipelines (RuntimeAsync reuses Async.cs as its source). Gated by `#if ROSLYN2` because Roslyn 2+ preserves named-argument metadata at the call site, so the decompiler renders `continueOnCapturedContext: false` when the binary was compiled by Roslyn 2+ and positional `false` for default csc / Roslyn 1.3.2. Also adds NoInliningTaskMethod — an async method carrying [MethodImpl(MethodImplOptions.NoInlining)] — so the runtime-async path exercises the impl-attribute masking added in the scaffolding commit: MetadataMethod strips the synthesized MethodImplOptions.Async (0x2000) bit from the decompiled output, and unrelated impl bits like NoInlining (0x0008) must still render in the surfaced [MethodImpl(...)] attribute. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…try-finally. The single-handler try-catch matcher was tied to the top-level shape: it required the try-catch be the last instruction in its parent block, that the post-catch "no exception" path be a direct Leave that exits the function, and that the flag-init's ILVariable be identical to the in-handler flag store. None of those hold for an inner try-catch sitting inside an outer try-finally where both await — the inner is followed by a `br continuation`, the no-exception path leaves the outer try-block (not the function), and SplitVariables hands out a separate ILVariable for the pre-init store. Drop the "must be last instruction" gate, accept Leave-to-any-ancestor and cross-container Branch as the no-exception exit (extracted into a new `IsContainerExit` helper), and match the flag-init by slot/kind/type the same way the multi-handler matcher already does. Closes Cluster 3 from icsharpcode#3745.
`try { throw new ...(); } finally { await ... }` lowers to a try whose only
exit is the throw (handled by the synthetic catch). The existing matcher
required at least one outward Branch to the continuation, which is too strict
— a throw-only try body produces zero outward branches but is still a valid
lowered shape. Two follow-on fixes were also needed:
- The pre-init's ILVariable diverges from the in-handler store after
SplitVariables when the try body has no path that reaches the dispatch's
load without going through the catch; match the flag init by slot/kind/type
instead of identity (same workaround the multi-handler matcher uses).
- With a throw-only try body the new TryFinally has unreachable endpoint,
so appending the no-exception successor after it would put a non-final
unreachable-endpoint instruction in the parent block. Skip the append in
that case — the parent block's endpoint is already correctly unreachable.
Closes Cluster 4 from icsharpcode#3745.
The flag-based early-return rewriter was tied to one specific lowered shape:
the try body's flag-setter had to be exactly `stloc flag(K); leave try`, the
post-try check had to be a `br checkBlock` (not an inline `IfInstruction`), and
the early path had to be a direct Leave or a forward to a one-instruction
leave-block whose target was the function body. None of those hold for
`try { try { return X; } finally { await ... } } finally { await ... }`:
- The inner flag-setter has a leading capture-forwarding store
(`stloc capture(X); stloc innerFlag(K); leave inner-try`).
- The inner check-block's early path branches to a multi-instruction helper
that sets the *outer* flag and leaves the outer try, instead of being a
direct return.
- SplitVariables hands out a separate ILVariable for the pre-init flag store
when the in-handler store is in a disjoint dataflow region.
Rebuild the matcher around the idea of a "template" — the chain of stores
the early path performs before its terminating Leave. Each flag-setter then
becomes its own prefix stores + a clone of the template, which collapses the
inner-then-outer flag chain in two passes (inner first, outer second, because
descendant order visits the inner TryFinally first). Also extend the
flag-setter scan to walk the whole try-block's descendants — after the inner
rewrite, the inner's spliced flag-setter lives inside the inner-try container
but still leaves outwards to the outer try, so it's an outer flag-setter from
the outer's perspective.
Add a `RUNTIMEASYNC` preprocessor symbol (defined when `EnableRuntimeAsync`
is set) and gate the new return-from-try-finally fixtures on it — the
state-machine async pipeline doesn't recover this shape, so it would expand
the same source into the `int result; try { ...; result = X; } finally { ... }
return result;` verbose form and the Async (state-machine) pretty test would
regress.
Closes Cluster 1 (1.1, 1.3) from icsharpcode#3745. Cluster 1.2 (void `return;` at the
end of a try-finally body) and 1.4 (break/continue across a try-finally) are
left for a follow-up: both round-trip semantically equivalently but the AST
emitter drops a trailing void `return;` and the break/continue lowering uses
a switch dispatch that the current single-K matcher can't recognize.
…ain.
The multi-handler matcher only recognized a switch-instruction dispatch — but
when a try-catch has just two handlers (or a handful with non-consecutive K
values), Roslyn emits an if-chain instead:
if (num == K_1) br case_K_1; br nextBlock
; nextBlock { if (num == K_2) br case_K_2; <leave outer | br end> }
Add a parallel matcher that walks the if-chain and collects (K, case-block)
pairs the same way MatchSwitchDispatch does, plus the terminating leave/branch
as the default exit. Call it as a fallback when the switch matcher rejects.
Also clone the default-exit before re-adding it to the continuation block —
in the if-chain shape it's a child of a *different* block (a later step in
the chain), not the now-cleared switch instruction, so the in-place re-add
relied on the switch's release cascade and didn't generalize.
Closes Cluster 2 from icsharpcode#3745.
After the cluster-1/3/4 fixes converged every caller on the same matching shape (match the slot/kind/type of a reference ILVariable, then check the init value), the `Predicate<StLoc>` parameter was just a hole through which each caller restated that logic verbatim. Fold the slot/kind/type check into the helper and have callers pass just the reference variable and a value matcher.
dd4d6a7 to
391bf28
Compare
Comment on lines
+621
to
+624
| if (!targetNet40 && roslynVersion == roslynLatestVersion && flags.HasFlag(CompilerOptions.EnableRuntimeAsync)) | ||
| { | ||
| otherOptions += "/features:runtime-async=on "; | ||
| } |
Comment on lines
+188
to
+189
| if (runtimeAsync) | ||
| return CSharp.LanguageVersion.CSharp15_0; |
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
Reverses Roslyn's C# 15 runtime-async lowering so methods compiled with
/features:runtime-async=onround-trip back to their source form. The runtime-async feature replaces the compiler-generated state-machine class with a method-impl flag (MethodImplOptions.Async= 0x2000) and a small set of runtime helper calls; that means our existing async-decompilation pipeline (built around recognising the state machine) sees no state machine at all and bails. This branch adds the missing reversers as a single new IL transformRuntimeAsyncExceptionRewriteTransforminvoked fromAsyncAwaitDecompilerwhen the method has the runtime-async impl bit, plus minor type-system / helper changes.What's covered:
Task/Task<T>/ValueTask/ValueTask<T>/[AsyncMethodBuilder]-attributed custom task return types (via a newTaskType.UnpackAnyTask).try { await … } catch (T ex) { …; [throw;] }andtry { await … } finally { … }— including theExceptionDispatchInfo.Capture/Throwrethrow idiom and the captured-object machinery the runtime emits.catch (T ex) when (filter)and the typelesscatch when (filter)form (Roslyn lowers both tocatch object when ({ isinst T; obj=ex; <filter> })).awaiton Task awaitables (AsyncHelpers.Await(task)-> ILAwait) and on non-Task awaitables (the manualGetAwaiter/IsCompleted/UnsafeAwaitAwaiter/GetResultpattern).await foreach(falls out of the existingUsingTransformonce the manual await pattern is collapsed and we feed it ILAwait).*thisinto a local at entry; we remap reads of that local back through the function'sthisparameter soi++doesn't decompile as<copy>.i++.return value;from inside atry { … }enclosed in a try-finally with await — runtime-async encodes it as a flag plus a post-finallyif (flag == K) return capture;; we collapse it back to a direct return inside the try so the user's finally still runs.The new transform shares the catch-handler match and the dominator-based block move with the existing
AwaitInFinallyTransform(extracted asinternal statichelpers) so we're not duplicating the state-machine-async logic.ICSharpCode.Decompiler.Tests/Helpers/Tester.csnow passes/debug:portablefor runtime-async compiles — without the state machine, the user's local variable names only survive in the PDB.The pretty-test sources (
Async.cs,AsyncForeach.cs,AsyncMain.cs,AsyncStreams.cs,AsyncUsing.cs,CustomTaskType.cs) are reused by addingRuntimeAsync*test methods that compile the same source with/features:runtime-async=onand expect the same decompiled output.Test plan
dotnet test --filter \"FullyQualifiedName~PrettyTestRunner.RuntimeAsync\"— 12/12 pass (the six new test methods × Optimize / non-Optimize).dotnet test --filter \"Name=Async|Name=AsyncForeach|Name=AsyncMain|Name=AsyncStreams|Name=AsyncUsing|Name=CustomTaskType\"— 98/98 classical-async pass (no regressions in the existing state-machine async path).PrettyTestRunnersweep — no regressions outside the async area.🤖 Generated with Claude Code