f920b4cbf5
Decrement was decrement-first with a single non-retried repair CAS. From zero, two unmatched decrements (SignalR calls OnDisconnectedAsync for a connection whose OnConnectedAsync faulted) capture -1 and -2; a real Increment then makes the count -1, and the first decrementer's stale CompareExchange(0, -1) matches and resets to zero — erasing a live connection, so the idle gate freezes an open dashboard. The same lost race also made Decrement report 0 when it had not written 0. Clamping now happens inside the compare-and-swap: read, clamp, publish, retry on loss. A lost race re-reads the fresh value instead of repairing a stale one. The counter moves to its own file per the one-public-type-per-file convention and gains direct tests: the zero floor under concurrent unmatched decrements, matched pairs settling at zero, and an interleaved connect/disconnect stress round. The stress test asserts the observable invariants only — the specific interleaving cannot be forced through the public API (verified: the previous implementation passes it), which its remarks now state rather than implying a reproducer. A hub wiring test is skipped for the EventsHub reason: driving Hub.OnConnectedAsync needs caller-clients and connection-context fakes, and the overrides are two lines of delegation to the tested type. Also documents that the API-key refresh's pre-gate time check races benignly.
42 lines
1.7 KiB
C#
42 lines
1.7 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|
|
|
/// <summary>
|
|
/// SignalR hub that pushes a fresh <see cref="DashboardSnapshot"/> on every
|
|
/// snapshot refresh. New connections receive the current snapshot
|
|
/// immediately via <see cref="OnConnectedAsync"/>; subsequent refreshes are
|
|
/// broadcast by <see cref="DashboardSnapshotPublisher"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Connections are counted into <see cref="DashboardSnapshotHubConnectionCounter"/>
|
|
/// so <see cref="DashboardSnapshotPublisher"/> can stop building and broadcasting
|
|
/// snapshots while nobody is watching.
|
|
/// </remarks>
|
|
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
|
public sealed class DashboardSnapshotHub(
|
|
IDashboardSnapshotService snapshotService,
|
|
DashboardSnapshotHubConnectionCounter connectionCounter) : Hub
|
|
{
|
|
/// <summary>Method name used to push snapshot updates to clients.</summary>
|
|
public const string SnapshotMessage = "SnapshotUpdated";
|
|
|
|
/// <inheritdoc />
|
|
public override async Task OnConnectedAsync()
|
|
{
|
|
// Count the viewer before seeding it, so the publisher resumes its tick
|
|
// no later than the first snapshot this connection renders.
|
|
connectionCounter.Increment();
|
|
await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false);
|
|
await base.OnConnectedAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
|
{
|
|
connectionCounter.Decrement();
|
|
await base.OnDisconnectedAsync(exception).ConfigureAwait(false);
|
|
}
|
|
}
|