Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<string>();
foreach (var s in saved)
{
if (s.IsDormant) continue;
Expand All @@ -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
{
Expand All @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/CodeShellManager/Models/AppState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ public class AppSettings
public bool IndexTerminalOutput { get; set; } = true;
public int OutputRetentionDays { get; set; } = 30; // 0 = keep forever

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
5 changes: 5 additions & 0 deletions src/CodeShellManager/Terminal/OutputIndexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
53 changes: 52 additions & 1 deletion src/CodeShellManager/Terminal/TerminalBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// <summary>AppSettings reference whose DebugTerminalTrace flag gates [DEBUG-tt] logging.</summary>
public AppSettings? DebugSettings { get; set; }
/// <summary>Short session-id prefix included in [DEBUG-tt] lines so multi-session logs are readable.</summary>
public string? DebugSessionId { get; set; }
private long _lastOutputTickMs;

public event Action<string>? RawOutputReceived;
public event Action? UserInput;

Expand All @@ -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 { }
Comment on lines +61 to +73
}

public TerminalBridge(WebView2 webView)
{
_webView = webView;
Expand Down Expand Up @@ -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)
Expand All @@ -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}");
});
}

Expand All @@ -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":
{
Expand Down
10 changes: 10 additions & 0 deletions src/CodeShellManager/Views/SettingsWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,16 @@
Foreground="#6c7086" FontSize="10" Margin="0,4,0,0"/>
</StackPanel>

<!-- Diagnostics -->
<TextBlock Text="DIAGNOSTICS" Style="{StaticResource SectionHeader}"/>
<Border BorderBrush="#313244" BorderThickness="0,0,0,1" Margin="0,4,0,0"/>

<StackPanel Margin="0,8,0,16">
<CheckBox x:Name="DebugTerminalTraceCheck" Content="Trace terminal input/output timing to crash.log"/>
<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."
Foreground="#6c7086" FontSize="10" Margin="22,0,0,0" TextWrapping="Wrap"/>
</StackPanel>

</StackPanel>
</ScrollViewer>

Expand Down
3 changes: 3 additions & 0 deletions src/CodeShellManager/Views/SettingsWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Loading