UpdateCollectorHealthTextAsync captures the tab scope before its await and applies the result after
Split out of #2907, where it was recorded as a "known adjacent gap" in the issue body and never filed. #2907's code fix (#2910) deliberately did not address it.
The shape
In Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs:
:214 private async Task UpdateCollectorHealthTextAsync()
:225 int? serverId = MainTabs.SelectedItem is TabItem { Content: ViewerServerTab serverTab } …
:233 ? await _dataService.GetCollectionHealthAsync(serverId.Value)
:234 : await _dataService.GetFleetCollectionHealthAsync();
The scope — which server, or the whole fleet — is read from MainTabs.SelectedItem before the await, and the result is applied after it. If the operator changes tab while that read is in flight, the answer painted belongs to the previously-selected scope. Last writer wins, and with two reads racing the later-starting one can land first.
Why #2907's fix does not cover it
#2907 made RefreshServerStatusAsync single-flight, which stops the same method stacking. This is a different failure: a single, non-overlapping call whose captured scope goes stale mid-flight. The guard prevents two reads existing at once; it does not make one read's scope stable across its own await.
Note UpdateCollectorHealthTextAsync is reached from RefreshServerStatusAsync (:185), so it inherits that guard — which is why this is a narrow race rather than a stacking problem.
Bounding it honestly
The window is one store read, now bounded at ViewerCommandDeadlines.InteractiveReadSeconds (15 s) by #2901, and it requires a tab change inside that window. The visible consequence is a health string briefly describing the wrong scope until the next refresh corrects it — a cosmetic wrong-number rather than a wrong action. Not urgent, and it is filed for the record rather than as a defect worth interrupting anything for.
Notes toward a fix
The conventional shape is to re-read the scope after the await and discard if it changed, or to pass the captured scope through and have the apply step verify it still matches before painting. The second composes better with the existing guard, because the replay path (#2910's _statusRefreshRequested) already exists to re-run when state moved underneath.
Worth checking whether siblings share the shape before fixing just this one — the same capture-before-await pattern is the kind of thing that appears more than once in timer-driven code-behind, and #2907's own investigation found the fan-out/guard split had been written in two places with nothing tying them together.
UpdateCollectorHealthTextAsynccaptures the tab scope before its await and applies the result afterSplit out of #2907, where it was recorded as a "known adjacent gap" in the issue body and never filed. #2907's code fix (#2910) deliberately did not address it.
The shape
In
Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs:The scope — which server, or the whole fleet — is read from
MainTabs.SelectedItembefore the await, and the result is applied after it. If the operator changes tab while that read is in flight, the answer painted belongs to the previously-selected scope. Last writer wins, and with two reads racing the later-starting one can land first.Why #2907's fix does not cover it
#2907 made
RefreshServerStatusAsyncsingle-flight, which stops the same method stacking. This is a different failure: a single, non-overlapping call whose captured scope goes stale mid-flight. The guard prevents two reads existing at once; it does not make one read's scope stable across its own await.Note
UpdateCollectorHealthTextAsyncis reached fromRefreshServerStatusAsync(:185), so it inherits that guard — which is why this is a narrow race rather than a stacking problem.Bounding it honestly
The window is one store read, now bounded at
ViewerCommandDeadlines.InteractiveReadSeconds(15 s) by #2901, and it requires a tab change inside that window. The visible consequence is a health string briefly describing the wrong scope until the next refresh corrects it — a cosmetic wrong-number rather than a wrong action. Not urgent, and it is filed for the record rather than as a defect worth interrupting anything for.Notes toward a fix
The conventional shape is to re-read the scope after the await and discard if it changed, or to pass the captured scope through and have the apply step verify it still matches before painting. The second composes better with the existing guard, because the replay path (#2910's
_statusRefreshRequested) already exists to re-run when state moved underneath.Worth checking whether siblings share the shape before fixing just this one — the same capture-before-await pattern is the kind of thing that appears more than once in timer-driven code-behind, and #2907's own investigation found the fan-out/guard split had been written in two places with nothing tying them together.