Files
ILSpy/ICSharpCode.Decompiler.Tests/PdbGenerationTestRunner.cs
Siegfried Pammer 4e2f28109e 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
2026-09-09 09:11:27 +02:00

702 lines
28 KiB
C#

// Copyright (c) 2019 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;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.CSharp.OutputVisitor;
using ICSharpCode.Decompiler.CSharp.Syntax;
using ICSharpCode.Decompiler.DebugInfo;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Tests.Helpers;
using NUnit.Framework;
namespace ICSharpCode.Decompiler.Tests
{
[TestFixture, Parallelizable(ParallelScope.All)]
public class PdbGenerationTestRunner
{
static readonly string TestCasePath = Tester.TestCasePath + "/PdbGen";
/// <summary>
/// How strictly a reconstructed breakpoint must match the original compiler's.
/// </summary>
enum Tolerance
{
/// <summary>Visible breakpoints must match on source line and column.</summary>
LinesAndColumns,
/// <summary>Only the source line span must match; column placement may differ.</summary>
Lines
}
[Test]
public void HelloWorld()
{
TestSequencePoints();
}
[Test]
public void ForLoopTests()
{
// The compiler hides the unconditional branch from the loop setup to the condition test;
// the decompiler folds that IL into the loop body's point instead. The residual pins it.
TestSequencePoints(knownResidual: true);
}
[Test]
public void LambdaCapturing()
{
// The decompiler emits one extra visible breakpoint that the C# compiler keeps hidden;
// the recorded residual pins that known, deliberate difference.
TestSequencePoints(knownResidual: true);
}
[Test]
public void Members()
{
TestSequencePoints(knownResidual: true);
}
[Test]
public void MemberInitializers()
{
(string peFileName, _) = CompileTestCase(nameof(MemberInitializers));
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);
var methodNames = PdbSequencePoints.ReadMethodNames(peFileName);
var actual = PdbSequencePoints.Read(generatedPdb);
var constructorRows = methodNames
.Where(pair => pair.Value == "MemberInitializers..ctor")
.Select(pair => pair.Key)
.ToArray();
Assert.That(constructorRows, Has.Length.EqualTo(2),
"the fixture must keep both overloaded constructors");
foreach (int row in constructorRows)
{
Assert.That(actual, Does.ContainKey(row),
$"constructor method row 0x{0x06000000 | row:x8} has no generated sequence points");
var visibleStartLines = actual[row]
.Where(point => !point.IsHidden)
.Select(point => point.StartLine)
.ToArray();
Assert.That(visibleStartLines, Does.Contain(5),
"the instance field initializer must be mapped in every constructor");
Assert.That(visibleStartLines, Does.Contain(7),
"the auto-property initializer must be mapped in every constructor");
}
}
[Test]
public void TryCatchFinally()
{
TestSequencePoints(knownResidual: true);
}
[Test]
public void SwitchStatement()
{
TestSequencePoints(knownResidual: true);
}
[Test]
public void AsyncAwait()
{
TestSequencePoints(knownResidual: true);
}
[Test]
public void YieldReturn()
{
TestSequencePoints(knownResidual: true);
}
[Test]
public void ForeachUsing()
{
// The using statement and foreach are lowered to try/finally; the decompiler places the
// disposal and the closing braces differently from the compiler.
TestSequencePoints(knownResidual: true);
}
[Test]
public void WhileLoops()
{
// The compiler keeps the while-condition back-branch hidden, while the decompiler
// projects the loop condition and body points onto the decompiled source layout.
TestSequencePoints(knownResidual: true);
}
[Test]
public void LockStatement()
{
// The compiler breakpoints the lock's closing brace and the static-field initializer in
// the .cctor; the decompiler reconstructs the lock exit and the cctor differently.
TestSequencePoints(knownResidual: true);
}
[Test]
public void LinqQuery()
{
// The query is lowered to lambdas; the decompiler emits an extra hidden point inside each
// lambda and places the enclosing foreach's point differently from the compiler.
TestSequencePoints(knownResidual: true);
}
[Test]
public void SwitchExpression()
{
// The compiler breakpoints each switch-expression arm; the decompiler breakpoints the
// method's opening brace and keeps the arm bodies hidden.
TestSequencePoints(knownResidual: true);
}
[Test]
public void LocalFunctions()
{
TestSequencePoints();
}
[Test]
public void GotoLabels()
{
TestSequencePoints();
}
[Test]
public void CheckedUnchecked()
{
TestSequencePoints();
}
[Test]
public void NestedLoops()
{
// Same residual as ForLoopTests, once per loop: the compiler keeps the back-branch from
// the increment to the condition test hidden, while the decompiler folds that IL into the
// loop body's point.
TestSequencePoints(knownResidual: true);
}
[Test]
public void ConditionalOperators()
{
// The decompiler emits an extra hidden point after each statement that the compiler
// does not, where the ?? / ?: result is consumed.
TestSequencePoints(knownResidual: true);
}
[Test]
public void PatternMatching()
{
TestSequencePoints();
}
[Test]
public void UsingDeclaration()
{
// The using declaration is lowered to a try/finally whose disposal point the decompiler
// reconstructs at a different location than the compiler.
TestSequencePoints(knownResidual: true);
}
[Test]
public void CustomPdbId()
{
// Generate a PDB for an assembly using a randomly-generated ID, then validate that the PDB uses the specified ID
(string peFileName, string pdbFileName) = CompileTestCase(nameof(CustomPdbId));
var moduleDefinition = new PEFile(peFileName);
var resolver = new UniversalAssemblyResolver(peFileName, false, moduleDefinition.Metadata.DetectTargetFrameworkId(), null, PEStreamOptions.PrefetchEntireImage);
var decompiler = new CSharpDecompiler(moduleDefinition, resolver, new DecompilerSettings());
var expectedPdbId = new BlobContentId(Guid.NewGuid(), (uint)Random.Shared.Next());
using (FileStream pdbStream = File.Open(Path.Combine(TestCasePath, nameof(CustomPdbId) + ".pdb"), FileMode.Create, FileAccess.ReadWrite))
{
new PortablePdbWriter { NoLogo = true, PdbId = expectedPdbId }
.WritePdb(moduleDefinition, decompiler, new DecompilerSettings(), pdbStream);
pdbStream.Position = 0;
var metadataReader = MetadataReaderProvider.FromPortablePdbStream(pdbStream).GetMetadataReader();
var generatedPdbId = new BlobContentId(metadataReader.DebugMetadataHeader.Id);
Assert.That(generatedPdbId.Guid, Is.EqualTo(expectedPdbId.Guid));
Assert.That(generatedPdbId.Stamp, Is.EqualTo(expectedPdbId.Stamp));
}
}
[Test]
public void EmbedSourceFiles_False_Omits_Embedded_Source()
{
// Generating a PDB alongside a project export (where the .cs are already on disk) should be
// able to skip embedding the source again. EmbedSourceFiles = false must drop the
// EmbeddedSource custom debug info (smaller PDB, no embeddedSourceLength) while keeping the
// document checksum/hash intact.
// Compile HelloWorld's source into a uniquely-named assembly so this test never collides
// with the HelloWorld fixture's .expected files under the fixture's parallel scope.
string sourceFile = Path.Combine(TestCasePath, nameof(HelloWorld) + ".cs");
string outputBase = Path.Combine(TestCasePath, nameof(EmbedSourceFiles_False_Omits_Embedded_Source) + ".expected");
CompileCSharpWithPdb(outputBase, sourceFile);
string peFileName = outputBase + ".dll";
var module = new PEFile(peFileName);
var resolver = new UniversalAssemblyResolver(peFileName, false,
module.Metadata.DetectTargetFrameworkId(), null, PEStreamOptions.PrefetchEntireImage);
byte[] WritePdbBytes(bool embedSources)
{
var decompiler = new CSharpDecompiler(module, resolver, new DecompilerSettings());
using var ms = new MemoryStream();
new PortablePdbWriter { NoLogo = true, EmbedSourceFiles = embedSources }
.WritePdb(module, decompiler, new DecompilerSettings(), ms);
return ms.ToArray();
}
// Inspect the portable PDB metadata directly (environment-independent): count the
// EmbeddedSource custom-debug-information rows and the source documents.
(int EmbeddedSources, int Documents) Inspect(byte[] pdb)
{
using var ms = new MemoryStream(pdb);
var reader = MetadataReaderProvider.FromPortablePdbStream(ms).GetMetadataReader();
int embedded = 0;
foreach (var handle in reader.CustomDebugInformation)
{
var cdi = reader.GetCustomDebugInformation(handle);
if (reader.GetGuid(cdi.Kind) == KnownGuids.EmbeddedSource)
embedded++;
}
return (embedded, reader.Documents.Count);
}
var withEmbed = WritePdbBytes(embedSources: true);
var withoutEmbed = WritePdbBytes(embedSources: false);
var embedInfo = Inspect(withEmbed);
var noEmbedInfo = Inspect(withoutEmbed);
Assert.That(embedInfo.EmbeddedSources, Is.GreaterThan(0),
"the default (embed = true) keeps the embedded-source blobs");
Assert.That(noEmbedInfo.EmbeddedSources, Is.EqualTo(0),
"embed = false must omit every embedded-source blob");
Assert.That(noEmbedInfo.Documents, Is.EqualTo(embedInfo.Documents).And.GreaterThan(0),
"the source documents (with their checksum/hash) must remain even without embedded source");
Assert.That(withoutEmbed.Length, Is.LessThan(withEmbed.Length),
"omitting embedded source must produce a strictly smaller PDB");
}
[Test]
public void ProgressReporting()
{
// Generate a PDB for an assembly and validate that the progress reporter is called with reasonable values
(string peFileName, string pdbFileName) = CompileTestCase(nameof(ProgressReporting));
var moduleDefinition = new PEFile(peFileName);
var resolver = new UniversalAssemblyResolver(peFileName, false, moduleDefinition.Metadata.DetectTargetFrameworkId(), null, PEStreamOptions.PrefetchEntireImage);
var decompiler = new CSharpDecompiler(moduleDefinition, resolver, new DecompilerSettings());
var lastFilesWritten = 0;
var totalFiles = -1;
Action<DecompilationProgress> reportFunc = progress => {
if (totalFiles == -1)
{
// Initialize value on first call
totalFiles = progress.TotalUnits;
}
Assert.That(totalFiles, Is.EqualTo(progress.TotalUnits));
Assert.That(lastFilesWritten + 1, Is.EqualTo(progress.UnitsCompleted));
lastFilesWritten = progress.UnitsCompleted;
};
using (FileStream pdbStream = File.Open(Path.Combine(TestCasePath, nameof(ProgressReporting) + ".pdb"), FileMode.Create, FileAccess.ReadWrite))
{
new PortablePdbWriter { NoLogo = true, Progress = new TestProgressReporter(reportFunc) }
.WritePdb(moduleDefinition, decompiler, new DecompilerSettings(), pdbStream);
pdbStream.Position = 0;
var metadataReader = MetadataReaderProvider.FromPortablePdbStream(pdbStream).GetMetadataReader();
var generatedPdbId = new BlobContentId(metadataReader.DebugMetadataHeader.Id);
}
Assert.That(lastFilesWritten, Is.EqualTo(totalFiles));
}
[Test]
public async Task PinnedRegionWarning()
{
// Decompiling this fixture makes DetectPinnedRegions duplicate a block shared by a pinned
// region and record a warning. The warning is emitted as an EmptyStatement that carries
// only a comment, so it prints no semicolon. Such a statement must still be assigned a text
// location, otherwise SequencePointBuilder crashes on the empty location while generating
// sequence points for the PDB.
string ilFile = Path.Combine(TestCasePath, nameof(PinnedRegionWarning) + ".il");
string peFileName = await Tester.AssembleIL(ilFile, AssemblerOptions.Library);
var module = new PEFile(peFileName);
var resolver = new UniversalAssemblyResolver(peFileName, false,
module.Metadata.DetectTargetFrameworkId(), null, PEStreamOptions.PrefetchEntireImage);
var settings = new DecompilerSettings();
var decompiler = new CSharpDecompiler(module, resolver, settings);
var syntaxTree = decompiler.DecompileType(new Decompiler.TypeSystem.FullTypeName("C"));
// Locations are recorded while printing, so the tree has to be run through the output
// visitor first - exactly as PortablePdbWriter does before calling CreateSequencePoints.
var output = new StringWriter();
TokenWriter tokenWriter = new TextWriterTokenWriter(output);
tokenWriter = TokenWriter.WrapInWriterThatSetsLocationsInAST(tokenWriter);
syntaxTree.AcceptVisitor(new CSharpOutputVisitor(tokenWriter, settings.CSharpFormattingOptions));
var warningStatement = syntaxTree.Descendants.OfType<EmptyStatement>().SingleOrDefault();
Assert.That(warningStatement, Is.Not.Null,
"the fixture must produce the warning-carrying EmptyStatement it is testing");
var comment = warningStatement.LeadingTrivia.Concat(warningStatement.TrailingTrivia).FirstOrDefault();
Assert.That(comment, Is.Not.Null, "the warning statement must carry a comment");
Assert.That(comment.StartLocation.IsEmpty, Is.False, "the carried comment must have been printed");
Assert.That(warningStatement.StartLocation, Is.EqualTo(comment.StartLocation),
"a comment-only EmptyStatement should take the location of the comment it carries");
Assert.DoesNotThrow(() => decompiler.CreateSequencePoints(syntaxTree));
}
[Test]
public async Task PinnedLocalSlot()
{
// The decompiler models a pinned-region local as a pointer (int*), which never matches the
// metadata local signature type (int& pinned). The slot index is still faithful to the IL,
// and the type is never written to the PDB, so generating debug info must not assert on the
// type difference.
string ilFile = Path.Combine(TestCasePath, nameof(PinnedLocalSlot) + ".il");
string peFileName = await Tester.AssembleIL(ilFile, AssemblerOptions.Library);
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());
var syntaxTree = decompiler.DecompileType(new Decompiler.TypeSystem.FullTypeName("C"));
Assert.DoesNotThrow(() => {
var debugInfo = new Decompiler.DebugInfo.DebugInfoGenerator(decompiler.TypeSystem);
syntaxTree.AcceptVisitor(debugInfo);
});
}
[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<string, string> {
{ Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) }
}, CompilerOptions.EnableRuntimeAsync | CompilerOptions.Library);
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");
}
}
[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 Action<DecompilationProgress> reportFunc;
public TestProgressReporter(Action<DecompilationProgress> reportFunc)
{
this.reportFunc = reportFunc;
}
public void Report(DecompilationProgress value)
{
reportFunc(value);
}
}
/// <summary>
/// Compiles the fixture with the C# compiler (producing a real PDB used as the oracle),
/// decompiles it and reconstructs a PDB with <see cref="PortablePdbWriter"/>, then compares
/// the two PDBs' breakpoint maps. The decompiler reconstructs IL ranges and local scopes
/// differently from the compiler, so the comparison asserts on source locations and hidden
/// point placement without depending on exact IL offsets.
/// </summary>
/// <param name="tolerance">
/// Whether visible breakpoints must match on column as well as line.
/// </param>
/// <param name="knownResidual">
/// When false, the breakpoint map must match the compiler exactly (after the projection
/// above). When true, the residual difference must match the committed
/// <c>&lt;testName&gt;.residual.txt</c> snapshot - this pins the known imperfections the
/// decompiler cannot yet reproduce, so an improvement or a regression both flip the test and
/// prompt a deliberate snapshot update (the same accept-the-diff workflow as the pretty tests).
/// </param>
private void TestSequencePoints(Tolerance tolerance = Tolerance.LinesAndColumns, bool knownResidual = false,
[CallerMemberName] string testName = null)
{
(string peFileName, string pdbFileName) = CompileTestCase(testName);
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);
var methodNames = PdbSequencePoints.ReadMethodNames(peFileName);
var actual = PdbSequencePoints.Read(generatedPdb);
Dictionary<int, List<PdbSequencePoints.RawSequencePoint>> expected;
using (var compilerPdb = File.OpenRead(pdbFileName))
expected = PdbSequencePoints.Read(compilerPdb);
// Oracle-free structural check on the decompiler's own PDB.
string wellFormed = PdbSequencePoints.CheckWellFormed(actual, methodNames);
Assert.That(wellFormed, Is.Empty, "the reconstructed PDB is not well-formed:\n" + wellFormed);
string residual = PdbSequencePoints.CompareBreakpointMaps(
expected, actual, methodNames,
includeColumns: tolerance == Tolerance.LinesAndColumns, includeHidden: true);
if (knownResidual)
{
string snapshotFile = Path.Combine(TestCasePath, testName + ".residual.txt");
string expectedResidual = File.Exists(snapshotFile)
? File.ReadAllText(snapshotFile).Replace("\r\n", "\n")
: "";
if (residual != expectedResidual)
{
// Setting ILSPY_ACCEPT_PDB_RESIDUAL=1 accepts the new residual in place, so a
// deliberate change is committed by re-running the test with the variable set
// rather than by hand-copying files.
if (Environment.GetEnvironmentVariable("ILSPY_ACCEPT_PDB_RESIDUAL") == "1")
{
File.WriteAllText(snapshotFile, residual);
}
else
{
File.WriteAllText(snapshotFile + ".generated", residual);
Assert.Fail($"the breakpoint-map residual changed; review the difference and, if intended, "
+ $"accept it by re-running this test with ILSPY_ACCEPT_PDB_RESIDUAL=1 set (or copy "
+ $"'{testName}.residual.txt.generated' over '{testName}.residual.txt')."
+ $"\n\nExpected:\n{expectedResidual}\nActual:\n{residual}");
}
}
}
else
{
Assert.That(residual, Is.Empty, "the reconstructed breakpoint map differs from the compiler's:\n" + residual);
}
}
[Test]
public void MemberInitializerSingleCtor()
{
// The lifted field and auto-property initializers must be breakpointable in the single
// explicit constructor.
TestSequencePoints(knownResidual: true);
}
[Test]
public void MemberInitializerChainedCtors()
{
// The non-chained constructor breakpoints the initializers; the this()-chained constructor
// does not run them and must not.
TestSequencePoints(knownResidual: true);
}
[Test]
public void MemberInitializerImplicitCtor()
{
// The compiler-generated default constructor runs the initializers and must breakpoint them.
TestSequencePoints(knownResidual: true);
}
[Test]
public void MemberInitializerStaticCtor()
{
// Static initializers run in the implicit static constructor and must be breakpointable there.
TestSequencePoints(knownResidual: true);
}
[Test]
public void MemberInitializerPrimaryCtor()
{
// The field initializer runs in the primary constructor (whose body is never printed) and
// must be breakpointable there.
TestSequencePoints(knownResidual: true);
}
[Test]
public void MemberInitializerEvents()
{
// A field-like event initializer runs in every instance constructor, like a field.
TestSequencePoints(knownResidual: true);
}
private static void CompileCSharpWithPdb(string outputBase, string sourceFile,
CompilerOptions compilerOptions = CompilerOptions.Library)
{
Tester.CompileCSharpWithPdb(outputBase, new Dictionary<string, string> {
{ Path.GetFileName(sourceFile), File.ReadAllText(sourceFile) }
}, compilerOptions);
}
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, compilerOptions);
string peFileName = Path.Combine(TestCasePath, testName + ".expected.dll");
string pdbFileName = Path.Combine(TestCasePath, testName + ".expected.pdb");
return (peFileName, pdbFileName);
}
}
}