From 092b8011aae86b672529374d8dca4c3dffb48769 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Wed, 13 May 2026 15:33:58 +0200 Subject: [PATCH 1/3] feat: terminal trace setting + shutdown/restore stability fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in AppSettings.DebugTerminalTrace (off by default) under Settings > Diagnostics. When on, TerminalBridge logs per-keystroke and per-output-chunk timing plus WPF dispatcher latency to crash.log under the [DEBUG-tt] prefix, so intermittent terminal-input freezes can be diagnosed after the fact. Zero cost when off. Also fixes two latent bugs surfaced while auditing crash.log: - OutputIndexer.Dispose now drains its writer task (2s bounded wait) before MainWindow closes the shared SqliteConnection, so pending FTS5 INSERTs finish first. _db.Close/Dispose in OnClosing are also try/caught defensively — SqliteConnection.Close has been observed to NRE internally during shutdown regardless of the indexer race. - Bulk restore now batches WebView2 UnauthorizedAccessException into a single consolidated dialog at end of restore instead of N "Restore Error" popups, with a message pointing at the likely cause (another running instance locking the WebView2 user-data folder). Co-Authored-By: Claude Opus 4.7 --- src/CodeShellManager/MainWindow.xaml.cs | 45 +++++++++++++++-- src/CodeShellManager/Models/AppState.cs | 7 +++ .../Terminal/OutputIndexer.cs | 5 ++ .../Terminal/TerminalBridge.cs | 49 ++++++++++++++++++- .../Views/SettingsWindow.xaml | 10 ++++ .../Views/SettingsWindow.xaml.cs | 3 ++ 6 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 4312db2..3e2e4d7 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -231,6 +231,10 @@ private async void OnLoaded(object sender, RoutedEventArgs e) // read-modify-write on ~/.claude.json at startup, so simultaneous // boots can corrupt the user's profile. 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; @@ -241,8 +245,11 @@ 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; } @@ -250,6 +257,17 @@ private async void OnLoaded(object sender, RoutedEventArgs e) { if (s.IsDormant) AddDormantSidebarItem(s); } + 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 { @@ -259,6 +277,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(); @@ -827,6 +853,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) @@ -3863,6 +3894,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 @@ -4068,8 +4100,13 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) await _vm.SaveStateAsync(); foreach (var vm in _vm.Sessions.ToList()) vm.Dispose(); - _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); } diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index 5a99fce..a19a925 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -80,6 +80,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..a910428 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 gap = _lastOutputTickMs == 0 ? 0 : now - _lastOutputTickMs; + _lastOutputTickMs = now; + Trace($"OUTPUT recv len={rawData.Length} gap-since-prev={gap}ms"); + } + RawOutputReceived?.Invoke(rawData); if (!_ready) @@ -167,8 +197,12 @@ 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(() => { + if (enqueueAt != 0) + Trace($"OUTPUT post dispatcher-latency={Environment.TickCount64 - enqueueAt}ms len={len}"); try { _webView.CoreWebView2?.PostWebMessageAsString(json); } catch { } }); @@ -193,9 +227,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 0cda35a..6016e23 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml +++ b/src/CodeShellManager/Views/SettingsWindow.xaml @@ -423,6 +423,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 107ead8..d412939 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml.cs +++ b/src/CodeShellManager/Views/SettingsWindow.xaml.cs @@ -48,6 +48,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null) TerminalLineHeight = current.TerminalLineHeight, IndexTerminalOutput = current.IndexTerminalOutput, OutputRetentionDays = current.OutputRetentionDays, + DebugTerminalTrace = current.DebugTerminalTrace, LaunchCommands = current.LaunchCommands.ToList(), }; @@ -86,6 +87,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; @@ -165,6 +167,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) From b5acd2c04f61abd5b72ca55f3b7f661f128bdcf9 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Wed, 13 May 2026 20:37:05 +0200 Subject: [PATCH 2/3] fix(shutdown): guard OnClosing against re-entry during async cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit's cancel-and-reclose pattern only gated on _shutdownComplete, which is set AFTER async cleanup finishes. If the user clicks the close button a second time while SaveStateAsync / claude disposal is mid-flight, OnClosing re-enters, falls through the _shutdownComplete=false branch, and runs the full cleanup sequence a second time concurrently — double SaveStateAsync (state.json corruption risk), double Dispose on each SessionViewModel (ObjectDisposedException), double _db.Close (already swallowed by try/catch, but still pointless work). Add an _isShuttingDown flag set immediately on first entry, before any await. Re-entries during the async phase still cancel the close (e.Cancel = true) but return without re-running cleanup. --- src/CodeShellManager/MainWindow.xaml.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index ef86ce8..d27d491 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -88,10 +88,13 @@ 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 this gate the window + // 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 - // cancels the close, runs the async cleanup, sets the flag, then re-invokes Close(); - // the second entry passes through to base.OnClosing. + // 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() @@ -4295,17 +4298,21 @@ private void CycleSession(bool forward) protected override async void OnClosing(System.ComponentModel.CancelEventArgs e) { - // Second entry: cleanup is finished, let WPF tear the window down for real. + // Final entry: cleanup is finished, let WPF tear the window down for real. if (_shutdownComplete) { base.OnClosing(e); return; } - // First entry: WPF won't wait for async work, so cancel this close, do the - // cleanup, then call Close() again to re-enter here through the branch above. + // 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); From 6efb4b267889743fbd54b785b552311ffc780dcd Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Wed, 13 May 2026 20:45:14 +0200 Subject: [PATCH 3/3] fix(diagnostics): clarify trace privacy + post-message before tracing Two small follow-ups from Copilot review on #36: - Settings help text now states explicitly that only timing/byte-length metadata is logged, never the actual keystroke or output content. The old wording ("Logs every keystroke...") could be read as content logging. - In OnPtyData's dispatcher callback, capture the dispatcher latency into a local first, then PostWebMessageAsString, then Trace. Previous ordering ran sync File.AppendAllText *before* the WebView2 post, which delayed terminal rendering whenever tracing was enabled. The measurement itself was already taken inside the callback so accuracy is unchanged. --- src/CodeShellManager/Terminal/TerminalBridge.cs | 8 ++++++-- src/CodeShellManager/Views/SettingsWindow.xaml | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index 4032063..6f9e050 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -201,10 +201,14 @@ private void OnPtyData(string rawData) int len = rawData.Length; WpfApplication.Current?.Dispatcher.BeginInvoke(() => { - if (enqueueAt != 0) - Trace($"OUTPUT post dispatcher-latency={Environment.TickCount64 - enqueueAt}ms len={len}"); + // 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}"); }); } diff --git a/src/CodeShellManager/Views/SettingsWindow.xaml b/src/CodeShellManager/Views/SettingsWindow.xaml index 0e73adc..5bda835 100644 --- a/src/CodeShellManager/Views/SettingsWindow.xaml +++ b/src/CodeShellManager/Views/SettingsWindow.xaml @@ -441,8 +441,8 @@ - - +