From d58a5c5ce7d82873c44ebff4634f37b2aa7029dc Mon Sep 17 00:00:00 2001 From: Ivan Berg Date: Tue, 7 Apr 2026 14:16:57 -0700 Subject: [PATCH 1/3] Fix MCP trace loading hang: add PMC sample support and remove dangerous UI fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PerfInfoPMCSample handler to BuildProcessSummary so traces collected with hardware counter profiling (not just standard CPU sampling) correctly enumerate processes - Remove fallback from OpenTrace to OpenTraceAsync when process list extraction fails — the old code returned a clean error, but baa4f950 introduced a path that opened a ProfileLoadWindow which also couldn't load, causing a hang - Reopen trace source for merged ETL files ("[multiple files]") so the second Process() pass gets fresh events instead of an exhausted source - Close ProfileLoadWindow in finally blocks to prevent dialog accumulation across repeated MCP open_trace calls - Guard MessageBox.Show calls with SuppressDialogsForAutomation to prevent UI deadlocks during MCP automation - Cache FindTraceProcesses result to avoid double ETL parsing when GetAvailableProcessesAsync and OpenTraceAsync run back-to-back - Use bidirectional Contains matching for process names so compound inputs like "msedgewebview2.exe webview-exe-name=searchhost.exe" resolve correctly - Add diagnostic logging throughout MCP flow for future debugging --- .../ProfileExplorerMcpServer.cs | 48 +++-- .../Profile/ETW/ETWEventProcessor.cs | 60 +++++- .../Mcp/McpActionExecutor.cs | 178 +++++++++++++----- .../Windows/ProfileLoadWindow.xaml.cs | 69 +++++-- 4 files changed, 279 insertions(+), 76 deletions(-) diff --git a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs index 8ae018db..313a1efa 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs +++ b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -75,8 +76,12 @@ public static async Task OpenTrace(string profileFilePath, string proces throw new InvalidOperationException("MCP action executor is not initialized"); } + Trace.TraceInformation($"[MCP] OpenTrace: file={profileFilePath}, process={processNameOrId}"); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + // First, check if this might be an ambiguous query by getting available processes GetAvailableProcessesResult processesResult = await _executor.GetAvailableProcessesAsync(profileFilePath); + Trace.TraceInformation($"[MCP] OpenTrace: GetAvailableProcesses completed in {stopwatch.ElapsedMilliseconds}ms, success={processesResult.Success}, count={processesResult.Processes?.Length ?? 0}"); if (processesResult.Success) { @@ -86,26 +91,36 @@ public static async Task OpenTrace(string profileFilePath, string proces var exactIdMatch = processesResult.Processes.FirstOrDefault(p => p.ProcessId == processId); if (exactIdMatch != null) { - // Direct match by ID - proceed with OpenTrace + Trace.TraceInformation($"[MCP] OpenTrace: exact ID match found (PID {processId})"); OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId); return SerializeOpenTraceResult(result, profileFilePath, processNameOrId); } } - // Check for exact name matches - var exactNameMatches = processesResult.Processes - .Where(p => p.Name.Equals(processNameOrId, StringComparison.OrdinalIgnoreCase) || - (p.ImageFileName?.Equals(processNameOrId, StringComparison.OrdinalIgnoreCase) ?? false)) + // Check for name matches using bidirectional Contains to handle compound names + // like "msedgewebview2.exe webview-exe-name=searchhost.exe" matching process "msedgewebview2" + var nameMatches = processesResult.Processes + .Where(p => (p.Name != null && ( + p.Name.Contains(processNameOrId, StringComparison.OrdinalIgnoreCase) || + processNameOrId.Contains(p.Name, StringComparison.OrdinalIgnoreCase))) || + (p.ImageFileName != null && ( + p.ImageFileName.Contains(processNameOrId, StringComparison.OrdinalIgnoreCase) || + processNameOrId.Contains(p.ImageFileName, StringComparison.OrdinalIgnoreCase)))) .ToArray(); - if (exactNameMatches.Length == 1) + Trace.TraceInformation($"[MCP] OpenTrace: name matching found {nameMatches.Length} match(es)"); + + if (nameMatches.Length == 1) { - // Single exact match - proceed with OpenTrace - OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId); + // Single match - proceed with OpenTrace using the matched process ID + string matchedProcessId = nameMatches[0].ProcessId.ToString(); + Trace.TraceInformation($"[MCP] OpenTrace: single name match, using PID {matchedProcessId} (name={nameMatches[0].Name})"); + OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, matchedProcessId); return SerializeOpenTraceResult(result, profileFilePath, processNameOrId); } // For ambiguous queries, provide all processes for LLM analysis + Trace.TraceInformation($"[MCP] OpenTrace: returning RequiresLLMAnalysis ({nameMatches.Length} matches)"); var llmAnalysisResult = new { Action = "OpenTrace", @@ -128,9 +143,20 @@ public static async Task OpenTrace(string profileFilePath, string proces return System.Text.Json.JsonSerializer.Serialize(llmAnalysisResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); } - // Fallback to direct OpenTrace call if we can't get the process list - OpenTraceResult directResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId); - return SerializeOpenTraceResult(directResult, profileFilePath, processNameOrId); + // Process list extraction failed - return error instead of falling back to + // OpenTraceAsync which would open a UI dialog that also can't load processes. + Trace.TraceInformation($"[MCP] OpenTrace: GetAvailableProcesses failed, returning error (not falling back to UI)"); + var errorResult2 = new + { + Action = "OpenTrace", + ProfileFilePath = profileFilePath, + ProcessNameOrId = processNameOrId, + Status = "Failed", + FailureReason = "ProcessListEmpty", + Description = processesResult.ErrorMessage ?? "Could not extract process list from trace file. The trace may use an unsupported format or be corrupted.", + Timestamp = DateTime.UtcNow + }; + return System.Text.Json.JsonSerializer.Serialize(errorResult2, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); } catch (Exception ex) { diff --git a/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs b/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs index 9794bc17..a070891e 100644 --- a/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs +++ b/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs @@ -124,6 +124,8 @@ public List BuildProcessSummary(ProcessListProgressHandler progr // when it is not set in the trace (-1). var kernel = new KernelTraceEventParser(source_, KernelTraceEventParser.ParserTrackingOptions.ThreadToProcess); + bool traceReopened = false; + if (!isRealTime_) { kernel.EventTraceHeader += data => { // If the trace has a known file name it's unlikely @@ -131,10 +133,20 @@ public List BuildProcessSummary(ProcessListProgressHandler progr // stop reading the entire trace early. if (data.LogFileName != "[multiple files]") { kernel = ReopenTrace(); + traceReopened = true; } }; source_.Process(); + + // For merged traces ("[multiple files]"), the first Process() call consumed + // all events to build the thread→process ID table. Reopen the trace so the + // second Process() call can re-read events for the actual sample collection. + DiagnosticLogger.LogInfo($"[BuildProcessSummary] First Process() done, traceReopened={traceReopened}"); + if (!traceReopened) { + kernel = ReopenTrace(); + DiagnosticLogger.LogInfo("[BuildProcessSummary] Reopened trace for merged ETL second pass"); + } } ProfileProcess HandleProcessEvent(ProcessTraceData data) { @@ -163,20 +175,24 @@ ProfileProcess HandleProcessEvent(ProcessTraceData data) { HandleProcessEvent(data); }; - kernel.PerfInfoSample += data => { + void HandleSampleEvent(int processId, double timeStampRelativeMSec) { if (cancelableTask.IsCanceled) { source_.StopProcessing(); } // The thread ID -> process ID mapping is used internally. - if (data.ProcessID < 0) { + if (processId < 0) { return; } sampleId++; var sampleWeight = TimeSpan.FromMilliseconds(samplingIntervalMS_); - var sampleTime = TimeSpan.FromMilliseconds(data.TimeStampRelativeMSec); - summaryBuilder.AddSample(sampleWeight, sampleTime, data.ProcessID); + var sampleTime = TimeSpan.FromMilliseconds(timeStampRelativeMSec); + summaryBuilder.AddSample(sampleWeight, sampleTime, processId); + } + + kernel.PerfInfoSample += data => { + HandleSampleEvent(data.ProcessID, data.TimeStampRelativeMSec); // Rebuild process list and update UI from time to time. if (sampleId - lastReportedSample >= SampleReportingInterval) { @@ -206,10 +222,44 @@ ProfileProcess HandleProcessEvent(ProcessTraceData data) { } }; + // Also handle PMC (Performance Monitor Counter) sample events, which are used + // in traces collected with hardware counter profiling instead of standard CPU sampling. + kernel.PerfInfoPMCSample += data => { + HandleSampleEvent(data.ProcessID, data.TimeStampRelativeMSec); + + if (sampleId - lastReportedSample >= SampleReportingInterval) { + List processList = null; + var currentTime = DateTime.UtcNow; + + if (sampleId - lastProcessListSample >= nextProcessListSample && + (currentTime - lastProcessListReport).TotalMilliseconds > 1000) { + processList = summaryBuilder.MakeSummaries(); + lastProcessListSample = sampleId; + lastProcessListReport = currentTime; + } + + if (progressCallback != null) { + int current = (int)data.TimeStampRelativeMSec; + int total = (int)source_.SessionDuration.TotalMilliseconds; + + progressCallback(new ProcessListProgress { + Total = total, + Current = current, + Processes = processList + }); + } + + lastReportedSample = sampleId; + } + }; + // Go again over events and accumulate samples to build the process summary. + DiagnosticLogger.LogInfo("[BuildProcessSummary] Starting second Process() call"); source_.Process(); + var result = summaryBuilder.MakeSummaries(); + DiagnosticLogger.LogInfo($"[BuildProcessSummary] Second Process() done, sampleId={sampleId}, processes={result?.Count ?? 0}"); profile.Dispose(); - return summaryBuilder.MakeSummaries(); + return result; } public RawProfileData ProcessEvents(ProfileLoadProgressHandler progressCallback, diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 7047bb29..83cff339 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; @@ -26,6 +27,11 @@ public class McpActionExecutor : IMcpActionExecutor private readonly MainWindow mainWindow; private readonly Dispatcher dispatcher; + // Cached process list from GetAvailableProcessesAsync to avoid re-parsing + // the ETL file when OpenTraceAsync is called immediately after. + private List cachedProcessList_; + private string cachedProcessListFilePath_; + public McpActionExecutor(MainWindow mainWindow) { this.mainWindow = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); @@ -34,6 +40,8 @@ public McpActionExecutor(MainWindow mainWindow) public async Task OpenTraceAsync(string profileFilePath, string processIdentifier) { + DiagnosticLogger.LogInfo($"[MCP] OpenTraceAsync: file={profileFilePath}, process={processIdentifier}"); + // Mark that MCP automation is active - suppress UI dialogs App.SuppressDialogsForAutomation = true; @@ -156,15 +164,18 @@ private async Task CheckIfTraceAlreadyLoadedAsync(string profil private async Task OpenTraceByProcessIdAsync(string profileFilePath, int processId) { + ProfileExplorer.UI.ProfileLoadWindow profileLoadWindow = null; try { - var loadResult = await LoadTraceAsync(profileFilePath); + var cachedList = ConsumeCachedProcessList(profileFilePath); + var loadResult = await LoadTraceAsync(profileFilePath, cachedList); + profileLoadWindow = loadResult.ProfileLoadWindow; if (!loadResult.Success) { return loadResult.Result; } - + // Select the process by PID - return await SelectProcessByPidAsync(loadResult.ProfileLoadWindow, processId); + return await SelectProcessByPidAsync(profileLoadWindow, processId); } catch (Exception ex) { @@ -175,18 +186,25 @@ private async Task OpenTraceByProcessIdAsync(string profileFile ErrorMessage = $"Unexpected error: {ex.Message}" }; } + finally + { + await CloseProfileLoadWindowOnFailureAsync(profileLoadWindow); + } } private async Task OpenTraceByProcessNameAsync(string profileFilePath, string processName) { + ProfileExplorer.UI.ProfileLoadWindow profileLoadWindow = null; try { // Load the trace and prepare the process list - var loadResult = await LoadTraceAsync(profileFilePath); + var cachedList = ConsumeCachedProcessList(profileFilePath); + var loadResult = await LoadTraceAsync(profileFilePath, cachedList); + profileLoadWindow = loadResult.ProfileLoadWindow; if (!loadResult.Success) { return loadResult.Result; } // Select the process by name - return await SelectProcessByNameAsync(loadResult.ProfileLoadWindow, processName); + return await SelectProcessByNameAsync(profileLoadWindow, processName); } catch (Exception ex) { return new OpenTraceResult { @@ -195,20 +213,68 @@ private async Task OpenTraceByProcessNameAsync(string profileFi ErrorMessage = $"Unexpected error: {ex.Message}" }; } + finally + { + await CloseProfileLoadWindowOnFailureAsync(profileLoadWindow); + } + } + + /// + /// Returns and clears the cached process list if it matches the given file path. + /// + private List ConsumeCachedProcessList(string profileFilePath) + { + var cachedList = cachedProcessList_; + var cachedPath = cachedProcessListFilePath_; + cachedProcessList_ = null; + cachedProcessListFilePath_ = null; + + if (cachedList != null && cachedPath != null && + Path.GetFullPath(cachedPath).Equals(Path.GetFullPath(profileFilePath), StringComparison.OrdinalIgnoreCase)) + { + return cachedList; + } + + return null; + } + + /// + /// Closes the ProfileLoadWindow if it's still open after an MCP operation fails or completes. + /// Prevents dialog accumulation across multiple open_trace calls. + /// + private async Task CloseProfileLoadWindowOnFailureAsync(ProfileExplorer.UI.ProfileLoadWindow window) + { + if (window == null) return; + try + { + await dispatcher.InvokeAsync(() => + { + if (window.IsVisible) + { + window.Close(); + } + }); + } + catch + { + // Ignore errors during cleanup + } } - private async Task<(bool Success, ProfileExplorer.UI.ProfileLoadWindow ProfileLoadWindow, OpenTraceResult Result)> LoadTraceAsync(string profileFilePath) { + private async Task<(bool Success, ProfileExplorer.UI.ProfileLoadWindow ProfileLoadWindow, OpenTraceResult Result)> LoadTraceAsync(string profileFilePath, List cachedProcessList = null) { + DiagnosticLogger.LogInfo($"[MCP] LoadTraceAsync: file={profileFilePath}, cachedProcessList={cachedProcessList != null} ({cachedProcessList?.Count ?? 0} processes)"); + // Execute the command in the background since ShowDialog() blocks var task = Task.Run(() => { dispatcher.Invoke(() => AppCommand.LoadProfile.Execute(null, mainWindow)); }); - + // Wait for the dialog to be created and shown (with timeout) var profileLoadWindow = await WaitForWindowAsync(TimeSpan.FromSeconds(5)); if (profileLoadWindow == null) { - var errorResult = new OpenTraceResult + var errorResult = new OpenTraceResult { Success = false, FailureReason = OpenTraceFailureReason.UIError, @@ -216,47 +282,64 @@ private async Task OpenTraceByProcessNameAsync(string profileFi }; return (false, null, errorResult); } - - // Step 1: Set the profile file path - await dispatcher.InvokeAsync(() => { - profileLoadWindow.ProfileFilePath = profileFilePath; - }); - // Step 2: Trigger the text changed logic to load the process list - await dispatcher.InvokeAsync(() => + if (cachedProcessList != null) { - var textChangedMethod = profileLoadWindow.GetType().GetMethod("ProfileAutocompleteBox_TextChanged", - BindingFlags.NonPublic | BindingFlags.Instance); - if (textChangedMethod != null) + // Fast path: inject the cached process list directly, skipping ETL re-parse. + // Keep SkipTextChangedProcessing=true permanently for MCP - the autocomplete box + // may fire TextChanged asynchronously (deferred for filtering), so resetting the + // flag to false would let the deferred event clear the process list we just set. + DiagnosticLogger.LogInfo($"[MCP] LoadTraceAsync: injecting cached process list ({cachedProcessList.Count} processes)"); + await dispatcher.InvokeAsync(() => { + profileLoadWindow.SkipTextChangedProcessing = true; + profileLoadWindow.ProfileFilePath = profileFilePath; + profileLoadWindow.SetPreloadedProcessList(cachedProcessList); + }); + } + else + { + // Normal path: set the file path and let the UI load the process list. + // Step 1: Set the profile file path + await dispatcher.InvokeAsync(() => { + profileLoadWindow.ProfileFilePath = profileFilePath; + }); + + // Step 2: Trigger the text changed logic to load the process list + await dispatcher.InvokeAsync(() => { - textChangedMethod.Invoke(profileLoadWindow, new object[] { profileLoadWindow, new RoutedEventArgs() }); - } - }); + var textChangedMethod = profileLoadWindow.GetType().GetMethod("ProfileAutocompleteBox_TextChanged", + BindingFlags.NonPublic | BindingFlags.Instance); + if (textChangedMethod != null) + { + textChangedMethod.Invoke(profileLoadWindow, new object[] { profileLoadWindow, new RoutedEventArgs() }); + } + }); - // Step 3: Wait for process list to finish loading (with timeout) - bool processListLoaded = await WaitForProcessListLoadedAsync(profileLoadWindow, TimeSpan.FromMinutes(2)); - if (!processListLoaded) - { - var errorResult = new OpenTraceResult + // Step 3: Wait for process list to finish loading (with timeout) + bool processListLoaded = await WaitForProcessListLoadedAsync(profileLoadWindow, TimeSpan.FromMinutes(2)); + if (!processListLoaded) { - Success = false, - FailureReason = OpenTraceFailureReason.ProcessListLoadTimeout, - ErrorMessage = "Timeout while loading process list from trace file" - }; - return (false, profileLoadWindow, errorResult); - } + var errorResult = new OpenTraceResult + { + Success = false, + FailureReason = OpenTraceFailureReason.ProcessListLoadTimeout, + ErrorMessage = "Timeout while loading process list from trace file" + }; + return (false, profileLoadWindow, errorResult); + } - // Step 4: Additional verification that ItemsSource is actually populated - var verificationResult = await WaitForItemsSourceAsync(profileLoadWindow, TimeSpan.FromSeconds(10)); - if (!verificationResult) - { - var errorResult = new OpenTraceResult + // Step 4: Additional verification that ItemsSource is actually populated + var verificationResult = await WaitForItemsSourceAsync(profileLoadWindow, TimeSpan.FromSeconds(10)); + if (!verificationResult) { - Success = false, - FailureReason = OpenTraceFailureReason.ProcessListLoadTimeout, - ErrorMessage = "Process list failed to load properly" - }; - return (false, profileLoadWindow, errorResult); + var errorResult = new OpenTraceResult + { + Success = false, + FailureReason = OpenTraceFailureReason.ProcessListLoadTimeout, + ErrorMessage = "Process list failed to load properly" + }; + return (false, profileLoadWindow, errorResult); + } } return (true, profileLoadWindow, null); @@ -467,7 +550,7 @@ private async Task WaitForWindowAsync(TimeSpan timeout) where T : Window private async Task WaitForProcessListLoadedAsync(ProfileExplorer.UI.ProfileLoadWindow window, TimeSpan timeout) { var startTime = DateTime.UtcNow; - const int MinimumProcessCount = 2; // Wait for at least 2 processes to ensure full loading + const int MinimumProcessCount = 1; // Reduced from 2: the Idle filter hides PID 0, so single-process traces show count=1 while (DateTime.UtcNow - startTime < timeout) { @@ -1315,9 +1398,12 @@ private async Task WaitForTimingDataAsync(ProfileExplorer.UI.IRDocumentHos public async Task GetAvailableProcessesAsync(string profileFilePath, double? minWeightPercentage = null, int? topCount = null) { + DiagnosticLogger.LogInfo($"[MCP] GetAvailableProcessesAsync: file={profileFilePath}"); + // Validate file exists first if (!File.Exists(profileFilePath)) { + DiagnosticLogger.LogInfo($"[MCP] GetAvailableProcessesAsync: file not found"); return new GetAvailableProcessesResult { Success = false, @@ -1334,8 +1420,10 @@ public async Task GetAvailableProcessesAsync(string var options = App.Settings.ProfileOptions; // Progress callback must be non-null (ETWEventProcessor calls it unconditionally). + var sw = Stopwatch.StartNew(); var processSummaries = await ETWProfileDataProvider.FindTraceProcesses( profileFilePath, options, _ => { }, cancelableTask); + DiagnosticLogger.LogInfo($"[MCP] GetAvailableProcessesAsync: FindTraceProcesses completed in {sw.ElapsedMilliseconds}ms, count={processSummaries?.Count ?? 0}"); if (processSummaries == null || processSummaries.Count == 0) { @@ -1346,6 +1434,10 @@ public async Task GetAvailableProcessesAsync(string }; } + // Cache for reuse by OpenTraceAsync to avoid re-parsing the ETL file. + cachedProcessList_ = processSummaries; + cachedProcessListFilePath_ = profileFilePath; + // Exclude Idle/kernel process and use non-idle percentages for meaningful results. var processes = processSummaries .Where(p => p.Process.ProcessId != ETWEventProcessor.KernelProcessId) diff --git a/src/ProfileExplorerUI/Windows/ProfileLoadWindow.xaml.cs b/src/ProfileExplorerUI/Windows/ProfileLoadWindow.xaml.cs index 55cafc84..7e585187 100644 --- a/src/ProfileExplorerUI/Windows/ProfileLoadWindow.xaml.cs +++ b/src/ProfileExplorerUI/Windows/ProfileLoadWindow.xaml.cs @@ -57,6 +57,7 @@ public partial class ProfileLoadWindow : Window, INotifyPropertyChanged { private bool excludeIdleProcess_ = true; private bool showLoadingProgress_; private bool openLoadedSession_; + private bool skipTextChangedProcessing_; private double lastProgressPercentage_ = 0; public ProfileLoadWindow(IUISession session, bool recordMode, @@ -238,6 +239,26 @@ public bool ExcludeIdleProcess { } } + /// + /// When true, ProfileAutocompleteBox_TextChanged skips loading the process list. + /// Used by MCP automation to inject a pre-loaded process list without re-parsing the ETL file. + /// + public bool SkipTextChangedProcessing { + get => skipTextChangedProcessing_; + set => skipTextChangedProcessing_ = value; + } + + /// + /// Injects a pre-loaded process list, bypassing the ETL file re-parse. + /// Used by MCP automation when the process list was already obtained via GetAvailableProcessesAsync. + /// + public void SetPreloadedProcessList(List processList) { + DiagnosticLogger.LogInfo($"[MCP] SetPreloadedProcessList: {processList?.Count ?? 0} processes"); + processList_ = processList; + DisplayProcessList(processList_); + DiagnosticLogger.LogInfo($"[MCP] SetPreloadedProcessList: DisplayProcessList done, ShowProcessList={ShowProcessList}, ItemsSource null={ProcessList.ItemsSource == null}"); + } + public event PropertyChangedEventHandler PropertyChanged; private void UpdatePerfCounterList() { @@ -427,9 +448,12 @@ private async Task LoadProfileTraceFile(SymbolFileSourceSettings symbolSet UpdateRejectedFiles(report); if (!success && !task.IsCanceled) { - using var centerForm = new DialogCenteringHelper(this); - MessageBox.Show($"Failed to load profile file {ProfileFilePath}", "Profile Explorer", - MessageBoxButton.OK, MessageBoxImage.Exclamation); + if (!App.SuppressDialogsForAutomation) { + using var centerForm = new DialogCenteringHelper(this); + MessageBox.Show($"Failed to load profile file {ProfileFilePath}", "Profile Explorer", + MessageBoxButton.OK, MessageBoxImage.Exclamation); + } + ProfileReportPanel.ShowReportWindow(report, Session); } @@ -652,17 +676,23 @@ private async Task StartRecordingSession() { string appPath = SetSessionApplicationPath(); if (!File.Exists(appPath)) { - using var centerForm = new DialogCenteringHelper(this); - MessageBox.Show($"Could not find profiled application: {appPath}", "Profile Explorer", - MessageBoxButton.OK, MessageBoxImage.Error); + if (!App.SuppressDialogsForAutomation) { + using var centerForm = new DialogCenteringHelper(this); + MessageBox.Show($"Could not find profiled application: {appPath}", "Profile Explorer", + MessageBoxButton.OK, MessageBoxImage.Error); + } + return; } } else if (recordingOptions_.SessionKind == ProfileSessionKind.AttachToProcess) { if (selectedProcSummary_ == null) { - using var centerForm = new DialogCenteringHelper(this); - MessageBox.Show("Select a running process to attach to", "Profile Explorer", - MessageBoxButton.OK, MessageBoxImage.Error); + if (!App.SuppressDialogsForAutomation) { + using var centerForm = new DialogCenteringHelper(this); + MessageBox.Show("Select a running process to attach to", "Profile Explorer", + MessageBoxButton.OK, MessageBoxImage.Error); + } + return; } @@ -705,9 +735,11 @@ private async Task StartRecordingSession() { DisplayProcessList(processList_); } else { - using var centerForm = new DialogCenteringHelper(this); - MessageBox.Show("Failed to record ETW sampling profile!", "Profile Explorer", - MessageBoxButton.OK, MessageBoxImage.Exclamation); + if (!App.SuppressDialogsForAutomation) { + using var centerForm = new DialogCenteringHelper(this); + MessageBox.Show("Failed to record ETW sampling profile!", "Profile Explorer", + MessageBoxButton.OK, MessageBoxImage.Exclamation); + } } } @@ -781,8 +813,8 @@ private void MaxFrequencyButton_Click(object sender, RoutedEventArgs e) { } private async void ProfileAutocompleteBox_TextChanged(object sender, RoutedEventArgs e) { - if (IsLoadingProfile) { - return; // Ignore during load of previous session. + if (IsLoadingProfile || skipTextChangedProcessing_) { + return; // Ignore during load of previous session or MCP pre-loaded list injection. } ShowProcessList = false; @@ -792,9 +824,12 @@ private async void ProfileAutocompleteBox_TextChanged(object sender, RoutedEvent processList_ = await LoadProcessList(ProfileFilePath); if (processList_ == null) { - using var centerForm = new DialogCenteringHelper(this); - MessageBox.Show("Failed to load ETL process list!", "Profile Explorer", - MessageBoxButton.OK, MessageBoxImage.Exclamation); + if (!App.SuppressDialogsForAutomation) { + using var centerForm = new DialogCenteringHelper(this); + MessageBox.Show("Failed to load ETL process list!", "Profile Explorer", + MessageBoxButton.OK, MessageBoxImage.Exclamation); + } + return; } From 37816a2b36650a879ff63243edf84d89b8d0575e Mon Sep 17 00:00:00 2001 From: Ivan Berg Date: Tue, 7 Apr 2026 15:35:20 -0700 Subject: [PATCH 2/3] Address PR feedback: rename cleanup method and use accurate failure reason - Rename CloseProfileLoadWindowOnFailureAsync to CloseProfileLoadWindowIfVisibleAsync since it's called unconditionally in finally blocks as a safety net - Change FailureReason from "ProcessListEmpty" to "GetAvailableProcessesFailed" to accurately describe the operation that failed regardless of underlying cause --- src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs | 2 +- src/ProfileExplorerUI/Mcp/McpActionExecutor.cs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs index 313a1efa..1f6c8d2f 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs +++ b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs @@ -152,7 +152,7 @@ public static async Task OpenTrace(string profileFilePath, string proces ProfileFilePath = profileFilePath, ProcessNameOrId = processNameOrId, Status = "Failed", - FailureReason = "ProcessListEmpty", + FailureReason = "GetAvailableProcessesFailed", Description = processesResult.ErrorMessage ?? "Could not extract process list from trace file. The trace may use an unsupported format or be corrupted.", Timestamp = DateTime.UtcNow }; diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 83cff339..a319c086 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -188,7 +188,7 @@ private async Task OpenTraceByProcessIdAsync(string profileFile } finally { - await CloseProfileLoadWindowOnFailureAsync(profileLoadWindow); + await CloseProfileLoadWindowIfVisibleAsync(profileLoadWindow); } } @@ -215,7 +215,7 @@ private async Task OpenTraceByProcessNameAsync(string profileFi } finally { - await CloseProfileLoadWindowOnFailureAsync(profileLoadWindow); + await CloseProfileLoadWindowIfVisibleAsync(profileLoadWindow); } } @@ -239,10 +239,11 @@ private List ConsumeCachedProcessList(string profileFilePath) } /// - /// Closes the ProfileLoadWindow if it's still open after an MCP operation fails or completes. + /// Closes the ProfileLoadWindow if it's still visible. /// Prevents dialog accumulation across multiple open_trace calls. + /// On success the dialog closes itself via LoadButton_Click; this is a safety net. /// - private async Task CloseProfileLoadWindowOnFailureAsync(ProfileExplorer.UI.ProfileLoadWindow window) + private async Task CloseProfileLoadWindowIfVisibleAsync(ProfileExplorer.UI.ProfileLoadWindow window) { if (window == null) return; try From 80bb4471d863a269b465516555add08755b2aa40 Mon Sep 17 00:00:00 2001 From: Ivan Berg Date: Tue, 7 Apr 2026 15:55:44 -0700 Subject: [PATCH 3/3] Return clear error for traces without profiling data When a trace has no CPU sampling, PMC, or CSwitch data (all process weights are zero), return a NoProfilingData error from OpenTrace instead of attempting to load an empty profile. Also fall back to process start/stop events for the process list when no sample events exist, so the trace contents are still discoverable via GetAvailableProcesses --- .../ProfileExplorerMcpServer.cs | 18 ++++++++++++++++++ .../Profile/ETW/ETWEventProcessor.cs | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs index 1f6c8d2f..7ca7693e 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs +++ b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs @@ -85,6 +85,24 @@ public static async Task OpenTrace(string profileFilePath, string proces if (processesResult.Success) { + // Check if the trace has any profiling data (CPU sampling, PMC, etc.) + bool hasProfilingData = processesResult.Processes.Any(p => p.WeightPercentage > 0); + if (!hasProfilingData) + { + Trace.TraceInformation("[MCP] OpenTrace: trace has no profiling samples (no CPU sampling, PMC, or CSwitch data)"); + var noSamplesResult = new + { + Action = "OpenTrace", + ProfileFilePath = profileFilePath, + ProcessNameOrId = processNameOrId, + Status = "Failed", + FailureReason = "NoProfilingData", + Description = "The trace file does not contain CPU sampling or performance counter data. It may have been collected without profiling enabled.", + Timestamp = DateTime.UtcNow + }; + return System.Text.Json.JsonSerializer.Serialize(noSamplesResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + // Check for exact matches first (process ID or exact name) if (int.TryParse(processNameOrId, out int processId)) { diff --git a/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs b/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs index a070891e..f5fc92ef 100644 --- a/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs +++ b/src/ProfileExplorerCore/Profile/ETW/ETWEventProcessor.cs @@ -257,7 +257,18 @@ void HandleSampleEvent(int processId, double timeStampRelativeMSec) { DiagnosticLogger.LogInfo("[BuildProcessSummary] Starting second Process() call"); source_.Process(); var result = summaryBuilder.MakeSummaries(); - DiagnosticLogger.LogInfo($"[BuildProcessSummary] Second Process() done, sampleId={sampleId}, processes={result?.Count ?? 0}"); + DiagnosticLogger.LogInfo($"[BuildProcessSummary] Second Process() done, sampleId={sampleId}, processes={result?.Count ?? 0}, profileProcesses={profile.Processes?.Count ?? 0}"); + + // For traces without sampling events (e.g., CSwitch/context-switch traces), + // no samples are collected but processes are discovered via ProcessStart/ProcessEnd. + // Build summaries from those processes with zero weight so the list isn't empty. + if (result.Count == 0 && profile.Processes != null && profile.Processes.Count > 0) { + DiagnosticLogger.LogInfo($"[BuildProcessSummary] No samples found, building process list from {profile.Processes.Count} discovered processes"); + foreach (var proc in profile.Processes) { + result.Add(new ProcessSummary(proc, TimeSpan.Zero)); + } + } + profile.Dispose(); return result; }