Release plans and cancel running work when a tab closes - #606
Merged
Merged
Conversation
A plan viewer registers its plan with PlanSessionManager when it loads, and only PlanViewerControl.Clear() took it back out. The docs in a query session's own strip already called it, but every window-level close did not: closing a plan tab, closing a query session (which left every plan viewer in it registered) and closing a detached plan window all removed the tab and nothing else. The plan then stayed in memory, and in the MCP list_plans answer, until the app exited. TryCloseTabAsync is the one place every window-level close ends (the X, middle-click, Ctrl+W, and the context menu's Close, Close Other Tabs and Close All Tabs), so it now releases the content of the tab it removed. The detached window's real close does the same. Detach and re-dock move the content and do not release it. The release reads the tab's content at close time, so a tab whose spinner was replaced by a plan releases the plan. Tests register nothing by count: each one reads the session id of the viewer it opened and asks the manager about that id alone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
The loading tab a query or plan capture opens has a Cancel button and an Escape handler. Both cancelled the session's current run, read from the _executionCts field when the key was pressed, and both stay attached after the run ends: a failed capture leaves its tab on screen, still focusable. Escape pressed on that tab later cancelled whatever run was newer, on a different tab. Each run now takes its own source from BeginRun and the handlers close over it, so they can only ever cancel the run their tab was opened for. BeginRun no longer disposes the source it replaces. Those handlers hold the older source and can fire after a newer run has started, and Cancel on a disposed source throws. This is the codebase's written rule, cancelled and never disposed, already applied to FetchDatabaseMetadataAsync and the Overview's refresh. MainWindow.PlanViewer.cs (Get Actual Plan from a file) already closes over a local source, so it needed no change. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
A tab close removed the tab and cancelled nothing, so work kept running on the server for a tab nobody could see, and a query or plan capture runs with no timeout. - A Query Store grid document is now released when it closes. The grid gets CancelFetch, which cancels its fetch and its database check and refuses to start the first fetch it posted at construction if the tab is already gone. It cancels and never disposes: the wait-stats expander reads _fetchCts.Token fresh when it runs, and Token on a disposed source throws. - One ReleaseDocument now says what each document kind gives up (plan viewer unregisters, grid and History cancel their fetch, a loading tab cancels the run it shows). CloseDocument and the ✕ built by CreateSubTab both go through it, so History's own onClose is gone. AddQueryStoreDocument is the one place a grid document is built; the two call sites had the same ten lines. - A loading tab remembers its run, so closing the tab cancels it. Closing a whole query session also cancels its current run and its database metadata fetch. The window-level Get Actual Plan tab does the same through AddLoadingTab. - A run whose result arrives after it was cancelled no longer builds a viewer. Its tab may already be closed, and a viewer built for it would register a plan that nothing is left to unregister. - RemoveDocument ignores a document that is not in the strip, since a cancelled run now removes its tab a second time after the close did. The Overview already cancels its own loads when it detaches from the visual tree, so it is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
The session's own KeyDown handler cancelled the current run for every Escape it heard, and it hears every Escape in the session. A keystroke on a finished plan, a Query Store grid or the Overview reached past its own tab and stopped a capture running somewhere else, which is the same defect the loading tab's own handlers had. Escape now cancels the current run only where that run is: in the editor, which is where it was started from, or on the tab that is showing it. Escape in the editor and on the loading tab behave as before. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza
erikdarlingdata
marked this pull request as ready for review
September 28, 2026 21:47
|
Reviewed the non-test changes. I found nothing blocking.
Minor points, none blocking:
|
Owner
Author
|
The DockPanel match is the same one the open-file path uses to find an existing plan tab (MainWindow.FileOps.cs, the existing.Content is DockPanel lookup), so a new wrapper would have to change both. ClosingAPlanTabTakesItsPlanOffTheMcpSessionList opens a plan file through the real path and fails if that tab stops releasing its viewer. A pasted plan tab is built by the same CreatePlanTabContent, but no test pastes one. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
This PR fixes three review findings about closing tabs in the desktop app. It has one commit per fix, plus a fourth commit for a related Escape case that you can drop on its own. Two premises in the review were partly wrong, and the last section of this list says where.
E2: a closed tab left its plan registered
Every plan viewer registers its plan with PlanSessionManager, and only PlanViewerControl.Clear() removed it. The plan then stayed in memory, and the MCP list_plans tool kept listing it, until the app exited.
E4: closing a tab cancelled nothing
E8: Escape on one tab cancelled another tab's run
Where the review was wrong or incomplete
Which component(s) does this affect?
How was this tested?
The new class is TabCloseCleanupTests, with 17 headless tests. They need no server. The manager is shared by the whole process. So each plan test reads the session id of the viewer it opened and asks the manager about that id alone. Tabs are closed through their own close buttons, so the real close paths run.
I switched each fix off and confirmed that its tests fail. With the releases and the per-run handlers off, 13 of the 14 tests then in the class failed. The one that passed checks that Escape on a loading tab still cancels its own run. With the session Escape check off, its one test failed.
Full suite, run once at the end: total 1143, failed 0, succeeded 1113, skipped 30. Of the 30 skipped tests, 28 are macOS keychain tests. The other 2 pin contracts for setups this run does not have. The build has 0 warnings.
Not done
Checklist
dotnet build -c Debug)dotnet test)🤖 Generated with Claude Code
https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza