diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs index 420b4a06e..499e82c4c 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs @@ -847,9 +847,15 @@ namespace System.Runtime.CompilerServices } } - public static void CompileCSharpWithPdb(string assemblyName, Dictionary sourceFiles) + public static void CompileCSharpWithPdb(string assemblyName, Dictionary sourceFiles, CompilerOptions compilerOptions = CompilerOptions.None) { var parseOptions = new CSharpParseOptions(languageVersion: Microsoft.CodeAnalysis.CSharp.LanguageVersion.Latest); + if (compilerOptions.HasFlag(CompilerOptions.EnableRuntimeAsync)) + { + // Mirror the out-of-process csc '/features:runtime-async=on' flag so the compiler lowers + // async methods to the .NET 11 runtime-async form (MethodImplAsync bit, no state machine). + parseOptions = parseOptions.WithFeatures(new[] { new KeyValuePair("runtime-async", "on") }); + } List embeddedTexts = new List(); List syntaxTrees = new List(); diff --git a/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs b/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs index 0f2b5cf1f..67632ae11 100644 --- a/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs @@ -359,6 +359,40 @@ namespace ICSharpCode.Decompiler.Tests }); } + [Test] + public void RuntimeAsync() + { + // A .NET 11 runtime-async method (MethodImplAsync bit, no MoveNext state machine) is + // IsAsync from its signature but never gets AsyncDebugInfo populated. The writer must omit + // MethodSteppingInformation for it - the C# compiler emits none either - rather than build + // a stepping blob from the default (uninitialized) AsyncDebugInfo and throw an NRE (#3754). + string sourceFile = Path.Combine(TestCasePath, nameof(RuntimeAsync) + ".cs"); + string outputBase = Path.Combine(TestCasePath, nameof(RuntimeAsync) + ".expected"); + Tester.CompileCSharpWithPdb(outputBase, new Dictionary { + { Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) } + }, CompilerOptions.EnableRuntimeAsync); + string peFileName = outputBase + ".dll"; + + var module = new PEFile(peFileName); + var resolver = new UniversalAssemblyResolver(peFileName, false, + module.Metadata.DetectTargetFrameworkId(), null, PEStreamOptions.PrefetchEntireImage); + var decompiler = new CSharpDecompiler(module, resolver, new DecompilerSettings()); + + using var generatedPdb = new MemoryStream(); + Assert.DoesNotThrow(() => new PortablePdbWriter { NoLogo = true } + .WritePdb(module, decompiler, new DecompilerSettings(), generatedPdb)); + + // The runtime-async method must carry no MethodSteppingInformation, matching the compiler. + generatedPdb.Position = 0; + var reader = MetadataReaderProvider.FromPortablePdbStream(generatedPdb).GetMetadataReader(); + foreach (var handle in reader.CustomDebugInformation) + { + var cdi = reader.GetCustomDebugInformation(handle); + Assert.That(reader.GetGuid(cdi.Kind), Is.Not.EqualTo(KnownGuids.MethodSteppingInformation), + "runtime-async methods have no yield/resume offsets; no stepping information should be emitted"); + } + } + private class TestProgressReporter : IProgress { private Action reportFunc; diff --git a/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/RuntimeAsync.cs b/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/RuntimeAsync.cs new file mode 100644 index 000000000..9e65d3b2a --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/RuntimeAsync.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Threading.Tasks; + +namespace ICSharpCode.Decompiler.Tests.TestCases.PdbGen; + +public class RuntimeAsync +{ + public async Task M(int x) + { + await Task.Yield(); + int y = x + 1; + return y; + } +} diff --git a/ICSharpCode.Decompiler/DebugInfo/PortablePdbWriter.cs b/ICSharpCode.Decompiler/DebugInfo/PortablePdbWriter.cs index f494744c5..4f1574a14 100644 --- a/ICSharpCode.Decompiler/DebugInfo/PortablePdbWriter.cs +++ b/ICSharpCode.Decompiler/DebugInfo/PortablePdbWriter.cs @@ -191,7 +191,12 @@ namespace ICSharpCode.Decompiler.DebugInfo metadata.GetOrAddBlob(BuildStateMachineHoistedLocalScopes(function)) )); } - if (function.IsAsync) + // Runtime-async methods (.NET 11) are IsAsync from their signature but carry no + // state machine, so AsyncAwaitDecompiler never populates AsyncDebugInfo and its + // Awaits stays an uninitialized ImmutableArray. They have no yield/resume offsets + // to record, so omit stepping information entirely - matching the C# compiler, + // which emits none either - rather than build a blob from the default struct. + if (function.IsAsync && !function.AsyncDebugInfo.Awaits.IsDefault) { customMethodDebugInfo.Add((methodHandle, metadata.GetOrAddGuid(KnownGuids.MethodSteppingInformation),