Repository navigation
Fix #2825: Plan Viewer crash from mutating the tab collection during WPF container generation - #2829
Conversation
…neration Right-clicking a FinOps High Impact / Expensive Queries row and choosing "View Stored Plan" on a not-yet-opened Plan Viewer crashed the whole Darling Viewer with an unhandled InvalidOperationException: "Cannot call StartAt when content generation is in progress". The shared StandalonePlanViewerController's inner plan TabControl has a trailing "+" sentinel tab whose SelectionChanged handler auto-adds a new empty sub-tab (an ItemCollection.Insert). The first time the Plan Viewer host tab is realized, the inner control's only item is the auto-selected "+" tab, and WPF's container generator coerces that selection WHILE GENERATING; inserting into Items then throws, and nothing catches it. HandleAddTabSelected now defers the insert via Dispatcher.BeginInvoke(Loaded) when only the "+" sentinel is present (the transient that only exists during first-time setup), guarded by a one-shot latch with a re-validate in the deferred callback, and stays synchronous for a genuine steady-state "+" click. The opener adds its own real sub-tab synchronously right after, so the deferred pass finds "+" deselected and no-ops: no duplicate tab, no reordering, no crash. Generator status is unusable here (already ContainersGenerated by the time the handler runs), so the item count is the discriminator. Parity: the fix lives in the shared controller, so Lite gets it too. The deprecated Dashboard keeps its own copy of the plan-viewer code and had the identical bug, so it got the identical guard. Tests: the PlanViewerCapabilityPinTests source-pin (Darling + Lite) gains a probe on the _addTabInsertDeferred latch so the deferral cannot silently regress. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6rFZwjKPCzF2CxtxM6Ugp
…r-reentrancy-crash # Conflicts: # CHANGELOG.md
|
Reviewed the diff (C#/WPF only — no T-SQL in this PR, so the collector/style conventions don't apply here). Fix logic checks out. Traced all four openers (Darling's
Parity is solid. The shared-controller fix covers Lite for free; the deprecated Dashboard's independent copy got an identical guard including the One theoretical (not practically reachable) edge case, noted but not blocking: the deferred No security, T-SQL, or Lite/Darling drift concerns found beyond what's noted above. |
The crash (#2825)
FinOps tab -> High Impact -> right-click the top query -> "View Stored Plan" took the whole Darling Viewer down:
Root cause (a classic WPF re-entrancy)
The shared
StandalonePlanViewerControllerowns an inner planTabControlwith a trailing "+" sentinel tab. ItsSelectionChangedhandler auto-adds a fresh empty sub-tab whenever the "+" tab becomes selected, by callingAddNewEmptyPlanSubTab(), which ends in anItemCollection.Insert.OpenStoredPlanInPlanViewercallsEnsureInitialized()(which adds the "+" tab, and WPF auto-selects it because it is the only item), then setsMainWindowPlanViewerTab.IsSelected = true. That first-time selection realizes the host tab's content, so the innerTabControlgenerates its containers for the first time. When the generator finishes it firesOnGeneratorStatusChanged, which coerces the inner selection to the already-selected "+" tab and raisesSelectionChangedwhile the generator is still in progress. The handler then doesItems.Insert(...)mid-generation,ItemContainerGenerator.StartAtthrows, nothing catches it, and the app dies.The
OpenPlanViewerButton_Clickpath (sidebar "Open Plan Viewer") has the same cold-realization shape and the same exposure; the reporter just hit it via FinOps.The fix (shared, minimal, behavior-preserving)
In
PerformanceMonitor.Ui/StandalonePlanViewerController.cs, theSelectionChangedhandler now calls a newHandleAddTabSelected():Items.Count > 1, real sub-tabs already present): a genuine user click on "+". The control is fully realized and idle, so the insert is done synchronously exactly as before - no dispatcher hop, no visual flash, no behavior change.Items.Count == 1): we are in first-time setup / realization (the state the mid-generation coercion happens in). Never insert now - defer via_planTabControl.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, ...)so it runs after the generation/layout pass unwinds, guarded by a one-shot_addTabInsertDeferredlatch so a burst of SelectionChanged notifications can't queue multiple inserts. The deferred callback re-validates_initializedand that "+" is STILL selected before adding.Why the item count is the discriminator (not the generator status)
By the time the handler runs, the generator has already flipped
StatustoContainersGenerated(it is set inside the generator'sDispose, beforeSelectionChangedis raised), soGeneratorStatuscannot distinguish the dangerous coercion from a safe click. But the count can: "+" is only ever left as the selected item with no real sub-tab during first-time setup - every steady-state "+" selection (a user click) happens when real sub-tabs already exist, with the control realized and idle. SoCount == 1 && "+" selectedis exactly the unsafe transient, andCount > 1 && "+" selectedis exactly a safe click.Why this preserves behavior (no duplicate tab, no reorder)
Every opener - FinOps "View Plan", "Open Plan Viewer", and the close-of-last-sub-tab handler - adds and selects a real sub-tab synchronously right after triggering the "+" transient. So by the time the deferred pass runs, "+" is no longer selected and the deferred add no-ops. The plan / empty sub-tab the opener created is the one that renders. New sub-tabs still insert before the "+" tab (unchanged
AddNewEmptyPlanSubTablogic), so the "+" stays last. The genuine user-click path is untouched.The deferral priority is
Loaded, matching the onlyDispatcher.BeginInvokepriority already used in this codebase (including the plan viewer's own focus deferral). The re-validate in the deferred callback covers teardown:Reset()clears_initialized(and the latch), so a deferral that survives a close does nothing.Parity
StandalonePlanViewerController, which Lite consumes viaLite/MainWindow.PlanViewer.cs(OpenPlanViewerButton_Click). Lite'sAddMultipleServersDialogonly borrows the controller's unique-label idiom in a comment; it does not host the plan TabControl, so it has no exposure. Lite is covered by the same one change.deprecated/Dashboard/MainWindow.PlanViewer.cskeeps its own copy of the plan-viewer code (a localAddNewEmptyPlanSubTab+ SelectionChanged handler, not the shared controller) and had the identical bug (OpenPlanViewerTabselects the host tab before adding the first sub-tab). It got the identical guard, adapted to its nullable_mainPlanTabControl(the close path nulls it, and the deferred callback guards!= null). Per the never-scope-down-a-Dashboard-parity-fix rule.Tests
A full WPF UI crash repro is not automatable headless, and the existing
PlanViewerCapabilityPinTestsare deliberately source-scan-only (no WPF / assembly load). So the guard is pinned there: bothDarling.TestsandLite.Testsget a newreentrancy-defer-addprobe on the_addTabInsertDeferredlatch (present in the shared controller both suites scan) plus its baseline line, so reverting the deferral to a synchronous insert trips the pin.Verification
dotnet buildsucceeded forPerformanceMonitor.Darling.Viewer,PerformanceMonitorLite, and the deprecatedDashboard(Debug): 0 errors, no new warnings (the one surfaced warning,CS8848inDataGridRowMarks.cs, is pre-existing and unrelated).dotnet testrunner itself is unusable in this environment due to an SDK testhost manifest bug (testhost.dllmissing fromtesthost.deps.json, version18.6.0-release-26377-103) - unrelated to this change; the test projects compile.Review
A general code-reviewer traced all four implementations (Darling opener, Lite opener, Dashboard opener, close-last-sub-tab) and confirmed: no new re-entrancy, no double-add (every opener selects a real sub-tab synchronously before the deferred callback runs, so it always no-ops), the "+" stays last,
Loadedis the correct priority, and teardown is safe. Two follow-ups came out of it:_addTabInsertDeferredlatch the way the sharedReset()does - a parity nit in this change, now mirrored.SelectionChangedhandler is subscribed per-init and never unsubscribed, so it accumulates one handler per open/close cycle. It exists onmainindependent of this fix and is benign after this change (the_addTabInsertDeferredlatch and the self-correcting selection collapse duplicate invocations to a single add - no crash, no double tab). Folding a subscription-lifecycle refactor into a high-severity crash fix would widen the blast radius in exactly the re-entrancy-sensitive area this PR is meant to make safe, so it is tracked separately.Closes #2825
🤖 Generated with Claude Code
https://claude.ai/code/session_01K6rFZwjKPCzF2CxtxM6Ugp