merge: RawBrowseModal post-await staleness guard (deferment §0.1 race ported from the 2026-08-05 sibling fix)

This commit is contained in:
Joseph Doherty
2026-08-09 21:08:00 -04:00
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;