Skip to content

UpdateCollectorHealthTextAsync captures the tab scope before its await and applies the result after #2924

Description

@erikdarlingdata

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions