fix(centralui): Topology live tick behind PollGate, refresh no longer unmounts tree (review 2026-10-21 07/P-2)
This commit is contained in:
@@ -141,6 +141,7 @@
|
|||||||
private readonly HashSet<int> _loadedSites = new();
|
private readonly HashSet<int> _loadedSites = new();
|
||||||
private bool _branchLoading;
|
private bool _branchLoading;
|
||||||
private readonly SemaphoreSlim _loadGate = new(1, 1);
|
private readonly SemaphoreSlim _loadGate = new(1, 1);
|
||||||
|
private readonly ZB.MOM.WW.ScadaBridge.CentralUI.Services.PollGate _pollGate = new();
|
||||||
private List<Area> _allAreas = new();
|
private List<Area> _allAreas = new();
|
||||||
private Dictionary<int, bool> _stalenessMap = new();
|
private Dictionary<int, bool> _stalenessMap = new();
|
||||||
|
|
||||||
@@ -242,25 +243,23 @@
|
|||||||
private void OnLiveUpdatesTick(object? state)
|
private void OnLiveUpdatesTick(object? state)
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
InvokeAsync(async () =>
|
// PollGate (arch-review S3): skip the tick while the previous refresh is still in
|
||||||
{
|
// flight, so a slow configuration DB cannot stack queries and renderer continuations.
|
||||||
if (_disposed || !_liveUpdates) return;
|
_ = _pollGate.RunAsync(
|
||||||
try
|
() => InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
|
if (_disposed || !_liveUpdates) return;
|
||||||
// Staleness is deliberately OFF the live loop: it only changes when a
|
// Staleness is deliberately OFF the live loop: it only changes when a
|
||||||
// template/instance is edited or redeployed, and recomputing it (a
|
// template/instance is edited or redeployed, and recomputing it (a
|
||||||
// full re-flatten per deployed instance) every 15s is the page's
|
// full re-flatten per deployed instance) every 15s is the page's
|
||||||
// dominant cost. The poll refreshes only the cheap deployed state.
|
// dominant cost. The poll refreshes only the cheap deployed state.
|
||||||
await LoadDeployedStateAsync();
|
await LoadDeployedStateAsync(isRefresh: true);
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}),
|
||||||
catch (ObjectDisposedException)
|
// Disposed between the guard and the dispatch (mirrors DebugView's
|
||||||
{
|
// SafeInvokeAsync / CentralUI-009); LoadDeployedStateAsync reports its own failures.
|
||||||
// Component disposed between the guard and the dispatch — ignore
|
_ => { });
|
||||||
// (mirrors DebugView's SafeInvokeAsync / CentralUI-009).
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLiveUpdatesToggled(ChangeEventArgs e)
|
private void OnLiveUpdatesToggled(ChangeEventArgs e)
|
||||||
@@ -339,12 +338,15 @@
|
|||||||
/// This is the live-update timer path; it does not recompute staleness. Internal so the live-update regression
|
/// This is the live-update timer path; it does not recompute staleness. Internal so the live-update regression
|
||||||
/// test can drive the timer's code path directly.
|
/// test can drive the timer's code path directly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal async Task LoadDeployedStateAsync()
|
internal async Task LoadDeployedStateAsync(bool isRefresh = false)
|
||||||
{
|
{
|
||||||
await _loadGate.WaitAsync();
|
await _loadGate.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_loading = true;
|
// Only the initial/explicit load unmounts the tree behind the spinner; the 15s
|
||||||
|
// live tick (isRefresh) must not, or any render landing mid-tick swaps the whole
|
||||||
|
// tree for the spinner and back (review 2026-10-21 07/P-2).
|
||||||
|
if (!isRefresh) _loading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -355,12 +357,16 @@
|
|||||||
_loadedSites.IntersectWith(permittedSiteIds);
|
_loadedSites.IntersectWith(permittedSiteIds);
|
||||||
foreach (var key in _loadedPages.Keys.Where(k => !permittedSiteIds.Contains(k.Site)).ToArray())
|
foreach (var key in _loadedPages.Keys.Where(k => !permittedSiteIds.Contains(k.Site)).ToArray())
|
||||||
_loadedPages.Remove(key);
|
_loadedPages.Remove(key);
|
||||||
_allInstances.Clear();
|
// Gather into locals and swap, so a render landing mid-fetch never sees
|
||||||
_allAreas.Clear();
|
// the cleared lists.
|
||||||
foreach (var siteId in _loadedSites)
|
var areas = new List<Area>();
|
||||||
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
|
var instances = new List<Instance>();
|
||||||
foreach (var (key, offset) in _loadedPages)
|
foreach (var siteId in _loadedSites.ToArray())
|
||||||
_allInstances.AddRange(await TemplateEngineRepository.GetTopologyInstancesAsync(key.Site, key.Area, offset, InstancePageSize));
|
areas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
|
||||||
|
foreach (var (key, offset) in _loadedPages.ToArray())
|
||||||
|
instances.AddRange(await TemplateEngineRepository.GetTopologyInstancesAsync(key.Site, key.Area, offset, InstancePageSize));
|
||||||
|
_allAreas = areas;
|
||||||
|
_allInstances = instances;
|
||||||
|
|
||||||
BuildTree();
|
BuildTree();
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -199,4 +199,35 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext
|
|||||||
Assert.Null(ex);
|
Assert.Null(ex);
|
||||||
Assert.Null(GetField<Timer?>(cut.Instance, "_liveUpdatesTimer"));
|
Assert.Null(GetField<Timer?>(cut.Instance, "_liveUpdatesTimer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void InvokeTick(object page) =>
|
||||||
|
page.GetType().GetMethod("OnLiveUpdatesTick", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.Invoke(page, new object?[] { null });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review 2026-10-21 07/P-2: a tick arriving while the previous refresh is still in
|
||||||
|
/// flight is skipped (PollGate), and a refresh never flips the page back to the spinner.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task LiveTick_WhilePreviousRefreshInFlight_IsSkipped_AndDoesNotUnmountTree()
|
||||||
|
{
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
_siteRepo.ClearReceivedCalls();
|
||||||
|
var release = new TaskCompletionSource<IReadOnlyList<Site>>();
|
||||||
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(release.Task);
|
||||||
|
|
||||||
|
InvokeTick(cut.Instance);
|
||||||
|
InvokeTick(cut.Instance);
|
||||||
|
InvokeTick(cut.Instance);
|
||||||
|
await Task.Delay(100);
|
||||||
|
|
||||||
|
// Three ticks, one query: the gate dropped the overlapping two.
|
||||||
|
await _siteRepo.Received(1).GetAllSitesAsync(Arg.Any<CancellationToken>());
|
||||||
|
// The in-flight refresh must not have swapped the tree for the spinner.
|
||||||
|
Assert.False(GetField<bool>(cut.Instance, "_loading"));
|
||||||
|
|
||||||
|
release.SetResult(new List<Site>());
|
||||||
|
await Task.Delay(100);
|
||||||
|
cut.Instance.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user