Skip to content

fix(shutdown): defer reclose via Dispatcher so OnClosing yields first#47

Merged
AThraen merged 1 commit into
mainfrom
fix/onclosing-reentrant-close
May 15, 2026
Merged

fix(shutdown): defer reclose via Dispatcher so OnClosing yields first#47
AThraen merged 1 commit into
mainfrom
fix/onclosing-reentrant-close

Conversation

@AThraen
Copy link
Copy Markdown
Contributor

@AThraen AThraen commented May 15, 2026

Summary

  • Fixes InvalidOperationException: Cannot ... call Close ... while a Window is closing. thrown from MainWindow.OnClosing at line 4770 when shutting down in --clean debug mode.
  • Queues the post-cleanup reclose via Dispatcher.BeginInvoke instead of calling Close() inline, so WPF gets a chance to process the cancelled first pass and reset its internal _isClosing flag.

Root cause

OnClosing is async void using a cancel-then-reclose pattern: first entry sets e.Cancel = true, runs async cleanup, then calls Close() to re-enter through the _shutdownComplete branch. The cancellation only takes effect once OnClosing yields control back to WPF — that's when WPF reads e.Cancel and resets _isClosing.

In --clean mode none of the awaits actually yield:

  • SaveStateAsync() short-circuits (if (App.CleanStart) return;), so its await resolves synchronously
  • The foreach over _vm.Sessions is empty because clean mode clears the session list, so await DisposeAndWaitForExitAsync never runs
  • The remaining DB close / tray dispose is synchronous

So OnClosing runs straight through to the final Close() without ever yielding — WPF's _isClosing is still true and Close() throws.

Fix

Swap inline Close() for _ = Dispatcher.BeginInvoke(new System.Action(Close)). OnClosing returns first, WPF resets _isClosing, then the queued Close() re-enters cleanly through the _shutdownComplete branch. Works in both the no-yield clean case and the normal case with live sessions.

Test plan

  • Run dotnet run --project src/CodeShellManager/CodeShellManager.csproj -- --clean → close window → verify no exception in crash.log and the app exits cleanly.
  • Run without --clean with several live sessions (including a Claude session) → close window → verify normal shutdown, state.json is written, and Claude sessions are disposed serially.
  • Double-click the X during async cleanup → verify the second OnClosing entry still hits the _isShuttingDown gate and doesn't re-run cleanup.

🤖 Generated with Claude Code

OnClosing is async void using a cancel-then-reclose pattern: first entry sets
e.Cancel=true, runs async cleanup, then calls Close() to re-enter through the
_shutdownComplete branch. The cancel only takes effect once OnClosing yields
control back to WPF — that's when WPF reads e.Cancel and resets its internal
_isClosing flag.

In --clean mode none of the awaits actually yield: SaveStateAsync short-circuits
(if App.CleanStart return), the foreach over _vm.Sessions is empty because
clean mode clears the session list, and the remaining work is synchronous. So
OnClosing runs straight through to the final Close() with WPF still in the
closing state, and Close() throws InvalidOperationException: "Cannot set
Visibility to Visible or call Show, ShowDialog, Close, or
WindowInteropHelper.EnsureHandle while a Window is closing."

Queue the reclose via Dispatcher.BeginInvoke so OnClosing returns first, WPF
resets _isClosing, and the queued Close() then re-enters cleanly. Works for
both the no-yield clean case and the normal case with live sessions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@AThraen AThraen merged commit 6c1b1f2 into main May 15, 2026
1 check passed
@AThraen AThraen deleted the fix/onclosing-reentrant-close branch May 15, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant