From 126ab841e7cc6d5f74c228f550fd84b77d936ee6 Mon Sep 17 00:00:00 2001 From: Ivan Berg Date: Wed, 15 Apr 2026 15:16:59 -0700 Subject: [PATCH 1/5] fix: improve binary finding for disassembly and symbol server lookups Prefer ImageSize from ImageID events (PE SizeOfImage from trace merge) over kernel ImageGroup events (mapped view size with slack pages). The kernel can report a larger size than the PE header, causing exact-match failures for local binaries and wrong symbol server lookup keys (404s). Also add an approximate match fallback that accepts binaries with matching timestamp but different size when all exact matches fail, and correct the ImageSize before symbol server lookups when a local file with matching timestamp provides the real PE SizeOfImage. --- .../Binary/PEBinaryInfoProvider.cs | 79 ++++++++++++++++--- .../Profile/ETW/ETWEventProcessor.cs | 18 ++++- .../Providers/ICompilerInfoProvider.cs | 6 ++ .../Settings/SymbolFileSourceSettings.cs | 2 + .../OptionsPanels/SymbolOptionsPanel.xaml | 5 ++ 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs index 017d3880..7d3ca74d 100644 --- a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs @@ -273,7 +273,9 @@ public static BinaryFileSearchResult LocateBinaryFile(BinaryFileDescriptor binar } // Quick check if trace was recorded on local machine. - string result = FindExactLocalBinaryFile(binaryFile); + string approximateMatchPath = null; + long correctedImageSize = 0; + string result = FindExactLocalBinaryFile(binaryFile, out approximateMatchPath, out correctedImageSize); if (result != null) { DiagnosticLogger.LogInfo($"[BinarySearch] Found exact local binary for {binaryFile.ImageName} at {result} ({sw.ElapsedMilliseconds}ms)"); @@ -283,6 +285,18 @@ public static BinaryFileSearchResult LocateBinaryFile(BinaryFileDescriptor binar return searchResult; } + // Correct ImageSize if the file exists locally with matching timestamp but + // different size. The symbol server indexes binaries by TimeDateStamp+SizeOfImage + // from the PE header, but the ETW kernel event may report a larger mapped view size + // (the kernel mapping can include extra slack pages). Using the wrong size causes + // 404s on the symbol server even when the binary IS indexed there. + if (correctedImageSize > 0 && correctedImageSize != binaryFile.ImageSize) { + DiagnosticLogger.LogInfo( + $"[BinarySearch] Correcting ImageSize for {binaryFile.ImageName} from ETW value {binaryFile.ImageSize} " + + $"to PE SizeOfImage {correctedImageSize} for symbol server lookup"); + binaryFile.ImageSize = correctedImageSize; + } + using var logWriter = new StringWriter(); try { @@ -324,7 +338,7 @@ public static BinaryFileSearchResult LocateBinaryFile(BinaryFileDescriptor binar if (result == null) { // Finally, try an approximate manual search. DiagnosticLogger.LogDebug($"[BinarySearch] Symbol server search failed, trying approximate local search for {binaryFile.ImageName}"); - result = FindMatchingLocalBinaryFile(binaryFile, settings); + result = FindMatchingLocalBinaryFile(binaryFile, settings, ref approximateMatchPath); } } catch (Exception ex) { @@ -347,6 +361,21 @@ public static BinaryFileSearchResult LocateBinaryFile(BinaryFileDescriptor binar binaryFile = GetBinaryFileInfo(result); searchResult = BinaryFileSearchResult.Success(binaryFile, result, searchLog); } + else if (settings.AllowApproximateBinaryMatch && + !string.IsNullOrEmpty(approximateMatchPath) && + File.Exists(approximateMatchPath)) { + // Fallback: use a binary with matching timestamp but different size. + // This handles cases where the kernel-reported ImageSize in the ETW trace + // differs from the PE header SizeOfImage, or the DLL was serviced. + DiagnosticLogger.LogWarning( + $"[BinarySearch] Using APPROXIMATE match for {binaryFile.ImageName} " + + $"at {approximateMatchPath} ({searchDuration.TotalMilliseconds:F0}ms) - " + + $"timestamp matches but image size differs"); + binaryFile = GetBinaryFileInfo(approximateMatchPath); + string details = $"Approximate match: timestamp matches but image size differs " + + $"(trace: {binaryFile.ImageSize}). Disassembly may not be fully accurate.\n{searchLog}"; + searchResult = BinaryFileSearchResult.ApproximateSuccess(binaryFile, approximateMatchPath, details); + } else { DiagnosticLogger.LogWarning($"[BinarySearch] Failed to find binary for {binaryFile.ImageName} ({searchDuration.TotalMilliseconds:F0}ms)"); searchResult = BinaryFileSearchResult.Failure(binaryFile, searchLog); @@ -374,14 +403,31 @@ private static string ResolveNtKernelPath(string path) { return path; } - private static string FindExactLocalBinaryFile(BinaryFileDescriptor binaryFile) { + private static string FindExactLocalBinaryFile(BinaryFileDescriptor binaryFile, + out string approximateMatchPath, + out long correctedImageSize) { + approximateMatchPath = null; + correctedImageSize = 0; + if (File.Exists(binaryFile.ImagePath)) { var fileInfo = GetBinaryFileInfo(binaryFile.ImagePath); - if (fileInfo != null && - fileInfo.TimeStamp == binaryFile.TimeStamp && - fileInfo.ImageSize == binaryFile.ImageSize) { - return binaryFile.ImagePath; + if (fileInfo != null && fileInfo.TimeStamp == binaryFile.TimeStamp) { + if (fileInfo.ImageSize == binaryFile.ImageSize) { + return binaryFile.ImagePath; + } + + // Timestamp matches but size differs. This commonly happens because the + // kernel-reported ImageSize in ETW events is the mapped view size, which + // can exceed the PE header's SizeOfImage by a few pages of slack. + // Record the local file as an approximate match and save the PE SizeOfImage + // so we can correct the symbol server lookup key. + approximateMatchPath = binaryFile.ImagePath; + correctedImageSize = fileInfo.ImageSize; + DiagnosticLogger.LogInfo( + $"[BinarySearch] Local binary for {binaryFile.ImageName} at {binaryFile.ImagePath}: " + + $"timestamp matches ({fileInfo.TimeStamp}), size differs " + + $"(PE SizeOfImage: {fileInfo.ImageSize}, ETW ImageSize: {binaryFile.ImageSize})"); } } @@ -389,7 +435,8 @@ private static string FindExactLocalBinaryFile(BinaryFileDescriptor binaryFile) } private static string FindMatchingLocalBinaryFile(BinaryFileDescriptor binaryFile, - SymbolFileSourceSettings settings) { + SymbolFileSourceSettings settings, + ref string approximateMatchPath) { // Manually search in the provided directories. // This helps in cases where the original fine name doesn't match // the one on disk, like it seems to happen sometimes with the SPEC runner. @@ -421,10 +468,18 @@ bool PathIsSubPath(string subPath, string basePath) { var fileInfo = GetBinaryFileInfo(file); - if (fileInfo != null && - fileInfo.TimeStamp == binaryFile.TimeStamp && - fileInfo.ImageSize == binaryFile.ImageSize) { - return file; + if (fileInfo != null && fileInfo.TimeStamp == binaryFile.TimeStamp) { + if (fileInfo.ImageSize == binaryFile.ImageSize) { + return file; + } + + // Track first approximate match (timestamp matches, size differs). + if (approximateMatchPath == null) { + approximateMatchPath = file; + DiagnosticLogger.LogInfo( + $"[BinarySearch] Approximate match for {binaryFile.ImageName} in search paths: " + + $"{file} (on-disk: {fileInfo.ImageSize}, trace: {binaryFile.ImageSize})"); + } } } } diff --git a/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs b/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs index 9794bc17..dd1e7700 100644 --- a/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs +++ b/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs @@ -275,6 +275,12 @@ public RawProfileData ProcessEvents(ProfileLoadProgressHandler progressCallback, if (lastProfileImage.TimeStamp == 0) { lastProfileImage.TimeStamp = data.TimeDateStamp; } + + // Prefer ImageSize from ImageID (PE header value from merge) over the kernel + // ImageGroup value (mapped view size which can have extra slack pages). + if (data.ImageSize > 0) { + lastProfileImage.Size = (int)data.ImageSize; + } } else { // The ImageGroup event should show up later in the stream. @@ -346,6 +352,7 @@ public RawProfileData ProcessEvents(ProfileLoadProgressHandler progressCallback, imageLoadEventCount++; string originalName = null; int timeStamp = data.TimeDateStamp; + int imageSize = data.ImageSize; bool sawImageId = false; if (lastImageIdData != null && lastImageIdData.TimeStampQPC == data.TimeStampQPC) { @@ -356,6 +363,15 @@ public RawProfileData ProcessEvents(ProfileLoadProgressHandler progressCallback, if (timeStamp == 0) { timeStamp = lastImageIdData.TimeDateStamp; } + + // Prefer the ImageSize from the ImageID event over the kernel ImageGroup event. + // The kernel reports the mapped view size which can exceed the PE SizeOfImage + // by a few slack pages. The ImageID event's ImageSize comes from reading the + // actual PE file during trace merge, so it matches the PE header and is the + // correct value for symbol server lookups (TimeDateStamp+SizeOfImage key). + if (lastImageIdData.ImageSize > 0) { + imageSize = (int)lastImageIdData.ImageSize; + } } else if (isRealTime_) { // In a capture session, the image is on the local machine, @@ -372,7 +388,7 @@ public RawProfileData ProcessEvents(ProfileLoadProgressHandler progressCallback, #endif var image = new ProfileImage(data.FileName, originalName, (long)data.ImageBase, - (long)data.DefaultBase, data.ImageSize, + (long)data.DefaultBase, imageSize, timeStamp, data.ImageChecksum); if (timeStamp != 0) { imageLoadWithTimestampCount++; diff --git a/src/ProfileExplorerCore/Providers/ICompilerInfoProvider.cs b/src/ProfileExplorerCore/Providers/ICompilerInfoProvider.cs index ea0e48ca..1ca9fe2d 100644 --- a/src/ProfileExplorerCore/Providers/ICompilerInfoProvider.cs +++ b/src/ProfileExplorerCore/Providers/ICompilerInfoProvider.cs @@ -41,6 +41,8 @@ public class BinaryFileSearchResult { public string FilePath { get; set; } [ProtoMember(4)] public string Details { get; set; } + [ProtoMember(5)] + public bool IsApproximateMatch { get; set; } public static BinaryFileSearchResult Success(BinaryFileDescriptor file, string filePath, string details = null) { return new BinaryFileSearchResult {Found = true, BinaryFile = file, FilePath = filePath, Details = details}; @@ -55,6 +57,10 @@ public static BinaryFileSearchResult Success(string filePath) { return new BinaryFileSearchResult {Found = false, BinaryFile = null, FilePath = filePath}; } + public static BinaryFileSearchResult ApproximateSuccess(BinaryFileDescriptor file, string filePath, string details = null) { + return new BinaryFileSearchResult {Found = true, BinaryFile = file, FilePath = filePath, Details = details, IsApproximateMatch = true}; + } + public static BinaryFileSearchResult Failure(BinaryFileDescriptor file, string details) { return new BinaryFileSearchResult {BinaryFile = file, Details = details}; } diff --git a/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs b/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs index c5f41877..77fbfc16 100644 --- a/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs +++ b/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs @@ -83,6 +83,8 @@ public SymbolFileSourceSettings() { public DateTime RejectedFilesCacheTime { get; set; } [ProtoMember(23)][OptionValue(3)] // 3 days default expiration public int RejectedFilesCacheExpirationDays { get; set; } + [ProtoMember(25)][OptionValue(true)] + public bool AllowApproximateBinaryMatch { get; set; } public bool HasAuthorizationToken => AuthorizationTokenEnabled && !string.IsNullOrEmpty(AuthorizationToken); public bool HasCompanyFilter => CompanyFilterEnabled; diff --git a/src/ProfileExplorerUI/OptionsPanels/SymbolOptionsPanel.xaml b/src/ProfileExplorerUI/OptionsPanels/SymbolOptionsPanel.xaml index 2b1e6acb..5d6938fe 100644 --- a/src/ProfileExplorerUI/OptionsPanels/SymbolOptionsPanel.xaml +++ b/src/ProfileExplorerUI/OptionsPanels/SymbolOptionsPanel.xaml @@ -57,6 +57,11 @@ Content="Default" ToolTip="Reset value to default percentage" /> + Date: Wed, 15 Apr 2026 15:20:08 -0700 Subject: [PATCH 2/5] TraceEvent & Source Server support upgrade TraceEvent from 3.1.28 to 3.1.30 Gets SourceLink wildcard/exact path mapping fix and SHA-384/SHA-512 PDB checksum support for newer .NET tooling. --- src/PDBViewer/PDBViewer.csproj | 2 +- src/ProfileExplorerCore/ProfileExplorerCore.csproj | 2 +- src/ProfileExplorerUI/ProfileExplorerUI.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PDBViewer/PDBViewer.csproj b/src/PDBViewer/PDBViewer.csproj index 15a71353..2eb0c8d6 100644 --- a/src/PDBViewer/PDBViewer.csproj +++ b/src/PDBViewer/PDBViewer.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/ProfileExplorerCore/ProfileExplorerCore.csproj b/src/ProfileExplorerCore/ProfileExplorerCore.csproj index 6f5fbd7b..c96ac884 100644 --- a/src/ProfileExplorerCore/ProfileExplorerCore.csproj +++ b/src/ProfileExplorerCore/ProfileExplorerCore.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/ProfileExplorerUI/ProfileExplorerUI.csproj b/src/ProfileExplorerUI/ProfileExplorerUI.csproj index 5ffc75da..037def2d 100644 --- a/src/ProfileExplorerUI/ProfileExplorerUI.csproj +++ b/src/ProfileExplorerUI/ProfileExplorerUI.csproj @@ -167,7 +167,7 @@ - + all From 1a1b81f06b81d4014e0c8bd6b3f75225cbbd57ed Mon Sep 17 00:00:00 2001 From: Ivan Berg Date: Wed, 15 Apr 2026 15:40:55 -0700 Subject: [PATCH 3/5] feat: add Azure DevOps auth for source server downloads Source server for Windows OS PDBs uses Azure DevOps, which requires Azure AD authentication. Previously only symweb URLs were authenticated, causing source downloads to receive an HTML sign-in page instead of source code. Reuse the existing Azure AD credential chain to provide Bearer tokens for *.visualstudio.com and dev.azure.com URLs. --- .../Binary/PDBDebugInfoProvider.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index acb31c55..775285cf 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -34,6 +34,7 @@ public sealed class PDBDebugInfoProvider : IDebugInfoProvider { private static ConcurrentDictionary resolvedSymbolsCache_ = new(); private static readonly StringWriter authLogWriter_; private static readonly SymwebHandler authSymwebHandler_; + private static readonly AzureDevOpsSourceHandler authAzDevOpsHandler_; private static readonly string authRecordPath_ = Path.Combine(Path.GetTempPath(), "ProfileExplorer", "auth_record.bin"); private static object undecorateLock_ = new(); // Global lock for undname. private ConcurrentDictionary sourceFileByRvaCache_ = new(); @@ -119,6 +120,7 @@ static PDBDebugInfoProvider() { authLogWriter_ = new StringWriter(); authSymwebHandler_ = new SymwebHandler(authLogWriter_, authCredential); + authAzDevOpsHandler_ = new AzureDevOpsSourceHandler(authLogWriter_, authCredential); } /// @@ -414,6 +416,7 @@ public static async Task public static SymbolReaderAuthenticationHandler CreateAuthHandler(SymbolFileSourceSettings settings) { var authHandler = new SymbolReaderAuthenticationHandler(); authHandler.AddHandler(authSymwebHandler_); + authHandler.AddHandler(authAzDevOpsHandler_); if (settings.AuthorizationTokenEnabled) { authHandler.AddHandler(new BasicAuthenticationHandler(settings, authLogWriter_)); @@ -1297,6 +1300,49 @@ private IDiaSymbol FindFunctionSymbolImpl(SymTagEnum symbolType, string function } } +/// +/// Handles Azure AD authentication for Azure DevOps source server URLs. +/// The source server for Windows OS PDBs uses Azure DevOps (*.visualstudio.com, dev.azure.com), +/// which requires a Bearer token with the Azure DevOps scope. +/// +sealed class AzureDevOpsSourceHandler : SymbolReaderAuthHandler { + private const string AzureDevOpsScope = "499b84ac-1321-427f-aa17-267ca6975798/.default"; + private readonly TokenCredential credential_; + + public AzureDevOpsSourceHandler(TextWriter log, TokenCredential credential) : + base(log, "AzureDevOps Source") { + credential_ = credential; + } + + protected override bool TryGetAuthority(Uri requestUri, out Uri authority) { + string host = requestUri.Host; + + if (host.EndsWith(".visualstudio.com", StringComparison.OrdinalIgnoreCase) || + host.EndsWith("dev.azure.com", StringComparison.OrdinalIgnoreCase)) { + authority = new Uri($"{requestUri.Scheme}://{requestUri.Host}"); + return true; + } + + authority = null; + return false; + } + + protected override async Task GetAuthTokenAsync(RequestContext context, + SymbolReaderHandlerDelegate next, + Uri authority, + CancellationToken cancellationToken) { + try { + var tokenRequest = new TokenRequestContext(new[] { AzureDevOpsScope }); + var accessToken = await credential_.GetTokenAsync(tokenRequest, cancellationToken); + return new AuthToken(AuthScheme.Bearer, accessToken.Token, accessToken.ExpiresOn.UtcDateTime, null); + } + catch (Exception ex) { + WriteLog($"AzureDevOps auth failed: {ex.Message}"); + return null; + } + } +} + sealed class BasicAuthenticationHandler : SymbolReaderAuthHandler { private SymbolFileSourceSettings settings_; From 77707653216afc413f3c93e898b7fbafa8e00da9 Mon Sep 17 00:00:00 2001 From: Ivan Berg Date: Wed, 15 Apr 2026 16:10:02 -0700 Subject: [PATCH 4/5] fix: crash in BringElementIntoView when element offset exceeds document length Guard against ArgumentOutOfRangeException when an IRElement's text offset is beyond the current document length, which can happen when a preview popup references elements from a different document context. --- src/ProfileExplorerUI/Document/IRDocument.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ProfileExplorerUI/Document/IRDocument.cs b/src/ProfileExplorerUI/Document/IRDocument.cs index ccf42c6e..9e96a148 100644 --- a/src/ProfileExplorerUI/Document/IRDocument.cs +++ b/src/ProfileExplorerUI/Document/IRDocument.cs @@ -438,6 +438,10 @@ public void BringTextOffsetIntoView(int offset) { public void BringElementIntoView(IRElement op, BringIntoViewStyle style = BringIntoViewStyle.Default) { + if (op.TextLocation.Offset < 0 || op.TextLocation.Offset >= Document.TextLength) { + return; + } + ignoreNextHoverEvent_ = true; ignoreNextCaretEvent_ = true; int line = Document.GetLineByOffset(op.TextLocation.Offset).LineNumber; From 9da4e96e1594961d59487d7bc7dc0866f47b14ec Mon Sep 17 00:00:00 2001 From: Ivan Berg Date: Wed, 15 Apr 2026 16:46:51 -0700 Subject: [PATCH 5/5] fix: correct approximate match details and cache key in binary search The details string was reporting the on-disk ImageSize instead of the trace-requested ImageSize after overwriting binaryFile. Also cache the result under the original trace descriptor so lookups for the same trace module hit the cache instead of repeating the search. --- src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs index 7d3ca74d..3d196218 100644 --- a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs @@ -367,14 +367,19 @@ public static BinaryFileSearchResult LocateBinaryFile(BinaryFileDescriptor binar // Fallback: use a binary with matching timestamp but different size. // This handles cases where the kernel-reported ImageSize in the ETW trace // differs from the PE header SizeOfImage, or the DLL was serviced. + long traceImageSize = binaryFile.ImageSize; + var originalDescriptor = binaryFile; DiagnosticLogger.LogWarning( $"[BinarySearch] Using APPROXIMATE match for {binaryFile.ImageName} " + $"at {approximateMatchPath} ({searchDuration.TotalMilliseconds:F0}ms) - " + $"timestamp matches but image size differs"); binaryFile = GetBinaryFileInfo(approximateMatchPath); string details = $"Approximate match: timestamp matches but image size differs " + - $"(trace: {binaryFile.ImageSize}). Disassembly may not be fully accurate.\n{searchLog}"; + $"(trace: {traceImageSize}, on-disk: {binaryFile.ImageSize}). Disassembly may not be fully accurate.\n{searchLog}"; searchResult = BinaryFileSearchResult.ApproximateSuccess(binaryFile, approximateMatchPath, details); + // Cache under the original trace descriptor so subsequent lookups for the + // same trace module hit the cache instead of repeating the search. + resolvedBinariesCache_.TryAdd(originalDescriptor, searchResult); } else { DiagnosticLogger.LogWarning($"[BinarySearch] Failed to find binary for {binaryFile.ImageName} ({searchDuration.TotalMilliseconds:F0}ms)");