diff --git a/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs b/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs index aef6809e..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); + 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 8ae018db..581c4744 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, string? symbolPath = null) { 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, symbolPath); 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, symbolPath); 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, symbolPath); return SerializeOpenTraceResult(directResult, profileFilePath, processNameOrId); } catch (Exception ex) diff --git a/src/ProfileExplorer.Mcp/Program.cs b/src/ProfileExplorer.Mcp/Program.cs index 218340e5..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) { + 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/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj index 44743e38..df54d862 100644 --- a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj +++ b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj @@ -35,4 +35,8 @@ + + + + diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index 37e94e85..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; @@ -24,6 +25,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 => { @@ -64,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. /// @@ -81,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(); @@ -170,9 +188,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 +217,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 +227,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)")}"); @@ -812,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)); diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index 73fc8607..6427b776 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,90 @@ 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); - - // 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); + List credentials; + + 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. + // For user-assigned managed identities, the client ID must be passed explicitly. + // 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"})"); + credentials = new List + { + WrapCredential(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 +463,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..121eb5da 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; @@ -187,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; } @@ -197,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. @@ -205,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); diff --git a/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs b/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs index 966b0e2e..3b861514 100644 --- a/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs +++ b/src/ProfileExplorerCore/Utilities/DiagnosticLogger.cs @@ -69,7 +69,8 @@ private static void EnsureInitialized() { private static void Initialize() { try { // Create log file in user's temp directory with timestamp - string tempDir = Path.GetTempPath(); + // 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"); 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()); diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 7047bb29..1e151faf 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,22 @@ 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, string? symbolPath = null) { + // 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;