diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..3dfa6219 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,333 @@ +# Profile Explorer — Architecture + +## Summary + +Profile Explorer is a Windows desktop application (WPF) for viewing CPU profiling traces collected via Event Tracing for Windows (ETW). It presents the hottest parts of a profiled application through an interactive UI: function list, flame graph, call tree, timeline, assembly view, and source view. A companion headless MCP server exposes the same profiling engine to AI assistants over the Model Context Protocol. + +The application was originally a compiler IR viewer and retains the ability to parse assembly into an internal IR, enabling control-flow graph visualization and interactive disassembly navigation. + +--- + +## High-Level Architecture + +``` ++-------------------------+ stdio (JSON-RPC) +-----------------------------+ +| GitHub Copilot CLI | <--------------------> | ProfileExplorer.McpServer | +| (AI Assistant) | | (headless, .NET 8) | ++-------------------------+ +-------------+---------------+ + | + ProfileExplorerCore (engine) + | + +----------+-----------+-----------+ + | | | | + ETW Symbols Disasm CallTree + (TraceEvent) (PDB/DIA) (Capstone) (in-mem) + | + v + +-------------+ + | .etl trace | + | files | + +-------------+ + ++-------------------------+ +| ProfileExplorerUI | Same engine (ProfileExplorerCore) +| (WPF desktop app) |--> with full interactive UI: flame graph, +| | call tree, assembly, source, timeline ++-------------------------+ +``` + +--- + +## Projects + +| Project | Type | Description | +|---------|------|-------------| +| **ProfileExplorerUI** | WPF app (.NET 8) | Main desktop application. All UI panels, session management, profiling views, scripting, VS extension integration. | +| **ProfileExplorerCore** | Class library | UI-independent engine: ETW trace loading, profile data model, call tree construction, IR parsing, binary analysis, symbol resolution, Capstone disassembly, Tree-sitter parsing, graph algorithms. | +| **ProfileExplorer.McpServer** | Console app (.NET 8) | Headless MCP server exposing profiling tools over stdio. Uses `ProfileExplorerCore` directly — no UI dependency. See [MCP Servers](#mcp-servers) below. | +| **ProfileExplorer.Mcp** | Class library | Shared MCP library used by the UI's embedded MCP server. Defines `IMcpActionExecutor` interface, tool routing (`ProfileExplorerTools`), and server configuration. Also includes a mock executor (`Program.cs`) for standalone testing of the MCP plumbing. | +| **ProfileExplorerCoreTests** | xUnit tests | Unit tests for the core library. | +| **ProfileExplorerUITests** | xUnit tests | Unit tests for UI logic. | +| **ManagedProfiler** | .NET profiler | JIT profiler extension for capturing JIT output assembly. | +| **PDBViewer** | WinForms utility | Small tool for displaying PDB debug info file contents. | +| **GrpcLib** | Protobuf library | GRPC protocol definitions for VS extension communication. | +| **VSExtension** | VSIX | Visual Studio extension that connects to the desktop app. | + +### External Dependencies (built from source) + +| Submodule | Purpose | +|-----------|---------| +| `src/external/capstone` | Capstone disassembly framework (x64/ARM64 instruction decoding) | +| `src/external/graphviz` | Graphviz graph layout engine (control-flow graph visualization) | +| `src/external/tree-sitter` | Tree-sitter parser generator (C/C++, C#, Rust source parsing) | +| `src/external/TreeListView` | WPF tree list view control | + +--- + +## Core Engine (ProfileExplorerCore) + +### Profile Data Pipeline + +``` +.etl file + | + v +ETWEventProcessor Reads raw ETW events via TraceEvent library + | Builds per-process sample lists and stack traces + v +ETWProfileDataProvider Orchestrates full profile loading: + | - Process enumeration (BuildProcessSummary) + | - Trace loading (LoadTraceAsync) + | - Symbol resolution (parallel PDB download) + | - Source line mapping + v +ProfileData In-memory profile model: + | - FunctionProfiles (per-function weight/time) + | - InstructionWeight (per-instruction hotspots) + | - Modules list + | - CallTree (rooted call graph) + v +ProfileCallTree Full call tree with: + | - GetSortedCallTreeNodes (all instances of a function) + | - GetCombinedCallTreeNode (merged view) + | - GetBacktrace (full stack for a node) + | - Caller/callee aggregation + v +UI views / MCP tools Consume the profile model for display or JSON output +``` + +### Key Data Types + +| Type | Location | Role | +|------|----------|------| +| `ProfileData` | Profile/Data/ProfileData.cs | Root container: function profiles, modules, call tree, metadata | +| `FunctionProfileData` | Profile/Data/FunctionProfileData.cs | Per-function: inclusive/exclusive weight, instruction weights, debug info | +| `ProfileCallTree` | Profile/CallTree/ProfileCallTree.cs | Full call tree with aggregation and backtrace support | +| `ProfileCallTreeNode` | Profile/CallTree/ProfileCallTreeNode.cs | Single node: function ref, weight, children, callers | +| `ETWEventProcessor` | Profile/ETW/ETWEventProcessor.cs | Low-level ETW event reading and sample extraction | +| `ETWProfileDataProvider` | Profile/ETW/ETWProfileDataProvider.cs | High-level trace loading orchestration (~1000 lines) | +| `IRTextFunction` | IRTextFunction.cs | Function identity (name + module) used as dictionary key | + +### Symbol Resolution + +Symbols are resolved in parallel during trace loading: +1. `_NT_SYMBOL_PATH` environment variable is read for symbol server configuration +2. A bellwether test probes symbol server health before bulk downloads +3. PDB files are downloaded in parallel with configurable timeouts (normal, degraded) +4. Failed downloads are negatively cached to avoid retry storms +5. Source line info is resolved on-demand per function (not bulk-loaded) + +Key settings in `SymbolFileSourceSettings`: +- `SymbolServerTimeoutSeconds` (default 10s) +- `BellwetherTestEnabled` / `BellwetherTimeoutSeconds` (5s) +- `DegradedTimeoutSeconds` (3s) +- `RejectPreviouslyFailedFiles` (negative cache) +- `WindowsPathFilterEnabled`, `CompanyFilterEnabled` (skip irrelevant binaries) + +--- + +## MCP Servers + +Profile Explorer exposes two MCP (Model Context Protocol) servers for AI assistant integration: + +### 1. Embedded MCP (UI) + +The WPF desktop app (`ProfileExplorerUI`) starts an embedded MCP stdio server on launch. This uses the `ProfileExplorer.Mcp` library: + +- `ProfileExplorer.Mcp` defines the `IMcpActionExecutor` interface and tool routing +- `ProfileExplorerUI` implements `McpActionExecutor`, which translates MCP tool calls into UI actions via the WPF dispatcher +- This enables "co-investigation" — a user interacts with the UI while an AI assistant simultaneously drives actions + +The library also includes a `MockMcpActionExecutor` (`Program.cs`) for standalone testing of the MCP plumbing without the full UI. + +### 2. Headless MCP (Console) + +`ProfileExplorer.McpServer` is a standalone console application that calls `ProfileExplorerCore` APIs directly — no WPF, no dispatcher, no UI dependency. It is the preferred server for pure AI-driven analysis workflows. + +| Aspect | Embedded (UI) | Headless (Console) | +|--------|---------------|-------------------| +| **Binary** | `ProfileExplorerUI.exe` | `ProfileExplorer.McpServer.exe` | +| **Engine** | `ProfileExplorer.Mcp` library → `IMcpActionExecutor` → WPF dispatcher | `ProfileExplorerCore` directly | +| **Coupling** | Loose — interface-based, requires UI | Tight — direct API calls, no UI | +| **Tools** | 6 tools via interface (`OpenTrace`, `GetStatus`, `GetAvailableProcesses`, `GetAvailableFunctions`, `GetAvailableBinaries`, `GetFunctionAssemblyToFile`) | 9 tools with richer features: multi-process selection, custom symbol paths, caller/callee with FunctionPct, CloseTrace for state reset, Capstone disassembly with per-instruction source lines and inline function info | +| **State** | Stateless — each call dispatched to UI which holds state | Stateful — `ProfileSession` singleton holds loaded profile, provider, and symbol settings | + +--- + +## MCP Server (ProfileExplorer.McpServer) + +The active MCP server. Exposes the profiling engine over MCP stdio transport. No WPF, no GUI — just the core engine. + +### Registration + +Registered in `~/.copilot/mcp-config.json` as `profile-explorer`: +```json +{ + "type": "stdio", + "command": "D:\\repos\\profile-explorer\\src\\ProfileExplorer.McpServer\\bin\\Release\\net8.0\\ProfileExplorer.McpServer.exe", + "env": { + "_NT_SYMBOL_PATH": "cache*c:\\programdata\\dbg\\sym;SRV*c:\\programdata\\dbg\\sym*https://symweb.azurefd.net" + } +} +``` + +### Tools + +| Tool | Description | +|------|-------------| +| `GetAvailableProcesses` | List processes in a trace file with weight percentages. Filtering by min weight %, top N. | +| `OpenTrace` | Start async loading of a trace for a specific process. Optional `symbolPath` for custom/private PDBs. Returns immediately. | +| `GetTraceLoadStatus` | Poll loading progress. Returns Loading/Complete/Failed. | +| `CloseTrace` | Close the current trace and fully reset session state (including symbol caches). Required before loading a new trace. | +| `GetAvailableFunctions` | List functions with self-time/total-time %. Filter by module, min %, top N. Uses PDB-resolved names. | +| `GetAvailableBinaries` | List modules/DLLs aggregated by CPU time. Filter by min %, top N. | +| `GetFunctionAssembly` | Instruction-level hotspots with Capstone disassembly, source line mapping, and inline function info. | +| `GetFunctionCallerCallee` | Callers, callees, and full backtraces for a function. Includes both trace-relative (`WeightPct`) and function-relative (`FunctionPct`) percentages. | +| `GetHelp` | Usage workflow documentation. | + +### Session Model + +`ProfileSession` is a static singleton holding loaded state: +- `LoadedProfile` — the `ProfileData` after successful load +- `Provider` — kept alive for on-demand source line resolution and disassembly +- `PendingLoad` — `Task` for async loading +- `TotalWeight` — pre-computed total weight for percentage calculations +- `LoadSemaphore` — concurrency guard preventing overlapping trace loads +- `SymbolSettings` — `SymbolFileSourceSettings` used for the current load (needed for disassembly) +- `LoadException` — captured exception if loading fails +- `LoadedFilePath` / `PendingFilePath` / `PendingProcessId` — track current and pending load targets +- `LoadedProcessIds` — list of process IDs included in the loaded profile +- `Report` — symbol resolution report with per-module resolution stats + +`Reset()` clears all state including static symbol caches (`PDBDebugInfoProvider.ClearResolvedCache()`), ensuring no stale negative caching between loads with different symbol paths. + +### Function Name Resolution + +`IRTextFunction.Name` is frozen as a hex placeholder (e.g., `28BC63`) during ETL parsing when PDB symbols are not yet loaded. The actual resolved name (e.g., `ExpWaitForSpinLockSharedAndAcquire`) lives on `FunctionDebugInfo.Name`, accessed via `FunctionProfileData.FunctionDebugInfo`. + +Two `ResolveFunctionName` helpers handle the lookup: +- `ResolveFunctionName(IRTextFunction)` — looks up `profile.FunctionProfiles[func].FunctionDebugInfo?.Name` +- `ResolveFunctionName(ProfileCallTreeNode)` — uses `node.FunctionDebugInfo?.Name` + +`FindFunction` searches by resolved PDB name first, falling back to the hex name. + +### FunctionPct vs WeightPct + +Call stack data includes two percentage types: +- `WeightPct` / `InclusivePct` — percentage of entire trace time (weight ÷ totalTraceWeight × 100) +- `FunctionPct` — percentage of the target function's time (weight ÷ functionWeight × 100), matching the GUI's drill-down view + +### Diagnostic Logging + +Structured diagnostic logging via `DiagnosticLogger` traces MCP operations, function resolution, and symbol loading. Log files are written alongside the trace file with timestamps. + +### Typical Workflow + +1. `GetAvailableProcesses(filePath)` — discover processes in a trace +2. `OpenTrace(filePath, processId, symbolPath?)` — start loading (async), optionally with custom symbol path +3. `GetTraceLoadStatus()` — poll until Complete +4. `GetAvailableFunctions(topCount: 20)` — find hot functions +5. `GetFunctionAssembly(name)` — drill into instruction-level hotspots +6. `GetFunctionCallerCallee(name)` — understand call context (with FunctionPct for drill-down) +7. `CloseTrace()` — reset state before loading a different trace + +--- + +## Desktop Application (ProfileExplorerUI) + +The WPF application provides the full interactive experience: + +### UI Architecture + +- **MainWindow** — split across partial classes for different concerns: + - `MainWindow.xaml.cs` — core window logic + - `MainWindowProfiling.cs` — profile loading and view management + - `MainWindowSession.cs` — session save/restore + - `MainWindowPanels.cs` — panel layout management + - `MainWindowDiff.cs` — diff/comparison mode + - `MainWindowDebugRpc.cs` — VS extension RPC handling +- **Panels/** — individual UI views (flame graph, call tree, function list, assembly, source, timeline, etc.) +- **Profile/** — profiling-specific UI components +- **Session/** — session persistence +- **Mcp/** — `McpActionExecutor.cs` bridges MCP commands to UI actions (used by the embedded MCP path) +- **Scripting/** — user scripting support + +### Performance Design + +The app is designed for near-instant interaction even with very large traces (10+ GB ETL files): +- Multi-threaded trace processing and filtering +- Async UI updates that don't block the main thread +- On-demand symbol resolution (only for viewed functions) +- Parallel PDB downloads with health-aware timeouts +- Incremental rendering for large data sets + +--- + +## Build System + +| Command | Description | +|---------|-------------| +| `build.cmd [debug\|release]` | x64 build (main project + external deps). Requires admin for msdia140.dll registration. | +| `build-arm64.cmd [debug\|release]` | Native ARM64 build. | +| `installer\x64\prepare.cmd` | Publish + create x64 installer (InnoSetup). | +| `installer\arm64\prepare.cmd` | Publish + create ARM64 installer. | +| `dotnet build src\ProfileExplorerUI\ProfileExplorerUI.csproj -c Release` | Build UI project only (after initial `build.cmd`). | +| `dotnet build src\ProfileExplorerCore\ProfileExplorerCore.csproj -c Release` | Build core library only (fast, ~2-3s). | + +**Prerequisites**: Visual Studio 2022 (.NET desktop + C++ desktop workloads), .NET 8.0 SDK. + +Output: `src\ProfileExplorerUI\bin\[Debug|Release]\net8.0-windows\ProfileExplorer.exe` + +--- + +## Dependencies + +| Package | Version | Used By | Purpose | +|---------|---------|---------|---------| +| `ModelContextProtocol` | 0.3.0-preview.4 | McpServer | MCP protocol (stdio transport, tool registration) | +| `Microsoft.Extensions.Hosting` | 9.0.0 | McpServer | .NET hosting / DI | +| `Microsoft.Extensions.Logging` | 9.0.0 | McpServer | Logging infrastructure | + +The core and UI projects have additional dependencies (TraceEvent for ETW, DIA SDK for PDB reading, etc.) managed through the solution and build scripts. + +--- + +## Known Limitations + +| Limitation | Impact | +|-----------|--------| +| Windows only | ETW and WPF are Windows-specific technologies | +| One trace at a time (MCP) | The headless MCP server holds a single `ProfileSession` — call `CloseTrace()` before loading a new trace | +| Symbol server latency | First load of a trace can be slow if PDBs need downloading from symbol servers | +| Large trace memory | Very large traces consume significant memory for the in-memory call tree and profile data | +| Release builds required | The user's MCP config points to Release output; Debug builds won't be picked up | + +--- + +## Repository Layout + +``` +profile-explorer/ + ARCHITECTURE.md This file + AGENTS.md Agent/Copilot instructions + CLAUDE.md Claude Code instructions + README.md User-facing documentation + build.cmd x64 build script + build-arm64.cmd ARM64 build script + src/ + ProfileExplorer.sln Solution file + ProfileExplorerUI/ WPF desktop application + ProfileExplorerCore/ Engine library (UI-independent) + ProfileExplorer.McpServer/ Headless MCP server (active) + ProfileExplorer.Mcp/ Embedded MCP server (original, not used — deadlocks on WPF dispatcher) + ProfileExplorerCoreTests/ Core unit tests + ProfileExplorerUITests/ UI unit tests + ManagedProfiler/ .NET JIT profiler + PDBViewer/ PDB viewer utility + GrpcLib/ VS extension protocol + VSExtension/ VS extension + external/ Git submodules (capstone, graphviz, tree-sitter) + docs/ Documentation site source + installer/ Installer scripts (InnoSetup) + scripts/ Utility scripts (log analysis, etc.) +``` diff --git a/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj b/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj index 5deafdda..8fc360ce 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj +++ b/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj new file mode 100644 index 00000000..738246f7 --- /dev/null +++ b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + ProfileExplorer.McpServer + + + + + + + + + + + + + + diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs new file mode 100644 index 00000000..a185d10a --- /dev/null +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -0,0 +1,827 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using ProfileExplorer.Core; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile.CallTree; +using ProfileExplorer.Core.Profile.Data; +using ProfileExplorer.Core.Profile.ETW; +using ProfileExplorer.Core.Settings; +using ProfileExplorer.Core.Utilities; + +namespace ProfileExplorer.McpServer; + +public class Program +{ + 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"); + + var builder = Host.CreateDefaultBuilder() + .ConfigureLogging(logging => + { + logging.ClearProviders(); + }) + .ConfigureServices(services => + { + services.AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + }); + + DiagnosticLogger.LogInfo("[MCP] ProfileExplorer MCP Server starting"); + DiagnosticLogger.LogInfo($"[MCP] Log file: {DiagnosticLogger.LogFilePath}"); + await builder.Build().RunAsync(); + } +} + +/// +/// Holds the loaded profile state for the current session. +/// +public static class ProfileSession +{ + public static ProfileData? LoadedProfile { get; set; } + public static ETWProfileDataProvider? Provider { get; set; } + public static SymbolFileSourceSettings? SymbolSettings { get; set; } + public static string? LoadedFilePath { get; set; } + public static List LoadedProcessIds { get; set; } = new(); + public static TimeSpan TotalWeight { get; set; } + public static ProfileDataReport? Report { get; set; } + + // Async loading state + public static Task? PendingLoad { get; set; } + public static string? PendingFilePath { get; set; } + public static string? PendingProcessId { get; set; } + public static Exception? LoadException { get; set; } + + // Concurrency guard — only one trace load at a time. + public static readonly SemaphoreSlim LoadSemaphore = new(1, 1); + + /// + /// Resets all session state and static caches for a clean trace load. + /// + public static void Reset() + { + (Provider as IDisposable)?.Dispose(); + LoadedProfile = null; + Provider = null; + SymbolSettings = null; + LoadedFilePath = null; + LoadedProcessIds = new(); + TotalWeight = TimeSpan.Zero; + Report = null; + PendingLoad = null; + PendingFilePath = null; + PendingProcessId = null; + LoadException = null; + + // Clear static resolution caches so each trace starts fresh. + PDBDebugInfoProvider.ClearResolvedCache(); + PEBinaryInfoProvider.ClearResolvedCache(); + } +} + +[McpServerToolType] +public static class ProfileTools +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true }; + + [McpServerTool, Description("Get the list of available processes from a trace file with optional weight filtering")] + public static string GetAvailableProcesses( + string profileFilePath, + [Description("Minimum weight percentage threshold to filter processes (e.g. 1.0 for >=1% weight)")] + double? minWeightPercentage = null, + [Description("Limit results to top N heaviest processes")] + int? topCount = null) + { + DiagnosticLogger.LogInfo($"[MCP] GetAvailableProcesses called: profileFilePath={profileFilePath}, minWeightPercentage={minWeightPercentage}, topCount={topCount}"); + var sw = Stopwatch.StartNew(); + + if (!File.Exists(profileFilePath)) + return Error("GetAvailableProcesses", $"File not found: {profileFilePath}"); + + try + { + var options = new ProfileDataProviderOptions(); + using var cancelTask = new CancelableTask(); + using var processor = new ETWEventProcessor(profileFilePath, options); + var summaries = processor.BuildProcessSummary( + (ProcessListProgress progress) => { }, cancelTask); + + // Compute total weight for percentages + var totalWeight = TimeSpan.Zero; + foreach (var s in summaries) + totalWeight += s.Weight; + + // Compute percentages and duration + foreach (var s in summaries) + { + s.WeightPercentage = totalWeight > TimeSpan.Zero + ? Math.Round(s.Weight / totalWeight * 100, 2) + : 0; + } + + // Sort by weight desc + summaries.Sort((a, b) => -a.Weight.CompareTo(b.Weight)); + + IEnumerable filtered = summaries; + if (minWeightPercentage.HasValue) + filtered = filtered.Where(p => p.WeightPercentage >= minWeightPercentage.Value); + if (topCount.HasValue) + filtered = filtered.Take(topCount.Value); + + var result = new + { + Action = "GetAvailableProcesses", + ProfileFilePath = profileFilePath, + Status = "Success", + TotalProcessCount = summaries.Count, + Processes = filtered.Select(p => new + { + ProcessId = p.Process.ProcessId, + Name = p.Process.Name ?? "", + ImageFileName = p.Process.ImageFileName ?? "", + Weight = p.Weight.ToString(), + WeightPercentage = p.WeightPercentage + }).ToArray(), + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + catch (Exception ex) + { + DiagnosticLogger.LogError($"[MCP] GetAvailableProcesses failed: {ex.Message}", ex); + return Error("GetAvailableProcesses", ex.Message); + } + } + + [McpServerTool, Description("Start loading a trace file with a specific process. Returns immediately. Use GetTraceLoadStatus to poll for completion.")] + public static string OpenTrace( + string profileFilePath, + [Description("Process name or ID. A name like 'diskspd' selects ALL matching processes. Comma-separated IDs (e.g. '9492,9500') select specific ones.")] + string processNameOrId, + [Description("Optional additional symbol search path (e.g. 'd:\\temp\\landy' for custom kernel symbols)")] + string? symbolPath = null) + { + DiagnosticLogger.LogInfo($"[MCP] OpenTrace called: profileFilePath={profileFilePath}, processNameOrId={processNameOrId}, symbolPath={symbolPath ?? "(none)"}"); + + if (!File.Exists(profileFilePath)) + return Error("OpenTrace", $"File not found: {profileFilePath}"); + + // Concurrency guard — only one trace load at a time. + if (!ProfileSession.LoadSemaphore.Wait(0)) + { + DiagnosticLogger.LogWarning("[MCP] OpenTrace rejected — another trace is currently loading"); + return Error("OpenTrace", "A trace is already loading. Wait for it to complete or call CloseTrace first."); + } + + // Reset all session state and static caches for a clean load. + ProfileSession.Reset(); + ProfileSession.PendingFilePath = profileFilePath; + ProfileSession.PendingProcessId = processNameOrId; + var loadStopwatch = Stopwatch.StartNew(); + + ProfileSession.PendingLoad = Task.Run(async () => + { + try + { + var options = new ProfileDataProviderOptions(); + var symbolSettings = new SymbolFileSourceSettings(); + symbolSettings.UseEnvironmentVarSymbolPaths = true; + if (!string.IsNullOrWhiteSpace(symbolPath)) + symbolSettings.InsertSymbolPath(symbolPath); + ProfileSession.SymbolSettings = symbolSettings; + + DiagnosticLogger.LogInfo($"[MCP] SymbolSettings: UseEnvVar=true, CustomPath={symbolPath ?? "(none)"}, EnvVar={symbolSettings.EnvironmentVarSymbolPath ?? "(not set)"}"); + DiagnosticLogger.LogInfo($"[MCP] SymbolPaths: {string.Join("; ", symbolSettings.SymbolPaths)}"); + + var report = new ProfileDataReport(); + var provider = new ETWProfileDataProvider(); + + // Resolve process IDs — supports comma-separated IDs or name-based matching (all matches) + var processIds = new List(); + var parts = processNameOrId.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.All(p => int.TryParse(p, out _))) + { + // All parts are numeric — explicit PID list + processIds = parts.Select(int.Parse).ToList(); + DiagnosticLogger.LogInfo($"[MCP] Using explicit PIDs: {string.Join(", ", processIds)}"); + } + else + { + // Name-based: find ALL matching processes + using var cancelTask2 = new CancelableTask(); + using var proc = new ETWEventProcessor(profileFilePath, options); + var summaries = proc.BuildProcessSummary( + (ProcessListProgress _) => { }, cancelTask2); + var matches = summaries.Where(s => + (s.Process.Name?.Equals(processNameOrId, StringComparison.OrdinalIgnoreCase) ?? false) || + (s.Process.ImageFileName?.Contains(processNameOrId, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + + if (matches.Count == 0) + throw new Exception($"Process '{processNameOrId}' not found in trace"); + + processIds = matches.Select(s => s.Process.ProcessId).ToList(); + DiagnosticLogger.LogInfo($"[MCP] Matched process '{processNameOrId}' to {matches.Count} PID(s): {string.Join(", ", processIds)}"); + } + + ProfileSession.LoadedProcessIds = processIds; + + using var cancelTask3 = new CancelableTask(); + var profile = await provider.LoadTraceAsync( + profileFilePath, + processIds, + options, + symbolSettings, + report, + progress => { Trace.WriteLine($"Load progress: {progress}"); }, + cancelTask3); + + if (profile == null) + throw new Exception("LoadTraceAsync returned null — trace loading failed"); + + // Compute total weight + var totalWeight = TimeSpan.Zero; + foreach (var kvp in profile.FunctionProfiles) + totalWeight += kvp.Value.ExclusiveWeight; + + ProfileSession.TotalWeight = totalWeight; + ProfileSession.LoadedProfile = profile; + ProfileSession.LoadedFilePath = profileFilePath; + ProfileSession.Provider = provider; + ProfileSession.Report = report; + + DiagnosticLogger.LogInfo($"[MCP] OpenTrace completed in {loadStopwatch.ElapsedMilliseconds}ms — {profile.FunctionProfiles.Count} functions, {report.Modules?.Count ?? 0} modules"); + return profile; + } + catch (Exception ex) + { + ProfileSession.LoadException = ex; + DiagnosticLogger.LogError($"[MCP] OpenTrace failed after {loadStopwatch.ElapsedMilliseconds}ms: {ex.Message}", ex); + return null; + } + finally + { + ProfileSession.LoadSemaphore.Release(); + } + }); + + var loadingResult = new + { + Action = "OpenTrace", + ProfileFilePath = profileFilePath, + ProcessNameOrId = processNameOrId, + Status = "Loading", + Description = "Trace loading started asynchronously. Call GetTraceLoadStatus() to poll for completion.", + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(loadingResult, JsonOpts); + } + + [McpServerTool, Description("Poll the status of an in-progress trace load started by OpenTrace. Call repeatedly until Status is 'Complete' or 'Failed'.")] + public static string GetTraceLoadStatus() + { + if (ProfileSession.PendingLoad == null) + return Error("GetTraceLoadStatus", "No trace load in progress. Call OpenTrace first."); + + if (!ProfileSession.PendingLoad.IsCompleted) + { + return JsonSerializer.Serialize(new + { + Action = "GetTraceLoadStatus", + Status = "Loading", + Description = "Trace is still loading (symbol resolution, profile processing). Poll again in 10-15 seconds.", + Timestamp = DateTime.UtcNow + }, JsonOpts); + } + + if (ProfileSession.LoadException != null) + { + var err = ProfileSession.LoadException.Message; + ProfileSession.PendingLoad = null; + return Error("GetTraceLoadStatus", err); + } + + if (ProfileSession.LoadedProfile != null) + { + ProfileSession.PendingLoad = null; + + // Build symbol resolution summary from the report. + object[]? moduleReport = null; + if (ProfileSession.Report?.Modules != null) + { + moduleReport = ProfileSession.Report.Modules + .OrderByDescending(m => m.HasDebugInfoLoaded ? 0 : 1) + .Select(m => (object)new + { + Module = m.ImageFileInfo?.ImageName ?? "Unknown", + SymbolsLoaded = m.HasDebugInfoLoaded, + BinaryState = m.State.ToString(), + PdbPath = m.DebugInfoFile?.FilePath, + BinaryPath = m.BinaryFileInfo?.FilePath + }).ToArray(); + } + + return JsonSerializer.Serialize(new + { + Action = "GetTraceLoadStatus", + Status = "Complete", + Description = $"Trace loaded successfully. {ProfileSession.LoadedProcessIds.Count} process(es), {ProfileSession.LoadedProfile.FunctionProfiles.Count} functions found.", + ProcessCount = ProfileSession.LoadedProcessIds.Count, + ProcessIds = ProfileSession.LoadedProcessIds, + FunctionCount = ProfileSession.LoadedProfile.FunctionProfiles.Count, + ModuleCount = ProfileSession.LoadedProfile.Modules?.Count ?? 0, + SymbolResolution = moduleReport, + Timestamp = DateTime.UtcNow + }, JsonOpts); + } + + ProfileSession.PendingLoad = null; + return Error("GetTraceLoadStatus", "Load completed but no profile data available"); + } + + [McpServerTool, Description("Get the list of available functions from the currently loaded process/trace")] + public static string GetAvailableFunctions( + [Description("Filter by module/DLL name (e.g. 'ntdll.dll', 'kernel32.dll'). Supports partial matching.")] + string? moduleName = null, + [Description("Minimum self-time percentage threshold (e.g. 0.1 for >=0.1% CPU usage).")] + double? minSelfTimePercentage = null, + [Description("Minimum total-time percentage threshold (e.g. 0.5 for >=0.5% total impact).")] + double? minTotalTimePercentage = null, + [Description("Limit results to top N functions (e.g. 10).")] + int? topCount = null, + [Description("Sort by self-time (true, default) or total-time (false).")] + bool sortBySelfTime = true) + { + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetAvailableFunctions", "No profile loaded. Call OpenTrace and wait for completion first."); + + var totalWeight = ProfileSession.TotalWeight; + var totalWeightMs = totalWeight.TotalMilliseconds; + + // Build function list + var functions = profile.FunctionProfiles.Select(kvp => + { + var func = kvp.Key; + var data = kvp.Value; + double selfPct = totalWeightMs > 0 ? data.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100 : 0; + double totalPct = totalWeightMs > 0 ? data.Weight.TotalMilliseconds / totalWeightMs * 100 : 0; + return new + { + Name = ResolveFunctionName(func), + ModuleName = func.ModuleName ?? "Unknown", + SelfTimePercentage = Math.Round(selfPct, 2), + TotalTimePercentage = Math.Round(totalPct, 2), + SelfTime = data.ExclusiveWeight.ToString(), + TotalTime = data.Weight.ToString() + }; + }); + + // Apply filters + if (!string.IsNullOrWhiteSpace(moduleName)) + functions = functions.Where(f => f.ModuleName.Contains(moduleName, StringComparison.OrdinalIgnoreCase)); + if (minSelfTimePercentage.HasValue) + functions = functions.Where(f => f.SelfTimePercentage >= minSelfTimePercentage.Value); + if (minTotalTimePercentage.HasValue) + functions = functions.Where(f => f.TotalTimePercentage >= minTotalTimePercentage.Value); + + // Sort and limit + var resultList = sortBySelfTime + ? functions.OrderByDescending(f => f.SelfTimePercentage).ToList() + : functions.OrderByDescending(f => f.TotalTimePercentage).ToList(); + + if (topCount.HasValue) + resultList = resultList.Take(topCount.Value).ToList(); + + var result = new + { + Action = "GetAvailableFunctions", + Status = "Success", + TotalFunctionCount = profile.FunctionProfiles.Count, + FilteredFunctionCount = resultList.Count, + Functions = resultList.ToArray(), + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Get the list of available binaries/DLLs from the currently loaded process/trace")] + public static string GetAvailableBinaries( + [Description("Minimum time percentage threshold to filter binaries.")] + double? minTimePercentage = null, + [Description("Minimum absolute time in milliseconds to filter binaries.")] + double? minTimeMs = null, + [Description("Limit results to top N binaries.")] + int? topCount = null) + { + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetAvailableBinaries", "No profile loaded. Call OpenTrace and wait for completion first."); + + var totalWeight = ProfileSession.TotalWeight; + var totalWeightMs = totalWeight.TotalMilliseconds; + + // Aggregate by module + var moduleAgg = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in profile.FunctionProfiles) + { + var modName = kvp.Key.ModuleName ?? "Unknown"; + if (!moduleAgg.TryGetValue(modName, out var existing)) + existing = TimeSpan.Zero; + moduleAgg[modName] = existing + kvp.Value.ExclusiveWeight; + } + + var binaries = moduleAgg.Select(kvp => + { + double pct = totalWeightMs > 0 ? kvp.Value.TotalMilliseconds / totalWeightMs * 100 : 0; + return new + { + Name = kvp.Key, + TimePercentage = Math.Round(pct, 2), + Time = kvp.Value.ToString(), + TimeMs = kvp.Value.TotalMilliseconds + }; + }); + + if (minTimePercentage.HasValue) + binaries = binaries.Where(b => b.TimePercentage >= minTimePercentage.Value); + if (minTimeMs.HasValue) + binaries = binaries.Where(b => b.TimeMs >= minTimeMs.Value); + + var sorted = binaries.OrderByDescending(b => b.TimePercentage); + var resultList = topCount.HasValue ? sorted.Take(topCount.Value).ToArray() : sorted.ToArray(); + + var result = new + { + Action = "GetAvailableBinaries", + Status = "Success", + TotalBinaryCount = moduleAgg.Count, + FilteredBinaryCount = resultList.Length, + Binaries = resultList, + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Get assembly code for a specific function by name")] + public static async Task GetFunctionAssembly(string functionName) + { + DiagnosticLogger.LogInfo($"[MCP] GetFunctionAssembly called: functionName={functionName}"); + var sw = Stopwatch.StartNew(); + + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetFunctionAssembly", "No profile loaded. Call OpenTrace and wait for completion first."); + + var match = FindFunction(profile, functionName); + + if (match == null) + return Error("GetFunctionAssembly", $"Function '{functionName}' not found"); + + var data = profile.FunctionProfiles[match]; + var debugInfo = data.FunctionDebugInfo; + + // Try to resolve source lines via the provider's debug info + IDebugInfoProvider? moduleDebugInfo = null; + if (ProfileSession.Provider != null) + moduleDebugInfo = ProfileSession.Provider.GetDebugInfoForFunction(match); + + if (moduleDebugInfo != null && debugInfo != null && !debugInfo.HasSourceLines) + moduleDebugInfo.PopulateSourceLines(debugInfo); + + // Try to disassemble the function using capstone + Dictionary? disasmMap = null; + long imageBase = 0; + if (ProfileSession.Provider != null && debugInfo != null && ProfileSession.SymbolSettings != null) + { + try + { + string? asmText = await ProfileSession.Provider.DisassembleFunctionAsync( + match, debugInfo, ProfileSession.SymbolSettings); + if (!string.IsNullOrEmpty(asmText)) + { + // Parse disassembly text: "{absoluteAddr:X}: {mnemonic} {operands}" + // Convert absolute addresses to function-relative offsets + disasmMap = new Dictionary(); + foreach (var line in asmText.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + int colonIdx = line.IndexOf(':'); + if (colonIdx > 0 && long.TryParse(line[..colonIdx].Trim(), + System.Globalization.NumberStyles.HexNumber, null, out long absAddr)) + { + if (imageBase == 0) imageBase = absAddr - debugInfo.RVA; + long funcOffset = absAddr - imageBase - debugInfo.RVA; + string instruction = line[(colonIdx + 1)..].Trim(); + disasmMap[funcOffset] = instruction; + } + } + } + } + catch (Exception ex) + { + Trace.TraceError($"Disassembly failed for {functionName}: {ex.Message}"); + } + } + + // Adjust for CPU IP skid: sampled IPs often point to the instruction AFTER the + // one that was actually executing. When we have a disassembly map, shift each + // sample's weight to the preceding valid instruction offset (same as UI's + // TryFindElementForOffset with InitialMultiplier=1). + var adjustedWeights = data.InstructionWeight; + if (disasmMap != null && data.InstructionWeight != null) + { + var adjusted = new Dictionary(); + foreach (var kv in data.InstructionWeight) + { + long adjustedOffset = kv.Key; + // Search backwards (up to 16 bytes) for the nearest valid instruction + for (long delta = 1; delta <= 16; delta++) + { + long candidate = kv.Key - delta; + if (candidate >= 0 && disasmMap.ContainsKey(candidate)) + { + adjustedOffset = candidate; + break; + } + } + if (adjusted.ContainsKey(adjustedOffset)) + adjusted[adjustedOffset] += kv.Value; + else + adjusted[adjustedOffset] = kv.Value; + } + adjustedWeights = adjusted; + } + + var totalWeightMs = ProfileSession.TotalWeight.TotalMilliseconds; + + var instructionWeights = adjustedWeights?.OrderByDescending(kv => kv.Value) + .Take(30) + .Select(kv => + { + string? sourceFile = null; + int? sourceLine = null; + + // Use per-RVA DIA lookup (same as UI) for accurate source line mapping + object[]? inlinees = null; + if (moduleDebugInfo != null && debugInfo != null) + { + long rva = kv.Key + debugInfo.RVA; + var lineInfo = moduleDebugInfo.FindSourceLineByRVA(rva, includeInlinees: true); + if (!lineInfo.IsUnknown) + { + sourceLine = lineInfo.Line; + sourceFile = lineInfo.FilePath; + } + if (lineInfo.Inlinees is { Count: > 0 }) + { + inlinees = lineInfo.Inlinees.Select(i => (object)new + { + Function = i.Function, + FilePath = i.FilePath, + Line = i.Line + }).ToArray(); + } + } + + // Look up disassembly text for this offset + string? asmInstruction = null; + disasmMap?.TryGetValue(kv.Key, out asmInstruction); + + double pctOfFunc = data.ExclusiveWeight.TotalMilliseconds > 0 + ? kv.Value.TotalMilliseconds / data.ExclusiveWeight.TotalMilliseconds * 100 : 0; + + return new + { + Offset = $"0x{kv.Key:X}", + Assembly = asmInstruction, + WeightMs = Math.Round(kv.Value.TotalMilliseconds, 1), + PctOfFunction = Math.Round(pctOfFunc, 1), + PctOfTrace = totalWeightMs > 0 ? Math.Round(kv.Value.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + SourceFile = sourceFile, + SourceLine = sourceLine, + Inlinees = inlinees + }; + }) + .ToArray(); + + var result = new + { + Action = "GetFunctionAssembly", + Status = "Success", + FunctionName = ResolveFunctionName(match), + ModuleName = match.ModuleName ?? "Unknown", + SelfTime = data.ExclusiveWeight.ToString(), + TotalTime = data.Weight.ToString(), + SelfPct = totalWeightMs > 0 ? Math.Round(data.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + HasDisassembly = disasmMap != null, + InstructionCount = data.InstructionWeight?.Count ?? 0, + InstructionWeights = instructionWeights, + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Get callers and callees for a function, showing who calls it and what it calls, with call stack traces")] + public static string GetFunctionCallerCallee( + string functionName, + [Description("Max number of callers to return (default 10)")] + int? maxCallers = null, + [Description("Max number of callees to return (default 10)")] + int? maxCallees = null, + [Description("Max number of full back-traces (call stacks) to return (default 5)")] + int? maxBacktraces = null) + { + DiagnosticLogger.LogInfo($"[MCP] GetFunctionCallerCallee called: functionName={functionName}, maxCallers={maxCallers}, maxCallees={maxCallees}, maxBacktraces={maxBacktraces}"); + + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetFunctionCallerCallee", "No profile loaded. Call OpenTrace and wait for completion first."); + + if (profile.CallTree == null) + return Error("GetFunctionCallerCallee", "No call tree data available in this profile."); + + var match = FindFunction(profile, functionName); + if (match == null) + return Error("GetFunctionCallerCallee", $"Function '{functionName}' not found"); + + var data = profile.FunctionProfiles[match]; + var totalWeightMs = ProfileSession.TotalWeight.TotalMilliseconds; + var functionWeightMs = data.Weight.TotalMilliseconds; + int callerLimit = maxCallers ?? 10; + int calleeLimit = maxCallees ?? 10; + int backtraceLimit = maxBacktraces ?? 5; + + // Get all call tree instances of this function, sorted by weight + var instances = profile.CallTree.GetSortedCallTreeNodes(match); + if (instances == null || instances.Count == 0) + return Error("GetFunctionCallerCallee", $"Function '{functionName}' has no call tree nodes"); + + // Aggregate callers across all instances + var callerAgg = new Dictionary(); + foreach (var inst in instances) + { + foreach (var caller in inst.Callers) + { + if (caller?.Function == null) continue; + var key = $"{caller.Function.ModuleName}!{ResolveFunctionName(caller)}"; + if (!callerAgg.TryGetValue(key, out var existing)) + existing = (TimeSpan.Zero, TimeSpan.Zero, caller.Function.ModuleName ?? "Unknown"); + callerAgg[key] = (existing.weight + caller.Weight, existing.exclusiveWeight + caller.ExclusiveWeight, existing.module); + } + } + + var callers = callerAgg + .OrderByDescending(kv => kv.Value.weight) + .Take(callerLimit) + .Select(kv => new + { + Function = kv.Key, + InclusiveTimeMs = Math.Round(kv.Value.weight.TotalMilliseconds, 2), + InclusivePct = totalWeightMs > 0 ? Math.Round(kv.Value.weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + FunctionPct = functionWeightMs > 0 ? Math.Round(kv.Value.weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0 + }).ToArray(); + + // Aggregate callees from the combined node's children + var combined = profile.CallTree.GetCombinedCallTreeNode(match); + var callees = Array.Empty(); + if (combined != null && combined.HasChildren) + { + callees = combined.Children + .Where(c => c?.Function != null) + .OrderByDescending(c => c.Weight) + .Take(calleeLimit) + .Select(c => new + { + Function = $"{c.Function.ModuleName}!{ResolveFunctionName(c)}", + InclusiveTimeMs = Math.Round(c.Weight.TotalMilliseconds, 2), + InclusivePct = totalWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + FunctionPct = functionWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, + SelfTimeMs = Math.Round(c.ExclusiveWeight.TotalMilliseconds, 2), + SelfPct = totalWeightMs > 0 ? Math.Round(c.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100, 2) : 0 + }).ToArray(); + } + + // Get top backtraces (full call stacks leading to this function) + var backtraces = instances + .Take(backtraceLimit) + .Select(inst => + { + var bt = profile.CallTree.GetBacktrace(inst); + return new + { + WeightMs = Math.Round(inst.Weight.TotalMilliseconds, 2), + WeightPct = totalWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + FunctionPct = functionWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, + Stack = bt.Select(n => $"{n.Function?.ModuleName}!{ResolveFunctionName(n)}").ToArray() + }; + }).ToArray(); + + var result = new + { + Action = "GetFunctionCallerCallee", + Status = "Success", + FunctionName = ResolveFunctionName(match), + ModuleName = match.ModuleName ?? "Unknown", + SelfTime = data.ExclusiveWeight.ToString(), + TotalTime = data.Weight.ToString(), + SelfPct = totalWeightMs > 0 ? Math.Round(data.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + TotalPct = totalWeightMs > 0 ? Math.Round(data.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + InstanceCount = instances.Count, + Callers = callers, + Callees = callees, + TopBacktraces = backtraces, + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Close the currently loaded trace and reset all session state. Use this to abandon a stuck load or free resources before loading a new trace.")] + public static string CloseTrace() + { + DiagnosticLogger.LogInfo("[MCP] CloseTrace called"); + ProfileSession.Reset(); + return JsonSerializer.Serialize(new + { + Action = "CloseTrace", + Status = "Success", + Description = "Trace closed and all session state reset.", + Timestamp = DateTime.UtcNow + }, JsonOpts); + } + + [McpServerTool, Description("Get help information about available MCP commands")] + public static string GetHelp() + { + var help = new + { + ServerName = "Profile Explorer Headless MCP Server", + Version = "2.0.0", + Description = "Headless MCP server for Profile Explorer — no GUI, direct engine access", + Workflow = new[] + { + "1. GetAvailableProcesses(filePath) — discover processes in a trace", + "2. OpenTrace(filePath, processNameOrId) — start loading (async). Name matches ALL processes (e.g. 'diskspd' loads all 4). Comma-separated IDs also supported.", + "3. GetTraceLoadStatus() — poll until 'Complete'", + "4. GetAvailableFunctions/GetAvailableBinaries — query the loaded profile", + "5. GetFunctionAssembly(name) — get instruction-level hotspot data", + "6. GetFunctionCallerCallee(name) — get callers, callees, and full call stacks", + "7. CloseTrace() — close trace and reset state (required before loading a new trace)" + } + }; + return JsonSerializer.Serialize(help, JsonOpts); + } + + // Prefer exact name match, then fall back to Contains + /// + /// Returns the PDB-resolved name for a function, falling back to IRTextFunction.Name (hex placeholder). + /// + private static string ResolveFunctionName(IRTextFunction func) + { + var data = ProfileSession.LoadedProfile?.FunctionProfiles.GetValueOrDefault(func); + var debugName = data?.FunctionDebugInfo?.Name; + return !string.IsNullOrEmpty(debugName) ? debugName : func.Name; + } + + /// + /// Returns the PDB-resolved name for a call tree node. + /// + private static string ResolveFunctionName(ProfileCallTreeNode node) + { + var debugName = node.FunctionDebugInfo?.Name; + return !string.IsNullOrEmpty(debugName) ? debugName : node.Function?.Name ?? "Unknown"; + } + + private static IRTextFunction? FindFunction(ProfileData profile, string functionName) + { + // Search by resolved PDB name first, then by IRTextFunction.Name (hex placeholder) + 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)); + } + + private static string Error(string action, string message) + { + return JsonSerializer.Serialize(new + { + Action = action, + Status = "Error", + Error = message, + Timestamp = DateTime.UtcNow + }, JsonOpts); + } +} diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index 9a04b354..09ea8f6f 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -62,6 +62,16 @@ public sealed class PDBDebugInfoProvider : IDebugInfoProvider { private static bool diaRegistrationFailed_; private static string diaRegistrationError_; + /// + /// Clears the static resolved symbols cache and resets logging state. + /// Call between trace loads to ensure a clean resolution state. + /// + public static void ClearResolvedCache() { + resolvedSymbolsCache_.Clear(); + loggedSymbolPathDetails_ = false; + lastLoggedAuthFailedState_ = false; + } + /// /// Returns true if DIA SDK (msdia140.dll) failed to load due to COM registration issues. /// diff --git a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs index 709c71ad..b8edc6e6 100644 --- a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs @@ -65,6 +65,16 @@ public override string ToString() { public sealed class PEBinaryInfoProvider : IBinaryInfoProvider, IDisposable { private static ConcurrentDictionary resolvedBinariesCache_ = new(); private static ConcurrentDictionary versionInfoCache_ = new(); + + /// + /// Clears the static resolved binaries and version info caches. + /// Call between trace loads to ensure a clean resolution state. + /// + public static void ClearResolvedCache() { + resolvedBinariesCache_.Clear(); + versionInfoCache_.Clear(); + } + private string filePath_; private PEReader reader_; diff --git a/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs b/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs index bf0369c9..cf4bf2ec 100644 --- a/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs +++ b/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs @@ -92,6 +92,55 @@ public void Dispose() { // ProfileModuleBuilder instances should live as long as the profile data is being used. } + /// + /// Get the debug info provider for a module by its image ID. + /// Used by headless MCP server for source line resolution. + /// + public IDebugInfoProvider GetDebugInfoForModule(int imageId) { + return imageModuleMap_.TryGetValue(imageId, out var builder) ? builder.DebugInfo : null; + } + + /// + /// Get the debug info provider for a function's parent module. + /// + public IDebugInfoProvider GetDebugInfoForFunction(IRTextFunction function) { + if (function?.ParentSummary == null) return null; + foreach (var builder in imageModuleMap_.Values) { + if (builder.Summary == function.ParentSummary) { + return builder.DebugInfo; + } + } + return null; + } + + /// + /// Disassemble a function to text using capstone. Locates the binary on-demand + /// via symbol server if needed. Returns null if binary cannot be found. + /// + public async Task DisassembleFunctionAsync(IRTextFunction function, FunctionDebugInfo funcDebugInfo, + SymbolFileSourceSettings symbolSettings) { + if (function?.ParentSummary == null || funcDebugInfo == null) return null; + + ProfileModuleBuilder moduleBuilder = null; + foreach (var builder in imageModuleMap_.Values) { + if (builder.Summary == function.ParentSummary) { + moduleBuilder = builder; + break; + } + } + if (moduleBuilder == null) return null; + + // Find the binary file (may download from symbol server) + var binFile = await moduleBuilder.FindBinaryFilePath(symbolSettings); + if (binFile == null || !binFile.Found) return null; + + // Create disassembler and decode + using var disasm = Disassembler.CreateForBinary(binFile.FilePath, moduleBuilder.DebugInfo, null); + if (disasm == null) return null; + + return disasm.DisassembleToText(funcDebugInfo); + } + public async Task LoadTraceAsync(string tracePath, List processIds, ProfileDataProviderOptions options, SymbolFileSourceSettings symbolSettings,