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;