merge: RawBrowseModal post-await staleness guard (deferment §0.1 race ported from the 2026-08-05 sibling fix)
This commit is contained in:
@@ -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 & 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;
|
||||
|
||||
Reference in New Issue
Block a user