fix(dashboard): bound EventsHub delivery with one drop-oldest pump per session
Publish fired a group SendAsync per event and returned, so a slow remote viewer accumulated unbounded pending sends plus cloned MxEvents. Hub delivery now goes through a 256-deep DropOldest channel per hub-watched session with a single drain task and time-bounded sends. The registry counts hub viewers separately and raises HubViewersReleased so the pump is torn down with the last remote viewer; in-process-only sessions start no pump.
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time-bounded SignalR send shared by the dashboard hub publishers. A group or
|
||||||
|
/// broadcast send completes only once every target connection has accepted the
|
||||||
|
/// frame, so one remote viewer with a full transport buffer would otherwise hold
|
||||||
|
/// the publisher's single drain loop for as long as that viewer stays slow.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BoundedHubSend
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends <paramref name="argument"/> as <paramref name="method"/> and waits at most
|
||||||
|
/// <paramref name="timeout"/> for it. The timeout cancels the send's own token, so
|
||||||
|
/// the lifetime manager abandons the pending write to the slow connection, and the
|
||||||
|
/// wait itself is bounded too, so a proxy that ignores the token cannot stall the
|
||||||
|
/// caller either.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="proxy">Target client proxy (a group or all connections).</param>
|
||||||
|
/// <param name="method">Client method name.</param>
|
||||||
|
/// <param name="argument">The single message argument.</param>
|
||||||
|
/// <param name="timeout">Upper bound on the send.</param>
|
||||||
|
/// <param name="stoppingToken">Host shutdown token; its cancellation propagates.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true"/> when the send completed in time; <see langword="false"/>
|
||||||
|
/// when it was abandoned at the timeout.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="OperationCanceledException"><paramref name="stoppingToken"/> was cancelled.</exception>
|
||||||
|
public static async Task<bool> SendAsync(
|
||||||
|
IClientProxy proxy,
|
||||||
|
string method,
|
||||||
|
object argument,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||||
|
bounded.CancelAfter(timeout);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await proxy.SendAsync(method, argument, bounded.Token)
|
||||||
|
.WaitAsync(bounded.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|||||||
/// Broadcasts MxEvents to the two dashboard audiences for a session: remote
|
/// Broadcasts MxEvents to the two dashboard audiences for a session: remote
|
||||||
/// <see cref="EventsHub"/> clients subscribed to the session's group, and
|
/// <see cref="EventsHub"/> clients subscribed to the session's group, and
|
||||||
/// in-process subscribers opened through
|
/// in-process subscribers opened through
|
||||||
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/>. Fire-and-forget: we
|
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/>. <see cref="Publish"/>
|
||||||
/// hand the send to the hub context and return immediately so the source gRPC
|
/// never blocks: both audiences are fed through bounded drop-oldest queues, so the
|
||||||
/// stream is never blocked. Errors are logged once and dropped — keeping the
|
/// source gRPC stream is never held up by a slow dashboard viewer. Errors are
|
||||||
/// SignalR mirror best-effort preserves the gRPC contract that exists today.
|
/// logged and dropped — keeping the SignalR mirror best-effort preserves the gRPC
|
||||||
|
/// contract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), tag
|
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), tag
|
||||||
@@ -26,31 +27,45 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|||||||
/// <see cref="EventsHub"/>), while the redaction decides what a permitted
|
/// <see cref="EventsHub"/>), while the redaction decides what a permitted
|
||||||
/// subscriber sees — so the value-leak seam stays closed whatever the ACL
|
/// subscriber sees — so the value-leak seam stays closed whatever the ACL
|
||||||
/// admits.
|
/// admits.
|
||||||
|
/// <para>
|
||||||
|
/// Hub delivery goes through one <see cref="HubPump"/> per watched session: a
|
||||||
|
/// <see cref="HubQueueCapacity"/>-deep drop-oldest channel drained by a single task
|
||||||
|
/// that sends one event at a time, each send time-bounded. An earlier version fired
|
||||||
|
/// a group send per event and returned, so a slow remote viewer accumulated an
|
||||||
|
/// unbounded number of pending sends — each holding a cloned event — inside the
|
||||||
|
/// SignalR lifetime manager. The pump is started by the first event published while
|
||||||
|
/// a hub connection watches the session and torn down when
|
||||||
|
/// <see cref="EventsHubViewerRegistry.HubViewersReleased"/> reports the last one gone.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="hubContext">Hub context used to send to the session's group.</param>
|
public sealed class DashboardEventBroadcaster : IDashboardEventBroadcaster, IDashboardSessionEventSubscriber, IDisposable
|
||||||
/// <param name="viewerRegistry">
|
|
||||||
/// Live-subscriber registry consulted before any per-event work is done. Both
|
|
||||||
/// audiences register here — hub connections by their SignalR connection id,
|
|
||||||
/// in-process subscriptions by a synthetic one — so the gate stays a single
|
|
||||||
/// source of truth.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
|
|
||||||
/// <param name="logger">Logger for best-effort mirror failures.</param>
|
|
||||||
public sealed class DashboardEventBroadcaster(
|
|
||||||
IHubContext<EventsHub> hubContext,
|
|
||||||
EventsHubViewerRegistry viewerRegistry,
|
|
||||||
IOptions<GatewayOptions> options,
|
|
||||||
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster, IDashboardSessionEventSubscriber
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Queue depth per in-process subscriber. The consumer is a Blazor page
|
/// Queue depth per in-process subscriber. The consumer is a Blazor page
|
||||||
/// rendering the newest handful of events, so a burst it cannot keep up with
|
/// rendering the newest handful of events, so a burst it cannot keep up with
|
||||||
/// is dropped oldest-first rather than allowed to grow — same best-effort
|
/// is dropped oldest-first rather than allowed to grow — same best-effort
|
||||||
/// contract the SignalR mirror already has.
|
/// contract the SignalR mirror has.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const int InProcessQueueCapacity = 256;
|
private const int InProcessQueueCapacity = 256;
|
||||||
|
|
||||||
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
|
/// <summary>
|
||||||
|
/// Queue depth of a session's hub-delivery pump. Same depth and drop policy as
|
||||||
|
/// the in-process feed: the remote viewer is a live view, so a burst it cannot
|
||||||
|
/// keep up with loses its oldest events rather than buffering without bound.
|
||||||
|
/// </summary>
|
||||||
|
internal const int HubQueueCapacity = 256;
|
||||||
|
|
||||||
|
private static readonly TimeSpan DefaultHubSendTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private readonly IHubContext<EventsHub> _hubContext;
|
||||||
|
|
||||||
|
private readonly EventsHubViewerRegistry _viewerRegistry;
|
||||||
|
|
||||||
|
private readonly ILogger<DashboardEventBroadcaster> _logger;
|
||||||
|
|
||||||
|
private readonly TimeSpan _hubSendTimeout;
|
||||||
|
|
||||||
|
private readonly bool _showTagValues;
|
||||||
|
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
|
|
||||||
@@ -63,6 +78,9 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
private readonly Dictionary<string, InProcessSubscription[]> _inProcessSubscribers =
|
private readonly Dictionary<string, InProcessSubscription[]> _inProcessSubscribers =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Live hub-delivery pumps per session. Guarded by <see cref="_syncRoot"/>.</summary>
|
||||||
|
private readonly Dictionary<string, HubPump> _hubPumps = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Total live in-process subscribers, read without the lock so the common
|
/// Total live in-process subscribers, read without the lock so the common
|
||||||
/// case — nobody has a session-details page open — never contends on it.
|
/// case — nobody has a session-details page open — never contends on it.
|
||||||
@@ -70,6 +88,61 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private int _inProcessSubscriberCount;
|
private int _inProcessSubscriberCount;
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="DashboardEventBroadcaster"/> class.</summary>
|
||||||
|
/// <param name="hubContext">Hub context used to send to the session's group.</param>
|
||||||
|
/// <param name="viewerRegistry">
|
||||||
|
/// Live-subscriber registry consulted before any per-event work is done. Both
|
||||||
|
/// audiences register here — hub connections by their SignalR connection id,
|
||||||
|
/// in-process subscriptions by a synthetic one — so the gate stays a single
|
||||||
|
/// source of truth.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
|
||||||
|
/// <param name="logger">Logger for best-effort mirror failures.</param>
|
||||||
|
public DashboardEventBroadcaster(
|
||||||
|
IHubContext<EventsHub> hubContext,
|
||||||
|
EventsHubViewerRegistry viewerRegistry,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
ILogger<DashboardEventBroadcaster> logger)
|
||||||
|
: this(hubContext, viewerRegistry, options, logger, DefaultHubSendTimeout)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance with a custom hub send timeout (test hook).</summary>
|
||||||
|
/// <param name="hubContext">Hub context used to send to the session's group.</param>
|
||||||
|
/// <param name="viewerRegistry">Live-subscriber registry.</param>
|
||||||
|
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
|
||||||
|
/// <param name="logger">Logger for best-effort mirror failures.</param>
|
||||||
|
/// <param name="hubSendTimeout">Upper bound on one group send before it is abandoned.</param>
|
||||||
|
internal DashboardEventBroadcaster(
|
||||||
|
IHubContext<EventsHub> hubContext,
|
||||||
|
EventsHubViewerRegistry viewerRegistry,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
ILogger<DashboardEventBroadcaster> logger,
|
||||||
|
TimeSpan hubSendTimeout)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
|
||||||
|
_viewerRegistry = viewerRegistry ?? throw new ArgumentNullException(nameof(viewerRegistry));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_hubSendTimeout = hubSendTimeout;
|
||||||
|
_showTagValues = options.Value.Dashboard.ShowTagValues;
|
||||||
|
_viewerRegistry.HubViewersReleased += OnHubViewersReleased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the number of live hub-delivery pumps (one per hub-watched session).</summary>
|
||||||
|
internal int ActiveHubPumpCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _hubPumps.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Publish(string sessionId, MxEvent mxEvent)
|
public void Publish(string sessionId, MxEvent mxEvent)
|
||||||
{
|
{
|
||||||
@@ -81,46 +154,126 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
// Every session's dashboard-mirror subscriber calls Publish for every event,
|
// Every session's dashboard-mirror subscriber calls Publish for every event,
|
||||||
// whether or not a browser is on that session's page. Without this gate the
|
// whether or not a browser is on that session's page. Without this gate the
|
||||||
// steady state — no dashboard viewer at all — still paid a deep protobuf
|
// steady state — no dashboard viewer at all — still paid a deep protobuf
|
||||||
// clone (redaction is on by default) plus a send to an empty SignalR group
|
// clone (redaction is on by default) per event. Bail before it.
|
||||||
// per event. Bail before both.
|
if (!_viewerRegistry.HasViewers(sessionId))
|
||||||
if (!viewerRegistry.HasViewers(sessionId))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MxEvent outbound = _showTagValues ? mxEvent : RedactValues(mxEvent);
|
MxEvent outbound = _showTagValues ? mxEvent : RedactValues(mxEvent);
|
||||||
|
|
||||||
// In-process delivery first: it is synchronous, cannot throw, and must not be
|
|
||||||
// skipped by the early return the hub send's guard clause takes.
|
|
||||||
DeliverInProcess(sessionId, outbound);
|
DeliverInProcess(sessionId, outbound);
|
||||||
|
|
||||||
// Wrap the Task acquisition in a try/catch so a hypothetical synchronous throw
|
// In-process-only viewers need no hub send at all; the pump exists only
|
||||||
// from SendAsync (e.g. an implementation that throws before returning the Task)
|
// while a remote connection is in the session's group.
|
||||||
// cannot escape Publish. The interface contract is never-throw; fire-and-forget.
|
if (_viewerRegistry.HasHubViewers(sessionId))
|
||||||
Task send;
|
{
|
||||||
|
EnqueueForHub(sessionId, outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops every hub pump and detaches from the viewer registry. The singleton is
|
||||||
|
/// disposed with the host; a pump's in-flight send is bounded by its timeout.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
HubPump[] pumps;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
pumps = [.. _hubPumps.Values];
|
||||||
|
_hubPumps.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_viewerRegistry.HubViewersReleased -= OnHubViewersReleased;
|
||||||
|
foreach (HubPump pump in pumps)
|
||||||
|
{
|
||||||
|
pump.Complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues an event on the session's hub pump, starting the pump on first use.
|
||||||
|
/// The write happens under the lock so it cannot land on a pump that a
|
||||||
|
/// concurrent teardown has already completed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">Session the event belongs to.</param>
|
||||||
|
/// <param name="outbound">The event as the dashboard should see it.</param>
|
||||||
|
private void EnqueueForHub(string sessionId, MxEvent outbound)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_hubPumps.TryGetValue(sessionId, out HubPump? pump))
|
||||||
|
{
|
||||||
|
pump = new HubPump(this, sessionId, HubQueueCapacity);
|
||||||
|
_hubPumps[sessionId] = pump;
|
||||||
|
}
|
||||||
|
|
||||||
|
pump.TryWrite(outbound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tears down a session's hub pump once its last hub viewer has gone. Re-checks
|
||||||
|
/// the registry under the lock: a viewer that re-subscribed in between keeps the
|
||||||
|
/// pump, and one that arrives after the teardown gets a fresh pump on the next
|
||||||
|
/// event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">Session whose last hub viewer left.</param>
|
||||||
|
private void OnHubViewersReleased(string sessionId)
|
||||||
|
{
|
||||||
|
HubPump? pump;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_viewerRegistry.HasHubViewers(sessionId) || !_hubPumps.Remove(sessionId, out pump))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pump.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends one event to the session's group, bounded by the hub send timeout.
|
||||||
|
/// Never throws: a failed or abandoned send is logged and the pump moves on.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">Session whose group receives the event.</param>
|
||||||
|
/// <param name="outbound">The event to send.</param>
|
||||||
|
/// <returns>A task that completes when the send finished or was abandoned.</returns>
|
||||||
|
private async Task SendToHubAsync(string sessionId, MxEvent outbound)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
send = hubContext.Clients
|
bool sent = await BoundedHubSend.SendAsync(
|
||||||
.Group(EventsHub.GroupName(sessionId))
|
_hubContext.Clients.Group(EventsHub.GroupName(sessionId)),
|
||||||
.SendAsync(EventsHub.EventMessage, outbound);
|
EventsHub.EventMessage,
|
||||||
|
outbound,
|
||||||
|
_hubSendTimeout,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!sent)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Dashboard event mirror to session {SessionId} timed out after {Timeout}; continuing.",
|
||||||
|
sessionId,
|
||||||
|
_hubSendTimeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} threw synchronously.", sessionId);
|
_logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} failed.", sessionId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!send.IsCompletedSuccessfully)
|
|
||||||
{
|
|
||||||
_ = send.ContinueWith(
|
|
||||||
t =>
|
|
||||||
{
|
|
||||||
if (t.Exception is { } ex)
|
|
||||||
{
|
|
||||||
logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} failed.", sessionId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TaskScheduler.Default);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +286,7 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
// usable for a subscriber that has no SignalR connection behind it. The
|
// usable for a subscriber that has no SignalR connection behind it. The
|
||||||
// "inproc-" prefix cannot collide with a SignalR connection id and makes the
|
// "inproc-" prefix cannot collide with a SignalR connection id and makes the
|
||||||
// origin obvious in a debugger.
|
// origin obvious in a debugger.
|
||||||
string connectionId = "inproc-" + Guid.NewGuid().ToString("N");
|
string connectionId = EventsHubViewerRegistry.InProcessConnectionIdPrefix + Guid.NewGuid().ToString("N");
|
||||||
InProcessSubscription subscription = new(this, sessionId, connectionId, InProcessQueueCapacity);
|
InProcessSubscription subscription = new(this, sessionId, connectionId, InProcessQueueCapacity);
|
||||||
|
|
||||||
// Register before the subscriber becomes a delivery target, exactly as
|
// Register before the subscriber becomes a delivery target, exactly as
|
||||||
@@ -142,7 +295,7 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
// Publish's gate still reports the session unwatched, silently dropping events
|
// Publish's gate still reports the session unwatched, silently dropping events
|
||||||
// it should receive. The cost of this order is at worst a redaction clone that
|
// it should receive. The cost of this order is at worst a redaction clone that
|
||||||
// reaches nobody for the width of the window.
|
// reaches nobody for the width of the window.
|
||||||
viewerRegistry.AddViewer(connectionId, sessionId);
|
_viewerRegistry.AddViewer(connectionId, sessionId);
|
||||||
|
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
{
|
{
|
||||||
@@ -227,12 +380,12 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewerRegistry.RemoveViewer(subscription.ConnectionId, subscription.SessionId);
|
_viewerRegistry.RemoveViewer(subscription.ConnectionId, subscription.SessionId);
|
||||||
|
|
||||||
// The synthetic connection id is used once and never reconnects, so nothing
|
// The synthetic connection id is used once and never reconnects, so nothing
|
||||||
// else will ever call ReleaseConnection for it; without this the registry
|
// else will ever call ReleaseConnection for it; without this the registry
|
||||||
// would retain an empty per-connection entry per subscription ever opened.
|
// would retain an empty per-connection entry per subscription ever opened.
|
||||||
viewerRegistry.ReleaseConnection(subscription.ConnectionId);
|
_viewerRegistry.ReleaseConnection(subscription.ConnectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -328,4 +481,56 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
_channel.Writer.TryComplete();
|
_channel.Writer.TryComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One session's hub-delivery pump: a bounded drop-oldest channel and the single
|
||||||
|
/// task that drains it into the session's SignalR group, one bounded send at a
|
||||||
|
/// time. The drain is the only reader, so at most one send per session is ever
|
||||||
|
/// pending in the lifetime manager.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class HubPump
|
||||||
|
{
|
||||||
|
private readonly Channel<MxEvent> _channel;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="HubPump"/> class and starts its drain.</summary>
|
||||||
|
/// <param name="owner">Broadcaster whose send path the drain uses.</param>
|
||||||
|
/// <param name="sessionId">Session whose group the pump feeds.</param>
|
||||||
|
/// <param name="capacity">Queue depth before the oldest queued event is dropped.</param>
|
||||||
|
internal HubPump(DashboardEventBroadcaster owner, string sessionId, int capacity)
|
||||||
|
{
|
||||||
|
_channel = Channel.CreateBounded<MxEvent>(new BoundedChannelOptions(capacity)
|
||||||
|
{
|
||||||
|
// DropOldest, not Wait: Publish runs on the gRPC event pipeline and
|
||||||
|
// must never block, and the newest events are the ones a live view wants.
|
||||||
|
FullMode = BoundedChannelFullMode.DropOldest,
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task.Run so the drain never starts inline on the publishing thread.
|
||||||
|
Completion = Task.Run(() => DrainAsync(owner, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the drain task; it completes once the channel is completed and emptied.</summary>
|
||||||
|
internal Task Completion { get; }
|
||||||
|
|
||||||
|
/// <summary>Queues an event, dropping the oldest queued one when full. Never blocks.</summary>
|
||||||
|
/// <param name="mxEvent">The event to queue.</param>
|
||||||
|
internal void TryWrite(MxEvent mxEvent) => _channel.Writer.TryWrite(mxEvent);
|
||||||
|
|
||||||
|
/// <summary>Completes the channel so the drain exits once it is empty.</summary>
|
||||||
|
internal void Complete() => _channel.Writer.TryComplete();
|
||||||
|
|
||||||
|
private async Task DrainAsync(DashboardEventBroadcaster owner, string sessionId)
|
||||||
|
{
|
||||||
|
ChannelReader<MxEvent> reader = _channel.Reader;
|
||||||
|
while (await reader.WaitToReadAsync().ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
while (reader.TryRead(out MxEvent? next))
|
||||||
|
{
|
||||||
|
await owner.SendToHubAsync(sessionId, next).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,27 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|||||||
/// simply goes away never calls <c>UnsubscribeSession</c>; the hub's
|
/// simply goes away never calls <c>UnsubscribeSession</c>; the hub's
|
||||||
/// <c>OnDisconnectedAsync</c> releases everything the connection held.
|
/// <c>OnDisconnectedAsync</c> releases everything the connection held.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Hub viewers are also counted on their own, separately from the in-process
|
||||||
|
/// subscribers that register under an <see cref="InProcessConnectionIdPrefix"/>
|
||||||
|
/// id: <see cref="DashboardEventBroadcaster"/> runs one bounded hub-delivery pump
|
||||||
|
/// per session only while a remote connection is watching it, and tears that pump
|
||||||
|
/// down on <see cref="HubViewersReleased"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class EventsHubViewerRegistry
|
public sealed class EventsHubViewerRegistry
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prefix of the synthetic connection ids in-process subscribers register under.
|
||||||
|
/// A SignalR connection id never carries it, so it is what tells the two
|
||||||
|
/// audiences apart for the hub-only count.
|
||||||
|
/// </summary>
|
||||||
|
public const string InProcessConnectionIdPrefix = "inproc-";
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, int> _viewersBySession = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, int> _viewersBySession = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, int> _hubViewersBySession = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _sessionsByConnection =
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _sessionsByConnection =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
@@ -53,8 +69,19 @@ public sealed class EventsHubViewerRegistry
|
|||||||
}
|
}
|
||||||
|
|
||||||
_viewersBySession.AddOrUpdate(sessionId, 1, static (_, count) => count + 1);
|
_viewersBySession.AddOrUpdate(sessionId, 1, static (_, count) => count + 1);
|
||||||
|
if (IsHubConnection(connectionId))
|
||||||
|
{
|
||||||
|
_hubViewersBySession.AddOrUpdate(sessionId, 1, static (_, count) => count + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised with a session id when its last remote hub viewer leaves (unsubscribe
|
||||||
|
/// or disconnect). In-process subscribers do not raise it. Handlers run on the
|
||||||
|
/// releasing thread and must not throw or block.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? HubViewersReleased;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records that <paramref name="connectionId"/> stopped watching
|
/// Records that <paramref name="connectionId"/> stopped watching
|
||||||
/// <paramref name="sessionId"/>. A removal with no matching
|
/// <paramref name="sessionId"/>. A removal with no matching
|
||||||
@@ -75,7 +102,7 @@ public sealed class EventsHubViewerRegistry
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ReleaseSession(sessionId);
|
Release(connectionId, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -107,7 +134,7 @@ public sealed class EventsHubViewerRegistry
|
|||||||
// same detached set must not let the session be decremented twice.
|
// same detached set must not let the session be decremented twice.
|
||||||
if (sessions.TryRemove(sessionId, out _))
|
if (sessions.TryRemove(sessionId, out _))
|
||||||
{
|
{
|
||||||
ReleaseSession(sessionId);
|
Release(connectionId, sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,32 +147,63 @@ public sealed class EventsHubViewerRegistry
|
|||||||
&& _viewersBySession.TryGetValue(sessionId, out int count)
|
&& _viewersBySession.TryGetValue(sessionId, out int count)
|
||||||
&& count > 0;
|
&& count > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether any remote <see cref="EventsHub"/> connection —
|
||||||
|
/// as opposed to an in-process subscriber — is watching the session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">Session id to test.</param>
|
||||||
|
/// <returns><see langword="true"/> when at least one hub connection is subscribed.</returns>
|
||||||
|
public bool HasHubViewers(string sessionId) =>
|
||||||
|
!string.IsNullOrEmpty(sessionId)
|
||||||
|
&& _hubViewersBySession.TryGetValue(sessionId, out int count)
|
||||||
|
&& count > 0;
|
||||||
|
|
||||||
|
private static bool IsHubConnection(string connectionId) =>
|
||||||
|
!connectionId.StartsWith(InProcessConnectionIdPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases one subscription from both counts it was recorded in, and raises
|
||||||
|
/// <see cref="HubViewersReleased"/> when it was the session's last hub viewer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connectionId">Connection that held the subscription.</param>
|
||||||
|
/// <param name="sessionId">Session id whose counts are released.</param>
|
||||||
|
private void Release(string connectionId, string sessionId)
|
||||||
|
{
|
||||||
|
ReleaseSession(_viewersBySession, sessionId);
|
||||||
|
if (IsHubConnection(connectionId) && ReleaseSession(_hubViewersBySession, sessionId))
|
||||||
|
{
|
||||||
|
HubViewersReleased?.Invoke(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decrements the session's viewer count, dropping the entry entirely at
|
/// Decrements the session's viewer count, dropping the entry entirely at
|
||||||
/// zero so the dictionary does not grow one key per session ever viewed.
|
/// zero so the dictionary does not grow one key per session ever viewed.
|
||||||
/// The compare-and-swap loop keeps the decrement correct against a
|
/// The compare-and-swap loop keeps the decrement correct against a
|
||||||
/// concurrent <see cref="AddViewer"/> on the same session.
|
/// concurrent <see cref="AddViewer"/> on the same session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="counts">The count map to decrement.</param>
|
||||||
/// <param name="sessionId">Session id whose count is released.</param>
|
/// <param name="sessionId">Session id whose count is released.</param>
|
||||||
private void ReleaseSession(string sessionId)
|
/// <returns><see langword="true"/> when this release dropped the count to zero.</returns>
|
||||||
|
private static bool ReleaseSession(ConcurrentDictionary<string, int> counts, string sessionId)
|
||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
if (!_viewersBySession.TryGetValue(sessionId, out int count))
|
if (!counts.TryGetValue(sessionId, out int count))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count <= 1)
|
if (count <= 1)
|
||||||
{
|
{
|
||||||
if (_viewersBySession.TryRemove(new KeyValuePair<string, int>(sessionId, count)))
|
if (counts.TryRemove(new KeyValuePair<string, int>(sessionId, count)))
|
||||||
{
|
{
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (_viewersBySession.TryUpdate(sessionId, count - 1, count))
|
else if (counts.TryUpdate(sessionId, count - 1, count))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+219
-54
@@ -12,7 +12,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|||||||
/// <c>MxGateway:Dashboard:ShowTagValues</c>: tag values are stripped
|
/// <c>MxGateway:Dashboard:ShowTagValues</c>: tag values are stripped
|
||||||
/// from the mirrored copy when the flag is off, present when it is on, and the
|
/// from the mirrored copy when the flag is off, present when it is on, and the
|
||||||
/// shared source event is never mutated. Also verifies the viewer gate — the
|
/// shared source event is never mutated. Also verifies the viewer gate — the
|
||||||
/// mirror does no work at all for a session nobody is watching.
|
/// mirror does no work at all for a session nobody is watching, and that hub
|
||||||
|
/// delivery goes through one bounded drop-oldest pump per hub-watched session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DashboardEventBroadcasterTests
|
public sealed class DashboardEventBroadcasterTests
|
||||||
{
|
{
|
||||||
@@ -22,11 +23,12 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = new();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
MxEvent source = BuildEventWithValue();
|
MxEvent source = BuildEventWithValue();
|
||||||
|
|
||||||
broadcaster.Publish("session-1", source);
|
broadcaster.Publish("session-1", source);
|
||||||
|
|
||||||
|
Assert.Equal(0, broadcaster.ActiveHubPumpCount);
|
||||||
Assert.Equal(0, hubContext.SendCount);
|
Assert.Equal(0, hubContext.SendCount);
|
||||||
Assert.Null(hubContext.LastArgument);
|
Assert.Null(hubContext.LastArgument);
|
||||||
}
|
}
|
||||||
@@ -38,40 +40,105 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = new();
|
||||||
viewers.AddViewer("conn-1", "session-2");
|
viewers.AddViewer("conn-1", "session-2");
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
|
Assert.Equal(0, broadcaster.ActiveHubPumpCount);
|
||||||
Assert.Equal(0, hubContext.SendCount);
|
Assert.Equal(0, hubContext.SendCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Once the last viewer leaves, the mirror stops sending again.</summary>
|
/// <summary>Once the last hub viewer leaves, the session's pump is torn down and sending stops.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_AfterLastViewerLeaves_StopsSending()
|
public async Task Publish_AfterLastViewerLeaves_StopsSending()
|
||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = new();
|
||||||
viewers.AddViewer("conn-1", "session-1");
|
viewers.AddViewer("conn-1", "session-1");
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
Assert.Equal(1, hubContext.SendCount);
|
await hubContext.WaitForSendCountAsync(1);
|
||||||
|
Assert.Equal(1, broadcaster.ActiveHubPumpCount);
|
||||||
|
|
||||||
viewers.RemoveViewer("conn-1", "session-1");
|
viewers.RemoveViewer("conn-1", "session-1");
|
||||||
|
Assert.Equal(0, broadcaster.ActiveHubPumpCount);
|
||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
|
Assert.Equal(0, broadcaster.ActiveHubPumpCount);
|
||||||
Assert.Equal(1, hubContext.SendCount);
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A hub viewer that cannot keep up costs a bounded queue, not an unbounded pile of
|
||||||
|
/// pending sends: while one send is stuck, further events drop oldest-first at the
|
||||||
|
/// pump's capacity, only one send is ever in flight, and the newest event survives.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_WhenHubSendStalls_BuffersAtMostTheCapacityAndKeepsTheNewest()
|
||||||
|
{
|
||||||
|
CapturingHubContext hubContext = new();
|
||||||
|
hubContext.Proxy.Gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
EventsHubViewerRegistry viewers = WatchedSession1();
|
||||||
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: true, viewers);
|
||||||
|
const int published = 2000;
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", BuildSequencedEvent(0));
|
||||||
|
await hubContext.WaitForSendCountAsync(1);
|
||||||
|
for (int i = 1; i < published; i++)
|
||||||
|
{
|
||||||
|
broadcaster.Publish("session-1", BuildSequencedEvent(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One send stuck in flight, nothing else started behind it.
|
||||||
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
|
Assert.Equal(1, hubContext.Proxy.MaxConcurrentSends);
|
||||||
|
|
||||||
|
hubContext.Proxy.Gate.SetResult();
|
||||||
|
await hubContext.WaitForLastItemHandleAsync(published - 1);
|
||||||
|
|
||||||
|
// The stuck event plus at most one full queue — the rest were dropped oldest-first.
|
||||||
|
Assert.InRange(hubContext.SendCount, 2, DashboardEventBroadcaster.HubQueueCapacity + 1);
|
||||||
|
Assert.Equal(1, hubContext.Proxy.MaxConcurrentSends);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A send that never completes is abandoned at the send timeout, so one stuck
|
||||||
|
/// connection cannot stop the pump delivering later events.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_WhenHubSendNeverCompletes_AbandonsItAtTheTimeoutAndContinues()
|
||||||
|
{
|
||||||
|
CapturingHubContext hubContext = new();
|
||||||
|
hubContext.Proxy.Gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
hubContext.Proxy.IgnoreCancellation = true;
|
||||||
|
using DashboardEventBroadcaster broadcaster = Create(
|
||||||
|
hubContext,
|
||||||
|
showTagValues: true,
|
||||||
|
WatchedSession1(),
|
||||||
|
hubSendTimeout: TimeSpan.FromMilliseconds(50));
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", BuildSequencedEvent(1));
|
||||||
|
broadcaster.Publish("session-1", BuildSequencedEvent(2));
|
||||||
|
|
||||||
|
await hubContext.WaitForSendCountAsync(2);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Values are stripped from the mirror when ShowTagValues is off; metadata survives.</summary>
|
/// <summary>Values are stripped from the mirror when ShowTagValues is off; metadata survives.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_WhenShowTagValuesFalse_RedactsValuesButKeepsMetadata()
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task Publish_WhenShowTagValuesFalse_RedactsValuesButKeepsMetadata()
|
||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1());
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1());
|
||||||
MxEvent source = BuildEventWithValue();
|
MxEvent source = BuildEventWithValue();
|
||||||
|
|
||||||
broadcaster.Publish("session-1", source);
|
broadcaster.Publish("session-1", source);
|
||||||
|
await hubContext.WaitForSendCountAsync(1);
|
||||||
|
|
||||||
MxEvent sent = Assert.IsType<MxEvent>(hubContext.LastArgument);
|
MxEvent sent = Assert.IsType<MxEvent>(hubContext.LastArgument);
|
||||||
Assert.Null(sent.Value);
|
Assert.Null(sent.Value);
|
||||||
@@ -88,13 +155,15 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
|
|
||||||
/// <summary>Redaction applies to a clone, so the shared source event keeps its values.</summary>
|
/// <summary>Redaction applies to a clone, so the shared source event keeps its values.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_WhenShowTagValuesFalse_DoesNotMutateSourceEvent()
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task Publish_WhenShowTagValuesFalse_DoesNotMutateSourceEvent()
|
||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1());
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, WatchedSession1());
|
||||||
MxEvent source = BuildEventWithValue();
|
MxEvent source = BuildEventWithValue();
|
||||||
|
|
||||||
broadcaster.Publish("session-1", source);
|
broadcaster.Publish("session-1", source);
|
||||||
|
await hubContext.WaitForSendCountAsync(1);
|
||||||
|
|
||||||
// The redaction must apply to a clone; the shared source keeps its values.
|
// The redaction must apply to a clone; the shared source keeps its values.
|
||||||
Assert.NotNull(source.Value);
|
Assert.NotNull(source.Value);
|
||||||
@@ -106,13 +175,15 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
|
|
||||||
/// <summary>Values pass through unredacted when ShowTagValues is on.</summary>
|
/// <summary>Values pass through unredacted when ShowTagValues is on.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_WhenShowTagValuesTrue_KeepsValues()
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task Publish_WhenShowTagValuesTrue_KeepsValues()
|
||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: true, WatchedSession1());
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: true, WatchedSession1());
|
||||||
MxEvent source = BuildEventWithValue();
|
MxEvent source = BuildEventWithValue();
|
||||||
|
|
||||||
broadcaster.Publish("session-1", source);
|
broadcaster.Publish("session-1", source);
|
||||||
|
await hubContext.WaitForSendCountAsync(1);
|
||||||
|
|
||||||
MxEvent sent = Assert.IsType<MxEvent>(hubContext.LastArgument);
|
MxEvent sent = Assert.IsType<MxEvent>(hubContext.LastArgument);
|
||||||
Assert.NotNull(sent.Value);
|
Assert.NotNull(sent.Value);
|
||||||
@@ -125,16 +196,18 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
/// An in-process subscriber gets the same redacted clone the hub group gets,
|
/// An in-process subscriber gets the same redacted clone the hub group gets,
|
||||||
/// and the shared source event is still left untouched.
|
/// and the shared source event is still left untouched.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Subscribe_WhenShowTagValuesFalse_DeliversRedactedCloneWithoutMutatingSource()
|
public async Task Subscribe_WhenShowTagValuesFalse_DeliversRedactedCloneWithoutMutatingSource()
|
||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = WatchedSession1();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
||||||
MxEvent source = BuildEventWithValue();
|
MxEvent source = BuildEventWithValue();
|
||||||
|
|
||||||
broadcaster.Publish("session-1", source);
|
broadcaster.Publish("session-1", source);
|
||||||
|
await hubContext.WaitForSendCountAsync(1);
|
||||||
|
|
||||||
MxEvent received = ReadOne(subscription);
|
MxEvent received = ReadOne(subscription);
|
||||||
Assert.Null(received.Value);
|
Assert.Null(received.Value);
|
||||||
@@ -153,17 +226,16 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
Assert.NotNull(source.OnAlarmTransition.LimitValue);
|
Assert.NotNull(source.OnAlarmTransition.LimitValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An in-process subscription opens the viewer gate the same way a hub client does.</summary>
|
/// <summary>
|
||||||
|
/// An in-process subscription opens the viewer gate the same way a hub client does,
|
||||||
|
/// but starts no hub pump: with no remote connection there is nobody to send to.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Subscribe_OpensTheViewerGate()
|
public void Subscribe_OpensTheViewerGate()
|
||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = new();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
|
||||||
Assert.Equal(0, hubContext.SendCount);
|
|
||||||
Assert.Null(hubContext.LastArgument);
|
|
||||||
|
|
||||||
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
||||||
|
|
||||||
@@ -171,8 +243,9 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
Assert.Equal(1, hubContext.SendCount);
|
Assert.Equal("session-1", ReadOne(subscription).SessionId);
|
||||||
Assert.NotNull(hubContext.LastArgument);
|
Assert.Equal(0, broadcaster.ActiveHubPumpCount);
|
||||||
|
Assert.Equal(0, hubContext.SendCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Disposing the last in-process subscription restores the no-viewers short-circuit.</summary>
|
/// <summary>Disposing the last in-process subscription restores the no-viewers short-circuit.</summary>
|
||||||
@@ -181,11 +254,10 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = new();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
Assert.Equal(1, hubContext.SendCount);
|
|
||||||
Assert.Equal("session-1", ReadOne(subscription).SessionId);
|
Assert.Equal("session-1", ReadOne(subscription).SessionId);
|
||||||
|
|
||||||
subscription.Dispose();
|
subscription.Dispose();
|
||||||
@@ -194,26 +266,28 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
Assert.Equal(1, hubContext.SendCount);
|
Assert.Equal(0, hubContext.SendCount);
|
||||||
Assert.False(subscription.Reader.TryRead(out _));
|
Assert.False(subscription.Reader.TryRead(out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Hub viewers and in-process subscribers are audiences of their own session only.</summary>
|
/// <summary>Hub viewers and in-process subscribers are audiences of their own session only.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_DeliversOnlyToTheSubscribedSessionsAudience()
|
public async Task Publish_DeliversOnlyToTheSubscribedSessionsAudience()
|
||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = new();
|
||||||
viewers.AddViewer("conn-1", "session-1");
|
viewers.AddViewer("conn-1", "session-1");
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-2");
|
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-2");
|
||||||
|
|
||||||
// The hub viewer's session must not spill into the in-process feed.
|
// The hub viewer's session must not spill into the in-process feed.
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
Assert.Equal(1, hubContext.SendCount);
|
await hubContext.WaitForSendCountAsync(1);
|
||||||
Assert.False(subscription.Reader.TryRead(out _));
|
Assert.False(subscription.Reader.TryRead(out _));
|
||||||
|
|
||||||
|
// The in-process-only session reaches its subscriber and starts no hub pump.
|
||||||
broadcaster.Publish("session-2", BuildEventWithValue("session-2"));
|
broadcaster.Publish("session-2", BuildEventWithValue("session-2"));
|
||||||
|
|
||||||
Assert.Equal("session-2", ReadOne(subscription).SessionId);
|
Assert.Equal("session-2", ReadOne(subscription).SessionId);
|
||||||
@@ -221,7 +295,8 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
// A session with no audience at all still short-circuits.
|
// A session with no audience at all still short-circuits.
|
||||||
broadcaster.Publish("session-3", BuildEventWithValue("session-3"));
|
broadcaster.Publish("session-3", BuildEventWithValue("session-3"));
|
||||||
|
|
||||||
Assert.Equal(2, hubContext.SendCount);
|
Assert.Equal(1, broadcaster.ActiveHubPumpCount);
|
||||||
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Disposing twice is a no-op and cannot release a sibling subscription's registration.</summary>
|
/// <summary>Disposing twice is a no-op and cannot release a sibling subscription's registration.</summary>
|
||||||
@@ -230,7 +305,7 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
{
|
{
|
||||||
CapturingHubContext hubContext = new();
|
CapturingHubContext hubContext = new();
|
||||||
EventsHubViewerRegistry viewers = new();
|
EventsHubViewerRegistry viewers = new();
|
||||||
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
using DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
IDashboardEventSubscription first = broadcaster.Subscribe("session-1");
|
IDashboardEventSubscription first = broadcaster.Subscribe("session-1");
|
||||||
using IDashboardEventSubscription second = broadcaster.Subscribe("session-1");
|
using IDashboardEventSubscription second = broadcaster.Subscribe("session-1");
|
||||||
|
|
||||||
@@ -241,7 +316,6 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
|
|
||||||
broadcaster.Publish("session-1", BuildEventWithValue());
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
Assert.Equal(1, hubContext.SendCount);
|
|
||||||
Assert.False(first.Reader.TryRead(out _));
|
Assert.False(first.Reader.TryRead(out _));
|
||||||
Assert.Equal("session-1", ReadOne(second).SessionId);
|
Assert.Equal("session-1", ReadOne(second).SessionId);
|
||||||
}
|
}
|
||||||
@@ -258,7 +332,8 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
private static DashboardEventBroadcaster Create(
|
private static DashboardEventBroadcaster Create(
|
||||||
CapturingHubContext hubContext,
|
CapturingHubContext hubContext,
|
||||||
bool showTagValues,
|
bool showTagValues,
|
||||||
EventsHubViewerRegistry viewers)
|
EventsHubViewerRegistry viewers,
|
||||||
|
TimeSpan? hubSendTimeout = null)
|
||||||
{
|
{
|
||||||
GatewayOptions gatewayOptions = new()
|
GatewayOptions gatewayOptions = new()
|
||||||
{
|
{
|
||||||
@@ -269,7 +344,21 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
hubContext,
|
hubContext,
|
||||||
viewers,
|
viewers,
|
||||||
Options.Create(gatewayOptions),
|
Options.Create(gatewayOptions),
|
||||||
NullLogger<DashboardEventBroadcaster>.Instance);
|
NullLogger<DashboardEventBroadcaster>.Instance,
|
||||||
|
hubSendTimeout ?? TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a value-less event whose item handle carries its publish order.</summary>
|
||||||
|
/// <param name="sequence">Publish order stamped into <see cref="MxEvent.ItemHandle"/>.</param>
|
||||||
|
/// <returns>The event.</returns>
|
||||||
|
private static MxEvent BuildSequencedEvent(int sequence)
|
||||||
|
{
|
||||||
|
return new MxEvent
|
||||||
|
{
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
SessionId = "session-1",
|
||||||
|
ItemHandle = sequence,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A registry with one hub connection watching <c>session-1</c>.</summary>
|
/// <summary>A registry with one hub connection watching <c>session-1</c>.</summary>
|
||||||
@@ -313,81 +402,157 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
/// <summary>Gets the group manager.</summary>
|
/// <summary>Gets the group manager.</summary>
|
||||||
public IGroupManager Groups { get; } = new NoopGroupManager();
|
public IGroupManager Groups { get; } = new NoopGroupManager();
|
||||||
|
|
||||||
|
/// <summary>Gets the capturing proxy every client lookup resolves to.</summary>
|
||||||
|
public CapturingClientProxy Proxy => _clients.Proxy;
|
||||||
|
|
||||||
/// <summary>Gets the first argument of the most recent send call.</summary>
|
/// <summary>Gets the first argument of the most recent send call.</summary>
|
||||||
public object? LastArgument => _clients.GroupProxy.LastArgument;
|
public object? LastArgument => _clients.Proxy.LastArgument;
|
||||||
|
|
||||||
/// <summary>Gets the number of send calls this fake has observed.</summary>
|
/// <summary>Gets the number of send calls this fake has observed.</summary>
|
||||||
public int SendCount => _clients.GroupProxy.SendCount;
|
public int SendCount => _clients.Proxy.SendCount;
|
||||||
|
|
||||||
|
/// <summary>Waits until at least <paramref name="count"/> sends have started.</summary>
|
||||||
|
/// <param name="count">The send count to wait for.</param>
|
||||||
|
/// <returns>A task that completes once the count is reached.</returns>
|
||||||
|
public Task WaitForSendCountAsync(int count) =>
|
||||||
|
WaitUntilAsync(() => SendCount >= count, $"{count} sends (saw {SendCount})");
|
||||||
|
|
||||||
|
/// <summary>Waits until a send carrying the given item handle has started.</summary>
|
||||||
|
/// <param name="itemHandle">Item handle of the event to wait for.</param>
|
||||||
|
/// <returns>A task that completes once the event was sent.</returns>
|
||||||
|
public Task WaitForLastItemHandleAsync(int itemHandle) =>
|
||||||
|
WaitUntilAsync(
|
||||||
|
() => LastArgument is MxEvent sent && sent.ItemHandle == itemHandle,
|
||||||
|
$"a send of item handle {itemHandle}");
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition, string description)
|
||||||
|
{
|
||||||
|
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
|
||||||
|
while (!condition())
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow > deadline)
|
||||||
|
{
|
||||||
|
Assert.Fail($"Timed out waiting for {description}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CapturingHubClients : IHubClients
|
private sealed class CapturingHubClients : IHubClients
|
||||||
{
|
{
|
||||||
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
|
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
|
||||||
public CapturingClientProxy GroupProxy { get; } = new();
|
public CapturingClientProxy Proxy { get; } = new();
|
||||||
|
|
||||||
/// <summary>Gets the shared capturing proxy for all connections.</summary>
|
/// <summary>Gets the shared capturing proxy for all connections.</summary>
|
||||||
public IClientProxy All => GroupProxy;
|
public IClientProxy All => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy, ignoring exclusions.</summary>
|
/// <summary>Returns the shared capturing proxy, ignoring exclusions.</summary>
|
||||||
/// <param name="excludedConnectionIds">Ignored.</param>
|
/// <param name="excludedConnectionIds">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy for any connection.</summary>
|
/// <summary>Returns the shared capturing proxy for any connection.</summary>
|
||||||
/// <param name="connectionId">Ignored.</param>
|
/// <param name="connectionId">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy Client(string connectionId) => GroupProxy;
|
public IClientProxy Client(string connectionId) => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy for any connection set.</summary>
|
/// <summary>Returns the shared capturing proxy for any connection set.</summary>
|
||||||
/// <param name="connectionIds">Ignored.</param>
|
/// <param name="connectionIds">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy for any group.</summary>
|
/// <summary>Returns the shared capturing proxy for any group.</summary>
|
||||||
/// <param name="groupName">Ignored.</param>
|
/// <param name="groupName">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy Group(string groupName) => GroupProxy;
|
public IClientProxy Group(string groupName) => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy for any group, ignoring exclusions.</summary>
|
/// <summary>Returns the shared capturing proxy for any group, ignoring exclusions.</summary>
|
||||||
/// <param name="groupName">Ignored.</param>
|
/// <param name="groupName">Ignored.</param>
|
||||||
/// <param name="excludedConnectionIds">Ignored.</param>
|
/// <param name="excludedConnectionIds">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy for any group set.</summary>
|
/// <summary>Returns the shared capturing proxy for any group set.</summary>
|
||||||
/// <param name="groupNames">Ignored.</param>
|
/// <param name="groupNames">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy for any user.</summary>
|
/// <summary>Returns the shared capturing proxy for any user.</summary>
|
||||||
/// <param name="userId">Ignored.</param>
|
/// <param name="userId">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy User(string userId) => GroupProxy;
|
public IClientProxy User(string userId) => Proxy;
|
||||||
|
|
||||||
/// <summary>Returns the shared capturing proxy for any user set.</summary>
|
/// <summary>Returns the shared capturing proxy for any user set.</summary>
|
||||||
/// <param name="userIds">Ignored.</param>
|
/// <param name="userIds">Ignored.</param>
|
||||||
/// <returns>The shared capturing proxy.</returns>
|
/// <returns>The shared capturing proxy.</returns>
|
||||||
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
|
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CapturingClientProxy : IClientProxy
|
private sealed class CapturingClientProxy : IClientProxy
|
||||||
{
|
{
|
||||||
|
private int _sendCount;
|
||||||
|
private int _inFlight;
|
||||||
|
private int _maxConcurrentSends;
|
||||||
|
private object? _lastArgument;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a gate every send waits on before completing; null completes
|
||||||
|
/// sends synchronously. Set it before the first publish.
|
||||||
|
/// </summary>
|
||||||
|
public TaskCompletionSource? Gate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets a value indicating whether a gated send ignores its cancellation token.</summary>
|
||||||
|
public bool IgnoreCancellation { get; set; }
|
||||||
|
|
||||||
/// <summary>Gets the first argument of the most recent send call.</summary>
|
/// <summary>Gets the first argument of the most recent send call.</summary>
|
||||||
public object? LastArgument { get; private set; }
|
public object? LastArgument => Volatile.Read(ref _lastArgument);
|
||||||
|
|
||||||
/// <summary>Gets the number of send calls made through this proxy.</summary>
|
/// <summary>Gets the number of send calls made through this proxy.</summary>
|
||||||
public int SendCount { get; private set; }
|
public int SendCount => Volatile.Read(ref _sendCount);
|
||||||
|
|
||||||
/// <summary>Records the send call arguments and completes synchronously.</summary>
|
/// <summary>Gets the highest number of sends that were in flight at once.</summary>
|
||||||
|
public int MaxConcurrentSends => Volatile.Read(ref _maxConcurrentSends);
|
||||||
|
|
||||||
|
/// <summary>Records the send call arguments and completes once the gate (if any) opens.</summary>
|
||||||
/// <param name="method">The SignalR method name.</param>
|
/// <param name="method">The SignalR method name.</param>
|
||||||
/// <param name="args">The method arguments.</param>
|
/// <param name="args">The method arguments.</param>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
/// <returns>A completed task.</returns>
|
/// <returns>A task that completes when the send is released.</returns>
|
||||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
SendCount++;
|
Volatile.Write(ref _lastArgument, args.Length > 0 ? args[0] : null);
|
||||||
LastArgument = args.Length > 0 ? args[0] : null;
|
Interlocked.Increment(ref _sendCount);
|
||||||
return Task.CompletedTask;
|
|
||||||
|
TaskCompletionSource? gate = Gate;
|
||||||
|
if (gate is null || gate.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackAsync(gate.Task, IgnoreCancellation ? CancellationToken.None : cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TrackAsync(Task gate, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int inFlight = Interlocked.Increment(ref _inFlight);
|
||||||
|
int observed;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
observed = Volatile.Read(ref _maxConcurrentSends);
|
||||||
|
}
|
||||||
|
while (inFlight > observed
|
||||||
|
&& Interlocked.CompareExchange(ref _maxConcurrentSends, inFlight, observed) != observed);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Decrement(ref _inFlight);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,4 +164,48 @@ public sealed class EventsHubViewerRegistryTests
|
|||||||
|
|
||||||
Assert.False(registry.HasViewers("session-1"));
|
Assert.False(registry.HasViewers("session-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>In-process subscribers count as viewers but not as hub viewers.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void HasHubViewers_CountsOnlyRemoteConnections()
|
||||||
|
{
|
||||||
|
EventsHubViewerRegistry registry = new();
|
||||||
|
|
||||||
|
registry.AddViewer(EventsHubViewerRegistry.InProcessConnectionIdPrefix + "a", "session-1");
|
||||||
|
|
||||||
|
Assert.True(registry.HasViewers("session-1"));
|
||||||
|
Assert.False(registry.HasHubViewers("session-1"));
|
||||||
|
|
||||||
|
registry.AddViewer("conn-1", "session-1");
|
||||||
|
|
||||||
|
Assert.True(registry.HasHubViewers("session-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The released event fires once, when the last hub viewer leaves — whether it
|
||||||
|
/// unsubscribes or disconnects — and never for in-process subscribers.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void HubViewersReleased_FiresWhenTheLastHubViewerLeaves()
|
||||||
|
{
|
||||||
|
EventsHubViewerRegistry registry = new();
|
||||||
|
List<string> released = [];
|
||||||
|
registry.HubViewersReleased += released.Add;
|
||||||
|
|
||||||
|
registry.AddViewer("conn-1", "session-1");
|
||||||
|
registry.AddViewer("conn-2", "session-1");
|
||||||
|
registry.AddViewer(EventsHubViewerRegistry.InProcessConnectionIdPrefix + "a", "session-1");
|
||||||
|
|
||||||
|
registry.RemoveViewer("conn-1", "session-1");
|
||||||
|
Assert.Empty(released);
|
||||||
|
|
||||||
|
registry.ReleaseConnection("conn-2");
|
||||||
|
Assert.Equal(["session-1"], released);
|
||||||
|
Assert.False(registry.HasHubViewers("session-1"));
|
||||||
|
|
||||||
|
// The in-process subscriber still keeps the session watched, and leaving raises nothing.
|
||||||
|
Assert.True(registry.HasViewers("session-1"));
|
||||||
|
registry.RemoveViewer(EventsHubViewerRegistry.InProcessConnectionIdPrefix + "a", "session-1");
|
||||||
|
Assert.Equal(["session-1"], released);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user