Browse Source

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 '<Main>' 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
pull/4124/head
Siegfried Pammer 1 week ago
parent
commit
4e2f28109e
  1. 7
      ICSharpCode.Decompiler.Tests/Helpers/Tester.cs
  2. 105
      ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs
  3. 30
      ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingCatchHandler.cs
  4. 57
      ICSharpCode.Decompiler.Tests/TestCases/PdbGen/AsyncSteppingEntryPoint.cs
  5. 8
      ICSharpCode.Decompiler/DebugInfo/AsyncDebugInfo.cs
  6. 37
      ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs

7
ICSharpCode.Decompiler.Tests/Helpers/Tester.cs

@ -932,7 +932,8 @@ namespace System.Runtime.CompilerServices
} }
} }
public static void CompileCSharpWithPdb(string assemblyName, Dictionary<string, string> sourceFiles, CompilerOptions compilerOptions = CompilerOptions.None) public static void CompileCSharpWithPdb(string assemblyName, Dictionary<string, string> sourceFiles,
CompilerOptions compilerOptions = CompilerOptions.None)
{ {
var parseOptions = new CSharpParseOptions(languageVersion: Microsoft.CodeAnalysis.CSharp.LanguageVersion.Latest); var parseOptions = new CSharpParseOptions(languageVersion: Microsoft.CodeAnalysis.CSharp.LanguageVersion.Latest);
if (compilerOptions.HasFlag(CompilerOptions.EnableRuntimeAsync)) if (compilerOptions.HasFlag(CompilerOptions.EnableRuntimeAsync))
@ -955,7 +956,9 @@ namespace System.Runtime.CompilerServices
var compilation = CSharpCompilation.Create(Path.GetFileNameWithoutExtension(assemblyName), var compilation = CSharpCompilation.Create(Path.GetFileNameWithoutExtension(assemblyName),
syntaxTrees, coreDefaultReferences.Select(r => MetadataReference.CreateFromFile(Path.Combine(RefAssembliesToolset.GetPath(CurrentNetCoreAppVersion), r))), syntaxTrees, coreDefaultReferences.Select(r => MetadataReference.CreateFromFile(Path.Combine(RefAssembliesToolset.GetPath(CurrentNetCoreAppVersion), r))),
new CSharpCompilationOptions( new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary, compilerOptions.HasFlag(CompilerOptions.Library)
? OutputKind.DynamicallyLinkedLibrary
: OutputKind.ConsoleApplication,
platform: Platform.AnyCpu, platform: Platform.AnyCpu,
optimizationLevel: OptimizationLevel.Release, optimizationLevel: OptimizationLevel.Release,
allowUnsafe: true, allowUnsafe: true,

105
ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs

@ -427,7 +427,7 @@ namespace ICSharpCode.Decompiler.Tests
string outputBase = Path.Combine(TestCasePath, nameof(RuntimeAsync) + ".expected"); string outputBase = Path.Combine(TestCasePath, nameof(RuntimeAsync) + ".expected");
Tester.CompileCSharpWithPdb(outputBase, new Dictionary<string, string> { Tester.CompileCSharpWithPdb(outputBase, new Dictionary<string, string> {
{ Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) } { Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) }
}, CompilerOptions.EnableRuntimeAsync); }, CompilerOptions.EnableRuntimeAsync | CompilerOptions.Library);
string peFileName = outputBase + ".dll"; string peFileName = outputBase + ".dll";
var module = new PEFile(peFileName); 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<string, uint> 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<string, uint> offsets)
=> string.Join("\n", offsets.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}: 0x{pair.Value:x}"));
}
/// <summary>
/// The catch handler offset out of every MethodSteppingInformation blob, keyed by the name of
/// the method that carries it.
/// </summary>
private static Dictionary<string, uint> ReadCatchHandlerOffsets(MetadataReader pdb, MetadataReader pe)
{
var offsets = new Dictionary<string, uint>();
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<DecompilationProgress> private class TestProgressReporter : IProgress<DecompilationProgress>
{ {
private Action<DecompilationProgress> reportFunc; private Action<DecompilationProgress> reportFunc;
@ -585,17 +678,19 @@ namespace ICSharpCode.Decompiler.Tests
TestSequencePoints(knownResidual: true); 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<string, string> { Tester.CompileCSharpWithPdb(outputBase, new Dictionary<string, string> {
{ Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) } { 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"); 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 peFileName = Path.Combine(TestCasePath, testName + ".expected.dll");
string pdbFileName = Path.Combine(TestCasePath, testName + ".expected.pdb"); string pdbFileName = Path.Combine(TestCasePath, testName + ".expected.pdb");

30
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<int> 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");
}
}

57
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);
}
}
}

8
ICSharpCode.Decompiler/DebugInfo/AsyncDebugInfo.cs

@ -25,6 +25,10 @@ namespace ICSharpCode.Decompiler.DebugInfo
{ {
public readonly struct AsyncDebugInfo public readonly struct AsyncDebugInfo
{ {
/// <summary>
/// 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.
/// </summary>
public readonly int CatchHandlerOffset; public readonly int CatchHandlerOffset;
public readonly ImmutableArray<Await> Awaits; public readonly ImmutableArray<Await> Awaits;
@ -49,7 +53,9 @@ namespace ICSharpCode.Decompiler.DebugInfo
public BlobBuilder BuildBlob(MethodDefinitionHandle moveNext) public BlobBuilder BuildBlob(MethodDefinitionHandle moveNext)
{ {
BlobBuilder blob = new BlobBuilder(); 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) foreach (var await in Awaits)
{ {
blob.WriteUInt32((uint)await.YieldOffset); blob.WriteUInt32((uint)await.YieldOffset);

37
ICSharpCode.Decompiler/IL/ControlFlow/AsyncAwaitDecompiler.cs

@ -25,6 +25,7 @@ using System.Reflection.Metadata;
using ICSharpCode.Decompiler.CSharp; using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.DebugInfo; using ICSharpCode.Decompiler.DebugInfo;
using ICSharpCode.Decompiler.Disassembler;
using ICSharpCode.Decompiler.IL.Transforms; using ICSharpCode.Decompiler.IL.Transforms;
using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.Decompiler.TypeSystem;
@ -62,6 +63,29 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
return method == entrypoint && metadata.GetString(definition.Name).Equals("<Main>", StringComparison.Ordinal); return method == entrypoint && metadata.GetString(definition.Name).Equals("<Main>", 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 enum AsyncMethodType
{ {
Void, Void,
@ -116,6 +140,7 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
if (!context.Settings.AsyncAwait) if (!context.Settings.AsyncAwait)
return; // abort if async/await decompilation is disabled return; // abort if async/await decompilation is disabled
this.context = context; this.context = context;
catchHandlerOffset = -1;
fieldToParameterMap.Clear(); fieldToParameterMap.Clear();
cachedFieldToParameterMap.Clear(); cachedFieldToParameterMap.Clear();
awaitBlocks.Clear(); awaitBlocks.Clear();
@ -180,7 +205,17 @@ namespace ICSharpCode.Decompiler.IL.ControlFlow
} }
awaitDebugInfos.SortBy(row => row.YieldOffset); 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, // Runtime-async analog of fieldToParameterMap's `<>4__this` capture: in a struct method,

Loading…
Cancel
Save