fix(adminui): guard RawBrowseModal's resolve against a superseded open

Blazor does not serialize OnParametersSetAsync against its own outstanding
task, and the modal's `_open` bool cannot tell two opens apart. ResetAndResolveAsync
awaits LoadMergedProbeConfigAsync and ResolveGroupContextAsync and then wrote
_driverType/_mergedConfig/_effectiveDeviceId/_groupPrefix/_canBrowse/_disabledReason
with no staleness re-check, so a close-reopen onto another device let the first
open's late continuation install its device's browse context under the displayed
modal — "Connect & browse" would silently dial the wrong controller and the commit
would land under the wrong tag-group.

Ports the per-open `_openGeneration` counter proven in the two sibling modals
(RawManualTagEntryModal, RawTagModal, 2026-08-05) to every post-await write here:
after both awaits, in the catch's error banner, and in the finally. The finally is
the one departure from the sibling shape and it is load-bearing — this modal resolves
inside a try/finally, so a stale `return` still runs the tail and would drop the LIVE
open's spinner mid-resolve.

Pinned by four RawBrowseModalStaleOpenTests facts, each observed red before the guard
and each re-falsified afterwards by reverting only its own guard. The modal calls
StateHasChanged on the open path, which throws without a render handle, so the test
attaches an inert Renderer subclass (file-local BL0006 pragma); [Parameter]s are set
by reflection per the BL0005 convention.

AdminUI suite 1117/1117 green.
This commit is contained in:
Joseph Doherty
2026-08-09 21:07:07 -04:00
parent 0125634400
commit 87637e4e2c
3 changed files with 450 additions and 4 deletions
@@ -279,6 +279,15 @@
[Parameter] public EventCallback OnSaved { get; set; }
private bool _open; // guards re-seeding on unrelated re-renders
/// <summary>
/// Identifies WHICH open a post-await write belongs to. <see cref="_open"/> is a bool and cannot
/// tell two opens apart, and Blazor does not serialize <c>OnParametersSetAsync</c> against its own
/// outstanding task — so a close-reopen onto another device overlaps the first open's resolve, and
/// last-completion-wins would point "Connect &amp; browse" at a controller nobody is looking at.
/// </summary>
private int _openGeneration;
private bool _resolving;
private bool _canBrowse;
private string? _disabledReason;
@@ -348,6 +357,8 @@
private async Task ResetAndResolveAsync()
{
var generation = ++_openGeneration;
_token = Guid.Empty;
_opening = false;
_openError = null;
@@ -374,6 +385,10 @@
// Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial
// the merged blob (same merge the driver-probe/test-connect path uses).
var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
// A superseded open must never install its device under the displayed modal — see
// _openGeneration. Every post-await write below is behind this check.
if (IsStale(generation)) { return; }
if (merged is { } m)
{
_driverType = m.DriverType;
@@ -384,6 +399,8 @@
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (IsStale(generation)) { return; } // same race, second await: this one retargets the commit
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
@@ -401,16 +418,37 @@
}
catch (Exception ex)
{
_canBrowse = false;
_disabledReason = $"Could not load device config: {ex.Message}";
// A superseded open that FAILS is the same defect wearing the catch block: its diagnostic
// names a device the operator is no longer looking at, and it would disable browsing for the
// one they are.
if (!IsStale(generation))
{
_canBrowse = false;
_disabledReason = $"Could not load device config: {ex.Message}";
}
}
finally
{
_resolving = false;
StateHasChanged();
// The tail state belongs to an open too: `return`ing out of the try above still runs this, so
// an unguarded finally would drop the LIVE open's spinner mid-resolve and render the
// half-reset panel underneath it.
if (!IsStale(generation))
{
_resolving = false;
StateHasChanged();
}
}
}
/// <summary>
/// Whether the open that captured <paramref name="generation"/> has been superseded — by a later
/// open, or by the modal being closed outright (a close raises no new generation, so
/// <see cref="Visible"/> is the half that catches it).
/// </summary>
/// <param name="generation">The <see cref="_openGeneration"/> value captured when the open started.</param>
/// <returns><see langword="true"/> when no state may be written for that open.</returns>
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
private async Task OpenBrowseAsync()
{
_opening = true;