Skip to content

Commit 7f3f813

Browse files
authored
Merge pull request #36 from umage-ai/feat/terminal-diagnostics
feat: terminal trace setting + shutdown/restore stability fixes
2 parents 13bf935 + 6efb4b2 commit 7f3f813

6 files changed

Lines changed: 145 additions & 6 deletions

File tree

src/CodeShellManager/MainWindow.xaml.cs

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ public partial class MainWindow : Window
8888
private readonly System.Windows.Threading.DispatcherTimer _windowStateTimer;
8989
private bool _windowStateReady = false; // don't save before state is loaded
9090

91+
// OnClosing is async void, which WPF does not await — without these gates the window
92+
// tears down while SaveStateAsync / claude disposal is still mid-flight. First entry
93+
// sets _isShuttingDown, cancels the close, runs the async cleanup, sets _shutdownComplete,
94+
// then re-invokes Close(); the second entry passes through to base.OnClosing. Any
95+
// intermediate re-entries (e.g. user double-clicks the X) hit the _isShuttingDown gate
96+
// and just cancel without re-running cleanup.
97+
private bool _isShuttingDown = false;
98+
private bool _shutdownComplete = false;
99+
91100
public MainWindow()
92101
{
93102
InitializeComponent();
@@ -252,6 +261,10 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
252261
// so simultaneous boots can corrupt the user's profile.
253262
int staggerMs = _vm.Settings.ClaudeLaunchStaggerMs;
254263
bool lastWasClaude = false;
264+
// WebView2 user-data folder access-denied is a common shared-failure
265+
// when another instance is running. Batch these so the user gets one
266+
// actionable dialog at the end instead of N "Restore Error" popups.
267+
var webView2AccessDenied = new List<string>();
255268
foreach (var s in saved)
256269
{
257270
if (s.IsDormant) continue;
@@ -262,11 +275,25 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
262275
catch (Exception ex)
263276
{
264277
Log($"Restore FAILED for '{s.Name}': {ex}");
265-
MessageBox.Show($"Failed to restore '{s.Name}': {ex.Message}",
266-
"Restore Error", MessageBoxButton.OK, MessageBoxImage.Warning);
278+
if (IsWebView2AccessDenied(ex))
279+
webView2AccessDenied.Add(s.Name);
280+
else
281+
MessageBox.Show($"Failed to restore '{s.Name}': {ex.Message}",
282+
"Restore Error", MessageBoxButton.OK, MessageBoxImage.Warning);
267283
}
268284
lastWasClaude = isClaude;
269285
}
286+
if (webView2AccessDenied.Count > 0)
287+
{
288+
MessageBox.Show(
289+
$"Could not initialize WebView2 for {webView2AccessDenied.Count} session(s):\n\n" +
290+
string.Join("\n", webView2AccessDenied.Select(n => " • " + n)) +
291+
"\n\nThis usually means another CodeShellManager instance is running, " +
292+
"or a previous instance didn't shut down cleanly. Close any other " +
293+
"instances (or wait a few seconds for the WebView2 user-data folder " +
294+
"to unlock) and reopen the affected sessions from the sidebar.",
295+
"WebView2 unavailable", MessageBoxButton.OK, MessageBoxImage.Warning);
296+
}
270297
}
271298
else
272299
{
@@ -276,6 +303,14 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
276303
}
277304
}
278305

306+
// Detects WebView2 user-data folder access-denied, which surfaces as
307+
// UnauthorizedAccessException from CoreWebView2Environment.CreateAsync /
308+
// CreateCoreWebView2ControllerAsync when another process is holding the
309+
// folder. We surface a clearer message in that specific case.
310+
private static bool IsWebView2AccessDenied(Exception ex) =>
311+
ex is UnauthorizedAccessException
312+
&& (ex.StackTrace?.Contains("WebView2", StringComparison.Ordinal) ?? false);
313+
279314
private void RestoreWindowState()
280315
{
281316
var bounds = _vm.GetSavedWindowBounds();
@@ -845,6 +880,11 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal
845880
var bridge = new TerminalBridge(webView);
846881
vm.Bridge = bridge;
847882
bridge.AcceleratorKeyPressed += OnBridgeAcceleratorKey;
883+
// Diagnostics — bridge logs per-keystroke / per-output-chunk timing when
884+
// AppSettings.DebugTerminalTrace is on. Shares the live settings ref so
885+
// toggling in the Settings dialog takes effect on existing sessions.
886+
bridge.DebugSettings = _vm.Settings;
887+
bridge.DebugSessionId = session.Id.Length >= 8 ? session.Id[..8] : session.Id;
848888

849889
// Wire output indexer and alert detector
850890
if (_db != null)
@@ -4058,6 +4098,7 @@ private void SettingsButton_Click(object sender, RoutedEventArgs e)
40584098
_vm.Settings.TerminalFontWeight = edited.TerminalFontWeight;
40594099
_vm.Settings.TerminalLetterSpacing = edited.TerminalLetterSpacing;
40604100
_vm.Settings.TerminalLineHeight = edited.TerminalLineHeight;
4101+
_vm.Settings.DebugTerminalTrace = edited.DebugTerminalTrace;
40614102
_ = _vm.SaveStateAsync();
40624103

40634104
// Push font settings to all active terminal sessions
@@ -4257,6 +4298,21 @@ private void CycleSession(bool forward)
42574298

42584299
protected override async void OnClosing(System.ComponentModel.CancelEventArgs e)
42594300
{
4301+
// Final entry: cleanup is finished, let WPF tear the window down for real.
4302+
if (_shutdownComplete)
4303+
{
4304+
base.OnClosing(e);
4305+
return;
4306+
}
4307+
4308+
// WPF won't wait for async work, so cancel this close. The reclose at the bottom
4309+
// re-enters through the _shutdownComplete branch above.
4310+
e.Cancel = true;
4311+
4312+
// Re-entry during async cleanup (e.g. user double-clicks the X) — just suppress.
4313+
if (_isShuttingDown) return;
4314+
_isShuttingDown = true;
4315+
42604316
_windowStateTimer.Stop();
42614317
if (_windowStateReady)
42624318
_vm.UpdateWindowState(WindowState, Left, Top, Width, Height);
@@ -4287,10 +4343,17 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e)
42874343
if (postExitMs > 0) await Task.Delay(Math.Min(postExitMs, 1000));
42884344
}
42894345

4290-
_db?.Close();
4291-
_db?.Dispose();
4346+
// OutputIndexer.Dispose now drains its worker first, but SqliteConnection.Close
4347+
// has been observed to throw NRE internally on shutdown — swallow + log so it
4348+
// doesn't escape as an unhandled exception during application exit.
4349+
try { _db?.Close(); }
4350+
catch (Exception ex) { Log($"OnClosing _db.Close threw: {ex}"); }
4351+
try { _db?.Dispose(); }
4352+
catch (Exception ex) { Log($"OnClosing _db.Dispose threw: {ex}"); }
42924353
App.TrayIcon?.Dispose();
4293-
base.OnClosing(e);
4354+
4355+
_shutdownComplete = true;
4356+
Close();
42944357
}
42954358

42964359
/// <summary>

src/CodeShellManager/Models/AppState.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ public class AppSettings
100100
public bool IndexTerminalOutput { get; set; } = true;
101101
public int OutputRetentionDays { get; set; } = 30; // 0 = keep forever
102102

103+
/// <summary>
104+
/// When on, TerminalBridge emits per-keystroke / per-output-chunk timing to
105+
/// crash.log (prefix [DEBUG-tt]) so intermittent freezes can be diagnosed
106+
/// after the fact. Off by default — has zero cost when off.
107+
/// </summary>
108+
public bool DebugTerminalTrace { get; set; } = false;
109+
103110
// Terminal font settings
104111
public string TerminalFontFamily { get; set; } = "'Cascadia Code', 'Cascadia Mono', Consolas, 'Courier New', monospace";
105112
public int TerminalFontSize { get; set; } = 14;

src/CodeShellManager/Terminal/OutputIndexer.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ public void Dispose()
7474
if (_disposed) return;
7575
_disposed = true;
7676
_queue.Writer.Complete();
77+
// Drain the worker before returning so any in-flight INSERTs finish before
78+
// the shared SqliteConnection is closed. Bounded wait so a slow/stuck
79+
// worker doesn't hang shutdown — pending writes are non-critical on exit.
80+
try { _worker.Wait(TimeSpan.FromSeconds(2)); }
81+
catch { /* AggregateException from worker exceptions — already swallowed inside */ }
7782
}
7883

7984
[GeneratedRegex(@"\x1B\[[0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?\x07|\x1B[=>]|\r", RegexOptions.Compiled)]

src/CodeShellManager/Terminal/TerminalBridge.cs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ public sealed class TerminalBridge : IDisposable
2727
// Output that arrived before the page finished loading is buffered here
2828
private readonly System.Text.StringBuilder _outputBuffer = new();
2929

30+
// Diagnostics — gated by AppSettings.DebugTerminalTrace. Zero cost when off.
31+
/// <summary>AppSettings reference whose DebugTerminalTrace flag gates [DEBUG-tt] logging.</summary>
32+
public AppSettings? DebugSettings { get; set; }
33+
/// <summary>Short session-id prefix included in [DEBUG-tt] lines so multi-session logs are readable.</summary>
34+
public string? DebugSessionId { get; set; }
35+
private long _lastOutputTickMs;
36+
3037
public event Action<string>? RawOutputReceived;
3138
public event Action? UserInput;
3239

@@ -51,6 +58,21 @@ private static void Log(string msg)
5158
catch { }
5259
}
5360

61+
private void Trace(string msg)
62+
{
63+
if (DebugSettings?.DebugTerminalTrace != true) return;
64+
try
65+
{
66+
string path = System.IO.Path.Combine(
67+
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
68+
"CodeShellManager", "crash.log");
69+
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
70+
System.IO.File.AppendAllText(path,
71+
$"[{DateTime.Now:HH:mm:ss.fff}] [DEBUG-tt] {DebugSessionId ?? "?"} {msg}\n");
72+
}
73+
catch { }
74+
}
75+
5476
public TerminalBridge(WebView2 webView)
5577
{
5678
_webView = webView;
@@ -157,6 +179,14 @@ public void AttachPty(PseudoTerminal pty)
157179

158180
private void OnPtyData(string rawData)
159181
{
182+
if (DebugSettings?.DebugTerminalTrace == true)
183+
{
184+
long now = Environment.TickCount64;
185+
long prev = System.Threading.Interlocked.Exchange(ref _lastOutputTickMs, now);
186+
long gap = prev == 0 ? 0 : now - prev;
187+
Trace($"OUTPUT recv len={rawData.Length} gap-since-prev={gap}ms");
188+
}
189+
160190
RawOutputReceived?.Invoke(rawData);
161191

162192
if (!_ready)
@@ -167,10 +197,18 @@ private void OnPtyData(string rawData)
167197
}
168198

169199
string json = JsonSerializer.Serialize(new { type = "output", data = rawData });
200+
long enqueueAt = DebugSettings?.DebugTerminalTrace == true ? Environment.TickCount64 : 0;
201+
int len = rawData.Length;
170202
WpfApplication.Current?.Dispatcher.BeginInvoke(() =>
171203
{
204+
// Capture latency before any work so Trace's file I/O doesn't inflate the
205+
// measurement, then post the WebView2 message before tracing so the trace
206+
// overhead doesn't delay terminal rendering.
207+
long latencyMs = enqueueAt != 0 ? Environment.TickCount64 - enqueueAt : 0;
172208
try { _webView.CoreWebView2?.PostWebMessageAsString(json); }
173209
catch { }
210+
if (enqueueAt != 0)
211+
Trace($"OUTPUT post dispatcher-latency={latencyMs}ms len={len}");
174212
});
175213
}
176214

@@ -193,9 +231,22 @@ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceived
193231
switch (type)
194232
{
195233
case "input":
196-
_pty?.Write(root.GetProperty("data").GetString() ?? "");
234+
{
235+
string data = root.GetProperty("data").GetString() ?? "";
236+
if (DebugSettings?.DebugTerminalTrace == true)
237+
{
238+
long t0 = Environment.TickCount64;
239+
Trace($"INPUT len={data.Length}");
240+
_pty?.Write(data);
241+
Trace($"PTY-WROTE elapsed={Environment.TickCount64 - t0}ms");
242+
}
243+
else
244+
{
245+
_pty?.Write(data);
246+
}
197247
UserInput?.Invoke();
198248
break;
249+
}
199250

200251
case "resize":
201252
{

src/CodeShellManager/Views/SettingsWindow.xaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,16 @@
436436
Foreground="#6c7086" FontSize="10" Margin="0,4,0,0"/>
437437
</StackPanel>
438438

439+
<!-- Diagnostics -->
440+
<TextBlock Text="DIAGNOSTICS" Style="{StaticResource SectionHeader}"/>
441+
<Border BorderBrush="#313244" BorderThickness="0,0,0,1" Margin="0,4,0,0"/>
442+
443+
<StackPanel Margin="0,8,0,16">
444+
<CheckBox x:Name="DebugTerminalTraceCheck" Content="Trace terminal input/output timing to crash.log"/>
445+
<TextBlock Text="Logs timing and byte-length metadata only — never the keystrokes or output contents — for each PTY read/write and dispatcher post, to %AppData%\CodeShellManager\crash.log (prefix [DEBUG-tt]). Use to diagnose terminal freezes. Off by default — turn on only when reproducing a problem."
446+
Foreground="#6c7086" FontSize="10" Margin="22,0,0,0" TextWrapping="Wrap"/>
447+
</StackPanel>
448+
439449
</StackPanel>
440450
</ScrollViewer>
441451

src/CodeShellManager/Views/SettingsWindow.xaml.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null)
5151
TerminalLineHeight = current.TerminalLineHeight,
5252
IndexTerminalOutput = current.IndexTerminalOutput,
5353
OutputRetentionDays = current.OutputRetentionDays,
54+
DebugTerminalTrace = current.DebugTerminalTrace,
5455
LaunchCommands = current.LaunchCommands.ToList(),
5556
};
5657

@@ -92,6 +93,7 @@ public SettingsWindow(AppSettings current, SearchService? searchService = null)
9293
MaxSearchResultsBox.Text = _edited.MaxSearchResults.ToString();
9394
IndexTerminalOutputCheck.IsChecked = _edited.IndexTerminalOutput;
9495
OutputRetentionDaysBox.Text = _edited.OutputRetentionDays.ToString();
96+
DebugTerminalTraceCheck.IsChecked = _edited.DebugTerminalTrace;
9597
_ = UpdateDatabaseSizeLabelAsync();
9698
_ = LoadUsageStatsAsync();
9799
ApiKeyBox.Password = _edited.AnthropicApiKey;
@@ -175,6 +177,7 @@ private void Save_Click(object sender, RoutedEventArgs e)
175177
_edited.IndexTerminalOutput = IndexTerminalOutputCheck.IsChecked == true;
176178
if (int.TryParse(OutputRetentionDaysBox.Text, out int retentionDays) && retentionDays >= 0)
177179
_edited.OutputRetentionDays = retentionDays;
180+
_edited.DebugTerminalTrace = DebugTerminalTraceCheck.IsChecked == true;
178181

179182
var commands = LaunchCommandsBox.Text
180183
.Split('\n', System.StringSplitOptions.RemoveEmptyEntries)

0 commit comments

Comments
 (0)