From 4e2f28109e0eae18cbbb72cc65eb37fe49e2e983 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 8 Sep 2026 20:12:03 +0200 Subject: [PATCH] Fix #2823: write the async catch handler offset the way consumers read it The async stepping blob's first field is the compiler-generated catch handler's IL offset plus one, and 0 when there is nothing to record. ILSpy wrote the raw offset, so a consumer decoding it as (value - 1) resolved an address in the middle of an instruction: Mono.Cecil throws ArgumentNullException while reading the body, which is why the reported assembly opened in ILSpy but killed ILLink on every MoveNext it had. CatchHandlerOffset now holds the offset it is named after, or -1 for "none", and BuildBlob applies the bias - the same model the compiler uses. It is recorded only where an escaping exception is unlikely to be observed: an async void method, and an async entry point. Recording it more widely would be worse than recording it nowhere, because an async Task method returns its exception through the Task and the debugger would then break on exceptions user code catches. Measured over csc 1.3.2 to 5.10: async void is handler+1 and a normal async Task is 0 in every version; only async Main changed, from 0 to handler+1 between 2.10 and 3.11. The entry point token names the synchronous '
' shim that exists because the runtime will not take .entrypoint on an async method, so the method to record is the one the shim calls. Reading that call is exact; matching the shim's siblings by name is not, and gets a 'Main' overload beside the real entry point wrong. Both tests compare against the compiler's own PDB for the same assembly, so the blobs describe the same IL and the field compares directly. Assisted-by: Claude:claude-opus-5:Claude Code --- .../Helpers/Tester.cs | 7 +- .../PdbGenerationTestRunner.cs | 105 +++++++++++++++++- .../PdbGen/AsyncSteppingCatchHandler.cs | 30 +++++ .../PdbGen/AsyncSteppingEntryPoint.cs | 57 ++++++++++ .../DebugInfo/AsyncDebugInfo.cs | 8 +- .../IL/ControlFlow/AsyncAwaitDecompiler.cs | 37 +++++- 6 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingCatchHandler.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingEntryPoint.cs diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs index c3f0a8076..904e39641 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs @@ -932,7 +932,8 @@ namespace System.Runtime.CompilerServices } } - public static void CompileCSharpWithPdb(string assemblyName, Dictionary sourceFiles, CompilerOptions compilerOptions = CompilerOptions.None) + 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)) @@ -955,7 +956,9 @@ namespace System.Runtime.CompilerServices var compilation = CSharpCompilation.Create(Path.GetFileNameWithoutExtension(assemblyName), syntaxTrees, coreDefaultReferences.Select(r => MetadataReference.CreateFromFile(Path.Combine(RefAssembliesToolset.GetPath(CurrentNetCoreAppVersion), r))), new CSharpCompilationOptions( - OutputKind.DynamicallyLinkedLibrary, + compilerOptions.HasFlag(CompilerOptions.Library) + ? OutputKind.DynamicallyLinkedLibrary + : OutputKind.ConsoleApplication, platform: Platform.AnyCpu, optimizationLevel: OptimizationLevel.Release, allowUnsafe: true, diff --git a/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs b/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs index cdffbe502..eb42e6cec 100644 --- a/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs @@ -427,7 +427,7 @@ namespace ICSharpCode.Decompiler.Tests string outputBase = Path.Combine(TestCasePath, nameof(RuntimeAsync) + ".expected"); Tester.CompileCSharpWithPdb(outputBase, new Dictionary { { Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) } - }, CompilerOptions.EnableRuntimeAsync); + }, CompilerOptions.EnableRuntimeAsync | CompilerOptions.Library); string peFileName = outputBase + ".dll"; var module = new PEFile(peFileName); @@ -450,6 +450,99 @@ namespace ICSharpCode.Decompiler.Tests } } + [Test] + public void AsyncSteppingCatchHandler() + { + // The catch handler field of the async stepping blob is the generated handler's IL offset + // plus one, and only for async void methods; 0 otherwise. A consumer decodes it as + // (value - 1), so a raw offset points into the middle of an instruction and Mono.Cecil + // throws while reading the body, taking ILLink with it (#2823). Both PDBs here describe + // the same assembly, so the compiler's value is a direct oracle. + (string peFileName, string pdbFileName) = CompileTestCase(nameof(AsyncSteppingCatchHandler)); + + 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(); + new PortablePdbWriter { NoLogo = true } + .WritePdb(module, decompiler, new DecompilerSettings(), generatedPdb); + + generatedPdb.Position = 0; + var actual = ReadCatchHandlerOffsets( + MetadataReaderProvider.FromPortablePdbStream(generatedPdb).GetMetadataReader(), module.Metadata); + using var compilerPdb = File.OpenRead(pdbFileName); + var expected = ReadCatchHandlerOffsets( + MetadataReaderProvider.FromPortablePdbStream(compilerPdb).GetMetadataReader(), module.Metadata); + + Assert.That(expected, Is.Not.Empty, "the fixture produced no async stepping information to compare against"); + Assert.That(Format(actual), Is.EqualTo(Format(expected))); + + static string Format(Dictionary offsets) + => string.Join("\n", offsets.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}: 0x{pair.Value:x}")); + } + + [Test] + public void AsyncSteppingEntryPoint() + { + // The compiler records a catch handler for two shapes, not one: an async void method, and + // an async entry point - which returns Task. Both are shapes nothing is expected to await, + // so an exception escaping them should reach the debugger as user-unhandled. The fixture + // holds all three cases with identical bodies, so only the entry-point and return-type + // distinctions can account for a difference. + // Without CompilerOptions.Library the fixture is compiled as an executable, which is what + // gives it an entry point to recognise. + (string peFileName, string pdbFileName) = CompileTestCase(nameof(AsyncSteppingEntryPoint), + CompilerOptions.None); + + 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(); + new PortablePdbWriter { NoLogo = true } + .WritePdb(module, decompiler, new DecompilerSettings(), generatedPdb); + + generatedPdb.Position = 0; + var actual = ReadCatchHandlerOffsets( + MetadataReaderProvider.FromPortablePdbStream(generatedPdb).GetMetadataReader(), module.Metadata); + using var compilerPdb = File.OpenRead(pdbFileName); + var expected = ReadCatchHandlerOffsets( + MetadataReaderProvider.FromPortablePdbStream(compilerPdb).GetMetadataReader(), module.Metadata); + + Assert.That(expected.Count, Is.EqualTo(4), "the fixture should produce four async state machines"); + Assert.That(expected.Values.Count(offset => offset != 0), Is.EqualTo(2), + "only the async void method and the entry point should carry a catch handler"); + Assert.That(Format(actual), Is.EqualTo(Format(expected))); + + static string Format(Dictionary offsets) + => string.Join("\n", offsets.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}: 0x{pair.Value:x}")); + } + + /// + /// The catch handler offset out of every MethodSteppingInformation blob, keyed by the name of + /// the method that carries it. + /// + private static Dictionary ReadCatchHandlerOffsets(MetadataReader pdb, MetadataReader pe) + { + var offsets = new Dictionary(); + foreach (var handle in pdb.CustomDebugInformation) + { + var cdi = pdb.GetCustomDebugInformation(handle); + if (pdb.GetGuid(cdi.Kind) != KnownGuids.MethodSteppingInformation) + continue; + var method = pe.GetMethodDefinition((MethodDefinitionHandle)cdi.Parent); + var declaringType = pe.GetTypeDefinition(method.GetDeclaringType()); + offsets[$"{pe.GetString(declaringType.Name)}.{pe.GetString(method.Name)}"] + = pdb.GetBlobReader(cdi.Value).ReadUInt32(); + } + return offsets; + } + private class TestProgressReporter : IProgress { private Action reportFunc; @@ -585,17 +678,19 @@ namespace ICSharpCode.Decompiler.Tests TestSequencePoints(knownResidual: true); } - private static void CompileCSharpWithPdb(string outputBase, string sourceFile) + private static void CompileCSharpWithPdb(string outputBase, string sourceFile, + CompilerOptions compilerOptions = CompilerOptions.Library) { Tester.CompileCSharpWithPdb(outputBase, new Dictionary { { Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) } - }); + }, compilerOptions); } - private (string peFileName, string pdbFileName) CompileTestCase(string testName) + private (string peFileName, string pdbFileName) CompileTestCase(string testName, + CompilerOptions compilerOptions = CompilerOptions.Library) { string sourceFile = Path.Combine(TestCasePath, testName + ".cs"); - CompileCSharpWithPdb(Path.Combine(TestCasePath, testName + ".expected"), sourceFile); + CompileCSharpWithPdb(Path.Combine(TestCasePath, testName + ".expected"), sourceFile, compilerOptions); string peFileName = Path.Combine(TestCasePath, testName + ".expected.dll"); string pdbFileName = Path.Combine(TestCasePath, testName + ".expected.pdb"); diff --git a/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingCatchHandler.cs b/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingCatchHandler.cs new file mode 100644 index 000000000..8fd1ea915 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingCatchHandler.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +internal class AsyncSteppingCatchHandler +{ + public static async Task RunAsync() + { + await Task.Yield(); + Console.WriteLine("run"); + } + + public static async Task SumAsync(int a, int b) + { + try + { + await Task.Yield(); + return a + b; + } + catch (InvalidOperationException) + { + return 0; + } + } + + public static async void FireAndForget() + { + await Task.Yield(); + Console.WriteLine("done"); + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingEntryPoint.cs b/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingEntryPoint.cs new file mode 100644 index 000000000..01a97b86d --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingEntryPoint.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; + +internal class AsyncSteppingEntryPoint +{ + public static async Task Main() + { + try + { + await Task.Yield(); + Console.WriteLine("main"); + } + catch (InvalidOperationException e) + { + Console.WriteLine(e.Message); + } + } + + public static async Task Main(int notTheEntryPoint) + { + try + { + await Task.Yield(); + Console.WriteLine(notTheEntryPoint); + } + catch (InvalidOperationException e) + { + Console.WriteLine(e.Message); + } + } + + public static async Task NotTheEntryPointAsync() + { + try + { + await Task.Yield(); + Console.WriteLine("other"); + } + catch (InvalidOperationException e) + { + Console.WriteLine(e.Message); + } + } + + public static async void FireAndForget() + { + try + { + await Task.Yield(); + Console.WriteLine("done"); + } + catch (InvalidOperationException e) + { + Console.WriteLine(e.Message); + } + } +} diff --git a/ICSharpCode.Decompiler/DebugInfo/AsyncDebugInfo.cs b/ICSharpCode.Decompiler/DebugInfo/AsyncDebugInfo.cs index 94d5f2747..d634e7f66 100644 --- a/ICSharpCode.Decompiler/DebugInfo/AsyncDebugInfo.cs +++ b/ICSharpCode.Decompiler/DebugInfo/AsyncDebugInfo.cs @@ -25,6 +25,10 @@ namespace ICSharpCode.Decompiler.DebugInfo { public readonly struct AsyncDebugInfo { + /// + /// IL offset of the compiler-generated catch handler whose exceptions the debugger should + /// report as user-unhandled, or -1 when there is none to record. + /// public readonly int CatchHandlerOffset; public readonly ImmutableArray Awaits; @@ -49,7 +53,9 @@ namespace ICSharpCode.Decompiler.DebugInfo public BlobBuilder BuildBlob(MethodDefinitionHandle moveNext) { BlobBuilder blob = new BlobBuilder(); - blob.WriteUInt32((uint)CatchHandlerOffset); + // The field is the handler's offset plus one; 0 is the encoding for "none", which is why + // a consumer reading it back subtracts one before resolving it to an instruction. + blob.WriteUInt32((uint)(CatchHandlerOffset + 1)); foreach (var await in Awaits) { blob.WriteUInt32((uint)await.YieldOffset); diff --git a/ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs b/ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs index f80a60a62..3775d2eec 100644 --- a/ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs +++ b/ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs @@ -25,6 +25,7 @@ using System.Reflection.Metadata; using ICSharpCode.Decompiler.CSharp; using ICSharpCode.Decompiler.DebugInfo; +using ICSharpCode.Decompiler.Disassembler; using ICSharpCode.Decompiler.IL.Transforms; using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.TypeSystem; @@ -62,6 +63,29 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow return method == entrypoint && metadata.GetString(definition.Name).Equals("
", StringComparison.Ordinal); } + static bool IsCalledByEntryPoint(MetadataFile module, MethodDefinitionHandle method) + { + var entrypoint = System.Reflection.Metadata.Ecma335.MetadataTokens.MethodDefinitionHandle(module.CorHeader?.EntryPointTokenOrRelativeVirtualAddress ?? 0); + if (entrypoint.IsNil || !IsCompilerGeneratedMainMethod(module, entrypoint)) + return false; + var shim = module.Metadata.GetMethodDefinition(entrypoint); + if (shim.RelativeVirtualAddress == 0) + return false; + var blob = module.GetMethodBody(shim.RelativeVirtualAddress).GetILReader(); + while (blob.RemainingBytes > 0) + { + var code = blob.DecodeOpCode(); + if (code != ILOpCode.Call) + { + blob.SkipOperand(code); + continue; + } + if (MetadataTokenHelpers.EntityHandleOrNil(blob.ReadInt32()) == method) + return true; + } + return false; + } + enum AsyncMethodType { Void, @@ -116,6 +140,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow if (!context.Settings.AsyncAwait) return; // abort if async/await decompilation is disabled this.context = context; + catchHandlerOffset = -1; fieldToParameterMap.Clear(); cachedFieldToParameterMap.Clear(); awaitBlocks.Clear(); @@ -180,7 +205,17 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow } awaitDebugInfos.SortBy(row => row.YieldOffset); - function.AsyncDebugInfo = new AsyncDebugInfo(catchHandlerOffset, awaitDebugInfos.ToImmutableArray()); + // The catchHandlerOffset marks the compiler-generated catch block. We need to distinguish + // a few cases: + // 1) async void methods always record the offset + // 2) the kickoff method of the async Main entry point always records the offset + // 3) in all the other cases nothing (-1) is emitted by csc. + var kickoff = function.Method?.MetadataToken ?? default; + bool recordCatchHandler = methodType == AsyncMethodType.Void + || (kickoff.Kind == HandleKind.MethodDefinition + && IsCalledByEntryPoint(context.PEFile, (MethodDefinitionHandle)kickoff)); + function.AsyncDebugInfo = new AsyncDebugInfo(recordCatchHandler ? catchHandlerOffset : -1, + awaitDebugInfos.ToImmutableArray()); } // Runtime-async analog of fieldToParameterMap's `<>4__this` capture: in a struct method,