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,