diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 93685ca..d27d491 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -88,6 +88,15 @@ public partial class MainWindow : Window private readonly System.Windows.Threading.DispatcherTimer _windowStateTimer; private bool _windowStateReady = false; // don't save before state is loaded + // OnClosing is async void, which WPF does not await — without these gates the window + // tears down while SaveStateAsync / claude disposal is still mid-flight. First entry + // sets _isShuttingDown, cancels the close, runs the async cleanup, sets _shutdownComplete, + // then re-invokes Close(); the second entry passes through to base.OnClosing. Any + // intermediate re-entries (e.g. user double-clicks the X) hit the _isShuttingDown gate + // and just cancel without re-running cleanup. + private bool _isShuttingDown = false; + private bool _shutdownComplete = false; + public MainWindow() { InitializeComponent(); @@ -252,6 +261,10 @@ private async void OnLoaded(object sender, RoutedEventArgs e) // so simultaneous boots can corrupt the user's profile. int staggerMs = _vm.Settings.ClaudeLaunchStaggerMs; bool lastWasClaude = false; + // WebView2 user-data folder access-denied is a common shared-failure + // when another instance is running. Batch these so the user gets one + // actionable dialog at the end instead of N "Restore Error" popups. + var webView2AccessDenied = new List(); foreach (var s in saved) { if (s.IsDormant) continue; @@ -262,11 +275,25 @@ private async void OnLoaded(object sender, RoutedEventArgs e) catch (Exception ex) { Log($"Restore FAILED for '{s.Name}': {ex}"); - MessageBox.Show($"Failed to restore '{s.Name}': {ex.Message}", - "Restore Error", MessageBoxButton.OK, MessageBoxImage.Warning); + if (IsWebView2AccessDenied(ex)) + webView2AccessDenied.Add(s.Name); + else + MessageBox.Show($"Failed to restore '{s.Name}': {ex.Message}", + "Restore Error", MessageBoxButton.OK, MessageBoxImage.Warning); } lastWasClaude = isClaude; } + if (webView2AccessDenied.Count > 0) + { + MessageBox.Show( + $"Could not initialize WebView2 for {webView2AccessDenied.Count} session(s):\n\n" + + string.Join("\n", webView2AccessDenied.Select(n => " • " + n)) + + "\n\nThis usually means another CodeShellManager instance is running, " + + "or a previous instance didn't shut down cleanly. Close any other " + + "instances (or wait a few seconds for the WebView2 user-data folder " + + "to unlock) and reopen the affected sessions from the sidebar.", + "WebView2 unavailable", MessageBoxButton.OK, MessageBoxImage.Warning); + } } else { @@ -276,6 +303,14 @@ private async void OnLoaded(object sender, RoutedEventArgs e) } } + // Detects WebView2 user-data folder access-denied, which surfaces as + // UnauthorizedAccessException from CoreWebView2Environment.CreateAsync / + // CreateCoreWebView2ControllerAsync when another process is holding the + // folder. We surface a clearer message in that specific case. + private static bool IsWebView2AccessDenied(Exception ex) => + ex is UnauthorizedAccessException + && (ex.StackTrace?.Contains("WebView2", StringComparison.Ordinal) ?? false); + private void RestoreWindowState() { var bounds = _vm.GetSavedWindowBounds(); @@ -845,6 +880,11 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal var bridge = new TerminalBridge(webView); vm.Bridge = bridge; bridge.AcceleratorKeyPressed += OnBridgeAcceleratorKey; + // Diagnostics — bridge logs per-keystroke / per-output-chunk timing when + // AppSettings.DebugTerminalTrace is on. Shares the live settings ref so + // toggling in the Settings dialog takes effect on existing sessions. + bridge.DebugSettings = _vm.Settings; + bridge.DebugSessionId = session.Id.Length >= 8 ? session.Id[..8] : session.Id; // Wire output indexer and alert detector if (_db != null) @@ -4058,6 +4098,7 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e) _vm.Settings.TerminalFontWeight = edited.TerminalFontWeight; _vm.Settings.TerminalLetterSpacing = edited.TerminalLetterSpacing; _vm.Settings.TerminalLineHeight = edited.TerminalLineHeight; + _vm.Settings.DebugTerminalTrace = edited.DebugTerminalTrace; _ = _vm.SaveStateAsync(); // Push font settings to all active terminal sessions @@ -4257,6 +4298,21 @@ private void CycleSession(bool forward) protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) { + // Final entry: cleanup is finished, let WPF tear the window down for real. + if (_shutdownComplete) + { + base.OnClosing(e); + return; + } + + // WPF won't wait for async work, so cancel this close. The reclose at the bottom + // re-enters through the _shutdownComplete branch above. + e.Cancel = true; + + // Re-entry during async cleanup (e.g. user double-clicks the X) — just suppress. + if (_isShuttingDown) return; + _isShuttingDown = true; + _windowStateTimer.Stop(); if (_windowStateReady) _vm.UpdateWindowState(WindowState, Left, Top, Width, Height); @@ -4287,10 +4343,17 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) if (postExitMs > 0) await Task.Delay(Math.Min(postExitMs, 1000)); } - _db?.Close(); - _db?.Dispose(); + // OutputIndexer.Dispose now drains its worker first, but SqliteConnection.Close + // has been observed to throw NRE internally on shutdown — swallow + log so it + // doesn't escape as an unhandled exception during application exit. + try { _db?.Close(); } + catch (Exception ex) { Log($"OnClosing _db.Close threw: {ex}"); } + try { _db?.Dispose(); } + catch (Exception ex) { Log($"OnClosing _db.Dispose threw: {ex}"); } App.TrayIcon?.Dispose(); - base.OnClosing(e); + + _shutdownComplete = true; + Close(); } /// diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index 79aebf8..1d19592 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -100,6 +100,13 @@ public class AppSettings public bool IndexTerminalOutput { get; set; } = true; public int OutputRetentionDays { get; set; } = 30; // 0 = keep forever + /// + /// When on, TerminalBridge emits per-keystroke / per-output-chunk timing to + /// crash.log (prefix [DEBUG-tt]) so intermittent freezes can be diagnosed + /// after the fact. Off by default — has zero cost when off. + /// + public bool DebugTerminalTrace { get; set; } = false; + // Terminal font settings public string TerminalFontFamily { get; set; } = "'Cascadia Code', 'Cascadia Mono', Consolas, 'Courier New', monospace"; public int TerminalFontSize { get; set; } = 14; diff --git a/src/CodeShellManager/Terminal/OutputIndexer.cs b/src/CodeShellManager/Terminal/OutputIndexer.cs index f5bbebe..012d7c0 100644 --- a/src/CodeShellManager/Terminal/OutputIndexer.cs +++ b/src/CodeShellManager/Terminal/OutputIndexer.cs @@ -74,6 +74,11 @@ public void Dispose() if (_disposed) return; _disposed = true; _queue.Writer.Complete(); + // Drain the worker before returning so any in-flight INSERTs finish before + // the shared SqliteConnection is closed. Bounded wait so a slow/stuck + // worker doesn't hang shutdown — pending writes are non-critical on exit. + try { _worker.Wait(TimeSpan.FromSeconds(2)); } + catch { /* AggregateException from worker exceptions — already swallowed inside */ } } [GeneratedRegex(@"\x1B\[[0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?\x07|\x1B[=>]|\r", RegexOptions.Compiled)] diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index 6f16ad7..6f9e050 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -27,6 +27,13 @@ public sealed class TerminalBridge : IDisposable // Output that arrived before the page finished loading is buffered here private readonly System.Text.StringBuilder _outputBuffer = new(); + // Diagnostics — gated by AppSettings.DebugTerminalTrace. Zero cost when off. + /// AppSettings reference whose DebugTerminalTrace flag gates [DEBUG-tt] logging. + public AppSettings? DebugSettings { get; set; } + /// Short session-id prefix included in [DEBUG-tt] lines so multi-session logs are readable. + public string? DebugSessionId { get; set; } + private long _lastOutputTickMs; + public event Action? RawOutputReceived; public event Action? UserInput; @@ -51,6 +58,21 @@ private static void Log(string msg) catch { } } + private void Trace(string msg) + { + if (DebugSettings?.DebugTerminalTrace != true) return; + try + { + string path = System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "CodeShellManager", "crash.log"); + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + System.IO.File.AppendAllText(path, + $"[{DateTime.Now:HH:mm:ss.fff}] [DEBUG-tt] {DebugSessionId ?? "?"} {msg}\n"); + } + catch { } + } + public TerminalBridge(WebView2 webView) { _webView = webView; @@ -157,6 +179,14 @@ public void AttachPty(PseudoTerminal pty) private void OnPtyData(string rawData) { + if (DebugSettings?.DebugTerminalTrace == true) + { + long now = Environment.TickCount64; + long prev = System.Threading.Interlocked.Exchange(ref _lastOutputTickMs, now); + long gap = prev == 0 ? 0 : now - prev; + Trace($"OUTPUT recv len={rawData.Length} gap-since-prev={gap}ms"); + } + RawOutputReceived?.Invoke(rawData); if (!_ready) @@ -167,10 +197,18 @@ private void OnPtyData(string rawData) } string json = JsonSerializer.Serialize(new { type = "output", data = rawData }); + long enqueueAt = DebugSettings?.DebugTerminalTrace == true ? Environment.TickCount64 : 0; + int len = rawData.Length; WpfApplication.Current?.Dispatcher.BeginInvoke(() => { + // Capture latency before any work so Trace's file I/O doesn't inflate the + // measurement, then post the WebView2 message before tracing so the trace + // overhead doesn't delay terminal rendering. + long latencyMs = enqueueAt != 0 ? Environment.TickCount64 - enqueueAt : 0; try { _webView.CoreWebView2?.PostWebMessageAsString(json); } catch { } + if (enqueueAt != 0) + Trace($"OUTPUT post dispatcher-latency={latencyMs}ms len={len}"); }); } @@ -193,9 +231,22 @@ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceived switch (type) { case "input": - _pty?.Write(root.GetProperty("data").GetString() ?? ""); + { + string data = root.GetProperty("data").GetString() ?? ""; + if (DebugSettings?.DebugTerminalTrace == true) + { + long t0 = Environment.TickCount64; + Trace($"INPUT len={data.Length}"); + _pty?.Write(data); + Trace($"PTY-WROTE elapsed={Environment.TickCount64 - t0}ms"); + } + else + { + _pty?.Write(data); + } UserInput?.Invoke(); break; + } case "resize": { diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml b/src/CodeShellManager/Views/SettingsWindow.xaml index 649a7ca..5bda835 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml +++ b/src/CodeShellManager/Views/SettingsWindow.xaml @@ -436,6 +436,16 @@ Foreground="#6c7086" FontSize="10" Margin="0,4,0,0"/> + + + + + + + + + diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml.cs b/src/CodeShellManager/Views/SettingsWindow.xaml.cs index a9c522b..7943f66 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml.cs +++ b/src/CodeShellManager/Views/SettingsWindow.xaml.cs @@ -51,6 +51,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null) TerminalLineHeight = current.TerminalLineHeight, IndexTerminalOutput = current.IndexTerminalOutput, OutputRetentionDays = current.OutputRetentionDays, + DebugTerminalTrace = current.DebugTerminalTrace, LaunchCommands = current.LaunchCommands.ToList(), }; @@ -92,6 +93,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null) MaxSearchResultsBox.Text = _edited.MaxSearchResults.ToString(); IndexTerminalOutputCheck.IsChecked = _edited.IndexTerminalOutput; OutputRetentionDaysBox.Text = _edited.OutputRetentionDays.ToString(); + DebugTerminalTraceCheck.IsChecked = _edited.DebugTerminalTrace; _ = UpdateDatabaseSizeLabelAsync(); _ = LoadUsageStatsAsync(); ApiKeyBox.Password = _edited.AnthropicApiKey; @@ -175,6 +177,7 @@ private void Save_Click(object sender, RoutedEventArgs e) _edited.IndexTerminalOutput = IndexTerminalOutputCheck.IsChecked == true; if (int.TryParse(OutputRetentionDaysBox.Text, out int retentionDays) && retentionDays >= 0) _edited.OutputRetentionDays = retentionDays; + _edited.DebugTerminalTrace = DebugTerminalTraceCheck.IsChecked == true; var commands = LaunchCommandsBox.Text .Split('\n', System.StringSplitOptions.RemoveEmptyEntries)