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..6f47c66e 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,17 @@ public static async Task OpenTrace(string profileFilePath, string proces throw new InvalidOperationException("MCP action executor is not initialized"); } + // 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) + { + 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 GetAvailableProcessesResult processesResult = await _executor.GetAvailableProcessesAsync(profileFilePath); @@ -648,6 +659,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 +759,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..f19065db 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; @@ -590,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(); @@ -597,7 +605,7 @@ protected override void OnStartup(StartupEventArgs e) { RegisterSettingsTypeConverters(); if (!Debugger.IsAttached) { - SetupExceptionHandling(); + SetupExceptionHandling(showUIPrompt: !IsMcpMode); } FixPopupPlacement(); @@ -626,10 +634,14 @@ protected override void OnStartup(StartupEventArgs e) { // Create and show the main window manually var mainWindow = new MainWindow(); - mainWindow.Show(); - - // Initialize MCP server if enabled - InitializeMcpServerAsync(mainWindow); + if (IsMcpMode) { + // Stay alive without a visible window until MCP shuts us down or a tool shows the window. + ShutdownMode = ShutdownMode.OnExplicitShutdown; + InitializeMcpServerAsync(mainWindow); + } + else { + mainWindow.Show(); + } } private void InitializeMcpServerAsync(MainWindow mainWindow) 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/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..fa0af416 100644 --- a/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs +++ b/src/ProfileExplorerUI/Mcp/McpActionExecutor.cs @@ -37,7 +37,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 +47,45 @@ public async Task OpenTraceAsync(string profileFilePath, string }; } - // Check if the requested trace and process is already loaded + 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; } - // Try to parse as a process ID first + // 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) + { + 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 = errorMessage + }; + } + if (int.TryParse(processIdentifier, out int processId)) { if (processId <= 0) @@ -67,6 +97,7 @@ public async Task OpenTraceAsync(string profileFilePath, string ErrorMessage = "Process ID must be a positive integer." }; } + return await OpenTraceByProcessIdAsync(profileFilePath, processId); } @@ -74,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. @@ -160,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) { @@ -182,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 { @@ -262,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 @@ -320,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)); @@ -412,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)); @@ -436,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. /// @@ -445,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) { @@ -1987,4 +2103,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() {