From 5162ac8fecf5e81318002a2279ad323c4ce048db Mon Sep 17 00:00:00 2001 From: test Date: Wed, 13 May 2026 17:43:05 +0800 Subject: [PATCH 1/6] Add CloseTrace MCP command and UI support Introduce a CloseTrace flow for MCP automation: add CloseTraceAsync to IMcpActionExecutor with CloseTraceResult and CloseTraceFailureReason types; implement CloseTrace MCP tool that returns structured JSON and mark OpenTrace to fail fast with TraceAlreadyLoaded when a profile is already open. Implement McpActionExecutor.CloseTraceAsync (drives UI dispatcher to close the session), a GetLoadedProfileSummaryAsync helper, and change OpenTrace behavior to require callers to close an existing trace first. Add MainWindow.CloseSessionAsync and clear ProfileData in SessionStateManager.Dispose to ensure the UI/MCP "is profile loaded" probe updates correctly. Add --mcp startup mode to App to suppress dialogs and keep the window hidden until needed. Also include a mock CloseTraceAsync in Program.cs for testing. These changes make trace closing idempotent, observable to automation, and prevent ambiguous open_trace operations when a trace is already loaded. --- src/ProfileExplorer.Mcp/IMcpActionExecutor.cs | 51 ++++++++++ .../ProfileExplorerMcpServer.cs | 77 ++++++++++++++- src/ProfileExplorer.Mcp/Program.cs | 10 ++ src/ProfileExplorerUI/App.xaml.cs | 15 ++- src/ProfileExplorerUI/MainWindowSession.cs | 8 ++ .../Mcp/McpActionExecutor.cs | 97 +++++++++++++++++-- .../Session/SessionStateManager.cs | 4 + 7 files changed, 254 insertions(+), 8 deletions(-) diff --git a/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs b/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs index aef6809e..447db368 100644 --- a/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs +++ b/src/ProfileExplorer.Mcp/IMcpActionExecutor.cs @@ -58,6 +58,14 @@ public interface IMcpActionExecutor /// Optional number to limit results to the top N binaries by performance (e.g., 10 for top 10 most time-consuming binaries) /// Task that completes with the list of available binaries in the currently loaded process Task GetAvailableBinariesAsync(double? minTimePercentage = null, TimeSpan? minTime = null, int? topCount = null); + + /// + /// Closes the currently loaded trace/profile session and releases its resources. + /// Idempotent: returns Success=true with WasLoaded=false when no profile session is loaded. + /// Only closes profile sessions; non-profile sessions (e.g. IR documents) are left untouched. + /// + /// Task that completes with details about what was closed + Task CloseTraceAsync(); } /// @@ -127,6 +135,49 @@ public enum OpenTraceFailureReason ProcessListLoadTimeout, ProfileLoadTimeout, UIError, + UnknownError, + TraceAlreadyLoaded +} + +/// +/// Result of a CloseTrace operation. Idempotent — Success=true with WasLoaded=false +/// is the expected response when no trace was loaded at the time of the call. +/// +public class CloseTraceResult +{ + public bool Success { get; set; } + public CloseTraceFailureReason FailureReason { get; set; } = CloseTraceFailureReason.None; + public string? ErrorMessage { get; set; } + + /// + /// True if a profile session was actually loaded and closed by this call. + /// False (with Success=true) when no profile was loaded — the call is a no-op. + /// + public bool WasLoaded { get; set; } + + /// + /// Path of the trace that was closed, if any. + /// + public string? ClosedProfilePath { get; set; } + + /// + /// Process id that was loaded in the closed trace, if known. + /// + public int? ClosedProcessId { get; set; } + + /// + /// Friendly name of the process that was loaded in the closed trace, if known. + /// + public string? ClosedProcessName { get; set; } +} + +/// +/// Specific reasons why a CloseTrace operation might fail. +/// +public enum CloseTraceFailureReason +{ + None, + UIError, UnknownError } diff --git a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs index 8ae018db..c0a87931 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs +++ b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs @@ -59,7 +59,7 @@ 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")] + [McpServerTool, Description("Open and load a trace file with a specific process by name or ID. Precondition: no trace may be loaded; call close_trace first if one is. Returns Failed/TraceAlreadyLoaded otherwise.")] public static async Task OpenTrace(string profileFilePath, string processNameOrId) { if (string.IsNullOrWhiteSpace(profileFilePath)) @@ -75,6 +75,22 @@ public static async Task OpenTrace(string profileFilePath, string proces throw new InvalidOperationException("MCP action executor is not initialized"); } + // Strict precondition: a profile must not already be loaded. Enforce here + // (before the process-list preflight below) so ambiguous-process responses + // don't bypass the check. + var status = await _executor.GetStatusAsync(); + if (status.IsProfileLoaded) + { + return SerializeOpenTraceResult(new OpenTraceResult + { + Success = false, + FailureReason = OpenTraceFailureReason.TraceAlreadyLoaded, + ErrorMessage = $"A trace is already loaded ('{status.CurrentProfilePath}'" + + (status.CurrentProcess != null ? $", PID {status.CurrentProcess.ProcessId}" : "") + + "). Call close_trace before open_trace." + }, profileFilePath, processNameOrId); + } + // First, check if this might be an ambiguous query by getting available processes GetAvailableProcessesResult processesResult = await _executor.GetAvailableProcessesAsync(profileFilePath); @@ -648,6 +664,60 @@ public static async Task GetFunctionAssembly(string functionName) } } + [McpServerTool, Description("Close the currently loaded trace and release its resources. Use between iterations when capturing fresh traces (build → capture → close_trace → open_trace → analyze). Idempotent: succeeds with WasLoaded=false when no trace is loaded. Only closes profile/trace sessions; non-profile sessions are left untouched.")] + public static async Task CloseTrace() + { + try + { + if (_executor == null) + { + throw new InvalidOperationException("MCP action executor is not initialized"); + } + + var result = await _executor.CloseTraceAsync(); + + if (!result.Success) + { + var errorResult = new + { + Action = "CloseTrace", + Status = "Failed", + FailureReason = result.FailureReason.ToString(), + Description = result.ErrorMessage ?? "Unknown error closing trace", + Timestamp = DateTime.UtcNow + }; + return System.Text.Json.JsonSerializer.Serialize(errorResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + + var successResult = new + { + Action = "CloseTrace", + Status = "Success", + WasLoaded = result.WasLoaded, + ClosedProfilePath = result.ClosedProfilePath, + ClosedProcessId = result.ClosedProcessId, + ClosedProcessName = result.ClosedProcessName, + Description = result.WasLoaded + ? $"Closed trace '{result.ClosedProfilePath}' (PID: {result.ClosedProcessId})" + : "No trace was loaded; close was a no-op", + Timestamp = DateTime.UtcNow + }; + return System.Text.Json.JsonSerializer.Serialize(successResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception ex) + { + var errorResult = new + { + Action = "CloseTrace", + Status = "Error", + Error = ex.Message, + Timestamp = DateTime.UtcNow + }; + + return System.Text.Json.JsonSerializer.Serialize(errorResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + } + #endregion #region Help Tool @@ -694,6 +764,11 @@ public static string GetHelp() Description = "Get assembly code for a specific function by double-clicking on it in the Summary pane, and save it to a file for later contextual reference by Copilot", Parameters = "functionName (string) - Name of the function to retrieve assembly for (supports partial matching)" }, + new { + Name = "CloseTrace", + Description = "Close the currently loaded trace and release its resources. Use between iterations when capturing fresh traces. Idempotent: succeeds even if no trace is loaded.", + Parameters = "none" + }, new { Name = "GetHelp", Description = "Get help information about available MCP commands", diff --git a/src/ProfileExplorer.Mcp/Program.cs b/src/ProfileExplorer.Mcp/Program.cs index 218340e5..ad3ff51b 100644 --- a/src/ProfileExplorer.Mcp/Program.cs +++ b/src/ProfileExplorer.Mcp/Program.cs @@ -428,4 +428,14 @@ public Task GetAvailableBinariesAsync(double? minTim Binaries = filteredBinaries }); } + + public Task CloseTraceAsync() + { + Console.WriteLine("Mock: CloseTraceAsync called"); + return Task.FromResult(new CloseTraceResult + { + Success = true, + WasLoaded = false + }); + } } diff --git a/src/ProfileExplorerUI/App.xaml.cs b/src/ProfileExplorerUI/App.xaml.cs index 65f10f6b..48cb4376 100644 --- a/src/ProfileExplorerUI/App.xaml.cs +++ b/src/ProfileExplorerUI/App.xaml.cs @@ -87,6 +87,11 @@ public partial class App : Application { /// When true, suppresses UI dialogs (like source file prompts) during MCP/automation operations. /// public static bool SuppressDialogsForAutomation; + /// + /// True when launched with --mcp: skip showing the MainWindow at startup + /// (lazily shown on first MCP tool call that needs the UI). + /// + public static bool IsMcpMode; private Task? mcpServerTask; private static List cachedSyntaxHighlightingFiles_; public static string ApplicationPath => Process.GetCurrentProcess().MainModule?.FileName; @@ -626,7 +631,15 @@ protected override void OnStartup(StartupEventArgs e) { // Create and show the main window manually var mainWindow = new MainWindow(); - mainWindow.Show(); + IsMcpMode = Array.Exists(e.Args, a => string.Equals(a, "--mcp", StringComparison.OrdinalIgnoreCase)); + if (IsMcpMode) { + // Stay alive without a visible window until MCP shuts us down or a tool shows the window. + ShutdownMode = ShutdownMode.OnExplicitShutdown; + SuppressDialogsForAutomation = true; + } + else { + mainWindow.Show(); + } // Initialize MCP server if enabled InitializeMcpServerAsync(mainWindow); diff --git a/src/ProfileExplorerUI/MainWindowSession.cs b/src/ProfileExplorerUI/MainWindowSession.cs index 637f2685..d028b590 100644 --- a/src/ProfileExplorerUI/MainWindowSession.cs +++ b/src/ProfileExplorerUI/MainWindowSession.cs @@ -709,6 +709,14 @@ private void StartSession(string filePath, SessionKind sessionKind) { HideStartPage(); } + /// + /// Public wrapper around for callers outside MainWindow + /// (in particular the MCP layer). + /// + public Task CloseSessionAsync(bool showStartPage = true) { + return EndSession(showStartPage); + } + private async Task EndSession(bool showStartPage = true) { await BeginSessionStateChange(); diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 7047bb29..43698d52 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; @@ -37,7 +38,6 @@ public async Task OpenTraceAsync(string profileFilePath, string // Mark that MCP automation is active - suppress UI dialogs App.SuppressDialogsForAutomation = true; - // Validate file exists first if (!File.Exists(profileFilePath)) { return new OpenTraceResult @@ -48,14 +48,32 @@ public async Task OpenTraceAsync(string profileFilePath, string }; } - // Check if the requested trace and process is already loaded - var alreadyLoadedResult = await CheckIfTraceAlreadyLoadedAsync(profileFilePath, processIdentifier); - if (alreadyLoadedResult != null) + // Strict precondition: a profile must not already be loaded. Caller is + // expected to invoke close_trace first. This also avoids the silent + // no-op from LoadProfile.CanExecute (MainWindowProfiling.cs:577 — + // !IsSessionStarted || ProfileData == null) when a profile is loaded. + var existingProfile = await GetLoadedProfileSummaryAsync(); + if (existingProfile != null) { - return alreadyLoadedResult; + return new OpenTraceResult + { + Success = false, + FailureReason = OpenTraceFailureReason.TraceAlreadyLoaded, + ErrorMessage = $"A trace is already loaded ('{existingProfile.Value.tracePath}', " + + $"PID {existingProfile.Value.processId}). Call close_trace before open_trace." + }; } - // Try to parse as a process ID first + // In --mcp mode the MainWindow was created hidden; show it now that we're + // about to load and display a trace. + await dispatcher.InvokeAsync(() => + { + if (!mainWindow.IsVisible) + { + mainWindow.Show(); + } + }); + if (int.TryParse(processIdentifier, out int processId)) { if (processId <= 0) @@ -1987,4 +2005,71 @@ private string ExtractModuleFullPath(string moduleName) return null; } } + + public async Task CloseTraceAsync() + { + try + { + var loaded = await GetLoadedProfileSummaryAsync(); + if (loaded == null) + { + return new CloseTraceResult + { + Success = true, + WasLoaded = false + }; + } + + // Drive the close on the UI thread. dispatcher.InvokeAsync(Func) returns a + // DispatcherOperation; awaiting only that DispatcherOperation would surface + // the inner Task without awaiting it ("Task" trap). Use .Task.Unwrap() so + // the await actually blocks until CloseSessionAsync completes. + await dispatcher + .InvokeAsync(() => mainWindow.CloseSessionAsync()) + .Task + .Unwrap(); + + return new CloseTraceResult + { + Success = true, + WasLoaded = true, + ClosedProfilePath = loaded.Value.tracePath, + ClosedProcessId = loaded.Value.processId, + ClosedProcessName = loaded.Value.processName + }; + } + catch (Exception ex) + { + return new CloseTraceResult + { + Success = false, + FailureReason = CloseTraceFailureReason.UnknownError, + ErrorMessage = $"Unexpected error closing trace: {ex.Message}" + }; + } + } + + /// + /// Returns identifying details of the currently loaded profile session, or null when + /// no profile is loaded. Reads via the dispatcher because it touches MainWindow state. + /// + private async Task<(string? tracePath, int? processId, string? processName)?> GetLoadedProfileSummaryAsync() + { + return await dispatcher.InvokeAsync(() => + { + var sessionState = mainWindow.SessionState; + var profileData = sessionState?.ProfileData; + var report = profileData?.Report; + + if (report == null) + { + return ((string? tracePath, int? processId, string? processName)?)null; + } + + string? tracePath = report.TraceInfo?.TraceFilePath; + int? processId = report.Process?.ProcessId; + string? processName = report.Process?.Name; + return (tracePath, processId, processName); + }); + } } diff --git a/src/ProfileExplorerUI/Session/SessionStateManager.cs b/src/ProfileExplorerUI/Session/SessionStateManager.cs index 66fc102e..e0fabdf8 100644 --- a/src/ProfileExplorerUI/Session/SessionStateManager.cs +++ b/src/ProfileExplorerUI/Session/SessionStateManager.cs @@ -364,6 +364,10 @@ public void EndSession() { documents_.Clear(); IsAutoSaveEnabled = false; + + // Required so MainWindow.LoadProfile.CanExecute (checks ProfileData == null) + // re-enables and the MCP "is profile loaded" probe goes false after a close. + ProfileData = null; } public async Task CancelPendingTasks() { From baa8a91125b36b714b371b161658efa9474aa9b4 Mon Sep 17 00:00:00 2001 From: test Date: Wed, 13 May 2026 17:49:31 +0800 Subject: [PATCH 2/6] Initialize MCP server only in headless startup Move InitializeMcpServerAsync(mainWindow) into the branch that keeps the app alive without a visible window so the MCP server is started when running in automation/headless mode. Remove the previous unconditional call after the if/else so the server is not started when the main window is shown. --- src/ProfileExplorerUI/App.xaml.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ProfileExplorerUI/App.xaml.cs b/src/ProfileExplorerUI/App.xaml.cs index 48cb4376..4308896e 100644 --- a/src/ProfileExplorerUI/App.xaml.cs +++ b/src/ProfileExplorerUI/App.xaml.cs @@ -636,13 +636,11 @@ protected override void OnStartup(StartupEventArgs e) { // Stay alive without a visible window until MCP shuts us down or a tool shows the window. ShutdownMode = ShutdownMode.OnExplicitShutdown; SuppressDialogsForAutomation = true; + InitializeMcpServerAsync(mainWindow); } else { mainWindow.Show(); } - - // Initialize MCP server if enabled - InitializeMcpServerAsync(mainWindow); } private void InitializeMcpServerAsync(MainWindow mainWindow) From 64ad00850c7a0df5b6fb50ebbc201341a1cfa7c1 Mon Sep 17 00:00:00 2001 From: test Date: Wed, 13 May 2026 17:56:07 +0800 Subject: [PATCH 3/6] Use non-nullable tuple in profile summary Remove unused System.Threading using and change GetLoadedProfileSummaryAsync to return a tuple with non-nullable string elements (tracePath, processName). Update the null-return cast and local variable declarations to match the new tuple types, simplifying the method's nullability annotations for callers. --- src/ProfileExplorerUI/Mcp/McpActionExecutor.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 43698d52..a19801fd 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -5,7 +5,6 @@ using System.IO; using System.Linq; using System.Reflection; -using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; @@ -2053,7 +2052,7 @@ await dispatcher /// Returns identifying details of the currently loaded profile session, or null when /// no profile is loaded. Reads via the dispatcher because it touches MainWindow state. /// - private async Task<(string? tracePath, int? processId, string? processName)?> GetLoadedProfileSummaryAsync() + private async Task<(string tracePath, int? processId, string processName)?> GetLoadedProfileSummaryAsync() { return await dispatcher.InvokeAsync(() => { @@ -2063,12 +2062,12 @@ await dispatcher if (report == null) { - return ((string? tracePath, int? processId, string? processName)?)null; + return ((string tracePath, int? processId, string processName)?)null; } - string? tracePath = report.TraceInfo?.TraceFilePath; + string tracePath = report.TraceInfo?.TraceFilePath; int? processId = report.Process?.ProcessId; - string? processName = report.Process?.Name; + string processName = report.Process?.Name; return (tracePath, processId, processName); }); } From ffca008d31e8b613e559441364470e9d483a722f Mon Sep 17 00:00:00 2001 From: test Date: Wed, 13 May 2026 18:25:54 +0800 Subject: [PATCH 4/6] Make open_trace idempotent; defer MainWindow show When a profile is already loaded, route the request through the existing OpenTraceAsync path so the call can succeed idempotently (same trace+process) or fail with TraceAlreadyLoaded (different trace) instead of returning a ambiguous precondition error. In McpActionExecutor add CheckIfTraceAlreadyLoadedAsync to short-circuit on the same trace+process, clarify the failure message to indicate a "different trace" is loaded, unify process-id vs name handling, and only show the hidden MainWindow after a successful load. These changes avoid ambiguous process-preflight behavior and prevent leaving the UI visible when open_trace ultimately fails. --- .../ProfileExplorerMcpServer.cs | 17 +++---- .../Mcp/McpActionExecutor.cs | 48 ++++++++++++------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs index c0a87931..6f47c66e 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs +++ b/src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs @@ -75,20 +75,15 @@ public static async Task OpenTrace(string profileFilePath, string proces throw new InvalidOperationException("MCP action executor is not initialized"); } - // Strict precondition: a profile must not already be loaded. Enforce here - // (before the process-list preflight below) so ambiguous-process responses - // don't bypass the check. + // If a profile is loaded, route directly to OpenTraceAsync so it can either + // succeed idempotently (same trace+process) or fail with TraceAlreadyLoaded + // (different trace). Skipping the ambiguity preflight ensures the agent gets + // a clear answer instead of a process list. var status = await _executor.GetStatusAsync(); if (status.IsProfileLoaded) { - return SerializeOpenTraceResult(new OpenTraceResult - { - Success = false, - FailureReason = OpenTraceFailureReason.TraceAlreadyLoaded, - ErrorMessage = $"A trace is already loaded ('{status.CurrentProfilePath}'" + - (status.CurrentProcess != null ? $", PID {status.CurrentProcess.ProcessId}" : "") + - "). Call close_trace before open_trace." - }, profileFilePath, processNameOrId); + OpenTraceResult preconditionResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId); + return SerializeOpenTraceResult(preconditionResult, profileFilePath, processNameOrId); } // First, check if this might be an ambiguous query by getting available processes diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index a19801fd..2a296813 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -47,9 +47,16 @@ public async Task OpenTraceAsync(string profileFilePath, string }; } - // Strict precondition: a profile must not already be loaded. Caller is - // expected to invoke close_trace first. This also avoids the silent - // no-op from LoadProfile.CanExecute (MainWindowProfiling.cs:577 — + // Idempotent: if the same trace+process is already loaded, succeed without reloading. + var alreadyLoadedResult = await CheckIfTraceAlreadyLoadedAsync(profileFilePath, processIdentifier); + if (alreadyLoadedResult != null) + { + return alreadyLoadedResult; + } + + // Strict precondition: a different profile must not already be loaded. + // Caller must call close_trace first. This also avoids the silent no-op + // from LoadProfile.CanExecute (MainWindowProfiling.cs:577 — // !IsSessionStarted || ProfileData == null) when a profile is loaded. var existingProfile = await GetLoadedProfileSummaryAsync(); if (existingProfile != null) @@ -58,21 +65,12 @@ public async Task OpenTraceAsync(string profileFilePath, string { Success = false, FailureReason = OpenTraceFailureReason.TraceAlreadyLoaded, - ErrorMessage = $"A trace is already loaded ('{existingProfile.Value.tracePath}', " + + ErrorMessage = $"A different trace is already loaded ('{existingProfile.Value.tracePath}', " + $"PID {existingProfile.Value.processId}). Call close_trace before open_trace." }; } - // In --mcp mode the MainWindow was created hidden; show it now that we're - // about to load and display a trace. - await dispatcher.InvokeAsync(() => - { - if (!mainWindow.IsVisible) - { - mainWindow.Show(); - } - }); - + OpenTraceResult result; if (int.TryParse(processIdentifier, out int processId)) { if (processId <= 0) @@ -84,11 +82,27 @@ await dispatcher.InvokeAsync(() => ErrorMessage = "Process ID must be a positive integer." }; } - return await OpenTraceByProcessIdAsync(profileFilePath, processId); + result = await OpenTraceByProcessIdAsync(profileFilePath, processId); + } + else + { + // If not a number, treat as process name + result = await OpenTraceByProcessNameAsync(profileFilePath, processIdentifier); } - // If not a number, treat as process name - return await OpenTraceByProcessNameAsync(profileFilePath, processIdentifier); + // In --mcp mode MainWindow is hidden; show it only on a successful load, + // not earlier — a late load failure should not leave a window behind. + if (result.Success) + { + await dispatcher.InvokeAsync(() => + { + if (!mainWindow.IsVisible) + { + mainWindow.Show(); + } + }); + } + return result; } /// From c19e6833c6dc6400ff1780c6f6267164a99eab2b Mon Sep 17 00:00:00 2001 From: test Date: Wed, 13 May 2026 18:37:30 +0800 Subject: [PATCH 5/6] Update McpActionExecutor.cs --- .../Mcp/McpActionExecutor.cs | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 2a296813..91d15136 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -70,7 +70,17 @@ public async Task OpenTraceAsync(string profileFilePath, string }; } - OpenTraceResult result; + // In --mcp mode the MainWindow was created hidden; show it now that we're + // about to load a trace. WPF requires Owner to have been Show()n before + // ProfileLoadWindow.Owner can be set during the load. + dispatcher.Invoke(() => + { + if (!mainWindow.IsVisible) + { + mainWindow.Show(); + } + }); + if (int.TryParse(processIdentifier, out int processId)) { if (processId <= 0) @@ -82,27 +92,11 @@ public async Task OpenTraceAsync(string profileFilePath, string ErrorMessage = "Process ID must be a positive integer." }; } - result = await OpenTraceByProcessIdAsync(profileFilePath, processId); - } - else - { - // If not a number, treat as process name - result = await OpenTraceByProcessNameAsync(profileFilePath, processIdentifier); + return await OpenTraceByProcessIdAsync(profileFilePath, processId); } - // In --mcp mode MainWindow is hidden; show it only on a successful load, - // not earlier — a late load failure should not leave a window behind. - if (result.Success) - { - await dispatcher.InvokeAsync(() => - { - if (!mainWindow.IsVisible) - { - mainWindow.Show(); - } - }); - } - return result; + // If not a number, treat as process name + return await OpenTraceByProcessNameAsync(profileFilePath, processIdentifier); } /// From 2a7a881197a9513a114f51f24eee00f861eb31e4 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 25 May 2026 16:44:32 +0800 Subject: [PATCH 6/6] address comments --- src/ProfileExplorerUI/App.xaml.cs | 7 +- src/ProfileExplorerUI/MainWindow.xaml.cs | 67 +++++--- src/ProfileExplorerUI/MainWindowProfiling.cs | 5 +- .../Mcp/McpActionExecutor.cs | 157 ++++++++++++++---- 4 files changed, 174 insertions(+), 62 deletions(-) diff --git a/src/ProfileExplorerUI/App.xaml.cs b/src/ProfileExplorerUI/App.xaml.cs index 4308896e..f19065db 100644 --- a/src/ProfileExplorerUI/App.xaml.cs +++ b/src/ProfileExplorerUI/App.xaml.cs @@ -595,6 +595,9 @@ protected override void OnStartup(StartupEventArgs e) { AppStartTime = DateTime.UtcNow; base.OnStartup(e); + IsMcpMode = Array.Exists(e.Args, a => string.Equals(a, "--mcp", StringComparison.OrdinalIgnoreCase)); + SuppressDialogsForAutomation = IsMcpMode; + // Initialize UI-specific JSON converters UIJsonUtils.Initialize(); @@ -602,7 +605,7 @@ protected override void OnStartup(StartupEventArgs e) { RegisterSettingsTypeConverters(); if (!Debugger.IsAttached) { - SetupExceptionHandling(); + SetupExceptionHandling(showUIPrompt: !IsMcpMode); } FixPopupPlacement(); @@ -631,11 +634,9 @@ protected override void OnStartup(StartupEventArgs e) { // Create and show the main window manually var mainWindow = new MainWindow(); - IsMcpMode = Array.Exists(e.Args, a => string.Equals(a, "--mcp", StringComparison.OrdinalIgnoreCase)); if (IsMcpMode) { // Stay alive without a visible window until MCP shuts us down or a tool shows the window. ShutdownMode = ShutdownMode.OnExplicitShutdown; - SuppressDialogsForAutomation = true; InitializeMcpServerAsync(mainWindow); } else { diff --git a/src/ProfileExplorerUI/MainWindow.xaml.cs b/src/ProfileExplorerUI/MainWindow.xaml.cs index e5140b26..0ceb32dd 100644 --- a/src/ProfileExplorerUI/MainWindow.xaml.cs +++ b/src/ProfileExplorerUI/MainWindow.xaml.cs @@ -99,6 +99,8 @@ public partial class MainWindow : Window, IUISession, INotifyPropertyChanged { private DocumentSearchPanel documentSearchPanel_; private bool initialDockLayoutRestored_; private bool profileControlsVisible; + private readonly TaskCompletionSource initialLoadCompleted_ = + new(TaskCreationOptions.RunContinuationsAsynchronously); public MainWindow() { InitializeComponent(); @@ -127,6 +129,7 @@ public bool ProfileControlsVisible { public event PropertyChangedEventHandler PropertyChanged; public SessionStateManager SessionState => sessionState_; + public Task InitialLoadCompleted => initialLoadCompleted_.Task; public IRTextSummary GetDocumentSummary(IRTextSection section) { return sessionState_.FindLoadedDocument(section).Summary; @@ -579,36 +582,50 @@ private void UpdateWindowTitle() { } private async void Window_Loaded(object sender, RoutedEventArgs e) { - await SetupCompilerTarget(); - SectionPanel.OpenSection += SectionPanel_OpenSection; - SectionPanel.EnterDiffMode += SectionPanel_EnterDiffMode; - SectionPanel.SyncDiffedDocumentsChanged += SectionPanel_SyncDiffedDocumentsChanged; - SectionPanel.DisplayCallGraph += SectionPanel_DisplayCallGraph; - SearchResultsPanel.OpenSection += SectionPanel_OpenSection; - - if (!RestoreDockLayout()) { - RegisterDefaultToolPanels(); - } + try { + if (compilerInfo_ == null) { + await SetupCompilerTarget(); + } + else { + SetupMainWindowCompilerTarget(); + } - ResetStatusBar(); + SectionPanel.OpenSection += SectionPanel_OpenSection; + SectionPanel.EnterDiffMode += SectionPanel_EnterDiffMode; + SectionPanel.SyncDiffedDocumentsChanged += SectionPanel_SyncDiffedDocumentsChanged; + SectionPanel.DisplayCallGraph += SectionPanel_DisplayCallGraph; + SearchResultsPanel.OpenSection += SectionPanel_OpenSection; - // Make help panel active on the first run. - if (App.IsFirstRun) { - await ShowPanel(ToolPanelKind.Help); - } + if (!RestoreDockLayout()) { + RegisterDefaultToolPanels(); + } - await ProcessStartupArgs(); + ResetStatusBar(); - if (!IsSessionStarted) { - UpdatePanelEnabledState(false); - } - else { - // Hide the start page if a file was loaded on start. - HideStartPage(); - } + // Make help panel active on the first run. + if (App.IsFirstRun) { + await ShowPanel(ToolPanelKind.Help); + } - if (App.Settings.GeneralSettings.CheckForUpdates) { - StartApplicationUpdateTimer(); + await ProcessStartupArgs(); + + if (!IsSessionStarted) { + UpdatePanelEnabledState(false); + } + else { + // Hide the start page if a file was loaded on start. + HideStartPage(); + } + + if (App.Settings.GeneralSettings.CheckForUpdates) { + StartApplicationUpdateTimer(); + } + + initialLoadCompleted_.TrySetResult(true); + } + catch (Exception ex) { + initialLoadCompleted_.TrySetException(ex); + throw; } } diff --git a/src/ProfileExplorerUI/MainWindowProfiling.cs b/src/ProfileExplorerUI/MainWindowProfiling.cs index 97f0a722..fa1ffc22 100644 --- a/src/ProfileExplorerUI/MainWindowProfiling.cs +++ b/src/ProfileExplorerUI/MainWindowProfiling.cs @@ -503,7 +503,10 @@ private async void LoadProfileExecuted(object sender, ExecutedRoutedEventArgs e) private async Task LoadProfile() { var window = new ProfileLoadWindow(this, false); - window.Owner = this; + if (IsVisible) { + window.Owner = this; + } + bool? result = window.ShowDialog(); if (result.HasValue && result.Value) { diff --git a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs index 91d15136..fa0af416 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -47,10 +47,21 @@ public async Task OpenTraceAsync(string profileFilePath, string }; } + if (string.IsNullOrWhiteSpace(processIdentifier)) + { + return new OpenTraceResult + { + Success = false, + FailureReason = OpenTraceFailureReason.UnknownError, + ErrorMessage = "Process identifier must be a process ID or process name." + }; + } + // Idempotent: if the same trace+process is already loaded, succeed without reloading. var alreadyLoadedResult = await CheckIfTraceAlreadyLoadedAsync(profileFilePath, processIdentifier); if (alreadyLoadedResult != null) { + await EnsureMainWindowReadyAsync(); return alreadyLoadedResult; } @@ -61,26 +72,20 @@ public async Task OpenTraceAsync(string profileFilePath, string var existingProfile = await GetLoadedProfileSummaryAsync(); if (existingProfile != null) { + string errorMessage = IsSameTraceFile(profileFilePath, existingProfile.Value.tracePath) + ? $"Trace '{existingProfile.Value.tracePath}' is already loaded with a different process " + + $"(PID {existingProfile.Value.processId}). Call close_trace before open_trace to switch processes." + : $"A different trace is already loaded ('{existingProfile.Value.tracePath}', " + + $"PID {existingProfile.Value.processId}). Call close_trace before open_trace."; + return new OpenTraceResult { Success = false, FailureReason = OpenTraceFailureReason.TraceAlreadyLoaded, - ErrorMessage = $"A different trace is already loaded ('{existingProfile.Value.tracePath}', " + - $"PID {existingProfile.Value.processId}). Call close_trace before open_trace." + ErrorMessage = errorMessage }; } - // In --mcp mode the MainWindow was created hidden; show it now that we're - // about to load a trace. WPF requires Owner to have been Show()n before - // ProfileLoadWindow.Owner can be set during the load. - dispatcher.Invoke(() => - { - if (!mainWindow.IsVisible) - { - mainWindow.Show(); - } - }); - if (int.TryParse(processIdentifier, out int processId)) { if (processId <= 0) @@ -92,6 +97,7 @@ public async Task OpenTraceAsync(string profileFilePath, string ErrorMessage = "Process ID must be a positive integer." }; } + return await OpenTraceByProcessIdAsync(profileFilePath, processId); } @@ -99,6 +105,40 @@ public async Task OpenTraceAsync(string profileFilePath, string return await OpenTraceByProcessNameAsync(profileFilePath, processIdentifier); } + private async Task EnsureMainWindowReadyAsync() + { + await dispatcher.InvokeAsync(() => + { + if (!mainWindow.IsVisible) + { + mainWindow.Show(); + } + }); + + await mainWindow.InitialLoadCompleted; + } + + private static bool IsSameTraceFile(string requestedPath, string loadedPath) + { + if (string.IsNullOrEmpty(requestedPath) || string.IsNullOrEmpty(loadedPath)) + { + return false; + } + + try + { + return string.Equals(Path.GetFullPath(requestedPath), + Path.GetFullPath(loadedPath), + StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is ArgumentException || + ex is NotSupportedException || + ex is PathTooLongException) + { + return string.Equals(requestedPath, loadedPath, StringComparison.OrdinalIgnoreCase); + } + } + /// /// Checks if the requested trace file and process is already loaded. /// Returns a successful OpenTraceResult if already loaded, or null if not loaded. @@ -185,11 +225,17 @@ private async Task OpenTraceByProcessIdAsync(string profileFile { var loadResult = await LoadTraceAsync(profileFilePath); if (!loadResult.Success) { + await CloseProfileLoadWindowAsync(loadResult.ProfileLoadWindow); return loadResult.Result; } // Select the process by PID - return await SelectProcessByPidAsync(loadResult.ProfileLoadWindow, processId); + var result = await SelectProcessByPidAsync(loadResult.ProfileLoadWindow, processId); + if (!result.Success) { + await CloseProfileLoadWindowAsync(loadResult.ProfileLoadWindow); + } + + return result; } catch (Exception ex) { @@ -207,11 +253,17 @@ private async Task OpenTraceByProcessNameAsync(string profileFi // Load the trace and prepare the process list var loadResult = await LoadTraceAsync(profileFilePath); if (!loadResult.Success) { + await CloseProfileLoadWindowAsync(loadResult.ProfileLoadWindow); return loadResult.Result; } // Select the process by name - return await SelectProcessByNameAsync(loadResult.ProfileLoadWindow, processName); + var result = await SelectProcessByNameAsync(loadResult.ProfileLoadWindow, processName); + if (!result.Success) { + await CloseProfileLoadWindowAsync(loadResult.ProfileLoadWindow); + } + + return result; } catch (Exception ex) { return new OpenTraceResult { @@ -287,6 +339,22 @@ await dispatcher.InvokeAsync(() => return (true, profileLoadWindow, null); } + private async Task CloseProfileLoadWindowAsync(ProfileExplorer.UI.ProfileLoadWindow profileLoadWindow) + { + if (profileLoadWindow == null) + { + return; + } + + await dispatcher.InvokeAsync(() => + { + if (profileLoadWindow.IsVisible) + { + profileLoadWindow.Close(); + } + }); + } + private async Task SelectProcessByPidAsync(ProfileExplorer.UI.ProfileLoadWindow profileLoadWindow, int processId) { // Step 5: Select the specified process from the process list // Use retry logic in case there are still brief timing issues @@ -345,17 +413,15 @@ private async Task SelectProcessByPidAsync(ProfileExplorer.UI.P ErrorMessage = $"Process with ID {processId} not found in trace file", }; } - + + await EnsureMainWindowReadyAsync(); + // Step 6: Execute the profile load (click Load button) - await dispatcher.InvokeAsync(() => + var loadClickResult = await InvokeLoadButtonClickAsync(profileLoadWindow); + if (!loadClickResult.Success) { - var loadButtonClickMethod = profileLoadWindow.GetType().GetMethod("LoadButton_Click", - BindingFlags.NonPublic | BindingFlags.Instance); - if (loadButtonClickMethod != null) - { - loadButtonClickMethod.Invoke(profileLoadWindow, new object[] { profileLoadWindow, new RoutedEventArgs() }); - } - }); + return loadClickResult; + } // Step 7: Wait for the profile to finish loading bool profileLoadCompleted = await WaitForProfileLoadingCompletedAsync(profileLoadWindow, TimeSpan.FromMinutes(30)); @@ -437,14 +503,13 @@ private async Task SelectProcessByNameAsync(ProfileExplorer.UI. }; } + await EnsureMainWindowReadyAsync(); + // Step 6: Execute the profile load (click Load button) - await dispatcher.InvokeAsync(() => { - var loadButtonClickMethod = profileLoadWindow.GetType().GetMethod("LoadButton_Click", - BindingFlags.NonPublic | BindingFlags.Instance); - if (loadButtonClickMethod != null) { - loadButtonClickMethod.Invoke(profileLoadWindow, new object[] { profileLoadWindow, new RoutedEventArgs() }); - } - }); + var loadClickResult = await InvokeLoadButtonClickAsync(profileLoadWindow); + if (!loadClickResult.Success) { + return loadClickResult; + } // Step 7: Wait for the profile to finish loading bool profileLoadCompleted = await WaitForProfileLoadingCompletedAsync(profileLoadWindow, TimeSpan.FromMinutes(30)); @@ -461,6 +526,32 @@ await dispatcher.InvokeAsync(() => { return new OpenTraceResult { Success = true }; } + private async Task InvokeLoadButtonClickAsync(ProfileExplorer.UI.ProfileLoadWindow profileLoadWindow) + { + OpenTraceResult errorResult = null; + + await dispatcher.InvokeAsync(() => + { + var loadButtonClickMethod = profileLoadWindow.GetType().GetMethod("LoadButton_Click", + BindingFlags.NonPublic | BindingFlags.Instance); + + if (loadButtonClickMethod == null) + { + errorResult = new OpenTraceResult + { + Success = false, + FailureReason = OpenTraceFailureReason.UIError, + ErrorMessage = "Failed to find profile load action." + }; + return; + } + + loadButtonClickMethod.Invoke(profileLoadWindow, new object[] { profileLoadWindow, new RoutedEventArgs() }); + }); + + return errorResult ?? new OpenTraceResult { Success = true }; + } + /// /// Waits for a window of type T to appear, with a timeout. /// @@ -470,8 +561,8 @@ private async Task WaitForWindowAsync(TimeSpan timeout) where T : Window while (DateTime.UtcNow - startTime < timeout) { - var window = await dispatcher.InvokeAsync(() => - mainWindow.OwnedWindows.OfType().FirstOrDefault()); + var window = await dispatcher.InvokeAsync(() => + Application.Current.Windows.OfType().FirstOrDefault(window => window.IsVisible)); if (window != null) {