From e88232580099534056ecfa6e889b6a5ce879c5e8 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Tue, 2 Jun 2026 17:14:38 -0700 Subject: [PATCH 1/3] Support embedded portable PDBs for managed symbol resolution Managed assemblies built with embedded carry their portable PDB inside the PE image rather than in a standalone .pdb file. Previously SymbolReader could not read these, so source/line lookup failed for such modules. Add the ability to open an embedded portable PDB: - SymbolReader.OpenEmbeddedPortablePdb reads the EmbeddedPortablePdb debug-directory entry from a module and returns a PortableSymbolModule. Results are cached under a key suffixed so they cannot collide with standalone-PDB cache entries. - SymbolReader.OpenSymbolFileForModuleFile is a module-oriented entry point that prefers a standalone PDB and falls back to an embedded portable PDB. - PortableSymbolModule gains a constructor that takes a MetadataReaderProvider (which owns its own backing memory). - TraceLog.OpenPdbForModuleFile falls back to the module's embedded portable PDB when the on-disk module matches the trace and no standalone PDB exists. Tests: add an EmbeddedPdbTestApp fixture (built with embedded PDBs) and cover the happy path, caching, not-embedded/missing-file cases, the module-oriented entry point (embedded and standalone), and the end-to-end TraceLog fallback path. All run cross-platform on net462 and net8.0. --- PerfView.sln | 25 ++ .../Symbols/PortableSymbolModule.cs | 13 + src/TraceEvent/Symbols/SymbolReader.cs | 111 ++++++++ .../EmbeddedPdbTestApp.csproj | 38 +++ .../EmbeddedPdbTestApp/EmbeddedTarget.cs | 20 ++ .../Symbols/SymbolReaderTests.cs | 244 ++++++++++++++++++ .../TraceEvent.Tests/TraceEvent.Tests.csproj | 13 + src/TraceEvent/TraceLog.cs | 17 +- 8 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj create mode 100644 src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedTarget.cs diff --git a/PerfView.sln b/PerfView.sln index 3da566c16..2007aade6 100644 --- a/PerfView.sln +++ b/PerfView.sln @@ -91,6 +91,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraceParserGen.Tests", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraceEvent.Benchmarks", "src\TraceEvent\TraceEvent.Benchmarks\TraceEvent.Benchmarks.csproj", "{F2FBDEF0-4C45-44B2-8B92-9C5763BD2E69}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TraceEvent", "TraceEvent", "{F2AE6042-2485-6774-F42B-98E120E28306}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TraceEvent.Tests", "TraceEvent.Tests", "{282B72FF-D1CF-1C67-705A-D384E1729A5D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmbeddedPdbTestApp", "src\TraceEvent\TraceEvent.Tests\EmbeddedPdbTestApp\EmbeddedPdbTestApp.csproj", "{1CE6D3C2-5E27-4296-89FD-5177387F0B15}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -445,10 +453,27 @@ Global {F2FBDEF0-4C45-44B2-8B92-9C5763BD2E69}.Release|x64.Build.0 = Release|Any CPU {F2FBDEF0-4C45-44B2-8B92-9C5763BD2E69}.Release|x86.ActiveCfg = Release|Any CPU {F2FBDEF0-4C45-44B2-8B92-9C5763BD2E69}.Release|x86.Build.0 = Release|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Debug|x64.ActiveCfg = Debug|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Debug|x64.Build.0 = Debug|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Debug|x86.ActiveCfg = Debug|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Debug|x86.Build.0 = Debug|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Release|Any CPU.Build.0 = Release|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Release|x64.ActiveCfg = Release|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Release|x64.Build.0 = Release|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Release|x86.ActiveCfg = Release|Any CPU + {1CE6D3C2-5E27-4296-89FD-5177387F0B15}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F2AE6042-2485-6774-F42B-98E120E28306} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {282B72FF-D1CF-1C67-705A-D384E1729A5D} = {F2AE6042-2485-6774-F42B-98E120E28306} + {1CE6D3C2-5E27-4296-89FD-5177387F0B15} = {282B72FF-D1CF-1C67-705A-D384E1729A5D} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F85A2A3-E0DF-4826-9BBA-4DFFA0F17150} EndGlobalSection diff --git a/src/TraceEvent/Symbols/PortableSymbolModule.cs b/src/TraceEvent/Symbols/PortableSymbolModule.cs index 09e54e0f9..30f8a8184 100644 --- a/src/TraceEvent/Symbols/PortableSymbolModule.cs +++ b/src/TraceEvent/Symbols/PortableSymbolModule.cs @@ -19,6 +19,19 @@ public PortableSymbolModule(SymbolReader reader, Stream stream, string pdbFileNa _metaData = _provider.GetMetadataReader(); } + /// + /// Creates a PortableSymbolModule from a MetadataReaderProvider that has already been + /// opened (e.g. an embedded portable PDB read out of a PE image via + /// PEReader.ReadEmbeddedPortablePdbDebugDirectoryData). The provider owns its own backing + /// memory, so it remains valid after the PEReader it came from has been disposed. This + /// PortableSymbolModule takes ownership of 'provider' and disposes it when disposed. + /// + internal PortableSymbolModule(SymbolReader reader, MetadataReaderProvider provider, string pdbFileName = "") : base(reader, pdbFileName) + { + _provider = provider; + _metaData = _provider.GetMetadataReader(); + } + public void Dispose() => _provider.Dispose(); public override Guid PdbGuid diff --git a/src/TraceEvent/Symbols/SymbolReader.cs b/src/TraceEvent/Symbols/SymbolReader.cs index b7ea16039..ac3e89f66 100644 --- a/src/TraceEvent/Symbols/SymbolReader.cs +++ b/src/TraceEvent/Symbols/SymbolReader.cs @@ -4,6 +4,8 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -711,6 +713,115 @@ public NativeSymbolModule OpenNativeSymbolFile(string pdbFileName) return OpenSymbolFile(pdbFileName) as NativeSymbolModule; } + /// + /// Attempts to open a portable PDB that is embedded inside the managed module image at + /// (modules built with <DebugType>embedded</DebugType>). + /// Returns a (a PortableSymbolModule) whose data comes + /// straight from the module's embedded debug directory, or null if + /// does not exist, is not a PE file, or has no embedded portable PDB. + /// + /// Because the PDB bytes are read from the same on-disk module, no separate GUID/age matching + /// against a standalone PDB file is needed. This is the embedded-PDB analog of + /// , which opens a standalone PDB file. + /// + /// The path to the managed module (.dll/.exe) that may contain an embedded portable PDB. + /// The symbol module for the embedded PDB, or null if none is present. + public ManagedSymbolModule OpenEmbeddedPortablePdb(string dllFilePath) + { + // Suffix the key so it cannot collide with the standalone-PDB cache entries (which are + // keyed by PDB file path), even when an embedded and a standalone PDB share the same module. + string cacheKey = dllFilePath + "|EmbeddedPdb"; + if (m_symbolModuleCache.TryGet(cacheKey, out ManagedSymbolModule ret)) + { + return ret; + } + + try + { + if (!File.Exists(dllFilePath)) + { + m_log.WriteLine("OpenEmbeddedPortablePdb: {0} does not exist.", dllFilePath); + return null; + } + + using (FileStream peStream = File.Open(dllFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (PEReader peReader = new PEReader(peStream)) + { + foreach (DebugDirectoryEntry entry in peReader.ReadDebugDirectory()) + { + if (entry.Type == DebugDirectoryEntryType.EmbeddedPortablePdb) + { + // The returned provider owns its own (decompressed) backing memory, so it + // remains valid after the PEReader and FileStream are disposed below. + MetadataReaderProvider provider = peReader.ReadEmbeddedPortablePdbDebugDirectoryData(entry); + ret = new PortableSymbolModule(this, provider, dllFilePath); + m_log.WriteLine("OpenEmbeddedPortablePdb: Found embedded portable PDB in {0}", dllFilePath); + break; + } + } + + if (ret == null) + { + m_log.WriteLine("OpenEmbeddedPortablePdb: {0} has no embedded portable PDB.", dllFilePath); + } + } + } + catch (Exception e) + { + m_log.WriteLine("OpenEmbeddedPortablePdb: Failure reading {0}: {1}", dllFilePath, e.Message); + ret = null; + } + + m_symbolModuleCache.Add(cacheKey, ret); + return ret; + } + + /// + /// Opens the symbols for a managed/native module given only the path to the module itself + /// (a .dll/.exe), without the caller having to know where the PDB lives. This is the magic, + /// module-oriented entry point: normal usage of points at a module, + /// not at a symbol file. + /// + /// Resolution order: + /// 1. A standalone PDB found via + /// (probing next to the module and along the symbol path / symbol servers), opened with + /// . + /// 2. Failing that, a portable PDB embedded inside the module image itself + /// (modules built with <DebugType>embedded</DebugType>), via + /// . + /// + /// Returns null if no symbols can be found for the module. + /// + /// The path to the managed/native module (.dll/.exe) to resolve symbols for. + /// The symbol module for the given module, or null if none could be found. + public ManagedSymbolModule OpenSymbolFileForModuleFile(string moduleFilePath) + { + // 1. Standalone PDB (next to the module, symbol path, symbol servers, NGEN, ...). + string pdbFilePath = FindSymbolFilePathForModule(moduleFilePath); + if (pdbFilePath != null) + { + ManagedSymbolModule standalone = OpenSymbolFile(pdbFilePath); + if (standalone != null) + { + if (string.IsNullOrEmpty(standalone.ExePath)) + { + standalone.ExePath = moduleFilePath; + } + + return standalone; + } + } + + // 2. Portable PDB embedded inside the module image itself. + ManagedSymbolModule embedded = OpenEmbeddedPortablePdb(moduleFilePath); + if (embedded != null && string.IsNullOrEmpty(embedded.ExePath)) + { + embedded.ExePath = moduleFilePath; + } + + return embedded; + } + internal R2RPerfMapSymbolModule OpenR2RPerfMapSymbolFile(string filePath, uint loadedLayoutTextOffset) { return new R2RPerfMapSymbolModule(filePath, loadedLayoutTextOffset); diff --git a/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj b/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj new file mode 100644 index 000000000..2de15b998 --- /dev/null +++ b/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj @@ -0,0 +1,38 @@ + + + + + + netstandard2.0 + EmbeddedPdbTestApp + EmbeddedPdbTestApp + + embedded + true + true + false + false + true + + + + + true + true + true + ..\..\..\MSFT.snk + + + diff --git a/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedTarget.cs b/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedTarget.cs new file mode 100644 index 000000000..48d929691 --- /dev/null +++ b/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedTarget.cs @@ -0,0 +1,20 @@ +namespace EmbeddedPdbTestApp +{ + /// + /// Minimal target type for the embedded-portable-PDB SymbolReader test. The test resolves the + /// metadata token of via reflection (do not rely on a fixed token, since the + /// netstandard2.0 build synthesizes attribute-type constructors ahead of it in the MethodDef table). + /// + /// IMPORTANT: SymbolReaderTests asserts the source line of the first sequence point of + /// (IL offset 0, the first statement). If you move the body below, + /// update the expected line number in the test. + /// + public static class EmbeddedTarget + { + public static int Add(int a, int b) + { + int sum = a + b; // first sequence point (IL offset 0) is on this line + return sum; + } + } +} diff --git a/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs b/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs index bb0eec08b..3030af803 100644 --- a/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs +++ b/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs @@ -1,4 +1,8 @@ +using FastSerialization; using Microsoft.Diagnostics.Symbols; +using Microsoft.Diagnostics.Tracing; +using Microsoft.Diagnostics.Tracing.Etlx; +using Microsoft.Diagnostics.Tracing.EventPipe; using PerfView.TestUtilities; using System; using System.Collections.Generic; @@ -135,6 +139,207 @@ public void PortablePdbHasValidSourceInfo() } } + [Fact] + public void EmbeddedPortablePdbResolvesSourceLine() + { + string dllPath = EmbeddedPdbTestAppPath; + + ManagedSymbolModule pdbFile = _symbolReader.OpenEmbeddedPortablePdb(dllPath); + using (pdbFile as IDisposable) + { + Assert.NotNull(pdbFile); + Assert.IsType(pdbFile); + + // Resolve the metadata token for EmbeddedTarget.Add from the referenced fixture assembly + // rather than hard-coding it: on netstandard2.0 the compiler synthesizes attribute types + // (e.g. EmbeddedAttribute, RefSafetyRulesAttribute) whose constructors occupy the first + // MethodDef rows, so Add is not token 0x06000001. + uint addToken = (uint)typeof(EmbeddedPdbTestApp.EmbeddedTarget) + .GetMethod(nameof(EmbeddedPdbTestApp.EmbeddedTarget.Add)).MetadataToken; + + // Resolve the source location of the method's first sequence point (the one covering + // IL offset 0). + SourceLocation sourceLocation = pdbFile.SourceLocationForManagedCode(addToken, ilOffset: 0); + Assert.NotNull(sourceLocation); + + SourceFile sourceFile = sourceLocation.SourceFile; + Assert.NotNull(sourceFile); + Assert.EndsWith("EmbeddedTarget.cs", sourceFile.BuildTimeFilePath, StringComparison.OrdinalIgnoreCase); + + // The first sequence point of Add() (IL offset 0) maps to its first statement, + // "int sum = a + b;", on this line of EmbeddedTarget.cs. If the fixture source moves, + // update this expectation. + Assert.Equal(15, sourceLocation.LineNumber); + } + } + + [Fact] + public void EmbeddedPortablePdbReadableAfterPEReaderDisposed() + { + // OpenEmbeddedPortablePdb disposes the PEReader/FileStream it used before returning, so a + // successful source-line lookup here proves the MetadataReaderProvider owns its own backing + // memory and survives that disposal. (Note: the SymbolReader still owns the returned module; + // disposing the SymbolReader itself would clear its cache and dispose this module.) + uint addToken = (uint)typeof(EmbeddedPdbTestApp.EmbeddedTarget) + .GetMethod(nameof(EmbeddedPdbTestApp.EmbeddedTarget.Add)).MetadataToken; + + ManagedSymbolModule pdbFile = _symbolReader.OpenEmbeddedPortablePdb(EmbeddedPdbTestAppPath); + using (pdbFile as IDisposable) + { + Assert.NotNull(pdbFile); + SourceLocation sourceLocation = pdbFile.SourceLocationForManagedCode(addToken, ilOffset: 0); + Assert.NotNull(sourceLocation); + Assert.NotNull(sourceLocation.SourceFile); + } + } + + [Fact] + public void OpenEmbeddedPortablePdbCachesResult() + { + // A second open of the same module must return the cached instance (the embedded-PDB cache + // key cannot collide with a standalone-PDB path). Do not dispose between calls: the + // SymbolReader owns the cached module's lifetime. + ManagedSymbolModule first = _symbolReader.OpenEmbeddedPortablePdb(EmbeddedPdbTestAppPath); + ManagedSymbolModule second = _symbolReader.OpenEmbeddedPortablePdb(EmbeddedPdbTestAppPath); + + Assert.NotNull(first); + Assert.Same(first, second); + } + + [Fact] + public void OpenEmbeddedPortablePdbReturnsNullWhenNotEmbedded() + { + // The test assembly itself is built with a standalone (portable) PDB, not an embedded one, + // so there is no EmbeddedPortablePdb debug-directory entry to read. + string dllWithoutEmbeddedPdb = typeof(SymbolReaderTests).Assembly.Location; + Assert.True(File.Exists(dllWithoutEmbeddedPdb)); + + ManagedSymbolModule pdbFile = _symbolReader.OpenEmbeddedPortablePdb(dllWithoutEmbeddedPdb); + Assert.Null(pdbFile); + } + + [Fact] + public void OpenEmbeddedPortablePdbReturnsNullForMissingFile() + { + string missing = Path.Combine(Path.GetTempPath(), "DoesNotExist_" + Guid.NewGuid().ToString("N") + ".dll"); + ManagedSymbolModule pdbFile = _symbolReader.OpenEmbeddedPortablePdb(missing); + Assert.Null(pdbFile); + } + + [Fact] + public void OpenSymbolFileForModuleFileResolvesEmbeddedPdb() + { + // The magic, module-oriented entry point: hand it the module path (not a PDB path) and it + // should transparently fall back to the module's embedded portable PDB. + uint addToken = (uint)typeof(EmbeddedPdbTestApp.EmbeddedTarget) + .GetMethod(nameof(EmbeddedPdbTestApp.EmbeddedTarget.Add)).MetadataToken; + + ManagedSymbolModule pdbFile = _symbolReader.OpenSymbolFileForModuleFile(EmbeddedPdbTestAppPath); + using (pdbFile as IDisposable) + { + Assert.NotNull(pdbFile); + Assert.IsType(pdbFile); + + // The module-oriented entry point stamps ExePath with the module it resolved symbols for. + Assert.Equal(EmbeddedPdbTestAppPath, pdbFile.ExePath); + + SourceLocation sourceLocation = pdbFile.SourceLocationForManagedCode(addToken, ilOffset: 0); + Assert.NotNull(sourceLocation); + Assert.EndsWith("EmbeddedTarget.cs", sourceLocation.SourceFile.BuildTimeFilePath, StringComparison.OrdinalIgnoreCase); + Assert.Equal(15, sourceLocation.LineNumber); + } + } + + [Fact] + public void OpenSymbolFileForModuleFileResolvesStandalonePdb() + { + // Covers the first resolution branch of the module-oriented entry point: a standalone PDB + // found next to the module (here, the test assembly's own portable PDB) takes precedence + // over the embedded-PDB fallback. + string moduleWithStandalonePdb = typeof(SymbolReaderTests).Assembly.Location; + Assert.True(File.Exists(moduleWithStandalonePdb)); + + uint methodToken = (uint)typeof(SymbolReaderTests) + .GetMethod(nameof(OpenSymbolFileForModuleFileResolvesStandalonePdb)).MetadataToken; + + // The PDB sits next to the DLL, which SymbolReader treats as an "unsafe" location; opt in + // to trusting it (as a real consumer would for a known-good build output directory). + _symbolReader.SecurityCheck = _ => true; + + ManagedSymbolModule pdbFile = _symbolReader.OpenSymbolFileForModuleFile(moduleWithStandalonePdb); + using (pdbFile as IDisposable) + { + Assert.NotNull(pdbFile); + Assert.IsType(pdbFile); + Assert.Equal(moduleWithStandalonePdb, pdbFile.ExePath); + + // The standalone PDB resolves this very test method back to this source file. + SourceLocation sourceLocation = pdbFile.SourceLocationForManagedCode(methodToken, ilOffset: 0); + Assert.NotNull(sourceLocation); + Assert.EndsWith("SymbolReaderTests.cs", sourceLocation.SourceFile.BuildTimeFilePath, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void OpenSymbolFileForModuleFileReturnsNullForMissingFile() + { + string missing = Path.Combine(Path.GetTempPath(), "DoesNotExist_" + Guid.NewGuid().ToString("N") + ".dll"); + ManagedSymbolModule pdbFile = _symbolReader.OpenSymbolFileForModuleFile(missing); + Assert.Null(pdbFile); + } + + [Fact] + public void TraceLogOpenPdbForModuleFileResolvesEmbeddedPdb() + { + // Exercises the trace symbol-resolution path end-to-end: TraceCodeAddresses.OpenPdbForModuleFile, + // given a managed module that (a) carries no PDB identity in the trace, (b) whose on-disk copy + // matches the trace (checksum + timestamp), and (c) has no standalone PDB next to it, must fall + // back to the module's embedded portable PDB. This is the only consumer of + // SymbolReader.OpenEmbeddedPortablePdb that the SymbolReaderTests above do not reach directly. + string dllPath = EmbeddedPdbTestAppPath; + + // Read the real PE checksum/timestamp from the fixture DLL so the TraceModuleUnchanged gate + // inside OpenPdbForModuleFile passes regardless of how/when the fixture was (re)built. + int imageChecksum; + int timeDateStamp; + using (var peFile = new PEFile.PEFile(dllPath)) + { + imageChecksum = (int)peFile.Header.CheckSum; + timeDateStamp = peFile.Header.TimeDateStampSec; + } + + uint addToken = (uint)typeof(EmbeddedPdbTestApp.EmbeddedTarget) + .GetMethod(nameof(EmbeddedPdbTestApp.EmbeddedTarget.Add)).MetadataToken; + + using (TraceLog traceLog = CreateEmptyInMemoryTraceLog()) + { + // Build a managed TraceModuleFile that points at the fixture DLL on disk. The internal + // constructor lower-cases the path, so restore the real-cased path afterwards because + // File.Exists (inside TraceModuleUnchanged) is case-sensitive on non-Windows. + TraceModuleFile moduleFile = new TraceModuleFile(dllPath, 0x10000, (ModuleFileIndex)0); + moduleFile.fileName = dllPath; + moduleFile.imageChecksum = imageChecksum; + moduleFile.timeDateStamp = timeDateStamp; + // Leave symbolInfo null so PdbSignature == Guid.Empty: with no recorded PDB identity, + // OpenPdbForModuleFile is forced down the on-disk-match -> embedded-PDB branch. + + ManagedSymbolModule pdbFile = traceLog.CodeAddresses.OpenPdbForModuleFile(_symbolReader, moduleFile); + using (pdbFile as IDisposable) + { + Assert.NotNull(pdbFile); + Assert.IsType(pdbFile); + + // OpenPdbForModuleFile stamps ExePath with the module it resolved symbols for. + Assert.Equal(dllPath, pdbFile.ExePath); + + SourceLocation sourceLocation = pdbFile.SourceLocationForManagedCode(addToken, ilOffset: 0); + Assert.NotNull(sourceLocation); + Assert.EndsWith("EmbeddedTarget.cs", sourceLocation.SourceFile.BuildTimeFilePath, StringComparison.OrdinalIgnoreCase); + Assert.Equal(15, sourceLocation.LineNumber); + } + } + } + [Fact] public void SourceLinkUrlsAreEscaped() { @@ -1402,6 +1607,45 @@ protected void PrepareTestData() protected string UnzippedSymbolReaderTestInputDir => Path.Combine(UnZippedDataDir, SymbolReaderTestInput); + /// + /// Path to the EmbeddedPdbTestApp fixture DLL (built with <DebugType>embedded</DebugType>) + /// that the TraceEvent.Tests project copies next to the test assembly. + /// + protected static string EmbeddedPdbTestAppPath + { + get + { + string path = Path.Combine(AppContext.BaseDirectory, "EmbeddedPdbTestApp.dll"); + Assert.True(File.Exists(path), "EmbeddedPdbTestApp.dll was not found next to the test assembly: " + path); + return path; + } + } + + /// + /// Builds a minimal, valid, empty entirely in memory (no ETW capture, so + /// it works cross-platform). Callers can then hand-populate s and + /// drive symbol-resolution APIs directly. + /// + private static TraceLog CreateEmptyInMemoryTraceLog() + { + // Generate a minimal in-memory nettrace stream with just enough metadata to be valid. + var writer = new EventPipeWriterV6(); + writer.WriteHeaders(); + writer.WriteMetadataBlock(new EventMetadata(1, "Microsoft-Windows-DotNETRuntime", "EventSource", 0)); + writer.WriteThreadBlock(w => w.WriteThreadEntry(1, 0, 0)); + writer.WriteEventBlock(w => w.WriteEventBlob(1, 1, 1, new byte[0])); + writer.WriteEndBlock(); + + using MemoryStream nettraceStream = new MemoryStream(writer.ToArray()); + TraceEventDispatcher eventSource = new EventPipeEventSource(nettraceStream); + + // Convert to in-memory ETLX and open it as a TraceLog (the caller owns disposal). + MemoryStream etlxStream = new MemoryStream(); + TraceLog.CreateFromEventPipeEventSources(eventSource, new IOStreamStreamWriter(etlxStream, SerializationSettings.Default, leaveOpen: true), null); + etlxStream.Position = 0; + return new TraceLog(etlxStream); + } + /// /// A handler for the in that /// can be used by unit tests to intercept requests to symbol server (for PDB diff --git a/src/TraceEvent/TraceEvent.Tests/TraceEvent.Tests.csproj b/src/TraceEvent/TraceEvent.Tests/TraceEvent.Tests.csproj index 0ffb5c455..589f6b8e9 100644 --- a/src/TraceEvent/TraceEvent.Tests/TraceEvent.Tests.csproj +++ b/src/TraceEvent/TraceEvent.Tests/TraceEvent.Tests.csproj @@ -32,6 +32,19 @@ + + + + + + + + + + diff --git a/src/TraceEvent/TraceLog.cs b/src/TraceEvent/TraceLog.cs index f397c03bb..dfbb35c97 100644 --- a/src/TraceEvent/TraceLog.cs +++ b/src/TraceEvent/TraceLog.cs @@ -9111,7 +9111,7 @@ private void LookupSymbolsForModule(SymbolReader reader, TraceModuleFile moduleF /// /// Look up the SymbolModule (open PDB) for a given moduleFile. Will generate NGEN pdbs as needed. /// - private unsafe ManagedSymbolModule OpenPdbForModuleFile(SymbolReader symReader, TraceModuleFile moduleFile) + internal unsafe ManagedSymbolModule OpenPdbForModuleFile(SymbolReader symReader, TraceModuleFile moduleFile) { string pdbFileName = null; // If we have a signature, use it @@ -9131,6 +9131,21 @@ private unsafe ManagedSymbolModule OpenPdbForModuleFile(SymbolReader symReader, if (TraceModuleUnchanged(moduleFile, symReader.m_log)) { pdbFileName = symReader.FindSymbolFilePathForModule(moduleFile.FilePath); + + // No standalone PDB found, but the on-disk module matches the trace. See if the + // module carries an embedded portable PDB (embedded) and use + // it directly. Because the bytes come from the matched-on-disk module, no GUID/age + // re-validation is required, so we return the module immediately. + if (pdbFileName == null) + { + ManagedSymbolModule embeddedSymbolModule = symReader.OpenEmbeddedPortablePdb(moduleFile.FilePath); + if (embeddedSymbolModule != null) + { + embeddedSymbolModule.ExePath = moduleFile.FilePath; + symReader.m_log.WriteLine("Opened embedded portable PDB for {0}", moduleFile.FilePath); + return embeddedSymbolModule; + } + } } } From 918113bd715f3c586caf8d447ec2eadfe8fde233 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Tue, 2 Jun 2026 19:08:14 -0700 Subject: [PATCH 2/3] Build embedded-PDB test fixture unoptimized for stable source lines In Release the C# compiler optimizes EmbeddedTarget.Add, shifting its first sequence point from the 'int sum = a + b;' line to the 'return' line, so the source-line assertions failed in the Release CI leg. Pin Optimize=false on the fixture so its emitted sequence points are stable regardless of the build configuration. --- .../EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj b/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj index 2de15b998..6a7f498f5 100644 --- a/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj +++ b/src/TraceEvent/TraceEvent.Tests/EmbeddedPdbTestApp/EmbeddedPdbTestApp.csproj @@ -20,6 +20,12 @@ embedded true true + + false false false true From fcbfa25308a55def28664941d36635ab0c2987f3 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Thu, 4 Jun 2026 14:42:48 -0700 Subject: [PATCH 3/3] Remove test-only OpenSymbolFileForModuleFile wrapper Per PR review: this wrapper was called only from tests, and TraceLog.OpenPdbForModuleFile already provides the standalone-then-embedded fallback for the trace path. Removed it and its three dedicated tests, keeping OpenEmbeddedPortablePdb (the building block TraceLog uses). --- src/TraceEvent/Symbols/SymbolReader.cs | 46 -------------- .../Symbols/SymbolReaderTests.cs | 62 ------------------- 2 files changed, 108 deletions(-) diff --git a/src/TraceEvent/Symbols/SymbolReader.cs b/src/TraceEvent/Symbols/SymbolReader.cs index ac3e89f66..bd7ea5796 100644 --- a/src/TraceEvent/Symbols/SymbolReader.cs +++ b/src/TraceEvent/Symbols/SymbolReader.cs @@ -776,52 +776,6 @@ public ManagedSymbolModule OpenEmbeddedPortablePdb(string dllFilePath) return ret; } - /// - /// Opens the symbols for a managed/native module given only the path to the module itself - /// (a .dll/.exe), without the caller having to know where the PDB lives. This is the magic, - /// module-oriented entry point: normal usage of points at a module, - /// not at a symbol file. - /// - /// Resolution order: - /// 1. A standalone PDB found via - /// (probing next to the module and along the symbol path / symbol servers), opened with - /// . - /// 2. Failing that, a portable PDB embedded inside the module image itself - /// (modules built with <DebugType>embedded</DebugType>), via - /// . - /// - /// Returns null if no symbols can be found for the module. - /// - /// The path to the managed/native module (.dll/.exe) to resolve symbols for. - /// The symbol module for the given module, or null if none could be found. - public ManagedSymbolModule OpenSymbolFileForModuleFile(string moduleFilePath) - { - // 1. Standalone PDB (next to the module, symbol path, symbol servers, NGEN, ...). - string pdbFilePath = FindSymbolFilePathForModule(moduleFilePath); - if (pdbFilePath != null) - { - ManagedSymbolModule standalone = OpenSymbolFile(pdbFilePath); - if (standalone != null) - { - if (string.IsNullOrEmpty(standalone.ExePath)) - { - standalone.ExePath = moduleFilePath; - } - - return standalone; - } - } - - // 2. Portable PDB embedded inside the module image itself. - ManagedSymbolModule embedded = OpenEmbeddedPortablePdb(moduleFilePath); - if (embedded != null && string.IsNullOrEmpty(embedded.ExePath)) - { - embedded.ExePath = moduleFilePath; - } - - return embedded; - } - internal R2RPerfMapSymbolModule OpenR2RPerfMapSymbolFile(string filePath, uint loadedLayoutTextOffset) { return new R2RPerfMapSymbolModule(filePath, loadedLayoutTextOffset); diff --git a/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs b/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs index 3030af803..21e4e9eb4 100644 --- a/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs +++ b/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs @@ -226,68 +226,6 @@ public void OpenEmbeddedPortablePdbReturnsNullForMissingFile() Assert.Null(pdbFile); } - [Fact] - public void OpenSymbolFileForModuleFileResolvesEmbeddedPdb() - { - // The magic, module-oriented entry point: hand it the module path (not a PDB path) and it - // should transparently fall back to the module's embedded portable PDB. - uint addToken = (uint)typeof(EmbeddedPdbTestApp.EmbeddedTarget) - .GetMethod(nameof(EmbeddedPdbTestApp.EmbeddedTarget.Add)).MetadataToken; - - ManagedSymbolModule pdbFile = _symbolReader.OpenSymbolFileForModuleFile(EmbeddedPdbTestAppPath); - using (pdbFile as IDisposable) - { - Assert.NotNull(pdbFile); - Assert.IsType(pdbFile); - - // The module-oriented entry point stamps ExePath with the module it resolved symbols for. - Assert.Equal(EmbeddedPdbTestAppPath, pdbFile.ExePath); - - SourceLocation sourceLocation = pdbFile.SourceLocationForManagedCode(addToken, ilOffset: 0); - Assert.NotNull(sourceLocation); - Assert.EndsWith("EmbeddedTarget.cs", sourceLocation.SourceFile.BuildTimeFilePath, StringComparison.OrdinalIgnoreCase); - Assert.Equal(15, sourceLocation.LineNumber); - } - } - - [Fact] - public void OpenSymbolFileForModuleFileResolvesStandalonePdb() - { - // Covers the first resolution branch of the module-oriented entry point: a standalone PDB - // found next to the module (here, the test assembly's own portable PDB) takes precedence - // over the embedded-PDB fallback. - string moduleWithStandalonePdb = typeof(SymbolReaderTests).Assembly.Location; - Assert.True(File.Exists(moduleWithStandalonePdb)); - - uint methodToken = (uint)typeof(SymbolReaderTests) - .GetMethod(nameof(OpenSymbolFileForModuleFileResolvesStandalonePdb)).MetadataToken; - - // The PDB sits next to the DLL, which SymbolReader treats as an "unsafe" location; opt in - // to trusting it (as a real consumer would for a known-good build output directory). - _symbolReader.SecurityCheck = _ => true; - - ManagedSymbolModule pdbFile = _symbolReader.OpenSymbolFileForModuleFile(moduleWithStandalonePdb); - using (pdbFile as IDisposable) - { - Assert.NotNull(pdbFile); - Assert.IsType(pdbFile); - Assert.Equal(moduleWithStandalonePdb, pdbFile.ExePath); - - // The standalone PDB resolves this very test method back to this source file. - SourceLocation sourceLocation = pdbFile.SourceLocationForManagedCode(methodToken, ilOffset: 0); - Assert.NotNull(sourceLocation); - Assert.EndsWith("SymbolReaderTests.cs", sourceLocation.SourceFile.BuildTimeFilePath, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void OpenSymbolFileForModuleFileReturnsNullForMissingFile() - { - string missing = Path.Combine(Path.GetTempPath(), "DoesNotExist_" + Guid.NewGuid().ToString("N") + ".dll"); - ManagedSymbolModule pdbFile = _symbolReader.OpenSymbolFileForModuleFile(missing); - Assert.Null(pdbFile); - } - [Fact] public void TraceLogOpenPdbForModuleFileResolvesEmbeddedPdb() {