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() {