From fa37d9deb97a513a2df006dddf2ea5fd4be49e5c Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Wed, 27 May 2026 16:07:45 -0700 Subject: [PATCH 01/10] symbols: add opt-in Managed Identity support for symbol server auth Add ManagedIdentityEnabled setting (ProtoMember 26, default false) to SymbolFileSourceSettings. Refactor PDBDebugInfoProvider static credential chain into BuildAndSetCredentialChain(bool) + public ReinitializeCredentials(settings) to allow runtime reconfiguration after settings load (static ctor runs before settings are available). When ManagedIdentityEnabled=true, use ManagedIdentityCredential exclusively (no developer/browser creds) for headless Azure deployments. When false (default), use the existing full developer chain unchanged. Expose useManagedIdentity parameter on MCP server OpenTrace tool for headless/Azure callers like BigRedPerfAI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.McpServer/Program.cs | 12 +- .../Binary/PDBDebugInfoProvider.cs | 105 ++++++++++++------ .../Settings/SymbolFileSourceSettings.cs | 2 + 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index 37e94e85..ea97b479 100644 --- a/src/ProfileExplorer.McpServer/Program.cs +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -170,9 +170,11 @@ public static string OpenTrace( [Description("Optional additional symbol search path (e.g. 'd:\\temp\\landy' for custom kernel symbols)")] string? symbolPath = null, [Description("Optional additional binary search path (e.g. 'd:\\temp' for loose binaries like storport.sys). Required for assembly-level disassembly via GetFunctionAssembly when the binary isn't on the symbol server.")] - string? binaryPath = null) + string? binaryPath = null, + [Description("Enable Azure Managed Identity for symbol server authentication. Use in headless/Azure environments where interactive browser auth is unavailable.")] + bool useManagedIdentity = false) { - DiagnosticLogger.LogInfo($"[MCP] OpenTrace called: profileFilePath={profileFilePath}, processNameOrId={processNameOrId}, symbolPath={symbolPath ?? "(none)"}, binaryPath={binaryPath ?? "(none)"}"); + DiagnosticLogger.LogInfo($"[MCP] OpenTrace called: profileFilePath={profileFilePath}, processNameOrId={processNameOrId}, symbolPath={symbolPath ?? "(none)"}, binaryPath={binaryPath ?? "(none)"}, useManagedIdentity={useManagedIdentity}"); if (!File.Exists(profileFilePath)) return Error("OpenTrace", $"File not found: {profileFilePath}"); @@ -197,6 +199,7 @@ public static string OpenTrace( var options = new ProfileDataProviderOptions(); var symbolSettings = new SymbolFileSourceSettings(); symbolSettings.UseEnvironmentVarSymbolPaths = true; + symbolSettings.ManagedIdentityEnabled = useManagedIdentity; if (!string.IsNullOrWhiteSpace(symbolPath)) symbolSettings.InsertSymbolPath(symbolPath); if (!string.IsNullOrWhiteSpace(binaryPath)) @@ -206,7 +209,10 @@ public static string OpenTrace( } ProfileSession.SymbolSettings = symbolSettings; - DiagnosticLogger.LogInfo($"[MCP] SymbolSettings: UseEnvVar=true, CustomPath={symbolPath ?? "(none)"}, EnvVar={symbolSettings.EnvironmentVarSymbolPath ?? "(not set)"}"); + // Reinitialize credential chain to pick up ManagedIdentityEnabled flag. + PDBDebugInfoProvider.ReinitializeCredentials(symbolSettings); + + DiagnosticLogger.LogInfo($"[MCP] SymbolSettings: UseEnvVar=true, CustomPath={symbolPath ?? "(none)"}, EnvVar={symbolSettings.EnvironmentVarSymbolPath ?? "(not set)"}, ManagedIdentity={useManagedIdentity}"); DiagnosticLogger.LogInfo($"[MCP] SymbolPaths: {string.Join("; ", symbolSettings.SymbolPaths)}"); DiagnosticLogger.LogInfo($"[MCP] BinarySearchPaths: {(options.HasBinarySearchPaths ? string.Join("; ", options.BinarySearchPaths) : "(none)")}"); diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index 73fc8607..286faa8a 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -33,9 +33,10 @@ 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 SymwebHandler authSymwebHandler_; + private static AzureDevOpsSourceHandler authAzDevOpsHandler_; private static readonly string authRecordPath_ = Path.Combine(Path.GetTempPath(), "ProfileExplorer", "auth_record.bin"); + private static readonly object credentialLock_ = new(); private static object undecorateLock_ = new(); // Global lock for undname. private ConcurrentDictionary sourceFileByRvaCache_ = new(); private ConcurrentDictionary sourceFileByNameCache_ = new(); @@ -84,53 +85,84 @@ public static void ClearResolvedCache() { public static string DiaRegistrationError => diaRegistrationError_; static PDBDebugInfoProvider() { + authLogWriter_ = new StringWriter(); + BuildAndSetCredentialChain(managedIdentityEnabled: false); + } + + /// + /// Rebuilds the token credential chain based on the provided settings. + /// This exists because the static constructor runs at class-load time, before + /// application settings are available. Call this after settings are loaded to + /// pick up the ManagedIdentityEnabled flag. + /// Thread-safe — uses credentialLock_. + /// + public static void ReinitializeCredentials(SymbolFileSourceSettings settings) { + BuildAndSetCredentialChain(settings?.ManagedIdentityEnabled ?? false); + } + + private static void BuildAndSetCredentialChain(bool managedIdentityEnabled) { // Create a single instance of the Symweb handler so that // when concurrent requests are made and login must be done, // the login page is displayed a single time, with other requests waiting for a token. // DefaultAzureCredential is not allowed per SFI. Mimic behavior while continuing - // to exclude the managed identity credential. + // to exclude the managed identity credential unless explicitly opted in. // NOTE: ChainedTokenCredential only falls through on CredentialUnavailableException. // Other exceptions (like "needs re-authentication") stop the chain. We wrap each // credential to catch auth failures and convert them to CredentialUnavailableException // so the chain continues to InteractiveBrowserCredential which prompts the user. - // Enable token cache persistence for browser credential so tokens survive process restarts. - // Additionally, load existing AuthenticationRecord for true silent auth across sessions. - // This prevents re-authentication on every trace load if VS credential fails. - var authRecord = LoadAuthenticationRecord(); - var browserCredentialOptions = new InteractiveBrowserCredentialOptions { - TokenCachePersistenceOptions = new TokenCachePersistenceOptions { - Name = "ProfileExplorer" // Unique cache name for this app - }, - AuthenticationRecord = authRecord // Enable silent auth with cached identity - }; - - TokenCredential browserCredential = new InteractiveBrowserCredential(browserCredentialOptions); + List credentials; - // If no auth record exists yet, the user will be prompted on first symbol download. - // After first successful auth, capture and save the AuthenticationRecord for future sessions. - if (authRecord == null) { - browserCredential = new CaptureAuthRecordCredential((InteractiveBrowserCredential)browserCredential); + if (managedIdentityEnabled) { + // In Azure/headless environments use Managed Identity exclusively. + // Developer credentials (VS, CLI, browser) are unnecessary and can be + // slow or misleading when running in a cloud context. + Trace.WriteLine("[Auth] Building credential chain: ManagedIdentity-only"); + credentials = new List + { + WrapCredential(new ManagedIdentityCredential()), + }; } + else { + // Developer/interactive chain. Enable token cache persistence for browser + // credential so tokens survive process restarts. Load any existing + // AuthenticationRecord for true silent auth across sessions. + var authRecord = LoadAuthenticationRecord(); + var browserCredentialOptions = new InteractiveBrowserCredentialOptions { + TokenCachePersistenceOptions = new TokenCachePersistenceOptions { + Name = "ProfileExplorer" + }, + AuthenticationRecord = authRecord + }; + + TokenCredential browserCredential = new InteractiveBrowserCredential(browserCredentialOptions); + + // If no auth record exists yet, the user will be prompted on first symbol download. + // After first successful auth, capture and save the record for future sessions. + if (authRecord == null) { + browserCredential = new CaptureAuthRecordCredential((InteractiveBrowserCredential)browserCredential); + } - var credentials = new List - { - WrapCredential(new EnvironmentCredential()), - WrapCredential(new WorkloadIdentityCredential()), - WrapCredential(new SharedTokenCacheCredential()), - WrapCredential(new VisualStudioCredential()), - WrapCredential(new AzureCliCredential()), - WrapCredential(new AzurePowerShellCredential()), - WrapCredential(new AzureDeveloperCliCredential()), - browserCredential // Don't wrap - final fallback should show errors - }; + credentials = new List + { + WrapCredential(new EnvironmentCredential()), + WrapCredential(new WorkloadIdentityCredential()), + WrapCredential(new SharedTokenCacheCredential()), + WrapCredential(new VisualStudioCredential()), + WrapCredential(new AzureCliCredential()), + WrapCredential(new AzurePowerShellCredential()), + WrapCredential(new AzureDeveloperCliCredential()), + browserCredential // Don't wrap - final fallback should show errors + }; + } var authCredential = new ChainedTokenCredential(credentials.ToArray()); - authLogWriter_ = new StringWriter(); - authSymwebHandler_ = new SymwebHandler(authLogWriter_, authCredential); - authAzDevOpsHandler_ = new AzureDevOpsSourceHandler(authLogWriter_, authCredential); + lock (credentialLock_) { + authSymwebHandler_ = new SymwebHandler(authLogWriter_, authCredential); + authAzDevOpsHandler_ = new AzureDevOpsSourceHandler(authLogWriter_, authCredential); + } } /// @@ -425,8 +457,11 @@ public static async Task public static SymbolReaderAuthenticationHandler CreateAuthHandler(SymbolFileSourceSettings settings) { var authHandler = new SymbolReaderAuthenticationHandler(); - authHandler.AddHandler(authSymwebHandler_); - authHandler.AddHandler(authAzDevOpsHandler_); + + lock (credentialLock_) { + authHandler.AddHandler(authSymwebHandler_); + authHandler.AddHandler(authAzDevOpsHandler_); + } if (settings.AuthorizationTokenEnabled) { authHandler.AddHandler(new BasicAuthenticationHandler(settings, authLogWriter_)); diff --git a/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs b/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs index 77fbfc16..c1b2ff04 100644 --- a/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs +++ b/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs @@ -85,6 +85,8 @@ public SymbolFileSourceSettings() { public int RejectedFilesCacheExpirationDays { get; set; } [ProtoMember(25)][OptionValue(true)] public bool AllowApproximateBinaryMatch { get; set; } + [ProtoMember(26)][OptionValue(false)] + public bool ManagedIdentityEnabled { get; set; } public bool HasAuthorizationToken => AuthorizationTokenEnabled && !string.IsNullOrEmpty(AuthorizationToken); public bool HasCompanyFilter => CompanyFilterEnabled; From 324ff6936dc764cf97cc49330d649eaa61cf4af8 Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Mon, 1 Jun 2026 09:26:15 -0700 Subject: [PATCH 02/10] feat(auth): add managed identity support for symweb symbol downloads - PDBDebugInfoProvider: read MANAGED_IDENTITY_CLIENT_ID env var to configure user-assigned managed identity when useManagedIdentity=true; previously used system-assigned MI which doesn't exist on Azure Batch nodes - McpActionExecutor: accept useManagedIdentity param in OpenTraceAsync; call PDBDebugInfoProvider.ReinitializeCredentials before trace load; force-add symweb to symbol paths on Batch nodes (which are not domain-joined and would otherwise only have the public MSDL server) - IMcpActionExecutor/ProfileExplorerMcpServer/Program: wire useManagedIdentity flag through MCP interface and JSON RPC handler - DiagnosticLogger: minor diagnostic improvements - ProfileExplorerUI.csproj: dependency updates for Azure.Identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.Mcp/IMcpActionExecutor.cs | 2 +- .../ProfileExplorerMcpServer.cs | 9 +++--- src/ProfileExplorer.Mcp/Program.cs | 2 +- .../Binary/PDBDebugInfoProvider.cs | 11 ++++++-- .../Utilities/DiagnosticLogger.cs | 4 +-- .../Mcp/McpActionExecutor.cs | 28 ++++++++++++++++++- .../ProfileExplorerUI.csproj | 7 +++-- 7 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs b/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs index aef6809e..cd08520f 100644 --- a/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs +++ b/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs @@ -16,7 +16,7 @@ public interface IMcpActionExecutor /// Path to the ETL trace file to open /// Process ID (e.g., "12345") or process name (e.g., "chrome.exe", "POWERPNT") to select and load from the trace /// Task that completes when the trace is fully loaded, with detailed result information - Task OpenTraceAsync(string profileFilePath, string processIdentifier); + Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false); /// /// Get the current status of the Profile Explorer UI diff --git a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs index 8ae018db..8ed3ac7a 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs +++ b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs @@ -60,7 +60,8 @@ public static void SetExecutor(IMcpActionExecutor executor) #region Simplified MCP Tool [McpServerTool, Description("Open and load a trace file with a specific process by name or ID in one complete operation")] - public static async Task OpenTrace(string profileFilePath, string processNameOrId) + public static async Task OpenTrace(string profileFilePath, string processNameOrId, + bool useManagedIdentity = false) { if (string.IsNullOrWhiteSpace(profileFilePath)) throw new ArgumentException("Profile file path cannot be empty", nameof(profileFilePath)); @@ -87,7 +88,7 @@ public static async Task OpenTrace(string profileFilePath, string proces if (exactIdMatch != null) { // Direct match by ID - proceed with OpenTrace - OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId); + OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity); return SerializeOpenTraceResult(result, profileFilePath, processNameOrId); } } @@ -101,7 +102,7 @@ public static async Task OpenTrace(string profileFilePath, string proces if (exactNameMatches.Length == 1) { // Single exact match - proceed with OpenTrace - OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId); + OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity); return SerializeOpenTraceResult(result, profileFilePath, processNameOrId); } @@ -129,7 +130,7 @@ public static async Task OpenTrace(string profileFilePath, string proces } // Fallback to direct OpenTrace call if we can't get the process list - OpenTraceResult directResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId); + OpenTraceResult directResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity); return SerializeOpenTraceResult(directResult, profileFilePath, processNameOrId); } catch (Exception ex) diff --git a/src/ProfileExplorer.Mcp/Program.cs b/src/ProfileExplorer.Mcp/Program.cs index 218340e5..ecb1c4b0 100644 --- a/src/ProfileExplorer.Mcp/Program.cs +++ b/src/ProfileExplorer.Mcp/Program.cs @@ -34,7 +34,7 @@ public static async Task Main(string[] args) /// Provides mock responses for all MCP tools to enable standalone testing /// public class MockMcpActionExecutor : IMcpActionExecutor { - public Task OpenTraceAsync(string profileFilePath, string processIdentifier) { + public Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false) { Console.WriteLine($"Mock: OpenTraceAsync called with process identifier: {processIdentifier}"); return Task.FromResult(new OpenTraceResult { Success = true }); } diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index 286faa8a..f616de4e 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -118,10 +118,17 @@ private static void BuildAndSetCredentialChain(bool managedIdentityEnabled) { // In Azure/headless environments use Managed Identity exclusively. // Developer credentials (VS, CLI, browser) are unnecessary and can be // slow or misleading when running in a cloud context. - Trace.WriteLine("[Auth] Building credential chain: ManagedIdentity-only"); + // For user-assigned managed identities, the client ID must be passed explicitly. + // Read MANAGED_IDENTITY_CLIENT_ID from the environment (set by the Batch task). + var managedIdentityClientId = Environment.GetEnvironmentVariable("MANAGED_IDENTITY_CLIENT_ID"); + var managedIdentityCredential = string.IsNullOrEmpty(managedIdentityClientId) + ? new ManagedIdentityCredential() + : new ManagedIdentityCredential(managedIdentityClientId); + Trace.WriteLine($"[Auth] Building credential chain: ManagedIdentity-only (clientId={managedIdentityClientId ?? "system-assigned"})"); + Console.Error.WriteLine($"[PE][Auth] ManagedIdentity credential chain initialized (clientId={managedIdentityClientId ?? "system-assigned"})"); credentials = new List { - WrapCredential(new ManagedIdentityCredential()), + WrapCredential(managedIdentityCredential), }; } else { diff --git a/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs b/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs index 966b0e2e..3af81afb 100644 --- a/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs +++ b/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs @@ -68,8 +68,8 @@ private static void EnsureInitialized() { /// private static void Initialize() { try { - // Create log file in user's temp directory with timestamp - string tempDir = Path.GetTempPath(); + // Prefer Azure Batch task working directory so logs are retrievable via Batch API + string tempDir = Environment.GetEnvironmentVariable("AZ_BATCH_TASK_WORKING_DIR") ?? Path.GetTempPath(); string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); string processId = Process.GetCurrentProcess().Id.ToString(); logFilePath_ = Path.Combine(tempDir, $"ProfileExplorer_Diagnostic_{timestamp}_{processId}.log"); diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 7047bb29..7efded95 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -10,8 +10,10 @@ using System.Windows.Input; using System.Windows.Threading; using ProfileExplorer.Mcp; +using ProfileExplorer.Core.Binary; using ProfileExplorer.Core.Profile.Data; using ProfileExplorer.Core.Profile.ETW; +using ProfileExplorer.Core.Settings; using ProfileExplorer.Core.Utilities; using ProfileExplorer.Core.IR; @@ -32,8 +34,32 @@ public McpActionExecutor(MainWindow mainWindow) this.dispatcher = mainWindow.Dispatcher; } - public async Task OpenTraceAsync(string profileFilePath, string processIdentifier) + public async Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false) { + // Configure symbol credential chain before loading the trace. + // In Azure Batch, useManagedIdentity=true routes PDB fetching through the + // user-assigned managed identity (client ID from MANAGED_IDENTITY_CLIENT_ID env var). + PDBDebugInfoProvider.ReinitializeCredentials(new SymbolFileSourceSettings + { + ManagedIdentityEnabled = useManagedIdentity + }); + + if (useManagedIdentity) + { + // Ensure symweb is in the symbol paths. + // Azure Batch nodes are not domain/AAD-joined, so SymbolFileSourceSettings.Reset() + // only adds msdl (public server), which ConstructSymbolSearchPath skips when + // PrimaryServerAuthFailed=false (it expects symweb to be primary). The result + // is an empty effective symbol path and all symbol downloads fail with NotFound. + // Force-add symweb here so MI-authenticated downloads work on Batch nodes. + var symSettings = App.Settings.SymbolSettings; + if (!symSettings.SymbolPaths.Any(p => p.Contains("symweb", StringComparison.OrdinalIgnoreCase))) + { + symSettings.AddSymbolServer(usePrivateServer: true); + Console.Error.WriteLine("[PE][Auth] Added symweb to symbol paths for managed identity mode"); + } + } + // Mark that MCP automation is active - suppress UI dialogs App.SuppressDialogsForAutomation = true; diff --git a/src/ProfileExplorerUI/ProfileExplorerUI.csproj b/src/ProfileExplorerUI/ProfileExplorerUI.csproj index 8801c812..23392158 100644 --- a/src/ProfileExplorerUI/ProfileExplorerUI.csproj +++ b/src/ProfileExplorerUI/ProfileExplorerUI.csproj @@ -5,9 +5,9 @@ net8.0-windows true main.ico - 1.2.1 - 1.2.1 - 1.2.1 + 1.2.2 + 1.2.2 + 1.2.2 Microsoft Corporation Profile Explorer @@ -68,6 +68,7 @@ + From b1c419f8b74f157da70d5da2a75ea5b919f33635 Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Mon, 1 Jun 2026 14:07:55 -0700 Subject: [PATCH 03/10] refactor(mcp): clean up symbol settings flow in OpenTrace - Add symbolPath parameter to IMcpActionExecutor.OpenTraceAsync and the open_trace MCP tool, allowing callers to explicitly supply symbol server paths rather than having PE infer them from environment heuristics. - McpActionExecutor now clones App.Settings.SymbolSettings as the base (preserving user-configured paths, rejection caches, etc.) and overlays the caller-supplied symbolPath and useManagedIdentity flag. Replaces the previous approach of constructing fresh settings and force-adding symweb when useManagedIdentity was true. - Remove Console.Error noise from PDBDebugInfoProvider credential init. - Update MockMcpActionExecutor signature to match interface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.Mcp/IMcpActionExecutor.cs | 4 +- .../ProfileExplorerMcpServer.cs | 8 ++-- src/ProfileExplorer.Mcp/Program.cs | 2 +- .../Binary/PDBDebugInfoProvider.cs | 3 +- .../Mcp/McpActionExecutor.cs | 38 +++++++------------ 5 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs b/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs index cd08520f..391d943d 100644 --- a/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs +++ b/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs @@ -15,8 +15,10 @@ public interface IMcpActionExecutor /// /// Path to the ETL trace file to open /// Process ID (e.g., "12345") or process name (e.g., "chrome.exe", "POWERPNT") to select and load from the trace + /// If true, authenticates symbol downloads using a managed identity credential instead of interactive login + /// Optional semicolon-separated symbol search path (e.g., "https://symweb.azurefd.net;C:\Symbols"). Prepended to any paths already configured. /// Task that completes when the trace is fully loaded, with detailed result information - Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false); + Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false, string? symbolPath = null); /// /// Get the current status of the Profile Explorer UI diff --git a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs index 8ed3ac7a..581c4744 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs +++ b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs @@ -61,7 +61,7 @@ public static void SetExecutor(IMcpActionExecutor executor) [McpServerTool, Description("Open and load a trace file with a specific process by name or ID in one complete operation")] public static async Task OpenTrace(string profileFilePath, string processNameOrId, - bool useManagedIdentity = false) + bool useManagedIdentity = false, string? symbolPath = null) { if (string.IsNullOrWhiteSpace(profileFilePath)) throw new ArgumentException("Profile file path cannot be empty", nameof(profileFilePath)); @@ -88,7 +88,7 @@ public static async Task OpenTrace(string profileFilePath, string proces if (exactIdMatch != null) { // Direct match by ID - proceed with OpenTrace - OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity); + OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity, symbolPath); return SerializeOpenTraceResult(result, profileFilePath, processNameOrId); } } @@ -102,7 +102,7 @@ public static async Task OpenTrace(string profileFilePath, string proces if (exactNameMatches.Length == 1) { // Single exact match - proceed with OpenTrace - OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity); + OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity, symbolPath); return SerializeOpenTraceResult(result, profileFilePath, processNameOrId); } @@ -130,7 +130,7 @@ public static async Task OpenTrace(string profileFilePath, string proces } // Fallback to direct OpenTrace call if we can't get the process list - OpenTraceResult directResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity); + OpenTraceResult directResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity, symbolPath); return SerializeOpenTraceResult(directResult, profileFilePath, processNameOrId); } catch (Exception ex) diff --git a/src/ProfileExplorer.Mcp/Program.cs b/src/ProfileExplorer.Mcp/Program.cs index ecb1c4b0..b15ffaff 100644 --- a/src/ProfileExplorer.Mcp/Program.cs +++ b/src/ProfileExplorer.Mcp/Program.cs @@ -34,7 +34,7 @@ public static async Task Main(string[] args) /// Provides mock responses for all MCP tools to enable standalone testing /// public class MockMcpActionExecutor : IMcpActionExecutor { - public Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false) { + public Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false, string? symbolPath = null) { Console.WriteLine($"Mock: OpenTraceAsync called with process identifier: {processIdentifier}"); return Task.FromResult(new OpenTraceResult { Success = true }); } diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index f616de4e..6427b776 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -119,13 +119,12 @@ private static void BuildAndSetCredentialChain(bool managedIdentityEnabled) { // Developer credentials (VS, CLI, browser) are unnecessary and can be // slow or misleading when running in a cloud context. // For user-assigned managed identities, the client ID must be passed explicitly. - // Read MANAGED_IDENTITY_CLIENT_ID from the environment (set by the Batch task). + // Read MANAGED_IDENTITY_CLIENT_ID from the environment. var managedIdentityClientId = Environment.GetEnvironmentVariable("MANAGED_IDENTITY_CLIENT_ID"); var managedIdentityCredential = string.IsNullOrEmpty(managedIdentityClientId) ? new ManagedIdentityCredential() : new ManagedIdentityCredential(managedIdentityClientId); Trace.WriteLine($"[Auth] Building credential chain: ManagedIdentity-only (clientId={managedIdentityClientId ?? "system-assigned"})"); - Console.Error.WriteLine($"[PE][Auth] ManagedIdentity credential chain initialized (clientId={managedIdentityClientId ?? "system-assigned"})"); credentials = new List { WrapCredential(managedIdentityCredential), diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 7efded95..1e151faf 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -34,31 +34,21 @@ public McpActionExecutor(MainWindow mainWindow) this.dispatcher = mainWindow.Dispatcher; } - public async Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false) + public async Task OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false, string? symbolPath = null) { - // Configure symbol credential chain before loading the trace. - // In Azure Batch, useManagedIdentity=true routes PDB fetching through the - // user-assigned managed identity (client ID from MANAGED_IDENTITY_CLIENT_ID env var). - PDBDebugInfoProvider.ReinitializeCredentials(new SymbolFileSourceSettings - { - ManagedIdentityEnabled = useManagedIdentity - }); - - if (useManagedIdentity) - { - // Ensure symweb is in the symbol paths. - // Azure Batch nodes are not domain/AAD-joined, so SymbolFileSourceSettings.Reset() - // only adds msdl (public server), which ConstructSymbolSearchPath skips when - // PrimaryServerAuthFailed=false (it expects symweb to be primary). The result - // is an empty effective symbol path and all symbol downloads fail with NotFound. - // Force-add symweb here so MI-authenticated downloads work on Batch nodes. - var symSettings = App.Settings.SymbolSettings; - if (!symSettings.SymbolPaths.Any(p => p.Contains("symweb", StringComparison.OrdinalIgnoreCase))) - { - symSettings.AddSymbolServer(usePrivateServer: true); - Console.Error.WriteLine("[PE][Auth] Added symweb to symbol paths for managed identity mode"); - } - } + // Start from the current saved settings (preserves user-configured paths, cache dir, etc.), + // then overlay caller-supplied parameters. On Batch nodes there are no saved settings so + // App.Settings.SymbolSettings will be the Reset() default (msdl only), and the caller is + // responsible for passing symweb via symbolPath. + var symbolSettings = App.Settings.SymbolSettings.Clone(); + symbolSettings.ManagedIdentityEnabled = useManagedIdentity; + if (!string.IsNullOrWhiteSpace(symbolPath)) + symbolSettings.InsertSymbolPath(symbolPath); + + // Apply settings so ProfileLoadWindow picks them up, and reinitialize + // the credential chain so PDB downloads use the right auth. + App.Settings.SymbolSettings = symbolSettings; + PDBDebugInfoProvider.ReinitializeCredentials(symbolSettings); // Mark that MCP automation is active - suppress UI dialogs App.SuppressDialogsForAutomation = true; From dda81ed02763bca4d9a9c354dda2286651b2ad8e Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Mon, 1 Jun 2026 14:10:18 -0700 Subject: [PATCH 04/10] refactor(logging): replace AZ_BATCH_TASK_WORKING_DIR with generic PROFILE_EXPLORER_LOG_DIR PE was hardcoded to check AZ_BATCH_TASK_WORKING_DIR for the log directory, making it Batch-specific. Replace with a generic PROFILE_EXPLORER_LOG_DIR env var. Callers (e.g. FUN AI on Batch) can set it to whatever path is appropriate for their environment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs b/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs index 3af81afb..3b861514 100644 --- a/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs +++ b/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs @@ -68,8 +68,9 @@ private static void EnsureInitialized() { /// private static void Initialize() { try { - // Prefer Azure Batch task working directory so logs are retrievable via Batch API - string tempDir = Environment.GetEnvironmentVariable("AZ_BATCH_TASK_WORKING_DIR") ?? Path.GetTempPath(); + // Create log file in user's temp directory with timestamp + // Allow a custom log directory via environment variable for easier access in some environments + string tempDir = Environment.GetEnvironmentVariable("PROFILE_EXPLORER_LOG_DIR") ?? Path.GetTempPath(); string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); string processId = Process.GetCurrentProcess().Id.ToString(); logFilePath_ = Path.Combine(tempDir, $"ProfileExplorer_Diagnostic_{timestamp}_{processId}.log"); From 005bd432b8740933a3c75962efb08a8e8856452b Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Tue, 2 Jun 2026 10:44:20 -0700 Subject: [PATCH 05/10] feat: add --log-dir arg to McpServer for caller-controlled log directory Allow callers to override the diagnostic log directory via --log-dir instead of relying on the PROFILE_EXPLORER_LOG_DIR environment variable being set externally. The arg is parsed before the first DiagnosticLogger call so Initialize() picks up the override naturally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.McpServer/Program.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index ea97b479..37cd399a 100644 --- a/src/ProfileExplorer.McpServer/Program.cs +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -24,6 +24,18 @@ public static async Task Main(string[] args) // Force-enable diagnostic logging — the MCP server is headless, always-on logging is essential. Environment.SetEnvironmentVariable("PROFILE_EXPLORER_DEBUG", "1"); + // Allow caller to specify the log directory via --log-dir . + // Must be applied before the first DiagnosticLogger call so Initialize() picks it up. + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i].Equals("--log-dir", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(args[i + 1])) + { + Environment.SetEnvironmentVariable("PROFILE_EXPLORER_LOG_DIR", args[i + 1]); + break; + } + } + var builder = Host.CreateDefaultBuilder() .ConfigureLogging(logging => { From 53e81ae9bdd6c91ae140f6ef37b0d8b2e283b86f Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Tue, 2 Jun 2026 10:47:53 -0700 Subject: [PATCH 06/10] fix: include native DLLs in publish output via Content items Replace custom CopyNativeDependencies MSBuild target (which only copied to build output) with Content items using CopyToPublishDirectory so capstone.dll, msdia140.dll, and tree-sitter DLLs are included when running dotnet publish. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProfileExplorer.McpServer.csproj | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj index 44743e38..ecc02946 100644 --- a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj +++ b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj @@ -20,19 +20,25 @@ - - - + + + - - - - From 58a9fa9b36edefdd3e6c25ad24e337037477073c Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Tue, 2 Jun 2026 11:47:57 -0700 Subject: [PATCH 07/10] fix: preserve srv* symbol server URLs in InsertSymbolPath Path.GetDirectoryName strips '//server' from 'srv*https://server' as a UNC-like path component, leaving just 'srv*https:'. Bypass TryGetDirectoryName for symbol server paths (starting with 'srv*'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Settings/SymbolFileSourceSettings.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs b/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs index c1b2ff04..121eb5da 100644 --- a/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs +++ b/src/ProfileExplorerCore/Settings/SymbolFileSourceSettings.cs @@ -189,7 +189,7 @@ public void AddSymbolServer(bool usePrivateServer) { } public bool HasSymbolPath(string path) { - path = Utils.TryGetDirectoryName(path).ToLowerInvariant(); + path = NormalizeSymbolPath(path).ToLowerInvariant(); return SymbolPaths.Find(item => item.ToLowerInvariant() == path) != null; } @@ -199,7 +199,7 @@ public void InsertSymbolPath(string path) { } foreach (string p in path.Split(";")) { - string dir = Utils.TryGetDirectoryName(p); + string dir = NormalizeSymbolPath(p); if (!string.IsNullOrEmpty(dir) && !HasSymbolPath(dir)) { SymbolPaths.Insert(0, dir); // Prepend path. @@ -207,6 +207,21 @@ public void InsertSymbolPath(string path) { } } + /// + /// Returns the canonical form of a symbol path entry. + /// Symbol server paths (starting with "srv*") contain URLs and must not be processed + /// with Path.GetDirectoryName — doing so would strip the URL host (e.g. //symweb.azurefd.net). + /// + private static string NormalizeSymbolPath(string path) { + if (string.IsNullOrEmpty(path)) { + return path; + } + + return path.TrimStart().StartsWith("srv*", StringComparison.OrdinalIgnoreCase) + ? path.Trim() + : Utils.TryGetDirectoryName(path); + } + public void InsertSymbolPaths(IEnumerable paths) { foreach (string path in paths) { InsertSymbolPath(path); From 7e45185220a7e8deeb4e1ab408a7317dcaf84de0 Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Tue, 2 Jun 2026 16:14:11 -0700 Subject: [PATCH 08/10] feat(mcp): add demangled C++ name lookup for GetFunctionAssembly PE stores decorated MSVC names (e.g. ?ProcessComposition@CComposition@@UEAAXXZ) in FunctionDebugInfo.Name, but callers pass human-readable demangled names (e.g. CComposition::ProcessComposition) from Kusto telemetry. FindFunction now builds a lazy per-trace dictionary mapping demangled names back to IRTextFunction, using PDBDebugInfoProvider.DemangleFunctionName with OnlyName|NoReturnType|NoSpecialKeywords options. The lookup order is: 1. Exact decorated name match 2. Exact demangled name match (via lazy dict) 3. Exact hex placeholder match 4. Contains on demangled name 5. Contains on decorated/hex name DemangledFunctionLookup is cleared in ProfileSession.Reset() so it rebuilds fresh on each new trace load. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.McpServer/Program.cs | 48 +++++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index 37cd399a..c3619f2f 100644 --- a/src/ProfileExplorer.McpServer/Program.cs +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -12,6 +12,7 @@ using ProfileExplorer.Core.Profile.CallTree; using ProfileExplorer.Core.Profile.Data; using ProfileExplorer.Core.Profile.ETW; +using ProfileExplorer.Core.Providers; using ProfileExplorer.Core.Settings; using ProfileExplorer.Core.Utilities; @@ -76,6 +77,10 @@ public static class ProfileSession // Concurrency guard — only one trace load at a time. public static readonly SemaphoreSlim LoadSemaphore = new(1, 1); + // Lazy-built lookup: demangled function name → IRTextFunction. + // Populated on first FindFunction call after a trace loads. + public static Dictionary? DemangledFunctionLookup { get; set; } + /// /// Resets all session state and static caches for a clean trace load. /// @@ -93,6 +98,7 @@ public static void Reset() PendingFilePath = null; PendingProcessId = null; LoadException = null; + DemangledFunctionLookup = null; // Clear static resolution caches so each trace starts fresh. PDBDebugInfoProvider.ClearResolvedCache(); @@ -830,12 +836,44 @@ private static string ResolveFunctionName(ProfileCallTreeNode node) private static IRTextFunction? FindFunction(ProfileData profile, string functionName) { - // Search by resolved PDB name first, then by IRTextFunction.Name (hex placeholder) + // 1. Exact match on PDB-resolved (possibly decorated) name. + var exactMatch = profile.FunctionProfiles.Keys + .FirstOrDefault(f => ResolveFunctionName(f).Equals(functionName, StringComparison.OrdinalIgnoreCase)); + if (exactMatch != null) return exactMatch; + + // 2. Exact match on demangled name (callers pass human-readable names; PE stores decorated MSVC names). + if (ProfileSession.DemangledFunctionLookup == null) + { + // Build once per trace load — demangling is not thread-safe so do it lazily here. + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var f in profile.FunctionProfiles.Keys) + { + var raw = ResolveFunctionName(f); + var demangled = PDBDebugInfoProvider.DemangleFunctionName(raw, + FunctionNameDemanglingOptions.OnlyName | FunctionNameDemanglingOptions.NoReturnType | + FunctionNameDemanglingOptions.NoSpecialKeywords); + lookup.TryAdd(demangled, f); + lookup.TryAdd(raw, f); // also keep decorated so we re-use this dict for all lookups + } + ProfileSession.DemangledFunctionLookup = lookup; + } + + if (ProfileSession.DemangledFunctionLookup.TryGetValue(functionName, out var demangledMatch)) + return demangledMatch; + + // 3. Exact match on IRTextFunction.Name (hex placeholder when no symbols). + var hexMatch = profile.FunctionProfiles.Keys + .FirstOrDefault(f => f.Name.Equals(functionName, StringComparison.OrdinalIgnoreCase)); + if (hexMatch != null) return hexMatch; + + // 4. Contains on demangled name (partial match). + var containsMatch = ProfileSession.DemangledFunctionLookup.Keys + .FirstOrDefault(k => k.Contains(functionName, StringComparison.OrdinalIgnoreCase)); + if (containsMatch != null && ProfileSession.DemangledFunctionLookup.TryGetValue(containsMatch, out var partialMatch)) + return partialMatch; + + // 5. Contains on decorated or hex placeholder name. return profile.FunctionProfiles.Keys - .FirstOrDefault(f => ResolveFunctionName(f).Equals(functionName, StringComparison.OrdinalIgnoreCase)) - ?? profile.FunctionProfiles.Keys - .FirstOrDefault(f => f.Name.Equals(functionName, StringComparison.OrdinalIgnoreCase)) - ?? profile.FunctionProfiles.Keys .FirstOrDefault(f => ResolveFunctionName(f).Contains(functionName, StringComparison.OrdinalIgnoreCase)) ?? profile.FunctionProfiles.Keys .FirstOrDefault(f => f.Name.Contains(functionName, StringComparison.OrdinalIgnoreCase)); From 05140c0a3911ed7f3b24566efc0667ce1079dd6e Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Wed, 3 Jun 2026 12:57:35 -0700 Subject: [PATCH 09/10] Restore NativeDependency build targets; add publish-time copy target Revert the McpServer.csproj native dependency change from Content items back to the original NativeDependency/CopyNativeDependencies pattern. Add a matching CopyNativeDependenciesOnPublish target that runs AfterTargets='Publish' to copy native DLLs (capstone, msdia140, tree-sitter) to \, ensuring they are included in self-contained win-x64 publish outputs used for Azure Batch deployment. Also revert unrelated ProfileExplorerUI.csproj changes (version bump and publish\ exclusion). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProfileExplorer.McpServer.csproj | 32 +++++++++---------- .../ProfileExplorerUI.csproj | 7 ++-- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj index ecc02946..df54d862 100644 --- a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj +++ b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj @@ -20,25 +20,23 @@ - - - + + + + + + + + + + + diff --git a/src/ProfileExplorerUI/ProfileExplorerUI.csproj b/src/ProfileExplorerUI/ProfileExplorerUI.csproj index 23392158..8801c812 100644 --- a/src/ProfileExplorerUI/ProfileExplorerUI.csproj +++ b/src/ProfileExplorerUI/ProfileExplorerUI.csproj @@ -5,9 +5,9 @@ net8.0-windows true main.ico - 1.2.2 - 1.2.2 - 1.2.2 + 1.2.1 + 1.2.1 + 1.2.1 Microsoft Corporation Profile Explorer @@ -68,7 +68,6 @@ - From 244f66d17dc433fd15dceef290d8cc61d8b494de Mon Sep 17 00:00:00 2001 From: Devin Roberts Date: Wed, 3 Jun 2026 13:12:12 -0700 Subject: [PATCH 10/10] Add DemangleFunctionName tests; wire MI into ProfileExplorer.exe startup Add FunctionNameDemanglingTests covering: - Non-decorated names pass through unchanged (plain C, hex placeholders, null/empty) - Decorated MSVC names demangle correctly with default options - OnlyName|NoReturnType|NoSpecialKeywords options (the exact flags used by FindFunction) produce a scoped name matching what Kusto callers supply Wire managed identity into ProfileExplorer.exe startup path: - App.OnStartup now calls PDBDebugInfoProvider.ReinitializeCredentials after LoadApplicationSettings so that ManagedIdentityEnabled from saved user settings is applied at launch, not just when OpenTrace is driven via MCP. Previously the static credential chain always started as the interactive chain regardless of saved settings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FunctionNameDemanglingTests.cs | 113 ++++++++++++++++++ src/ProfileExplorerUI/App.xaml.cs | 7 ++ 2 files changed, 120 insertions(+) create mode 100644 src/ProfileExplorerCoreTests/FunctionNameDemanglingTests.cs diff --git a/src/ProfileExplorerCoreTests/FunctionNameDemanglingTests.cs b/src/ProfileExplorerCoreTests/FunctionNameDemanglingTests.cs new file mode 100644 index 00000000..11eeaf10 --- /dev/null +++ b/src/ProfileExplorerCoreTests/FunctionNameDemanglingTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Providers; + +namespace ProfileExplorer.CoreTests; + +/// +/// Tests for PDBDebugInfoProvider.DemangleFunctionName, which is used by the +/// MCP server's FindFunction to match caller-supplied human-readable names +/// (from Kusto) against the decorated MSVC names stored in the trace. +/// +[TestClass] +public class FunctionNameDemanglingTests { + // ── Non-decorated names pass through unchanged ────────────────────────── + + [TestMethod] + public void Demangle_PlainCName_ReturnsUnchanged() { + Assert.AreEqual("NtWaitForSingleObject", + PDBDebugInfoProvider.DemangleFunctionName("NtWaitForSingleObject")); + } + + [TestMethod] + public void Demangle_EmptyString_ReturnsEmpty() { + Assert.AreEqual("", + PDBDebugInfoProvider.DemangleFunctionName("")); + } + + [TestMethod] + public void Demangle_NullString_ReturnsNull() { + // The method checks IsNullOrEmpty and returns the input. + string? input = null; + Assert.IsNull(PDBDebugInfoProvider.DemangleFunctionName(input!)); + } + + [TestMethod] + public void Demangle_HexPlaceholder_ReturnsUnchanged() { + // Hex addresses used when no symbols are available never start with '?' + Assert.AreEqual("0x00007fff1234abcd", + PDBDebugInfoProvider.DemangleFunctionName("0x00007fff1234abcd")); + } + + // ── Decorated MSVC names → demangled ──────────────────────────────────── + + [TestMethod] + public void Demangle_MsvcDecoratedName_DefaultOptions_ReturnsFullSignature() { + // Default options: full signature including return type, parameters, qualifiers. + string result = PDBDebugInfoProvider.DemangleFunctionName( + "?ProcessComposition@CComposition@@UEAAXXZ"); + // Must at least contain the class and method name. + StringAssert.Contains(result, "CComposition"); + StringAssert.Contains(result, "ProcessComposition"); + } + + [TestMethod] + public void Demangle_MsvcDecoratedName_OnlyName_ReturnsScopedNameOnly() { + // OnlyName | NoReturnType | NoSpecialKeywords is the combination used by + // FindFunction when building the DemangledFunctionLookup dictionary. + var options = FunctionNameDemanglingOptions.OnlyName | + FunctionNameDemanglingOptions.NoReturnType | + FunctionNameDemanglingOptions.NoSpecialKeywords; + + string result = PDBDebugInfoProvider.DemangleFunctionName( + "?ProcessComposition@CComposition@@UEAAXXZ", options); + + Assert.AreEqual("CComposition::ProcessComposition", result); + } + + [TestMethod] + public void Demangle_MsvcDecoratedName_OnlyName_StaticMethod() { + var options = FunctionNameDemanglingOptions.OnlyName | + FunctionNameDemanglingOptions.NoReturnType | + FunctionNameDemanglingOptions.NoSpecialKeywords; + + string result = PDBDebugInfoProvider.DemangleFunctionName( + "?Create@CFoo@@SAPAV1@XZ", options); + + StringAssert.Contains(result, "CFoo"); + StringAssert.Contains(result, "Create"); + } + + [TestMethod] + public void Demangle_MsvcDecoratedName_OnlyName_GlobalFunction() { + var options = FunctionNameDemanglingOptions.OnlyName | + FunctionNameDemanglingOptions.NoReturnType | + FunctionNameDemanglingOptions.NoSpecialKeywords; + + // Global (non-member) C++ function: ?MyFunc@@YAXXZ → MyFunc + string result = PDBDebugInfoProvider.DemangleFunctionName( + "?MyFunc@@YAXXZ", options); + + Assert.AreEqual("MyFunc", result); + } + + // ── Round-trip: decorated → demangled matches what callers supply ──────── + + [TestMethod] + public void Demangle_FindFunctionOptions_MatchesKustoStyleName() { + // Kusto stores human-readable names like "CComposition::ProcessComposition". + // Verify that the same options used in FindFunction produce an exact match. + var options = FunctionNameDemanglingOptions.OnlyName | + FunctionNameDemanglingOptions.NoReturnType | + FunctionNameDemanglingOptions.NoSpecialKeywords; + + string decorated = "?ProcessComposition@CComposition@@UEAAXXZ"; + string kustoName = "CComposition::ProcessComposition"; + + string demangled = PDBDebugInfoProvider.DemangleFunctionName(decorated, options); + Assert.AreEqual(kustoName, demangled, + "Demangled name must exactly match the human-readable name Kusto provides."); + } +} diff --git a/src/ProfileExplorerUI/App.xaml.cs b/src/ProfileExplorerUI/App.xaml.cs index 65f10f6b..da3fe124 100644 --- a/src/ProfileExplorerUI/App.xaml.cs +++ b/src/ProfileExplorerUI/App.xaml.cs @@ -11,6 +11,7 @@ using System.Windows.Media; using System.Windows.Shell; using System.Xml; +using ProfileExplorer.Core.Binary; using ProfileExplorer.Core.Utilities; using ProfileExplorer.UI.Settings; using ProfileExplorer.UI.Mcp; @@ -616,6 +617,12 @@ protected override void OnStartup(StartupEventArgs e) { IsFirstRun = true; } + // Reinitialize the PDB credential chain now that settings are loaded. + // The static constructor always defaults to the interactive (non-MI) chain; + // this picks up ManagedIdentityEnabled from saved user settings so that + // traces loaded via the GUI (not MCP) also use the correct auth path. + PDBDebugInfoProvider.ReinitializeCredentials(Settings.SymbolSettings); + // Configure CoreSettingsProvider to use UI settings instead of defaults CoreSettingsProvider.SetProvider(new UISettingsProvider());