Repository navigation
Notice a plan arriving by every route, not just the one with a test (#447) - #481
Conversation
…447) Compare Plans came back disabled after running two queries, on a build containing the fix that was supposed to have sorted that out. #449 fixed the enablement rule and left the refresh: a plan produced by executing a query lands by having an existing tab's Content replaced, and #449 subscribed to the tab collection, which says nothing about that. The same shape sits at window level in Get Actual Plan on a file tab. Both tab controls now go through TabContentWatcher, which reports collection changes and content replacement, so the next path that produces a plan is correct without its author knowing the watcher exists. The five hand-written refreshes in Plans.cs go with it - there is one place that decides now. The owner lookup moves off TopLevel.GetTopLevel and onto the logical tree. A TabControl realises the selected tab and nothing else, so a session in a background tab could not see its own window, and a query left running while the user works elsewhere lands its plan in exactly that state - the fallback then reinstated the original bug. Erik's own table had Query Store down as broken; it is not. Its two plan-producing sites go through AddPlanTab, which did refresh. What was broken is both execution paths and the window-level actual plan. The tests now drive those paths rather than the file path they avoided last time. What still needs a SQL Server is producing plan XML, and only that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016a1AnKAHwcALrwdYVVrpgR
|
Reviewed the diff (QuerySessionControl.*, MainWindow.PlanViewer.cs/axaml.cs, new TabContentWatcher, and the expanded test file). The root-cause diagnosis holds up: Checked for issues and didn't find any:
No inline comments — nothing rises to a real finding. |
…rikdarlingdata#490) Session restore had two gaps, both from writing the list only at clean close (erikdarlingdata#468's original scope): any abnormal exit -- crash, task kill, an OS "shut down anyway" past the dirty-tab prompt -- restored zero tabs, and a file-backed plan or query detached into its own window was never written down at all, because SaveOpenPlans walked MainTabControl alone. Membership changes now write the list as they happen, debounced one second so a burst (restore, Close All) lands as one write. The trigger rides the erikdarlingdata#481 TabContentWatcher rather than the call sites, for exactly that commit's reason: a persist remembered at sixteen call sites gets forgotten at the seventeenth. The two changes the strip cannot show get explicit calls -- the detached register (a window closing changes no tab) and SaveQueryToPath (a scratch gaining its first file changes no membership). Detached windows join the collected set through a second register next to erikdarlingdata#473's: the prompt register is query-sessions-only because only an edit can be lost, while persistence needs every FILE-backed detached window, plans included. Detached entries append after the docked tabs; on the next start they come back as ordinary docked tabs, deliberately not re-detached windows. The crash-loop defense is kept and sharpened: RestoreOpenPlans clears and saves the empty list BEFORE the first open (it used to clear after the loop, which only defended against crashes after restore finished), and each file that opens successfully re-enters through the debounced writer, which restore flushes synchronously at its end. Net invariant: a file that crashes the app during load never persists -- it died before its own re-add -- while everything that opened does. A crash mid-restore still loses the tabs opened before it (their re-add was pending, the UI thread never flushed it); accepted, and said so in the code. OnClosed keeps its save as the final authoritative write, now with the debounce timer stopped first so no tick lands in a torn-down window. Under the test host no real timer is armed at all -- the suite shares one dispatcher, and a stray tick would write one test's tabs over another's staged state -- so tests drive the flush through a deterministic seam and assert the redirected settings file (erikdarlingdata#451/erikdarlingdata#487). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
Fixes #447, which I closed once already and should not have.
What was actually wrong
#449 fixed the enablement rule. It did not fix the refresh, and joshdbe's report was never about the rule.
A plan gets into a session's sub-tabs two ways, and they are shaped differently:
AddPlanTab.loadingTab.Content = viewerswaps the plan in. The tab count never changes.#449 subscribed to
MainTabControl.Itemscollection-changed and called that the single seam. A collection subscription cannot see the second shape, so every plan produced by running a query landed without a word to anybody. Two queries in two tabs, Compare dead in both — exactly as reported, on a build containing the fix.The same shape sits at window level: Get Actual Plan on a file tab opens a spinner tab and later assigns
tab.Content = CreatePlanTabContent(actualViewer)over it. Second instance of the same bug, never reported because you need a server to reach it.And a third, found by the new tests rather than by reading:
UpdateCompareButtonStatelocated its window withTopLevel.GetTopLevel. ATabControlrealises the selected tab's content and nothing else, so a session sitting in a background tab has no visual root, cannot find its own window, and falls back to counting its own plans — which is the original bug, restored. Start a query in one tab, work in another while it runs, and its plan lands in precisely that state.Correcting my own table
I posted a table on the issue. Two of its three rows are wrong, and following it would have fixed the wrong things:
.sqlplanfile into a session refreshes, viaPlans.cs:109AddPlanTabhas exactly two callers and both are Query Store. Opening a plan file opens a window-level tab (MainWindow.FileOps.cs), which the collection subscription did catch.SubTabControl.Items.AddsitesAddPlanTab, which calledUpdateCompareButtonState. The fourItems.Addsites I pointed at add the QS grid, the QS overview and the history tab — containers, not plans.The new Query Store test passes on
devunmodified. It is in here as a pin on the seam swap below, not as a reproduction of anything.The seam
Helpers/TabContentWatcher.cs, wired once toMainTabControland once to each session'sSubTabControl. It reports collection changes and content replacement on any tab it holds.I considered just adding the missing calls at the three sites. The reason I did not is the reason we are here: #449's call sites were correct for the paths their author had in mind, and the next author did not know to look. A plan cannot reach the screen without either adding a tab or filling one in, so watching both is the smallest thing that is also complete — the next plan-producing path is right without anyone remembering this file exists.
Given that, the five hand-written
UpdateCompareButtonState()calls inPlans.csare gone. Leaving them would have meant two mechanisms doing one job, and the whole argument is that there should be one place that decides.Content replacement is watched through
TabItem.PropertyChangedfiltered toContentProperty, with subscriptions tracked in a set rather than derived from the collection-changed args — aResetcarries neitherOldItemsnorNewItemsand would leave stale subscriptions behind.The test, which is the part I got wrong last time
The old test file built its scenario from plan files, with a comment saying executed queries need a live SQL Server and the defect does not require one. The second half was true. The first half was the bug: a plan file opens a window-level tab, which is the one path that was already recomputing. Every assertion passed against a build where the reported behaviour was unchanged. A test that cannot reach the broken code is not coverage, and I shipped it as though it were.
Four new tests, driving the paths a plan actually arrives by:
RunningAQueryInEachOfTwoSessionsOffersCompareInBoth— the report, verbatim.RunningTwoQueriesInOneSessionOffersCompareThere— the case the pre-Offer Compare Plans across query sessions, not just within one (#447) #449 arithmetic did handle, now that the arithmetic is gone.QueryStorePlansOfferCompare_AndClosingOneTakesItAway— Query Store's add path, and closing a plan tab through its own close button.AnActualPlanArrivingInAnExistingWindowTabIsNoticed— window-level content replacement.No SQL Server. What needs one is producing plan XML, and only that:
ShowCapturedPlanis the landing step both execution paths end at, and it is now internal so a test can hand it a committed fixture in place of what the server would have returned. Same forOnQueryStorePlansSelected, which needs plans, not a connection.Red first, three ways. With the extractions kept so the tests compile, and behaviour put back to
dev:devbehaviour)TopLevel.GetTopLevelAll four green with the fix in.
What is still uncovered
CaptureAndShowPlanandGetActualPlan_Clickabove the landing step — connecting,SET STATISTICS XML ON, cancellation, the error paths. Unchanged by this and still server-only.OnHistoryPlanLoadRequestedgoes through the sameAddPlanTabas the grid path that is covered; one of the two is tested.TopLevelhole is fixed and the tests happen to exercise the unrealised case, but nothing asserts it deliberately — no test switches tabs mid-flight.Counts
388 tests on
origin/devbefore this, 0 failed, 2 skipped. 392 after, 0 failed, 2 skipped. Five consecutive full runs, stable, ~15s each.🤖 Generated with Claude Code
https://claude.ai/code/session_016a1AnKAHwcALrwdYVVrpgR