@@ -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 \n This 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>
0 commit comments