mbproxy: fix the dashboard's C2/M-series review findings
Closes the on-demand-capture leak cluster from the code review. The capture's armed state was driven off SignalR's ConnectionId, which changes on every transport reconnect, so a reconnect-during-view leaked a subscriber and left the capture armed forever with no viewer. PlcSubscriptionTracker now keys on a stable per-page-load tabId, and StatusBroadcaster reconciles capture arm state from the live viewer set each push cycle — making arming single-threaded and reconnect-safe. Also fixes the TagValueCapture disarm-vs-record race, the bind-failure broadcaster/listener leak, removes dead JSON-context code, and reworks the frontend cold-start retry plus an unknown-PLC watchdog. Adds tracker / broadcaster-loop / race / wire-shape test coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -312,7 +312,7 @@ The UI is a Bootstrap 5 single-page app served from embedded assets under `src/M
|
||||
|
||||
## Debug View Data
|
||||
|
||||
The detail page's debug view is fed by an **on-demand per-tag value capture** (`Proxy/TagValueCapture.cs`, one per PLC, held in `Proxy/TagCaptureRegistry.cs`). The `BcdPduPipeline` records the last raw/decoded value for each configured BCD tag — but only while the capture is *armed*. `StatusHub` arms a PLC's capture when the first detail page subscribes and disarms it (clearing all slots) when the last viewer leaves, so the hot path carries zero cost when nobody is watching. The per-PLC payload is `PlcDetailResponse` (`src/Mbproxy/Admin/DebugDto.cs`):
|
||||
The detail page's debug view is fed by an **on-demand per-tag value capture** (`Proxy/TagValueCapture.cs`, one per PLC, held in `Proxy/TagCaptureRegistry.cs`). The `BcdPduPipeline` records the last raw/decoded value for each configured BCD tag — but only while the capture is *armed*. `StatusBroadcaster` reconciles arm state every push cycle from `PlcSubscriptionTracker`: a PLC's capture is armed exactly while at least one detail-page browser tab is open, and disarmed (clearing all slots) otherwise — so the hot path carries zero cost when nobody is watching. The tracker keys on a stable per-page-load tab id, not the SignalR `ConnectionId`, so a transport reconnect cannot leak an armed capture. The per-PLC payload is `PlcDetailResponse` (`src/Mbproxy/Admin/DebugDto.cs`):
|
||||
|
||||
> When the response cache is enabled, an FC03/FC04 **cache hit** bypasses the pipeline. To keep the debug view live for cached tags, each cache entry carries the tag observations captured when it was stored (only when a viewer was armed at that time); a hit replays them into the capture, re-stamped to the hit time. The debug view therefore reflects the value the client actually receives — cache-served reads included — not only backend round-trips.
|
||||
|
||||
|
||||
@@ -166,6 +166,10 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
|
||||
/// </summary>
|
||||
private async Task StartAppAsync(int port, CancellationToken ct)
|
||||
{
|
||||
// Declared outside the try so the catch can dispose a built-but-not-fully-started
|
||||
// app on a bind failure (M6 — otherwise a built WebApplication or a started
|
||||
// Kestrel listener leaks on any throw after Build()).
|
||||
WebApplication? app = null;
|
||||
try
|
||||
{
|
||||
// Use CreateSlimBuilder with explicit args (empty) to avoid inheriting
|
||||
@@ -187,17 +191,18 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
|
||||
});
|
||||
|
||||
// SignalR hub for the live dashboard. The inner WebApplication has its own
|
||||
// DI container, so the singletons StatusHub depends on are re-registered here.
|
||||
// camelCase payloads keep the wire shape identical to GET /status.json.
|
||||
// DI container, so the singleton StatusHub depends on is re-registered here.
|
||||
// The payload serialises via reflection-based System.Text.Json with a
|
||||
// camelCase policy — the same wire shape as GET /status.json. The project
|
||||
// does not trim/AOT, so a reflection JSON path is acceptable here.
|
||||
builder.Services
|
||||
.AddSignalR()
|
||||
.AddJsonProtocol(o =>
|
||||
o.PayloadSerializerOptions.PropertyNamingPolicy =
|
||||
System.Text.Json.JsonNamingPolicy.CamelCase);
|
||||
builder.Services.AddSingleton(_captureRegistry);
|
||||
builder.Services.AddSingleton(_subscriptionTracker);
|
||||
|
||||
var app = builder.Build();
|
||||
app = builder.Build();
|
||||
|
||||
// ── Routes ───────────────────────────────────────────────────────
|
||||
// GET / — fleet dashboard SPA shell
|
||||
@@ -252,9 +257,26 @@ internal sealed partial class AdminEndpointHost : IAsyncDisposable
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Bind failed — log and continue. Proxy listeners are unaffected.
|
||||
// Bind (or post-bind setup) failed — log and continue. Proxy listeners are
|
||||
// unaffected. Tear down anything that started before the failure so neither
|
||||
// the push loop nor a bound Kestrel listener leaks (M6).
|
||||
LogAdminBindFailed(_logger, port, ex.Message);
|
||||
|
||||
if (_broadcaster is { } broadcaster)
|
||||
{
|
||||
_broadcaster = null;
|
||||
try { await broadcaster.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
_app = null;
|
||||
if (app is not null)
|
||||
{
|
||||
try { await app.StopAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort — may never have started */ }
|
||||
try { await app.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
// ── Wire DTOs for the connection-detail debug view ───────────────────────────
|
||||
// Pushed over SignalR to subscribers of a single PLC's detail page. camelCase via
|
||||
// JsonKnownNamingPolicy.CamelCase on the source-gen context below.
|
||||
// Pushed over SignalR to subscribers of a single PLC's detail page. The SignalR hub
|
||||
// serialises these via reflection-based System.Text.Json with a camelCase property
|
||||
// policy (see AdminEndpointHost's AddJsonProtocol) — the same wire shape as
|
||||
// GET /status.json. The project does not trim/AOT, so the reflection path is fine.
|
||||
|
||||
/// <summary>
|
||||
/// Per-PLC payload pushed to detail-page subscribers: the standard per-PLC status
|
||||
@@ -52,12 +52,3 @@ public sealed record TagValueDto(
|
||||
string? UpdatedAtUtc,
|
||||
/// <summary>Seconds since the observation; <c>null</c> when no traffic yet.</summary>
|
||||
double? AgeSeconds);
|
||||
|
||||
// ── Source-generation context ─────────────────────────────────────────────────
|
||||
|
||||
[JsonSerializable(typeof(PlcDetailResponse))]
|
||||
[JsonSerializable(typeof(PlcDebugSnapshot))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
internal partial class DebugJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -1,83 +1,109 @@
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which SignalR connections are subscribed to which PLC detail pages, so the
|
||||
/// admin layer knows (a) when to arm / disarm a PLC's tag-value capture — capture is
|
||||
/// armed only while at least one detail page is open — and (b) which PLC groups the
|
||||
/// <see cref="StatusBroadcaster"/> needs to push to.
|
||||
/// Tracks which browser tabs currently have a PLC connection-detail page open, so the
|
||||
/// admin layer knows which PLC tag-value captures should be armed and which PLC groups
|
||||
/// the <see cref="StatusBroadcaster"/> needs to push to.
|
||||
///
|
||||
/// <para>Registered as a DI singleton; <see cref="StatusHub"/> instances (transient,
|
||||
/// one per hub call) share this single tracker. All methods are thread-safe under a
|
||||
/// single lock — subscription churn is low-frequency (one event per detail-page
|
||||
/// open/close), so lock contention is a non-issue.</para>
|
||||
/// <para><b>Why tabs, not SignalR connections.</b> A SignalR connection is assigned a
|
||||
/// fresh <c>ConnectionId</c> on every transport reconnect (a WebSocket drop, a
|
||||
/// long-polling cycle, a network blip). Counting <em>connections</em> therefore leaks a
|
||||
/// subscriber on every reconnect — the old connection's <c>OnDisconnectedAsync</c> is
|
||||
/// not ordered against the new connection's re-subscribe, so the count never returns to
|
||||
/// 0 and the capture stays armed forever with no viewer. Instead each detail page sends
|
||||
/// a stable per-page-load <c>tabId</c>: a tab "views" a PLC for as long as it has at
|
||||
/// least one live connection. A reconnect is just the same tab acquiring a new
|
||||
/// connection, so it cannot leak; a tab is released only when its <em>last</em>
|
||||
/// connection is gone (the page closed, or SignalR's keepalive timeout elapsed for an
|
||||
/// abruptly-killed tab).</para>
|
||||
///
|
||||
/// <para>Registered as a DI singleton; the transient <see cref="StatusHub"/> instances
|
||||
/// share this one tracker. All methods are thread-safe under a single lock —
|
||||
/// subscription churn is low-frequency (one event per detail-page open / close /
|
||||
/// reconnect), so lock contention is a non-issue. The tracker never arms or disarms a
|
||||
/// capture itself — <see cref="StatusBroadcaster"/> reconciles arm state each push
|
||||
/// cycle from <see cref="ActivePlcs"/>, which keeps arming single-threaded.</para>
|
||||
/// </summary>
|
||||
internal sealed class PlcSubscriptionTracker
|
||||
{
|
||||
/// <summary>Live state for one browser tab: its connections and the PLCs it views.</summary>
|
||||
private sealed class TabState
|
||||
{
|
||||
public readonly HashSet<string> Connections = new(StringComparer.Ordinal);
|
||||
public readonly HashSet<string> Plcs = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private readonly object _gate = new();
|
||||
|
||||
// PLC name → number of connections currently subscribed to its detail page.
|
||||
private readonly Dictionary<string, int> _plcCounts = new(StringComparer.Ordinal);
|
||||
// tabId → tab state (live connections + the PLC detail pages it has open).
|
||||
private readonly Dictionary<string, TabState> _tabs = new(StringComparer.Ordinal);
|
||||
|
||||
// Connection id → the set of PLC names that connection is subscribed to.
|
||||
private readonly Dictionary<string, HashSet<string>> _byConnection = new(StringComparer.Ordinal);
|
||||
// connectionId → owning tabId, so a disconnect can find (and decrement) its tab.
|
||||
private readonly Dictionary<string, string> _connToTab = new(StringComparer.Ordinal);
|
||||
|
||||
// PLC name → number of distinct tabs currently viewing its detail page.
|
||||
private readonly Dictionary<string, int> _plcViewerTabs = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Records that <paramref name="connectionId"/> subscribed to <paramref name="plcName"/>.
|
||||
/// Returns <c>true</c> when this is the PLC's first subscriber (count 0 → 1), the
|
||||
/// signal to arm its capture. Returns <c>false</c> for a redundant re-subscribe.
|
||||
/// Records that connection <paramref name="connectionId"/>, belonging to browser tab
|
||||
/// <paramref name="tabId"/>, has the detail page for <paramref name="plcName"/> open.
|
||||
/// Idempotent: a reconnect (same tab, new connection) or a repeated call for an
|
||||
/// already-subscribed tag does not double-count the tab.
|
||||
/// </summary>
|
||||
public bool Add(string connectionId, string plcName)
|
||||
public void SubscribePlc(string connectionId, string tabId, string plcName)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byConnection.TryGetValue(connectionId, out var set))
|
||||
_byConnection[connectionId] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (!_tabs.TryGetValue(tabId, out var tab))
|
||||
_tabs[tabId] = tab = new TabState();
|
||||
|
||||
if (!set.Add(plcName))
|
||||
return false; // this connection was already subscribed to this PLC
|
||||
tab.Connections.Add(connectionId);
|
||||
_connToTab[connectionId] = tabId;
|
||||
|
||||
int count = _plcCounts.GetValueOrDefault(plcName);
|
||||
_plcCounts[plcName] = count + 1;
|
||||
return count == 0;
|
||||
if (tab.Plcs.Add(plcName))
|
||||
_plcViewerTabs[plcName] = _plcViewerTabs.GetValueOrDefault(plcName) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops every subscription held by <paramref name="connectionId"/> (called from
|
||||
/// <see cref="StatusHub.OnDisconnectedAsync"/>). Returns the PLC names whose
|
||||
/// subscriber count fell to 0 — the signal to disarm their captures.
|
||||
/// Drops connection <paramref name="connectionId"/>. If it was the last live
|
||||
/// connection of its tab, the tab is released and its PLC subscriptions decremented.
|
||||
/// A still-live sibling connection (reconnect overlap) keeps the tab — and its
|
||||
/// captures — alive. Safe to call for an unknown / fleet-only connection (no-op).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> RemoveConnection(string connectionId)
|
||||
public void RemoveConnection(string connectionId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byConnection.Remove(connectionId, out var set))
|
||||
return Array.Empty<string>();
|
||||
if (!_connToTab.Remove(connectionId, out var tabId))
|
||||
return;
|
||||
if (!_tabs.TryGetValue(tabId, out var tab))
|
||||
return;
|
||||
|
||||
var dropped = new List<string>();
|
||||
foreach (var plcName in set)
|
||||
tab.Connections.Remove(connectionId);
|
||||
if (tab.Connections.Count > 0)
|
||||
return; // the tab is still alive on another connection
|
||||
|
||||
_tabs.Remove(tabId);
|
||||
foreach (var plcName in tab.Plcs)
|
||||
{
|
||||
int count = _plcCounts.GetValueOrDefault(plcName);
|
||||
int count = _plcViewerTabs.GetValueOrDefault(plcName);
|
||||
if (count <= 1)
|
||||
{
|
||||
_plcCounts.Remove(plcName);
|
||||
dropped.Add(plcName);
|
||||
}
|
||||
_plcViewerTabs.Remove(plcName);
|
||||
else
|
||||
{
|
||||
_plcCounts[plcName] = count - 1;
|
||||
}
|
||||
_plcViewerTabs[plcName] = count - 1;
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>PLC names that currently have at least one detail-page subscriber.</summary>
|
||||
/// <summary>PLC names that currently have at least one detail-page tab open.</summary>
|
||||
public IReadOnlyList<string> ActivePlcs()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _plcCounts.Count == 0 ? Array.Empty<string>() : _plcCounts.Keys.ToArray();
|
||||
return _plcViewerTabs.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: _plcViewerTabs.Keys.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,13 @@ internal sealed class StatusBroadcaster : IAsyncDisposable
|
||||
_logger.LogError(ex, "StatusBroadcaster: fleet push failed");
|
||||
}
|
||||
|
||||
foreach (var plcName in _tracker.ActivePlcs())
|
||||
// Reconcile capture arm state from the live viewer set. This is the single
|
||||
// arm/disarm authority — doing it here (one thread, every cycle) means a SignalR
|
||||
// reconnect or a hot-reload capture rebuild can never strand a capture armed.
|
||||
var activePlcs = _tracker.ActivePlcs();
|
||||
_captureRegistry.ReconcileArmed(activePlcs);
|
||||
|
||||
foreach (var plcName in activePlcs)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
@@ -10,15 +9,18 @@ namespace Mbproxy.Admin;
|
||||
/// <see cref="FleetGroup"/> group and receives a <c>"fleet"</c> message every
|
||||
/// push tick.</item>
|
||||
/// <item><see cref="SubscribePlc"/> — a connection-detail page (<c>GET /plc/{name}</c>)
|
||||
/// joins <see cref="PlcGroup"/> and receives a <c>"plc"</c> message. The first
|
||||
/// subscriber to a PLC arms that PLC's tag-value capture; the last to leave
|
||||
/// disarms it (on-demand capture).</item>
|
||||
/// joins <see cref="PlcGroup"/> and receives a <c>"plc"</c> message. It also
|
||||
/// registers the calling tab with the <see cref="PlcSubscriptionTracker"/> so
|
||||
/// the PLC's tag-value capture is armed while the page is open.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>The hub itself is transient (one instance per call). Cross-call state — the
|
||||
/// subscriber counts that drive capture arming — lives in the singleton
|
||||
/// <see cref="PlcSubscriptionTracker"/>. The actual pushes are issued by
|
||||
/// <see cref="StatusBroadcaster"/>, not the hub.</para>
|
||||
/// <para>The hub itself is transient (one instance per call). Cross-call state — which
|
||||
/// tabs view which PLCs — lives in the singleton <see cref="PlcSubscriptionTracker"/>.
|
||||
/// The hub deliberately does <b>not</b> arm or disarm captures: a SignalR reconnect
|
||||
/// gives the connection a new <c>ConnectionId</c>, and arming off that lifetime leaks.
|
||||
/// Instead the hub only mutates the tracker (keyed on a stable client <c>tabId</c>) and
|
||||
/// <see cref="StatusBroadcaster"/> reconciles capture arm state from the tracker each
|
||||
/// push cycle. The actual pushes are issued by the broadcaster, not the hub.</para>
|
||||
/// </summary>
|
||||
internal sealed class StatusHub : Hub
|
||||
{
|
||||
@@ -29,39 +31,36 @@ internal sealed class StatusHub : Hub
|
||||
public static string PlcGroup(string plcName) => "plc:" + plcName;
|
||||
|
||||
private readonly PlcSubscriptionTracker _tracker;
|
||||
private readonly TagCaptureRegistry _captureRegistry;
|
||||
|
||||
public StatusHub(PlcSubscriptionTracker tracker, TagCaptureRegistry captureRegistry)
|
||||
{
|
||||
_tracker = tracker;
|
||||
_captureRegistry = captureRegistry;
|
||||
}
|
||||
public StatusHub(PlcSubscriptionTracker tracker) => _tracker = tracker;
|
||||
|
||||
/// <summary>Subscribes the calling connection to fleet-wide status pushes.</summary>
|
||||
public Task SubscribeFleet()
|
||||
=> Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes the calling connection to one PLC's detail pushes and arms that PLC's
|
||||
/// tag-value capture if this is its first viewer.
|
||||
/// Subscribes the calling connection to one PLC's detail pushes. <paramref name="tabId"/>
|
||||
/// is a stable per-page-load identifier supplied by the client so a transport
|
||||
/// reconnect (which changes <c>ConnectionId</c>) is recognised as the same viewer.
|
||||
/// </summary>
|
||||
public async Task SubscribePlc(string plcName)
|
||||
public async Task SubscribePlc(string plcName, string tabId)
|
||||
{
|
||||
// Register with the tracker first (synchronous, lock-guarded) so this connection's
|
||||
// own OnDisconnectedAsync — which SignalR dispatches only after this invocation's
|
||||
// Task completes — always observes a consistent state. Capture arming is NOT done
|
||||
// here; StatusBroadcaster reconciles it each cycle from the tracker.
|
||||
_tracker.SubscribePlc(Context.ConnectionId, tabId, plcName);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, PlcGroup(plcName)).ConfigureAwait(false);
|
||||
|
||||
if (_tracker.Add(Context.ConnectionId, plcName))
|
||||
_captureRegistry.Arm(plcName); // no-op for an unknown PLC name
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On disconnect, drops every subscription the connection held and disarms the
|
||||
/// capture of any PLC whose last viewer just left.
|
||||
/// On disconnect, releases the connection from its tab. If it was the tab's last
|
||||
/// connection the tab's PLC subscriptions are dropped; the broadcaster disarms the
|
||||
/// now-unviewed captures on its next cycle.
|
||||
/// </summary>
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
foreach (var plcName in _tracker.RemoveConnection(Context.ConnectionId))
|
||||
_captureRegistry.Disarm(plcName);
|
||||
|
||||
_tracker.RemoveConnection(Context.ConnectionId);
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,30 +237,37 @@
|
||||
}
|
||||
|
||||
// ── SignalR ────────────────────────────────────────────────────────────
|
||||
function connect() {
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hub/status')
|
||||
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
|
||||
.build();
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hub/status')
|
||||
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
|
||||
.build();
|
||||
|
||||
connection.on('fleet', onSnapshot);
|
||||
connection.on('fleet', onSnapshot);
|
||||
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
|
||||
connection.onreconnected(() => {
|
||||
setConn('connected');
|
||||
connection.invoke('SubscribeFleet').catch(() => {});
|
||||
});
|
||||
connection.onclose(() => setConn('disconnected', 'disconnected'));
|
||||
|
||||
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
|
||||
connection.onreconnected(() => { setConn('connected'); connection.invoke('SubscribeFleet'); });
|
||||
connection.onclose(() => setConn('disconnected', 'disconnected'));
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
setConn('connecting', 'connecting');
|
||||
// Cold start. withAutomaticReconnect only recovers an already-established
|
||||
// connection, so the initial connect needs its own retry: capped exponential
|
||||
// backoff, and start() only when the socket is actually Disconnected so a
|
||||
// subscribe-only failure never tries to re-start a live connection.
|
||||
let retryMs = 1000;
|
||||
async function connect() {
|
||||
setConn('connecting', 'connecting');
|
||||
try {
|
||||
if (connection.state === signalR.HubConnectionState.Disconnected)
|
||||
await connection.start();
|
||||
await connection.invoke('SubscribeFleet');
|
||||
setConn('connected');
|
||||
} catch {
|
||||
setConn('disconnected', 'retrying');
|
||||
setTimeout(start, 3000);
|
||||
}
|
||||
await connection.invoke('SubscribeFleet');
|
||||
setConn('connected');
|
||||
retryMs = 1000;
|
||||
} catch {
|
||||
setConn('disconnected', 'retrying');
|
||||
setTimeout(connect, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, 30000);
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
// ── Boot ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -175,10 +175,16 @@
|
||||
return Math.round((100 * hit) / total) + '%';
|
||||
}
|
||||
|
||||
// ── Render: PLC removed by hot-reload ──────────────────────────────────
|
||||
function renderMissing() {
|
||||
$('notice').hidden = false;
|
||||
// ── Notices: PLC removed by hot-reload, or unknown / unreachable ───────
|
||||
function showNotice(msg) {
|
||||
const n = $('notice');
|
||||
n.textContent = msg;
|
||||
n.hidden = false;
|
||||
$('cards').hidden = true;
|
||||
}
|
||||
function renderMissing() {
|
||||
showNotice('This PLC is no longer in the configuration — it was likely ' +
|
||||
'removed by a hot-reload. Counters and the debug view are unavailable.');
|
||||
$('cards').innerHTML = '';
|
||||
$('plc-sub').textContent = 'not configured';
|
||||
$('plc-state').innerHTML = '<span class="chip chip-idle">removed</span>';
|
||||
@@ -220,6 +226,7 @@
|
||||
|
||||
// ── Snapshot handler ───────────────────────────────────────────────────
|
||||
function onDetail(detail) {
|
||||
gotSnapshot();
|
||||
if (detail.plc) renderPlc(detail.plc);
|
||||
else renderMissing();
|
||||
renderDebug(detail.debug || { captureArmed: false, tags: [] });
|
||||
@@ -231,31 +238,65 @@
|
||||
$('conn-text').textContent = text || state;
|
||||
}
|
||||
|
||||
// ── Unknown-PLC watchdog ───────────────────────────────────────────────
|
||||
// SubscribePlc succeeds for any name; an unconfigured PLC simply never
|
||||
// produces a 'plc' push. If no snapshot lands shortly after connecting,
|
||||
// say so instead of sitting on "Waiting for first snapshot…" forever.
|
||||
let firstSnapshotTimer = null;
|
||||
function armSnapshotWatchdog() {
|
||||
clearTimeout(firstSnapshotTimer);
|
||||
firstSnapshotTimer = setTimeout(() => {
|
||||
showNotice(`No data for "${plcName}". This PLC is not in the proxy ` +
|
||||
`configuration, or the admin feed is not delivering — ` +
|
||||
`check the name against the fleet page.`);
|
||||
}, 6000);
|
||||
}
|
||||
function gotSnapshot() {
|
||||
clearTimeout(firstSnapshotTimer);
|
||||
firstSnapshotTimer = null;
|
||||
}
|
||||
|
||||
// ── SignalR ────────────────────────────────────────────────────────────
|
||||
function connect() {
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hub/status')
|
||||
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
|
||||
.build();
|
||||
// Stable per-page-load id so a transport reconnect (which assigns a fresh
|
||||
// ConnectionId) is recognised server-side as the same viewer — that is what
|
||||
// keeps the PLC's tag-value capture from leaking armed. Math.random, not
|
||||
// crypto.randomUUID: the dashboard is served over plain http on the LAN,
|
||||
// where randomUUID is unavailable (non-secure context).
|
||||
const tabId = 't-' + Date.now().toString(36) + '-' +
|
||||
Math.random().toString(36).slice(2, 10);
|
||||
|
||||
connection.on('plc', onDetail);
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hub/status')
|
||||
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000])
|
||||
.build();
|
||||
|
||||
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
|
||||
connection.onreconnected(() => { setConn('connected'); connection.invoke('SubscribePlc', plcName); });
|
||||
connection.onclose(() => setConn('disconnected', 'disconnected'));
|
||||
connection.on('plc', onDetail);
|
||||
connection.onreconnecting(() => setConn('connecting', 'reconnecting'));
|
||||
connection.onreconnected(() => {
|
||||
setConn('connected');
|
||||
connection.invoke('SubscribePlc', plcName, tabId).catch(() => {});
|
||||
});
|
||||
connection.onclose(() => setConn('disconnected', 'disconnected'));
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
setConn('connecting', 'connecting');
|
||||
// Cold start. withAutomaticReconnect only recovers an already-established
|
||||
// connection, so the initial connect needs its own retry: capped exponential
|
||||
// backoff, and start() only when the socket is actually Disconnected so a
|
||||
// subscribe-only failure never tries to re-start a live connection.
|
||||
let retryMs = 1000;
|
||||
async function connect() {
|
||||
setConn('connecting', 'connecting');
|
||||
try {
|
||||
if (connection.state === signalR.HubConnectionState.Disconnected)
|
||||
await connection.start();
|
||||
await connection.invoke('SubscribePlc', plcName);
|
||||
setConn('connected');
|
||||
} catch {
|
||||
setConn('disconnected', 'retrying');
|
||||
setTimeout(start, 3000);
|
||||
}
|
||||
await connection.invoke('SubscribePlc', plcName, tabId);
|
||||
setConn('connected');
|
||||
armSnapshotWatchdog();
|
||||
retryMs = 1000;
|
||||
} catch {
|
||||
setConn('disconnected', 'retrying');
|
||||
setTimeout(connect, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, 30000);
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', connect);
|
||||
|
||||
@@ -11,9 +11,10 @@ namespace Mbproxy.Proxy;
|
||||
/// <para>Registered as a DI singleton. <see cref="ProxyWorker"/> and
|
||||
/// <see cref="Configuration.ConfigReconciler"/> call <see cref="GetOrCreate"/> as they
|
||||
/// build each <see cref="PerPlcContext"/>; <see cref="Configuration.ConfigReconciler"/>
|
||||
/// calls <see cref="Remove"/> for hot-reload-removed PLCs. The admin
|
||||
/// <c>StatusHub</c> / <c>StatusBroadcaster</c> call <see cref="Arm"/> /
|
||||
/// <see cref="Disarm"/> / <see cref="DisarmAll"/> / <see cref="TryGet"/>.</para>
|
||||
/// calls <see cref="Remove"/> for hot-reload-removed PLCs. <c>StatusBroadcaster</c>
|
||||
/// calls <see cref="ReconcileArmed"/> every push cycle (the single arm/disarm authority)
|
||||
/// and <see cref="DisarmAll"/> on shutdown; <c>StatusSnapshotBuilder</c> calls
|
||||
/// <see cref="TryGet"/>.</para>
|
||||
/// </summary>
|
||||
internal sealed class TagCaptureRegistry
|
||||
{
|
||||
@@ -23,20 +24,17 @@ internal sealed class TagCaptureRegistry
|
||||
/// <summary>
|
||||
/// Returns the capture for <paramref name="plcName"/>, creating it on first call.
|
||||
/// A subsequent call (hot-reload reseat/restart, where the tag set may have changed)
|
||||
/// rebuilds the capture for <paramref name="map"/>'s current tags, preserving the
|
||||
/// armed flag so an open detail page keeps capturing across the reload.
|
||||
/// rebuilds the capture for <paramref name="map"/>'s current tags. The rebuilt
|
||||
/// capture is disarmed; <see cref="StatusBroadcaster"/> re-arms it on its next push
|
||||
/// cycle (within one <c>AdminPushIntervalMs</c>) if the PLC still has a viewer — so
|
||||
/// arm state is never carried across the rebuild, which removes any race between
|
||||
/// arming and the rebuild.
|
||||
/// </summary>
|
||||
public TagValueCapture GetOrCreate(string plcName, BcdTagMap map)
|
||||
=> _captures.AddOrUpdate(
|
||||
plcName,
|
||||
_ => new TagValueCapture(map.All),
|
||||
(_, existing) =>
|
||||
{
|
||||
var rebuilt = new TagValueCapture(map.All);
|
||||
if (existing.IsArmed)
|
||||
rebuilt.Arm();
|
||||
return rebuilt;
|
||||
});
|
||||
(_, _) => new TagValueCapture(map.All));
|
||||
|
||||
/// <summary>Drops the capture for a hot-reload-removed PLC.</summary>
|
||||
public void Remove(string plcName) => _captures.TryRemove(plcName, out _);
|
||||
@@ -45,18 +43,28 @@ internal sealed class TagCaptureRegistry
|
||||
public bool TryGet(string plcName, out TagValueCapture capture)
|
||||
=> _captures.TryGetValue(plcName, out capture!);
|
||||
|
||||
/// <summary>Arms a PLC's capture. No-op for an unknown PLC name.</summary>
|
||||
public void Arm(string plcName)
|
||||
/// <summary>
|
||||
/// Reconciles every capture's armed state against <paramref name="activePlcs"/> —
|
||||
/// the set of PLCs that currently have a detail-page viewer. Captures for active
|
||||
/// PLCs are armed, all others disarmed. Called once per push cycle by
|
||||
/// <see cref="StatusBroadcaster"/>, so it is the <b>single</b> arm/disarm authority:
|
||||
/// no hub thread ever arms a capture, which both removes the race against a
|
||||
/// hot-reload <see cref="GetOrCreate"/> and makes a leaked subscriber impossible to
|
||||
/// reach here (the tracker is reconnect-safe).
|
||||
/// </summary>
|
||||
public void ReconcileArmed(IReadOnlyCollection<string> activePlcs)
|
||||
{
|
||||
if (_captures.TryGetValue(plcName, out var c))
|
||||
c.Arm();
|
||||
}
|
||||
var active = activePlcs as IReadOnlySet<string>
|
||||
?? activePlcs.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Disarms a PLC's capture. No-op for an unknown PLC name.</summary>
|
||||
public void Disarm(string plcName)
|
||||
{
|
||||
if (_captures.TryGetValue(plcName, out var c))
|
||||
c.Disarm();
|
||||
foreach (var (name, capture) in _captures)
|
||||
{
|
||||
bool shouldArm = active.Contains(name);
|
||||
if (shouldArm && !capture.IsArmed)
|
||||
capture.Arm();
|
||||
else if (!shouldArm && capture.IsArmed)
|
||||
capture.Disarm();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -131,6 +131,15 @@ internal sealed class TagValueCapture
|
||||
new TagValueObservation(
|
||||
_addresses[idx], _widths[idx], _names[idx], rawLow, rawHigh, decoded, direction,
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
// A concurrent Disarm() may have flipped _armed (and cleared the slots) between
|
||||
// the _armed check above and the write just made — which would strand a stale
|
||||
// observation on a disarmed capture, defeating the "reopened page shows no stale
|
||||
// data" contract. Re-read _armed: if it is now false, Disarm has either already
|
||||
// run (so this write must be undone) or is still running (its own slot-clear
|
||||
// pass will null this slot). Either way, null it here to be safe.
|
||||
if (!_armed)
|
||||
Volatile.Write(ref _slots[idx], null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json;
|
||||
using Mbproxy.Admin;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Locks the SignalR payload wire shape. The hub serialises detail / fleet payloads
|
||||
/// with a camelCase property policy (see <c>AdminEndpointHost</c>'s <c>AddJsonProtocol</c>),
|
||||
/// and the dashboard JS reads camelCase field names — so a regression to the naming
|
||||
/// policy would silently break every field on the live feed with no other failing test.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DebugDtoSerializationTests
|
||||
{
|
||||
// The exact policy AdminEndpointHost configures on the hub's PayloadSerializerOptions.
|
||||
private static readonly JsonSerializerOptions HubOptions =
|
||||
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
[Fact]
|
||||
public void PlcDetailResponse_SerializesWithCamelCaseFieldNames()
|
||||
{
|
||||
var detail = new PlcDetailResponse(
|
||||
Plc: null,
|
||||
Debug: new PlcDebugSnapshot(
|
||||
CaptureArmed: true,
|
||||
Tags: [new TagValueDto(
|
||||
Address: 100, Width: 16, Name: "Left AirSP", HasValue: true,
|
||||
Direction: "read", RawHex: "0x1234", DecodedValue: 1234,
|
||||
UpdatedAtUtc: "2026-05-16T00:00:00Z", AgeSeconds: 1.5)]));
|
||||
|
||||
string json = JsonSerializer.Serialize(detail, HubOptions);
|
||||
|
||||
// Case.Sensitive throughout — Shouldly's string contains defaults to
|
||||
// case-insensitive, which would not distinguish camelCase from PascalCase.
|
||||
json.ShouldContain("\"captureArmed\"", Case.Sensitive);
|
||||
json.ShouldContain("\"decodedValue\"", Case.Sensitive);
|
||||
json.ShouldContain("\"updatedAtUtc\"", Case.Sensitive);
|
||||
json.ShouldNotContain("\"CaptureArmed\"", Case.Sensitive);
|
||||
json.ShouldNotContain("\"DecodedValue\"", Case.Sensitive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Mbproxy.Admin;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PlcSubscriptionTracker"/> — the tab-keyed, reconnect-safe
|
||||
/// record of which PLC detail pages are open. Includes a concurrency stress test, since
|
||||
/// the tracker is mutated from multiple SignalR hub-dispatch threads.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PlcSubscriptionTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Subscribe_ThenRemoveLastConnection_ClearsViewer()
|
||||
{
|
||||
var t = new PlcSubscriptionTracker();
|
||||
t.SubscribePlc("c1", "tab", "plc");
|
||||
t.ActivePlcs().ShouldBe(["plc"]);
|
||||
|
||||
t.RemoveConnection("c1");
|
||||
t.ActivePlcs().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameTab_TwoConnections_StaysActiveUntilLastConnectionGone()
|
||||
{
|
||||
// Reconnect overlap: the same tab briefly holds two connections. Dropping the
|
||||
// old one must not release the tab — this is the leak C2 guards against.
|
||||
var t = new PlcSubscriptionTracker();
|
||||
t.SubscribePlc("c-old", "tab", "plc");
|
||||
t.SubscribePlc("c-new", "tab", "plc");
|
||||
|
||||
t.RemoveConnection("c-old");
|
||||
t.ActivePlcs().ShouldContain("plc", "the tab is still alive on the second connection");
|
||||
|
||||
t.RemoveConnection("c-new");
|
||||
t.ActivePlcs().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistinctTabs_AreCountedSeparately()
|
||||
{
|
||||
var t = new PlcSubscriptionTracker();
|
||||
t.SubscribePlc("c1", "tab-A", "plc");
|
||||
t.SubscribePlc("c2", "tab-B", "plc");
|
||||
|
||||
t.RemoveConnection("c1");
|
||||
t.ActivePlcs().ShouldContain("plc", "the second tab still views the PLC");
|
||||
|
||||
t.RemoveConnection("c2");
|
||||
t.ActivePlcs().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedSubscribe_SameTabSamePlc_IsIdempotent()
|
||||
{
|
||||
var t = new PlcSubscriptionTracker();
|
||||
t.SubscribePlc("c1", "tab", "plc");
|
||||
t.SubscribePlc("c1", "tab", "plc"); // redundant repeat
|
||||
t.ActivePlcs().ShouldBe(["plc"]);
|
||||
|
||||
t.RemoveConnection("c1");
|
||||
t.ActivePlcs().ShouldBeEmpty("a repeated subscribe must not inflate the viewer count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneConnection_MultiplePlcs_AllReleasedTogether()
|
||||
{
|
||||
var t = new PlcSubscriptionTracker();
|
||||
t.SubscribePlc("c1", "tab", "plc-a");
|
||||
t.SubscribePlc("c1", "tab", "plc-b");
|
||||
t.ActivePlcs().Count.ShouldBe(2);
|
||||
|
||||
t.RemoveConnection("c1");
|
||||
t.ActivePlcs().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveConnection_Unknown_IsNoOp()
|
||||
{
|
||||
var t = new PlcSubscriptionTracker();
|
||||
|
||||
Should.NotThrow(() => t.RemoveConnection("never-seen"));
|
||||
t.ActivePlcs().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentSubscribeAndRemove_NeverLeaksOrThrows()
|
||||
{
|
||||
var t = new PlcSubscriptionTracker();
|
||||
const int tasks = 16;
|
||||
const int iterations = 5_000;
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, tasks).Select(taskNo => Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
string conn = $"c{taskNo}-{i}";
|
||||
string tab = $"tab{taskNo}-{i}";
|
||||
t.SubscribePlc(conn, tab, "plc");
|
||||
t.RemoveConnection(conn);
|
||||
}
|
||||
}, TestContext.Current.CancellationToken)));
|
||||
|
||||
t.ActivePlcs().ShouldBeEmpty(
|
||||
"every subscribe was paired with a remove — no viewer count may leak");
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ public sealed class StatusBroadcasterTests
|
||||
hostBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
// Fast tick so the LoopAsync test observes several cycles quickly.
|
||||
["Mbproxy:AdminPushIntervalMs"] = "100",
|
||||
});
|
||||
hostBuilder.Services.AddSerilog(
|
||||
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
|
||||
@@ -96,7 +98,7 @@ public sealed class StatusBroadcasterTests
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
|
||||
h.Tracker.Add("conn-1", "plc-x");
|
||||
h.Tracker.SubscribePlc("conn-1", "tab-1", "plc-x");
|
||||
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -105,16 +107,60 @@ public sealed class StatusBroadcasterTests
|
||||
push.Detail.Debug.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushOnce_ReconcilesCaptureArmState_FromActiveViewers()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
|
||||
|
||||
// No viewer yet — a push must leave the capture disarmed.
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeFalse("no detail page open — capture stays disarmed");
|
||||
|
||||
// A viewer opens the detail page — the next push arms the capture.
|
||||
h.Tracker.SubscribePlc("conn-1", "tab-1", "plc-x");
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
capture.IsArmed.ShouldBeTrue("the broadcaster reconciles the capture armed for a viewed PLC");
|
||||
|
||||
// The viewer leaves — the next push disarms it again.
|
||||
h.Tracker.RemoveConnection("conn-1");
|
||||
await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
|
||||
capture.IsArmed.ShouldBeFalse("the broadcaster disarms a capture once its last viewer leaves");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_DisarmsEveryCapture()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
|
||||
h.Registry.Arm("plc-x");
|
||||
h.Registry.ReconcileArmed(["plc-x"]);
|
||||
|
||||
await h.Broadcaster.StopAsync();
|
||||
|
||||
h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Loop_PushesRepeatedly_ThenStopsAfterStopAsync()
|
||||
{
|
||||
await using var h = await BuildAsync();
|
||||
|
||||
h.Broadcaster.Start();
|
||||
|
||||
// The harness runs at AdminPushIntervalMs = 100 ms; wait (generously) for the
|
||||
// background loop to complete several cycles.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (h.Sink.FleetPushes.Count < 3 && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
|
||||
h.Sink.FleetPushes.Count.ShouldBeGreaterThanOrEqualTo(3,
|
||||
"the background loop must push the fleet snapshot every interval");
|
||||
|
||||
await h.Broadcaster.StopAsync();
|
||||
int afterStop = h.Sink.FleetPushes.Count;
|
||||
await Task.Delay(400, TestContext.Current.CancellationToken);
|
||||
h.Sink.FleetPushes.Count.ShouldBe(afterStop, "no pushes may occur after StopAsync");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
using Mbproxy.Admin;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StatusHub"/> — group joins and on-demand capture
|
||||
/// arming. Uses hand-written SignalR test doubles (see <see cref="SignalRFakes"/>);
|
||||
/// no SignalR host is started.
|
||||
/// Unit tests for <see cref="StatusHub"/> — group joins and subscription tracking.
|
||||
/// Capture arming is the broadcaster's job; the hub only mutates the
|
||||
/// <see cref="PlcSubscriptionTracker"/>. Uses hand-written SignalR test doubles
|
||||
/// (see <see cref="SignalRFakes"/>); no SignalR host is started.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StatusHubTests
|
||||
{
|
||||
private static StatusHub MakeHub(
|
||||
string connectionId,
|
||||
PlcSubscriptionTracker tracker,
|
||||
TagCaptureRegistry registry,
|
||||
out FakeGroupManager groups)
|
||||
string connectionId, PlcSubscriptionTracker tracker, out FakeGroupManager groups)
|
||||
{
|
||||
groups = new FakeGroupManager();
|
||||
return new StatusHub(tracker, registry)
|
||||
return new StatusHub(tracker)
|
||||
{
|
||||
Context = new FakeHubCallerContext(connectionId),
|
||||
Groups = groups,
|
||||
@@ -31,7 +27,7 @@ public sealed class StatusHubTests
|
||||
[Fact]
|
||||
public async Task SubscribeFleet_JoinsFleetGroup()
|
||||
{
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), new TagCaptureRegistry(), out var groups);
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), out var groups);
|
||||
|
||||
await hub.SubscribeFleet();
|
||||
|
||||
@@ -39,53 +35,63 @@ public sealed class StatusHubTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribePlc_JoinsPlcGroup_AndArmsCapture()
|
||||
public async Task SubscribePlc_JoinsPlcGroup_AndTracksViewer()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
var hub = MakeHub("conn-1", tracker, out var groups);
|
||||
|
||||
await hub.SubscribePlc("plc-1");
|
||||
await hub.SubscribePlc("plc-1", "tab-A");
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("plc-1")));
|
||||
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeTrue();
|
||||
tracker.ActivePlcs().ShouldContain("plc-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondSubscriber_FirstLeaveKeepsArmed_LastLeaveDisarms()
|
||||
public async Task Reconnect_SameTab_NewConnection_DoesNotLeakViewer()
|
||||
{
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", BcdTagMap.Empty);
|
||||
// A transport reconnect: the same browser tab acquires a new ConnectionId and
|
||||
// re-subscribes; the old connection's OnDisconnectedAsync then fires late. The
|
||||
// PLC must not be left with a stranded viewer once the tab finally closes.
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
|
||||
var hub1 = MakeHub("conn-1", tracker, registry, out _);
|
||||
var hub2 = MakeHub("conn-2", tracker, registry, out _);
|
||||
var first = MakeHub("conn-old", tracker, out _);
|
||||
await first.SubscribePlc("plc-1", "tab-A");
|
||||
|
||||
await hub1.SubscribePlc("plc-1");
|
||||
await hub2.SubscribePlc("plc-1");
|
||||
var second = MakeHub("conn-new", tracker, out _);
|
||||
await second.SubscribePlc("plc-1", "tab-A");
|
||||
|
||||
registry.TryGet("plc-1", out var capture).ShouldBeTrue();
|
||||
capture.IsArmed.ShouldBeTrue();
|
||||
await first.OnDisconnectedAsync(null); // late disconnect of the old connection
|
||||
tracker.ActivePlcs().ShouldContain("plc-1",
|
||||
"the tab is still open on the reconnected connection");
|
||||
|
||||
// First viewer leaves — a second viewer remains, so capture stays armed.
|
||||
await hub1.OnDisconnectedAsync(null);
|
||||
capture.IsArmed.ShouldBeTrue("capture must stay armed while another detail page is open");
|
||||
|
||||
// Last viewer leaves — capture disarms.
|
||||
await hub2.OnDisconnectedAsync(null);
|
||||
capture.IsArmed.ShouldBeFalse("capture must disarm when the last viewer leaves");
|
||||
await second.OnDisconnectedAsync(null); // the tab finally closes
|
||||
tracker.ActivePlcs().ShouldBeEmpty("no viewer may be stranded after the tab closes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribePlc_UnknownPlc_DoesNotThrow_AndArmsNothing()
|
||||
public async Task TwoTabs_FirstCloseKeepsActive_LastCloseClears()
|
||||
{
|
||||
var registry = new TagCaptureRegistry(); // no captures registered
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), registry, out var groups);
|
||||
var tracker = new PlcSubscriptionTracker();
|
||||
|
||||
await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost"));
|
||||
var tabA = MakeHub("conn-a", tracker, out _);
|
||||
var tabB = MakeHub("conn-b", tracker, out _);
|
||||
await tabA.SubscribePlc("plc-1", "tab-A");
|
||||
await tabB.SubscribePlc("plc-1", "tab-B");
|
||||
|
||||
await tabA.OnDisconnectedAsync(null);
|
||||
tracker.ActivePlcs().ShouldContain("plc-1", "a second tab is still viewing the PLC");
|
||||
|
||||
await tabB.OnDisconnectedAsync(null);
|
||||
tracker.ActivePlcs().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribePlc_UnknownPlc_DoesNotThrow()
|
||||
{
|
||||
var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), out var groups);
|
||||
|
||||
await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost", "tab-A"));
|
||||
|
||||
groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("ghost")));
|
||||
registry.TryGet("ghost", out _).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ using Xunit;
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TagCaptureRegistry"/> — the shared seam that arms and
|
||||
/// disarms per-PLC <see cref="TagValueCapture"/> instances.
|
||||
/// Unit tests for <see cref="TagCaptureRegistry"/> — the shared seam holding per-PLC
|
||||
/// <see cref="TagValueCapture"/> instances. Arm state is reconciled in bulk against the
|
||||
/// live viewer set (not toggled per PLC) so the broadcaster is the single authority.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TagCaptureRegistryTests
|
||||
@@ -25,48 +26,69 @@ public sealed class TagCaptureRegistryTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsSameInstance_OnRepeatCall_WhenTagSetUnchanged()
|
||||
public void GetOrCreate_ReturnsLiveInstance_OnRepeatCall()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
var first = registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
var second = registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
|
||||
// AddOrUpdate's update path rebuilds; both must be live and consistent.
|
||||
second.TagCount.ShouldBe(1);
|
||||
registry.TryGet("plc-1", out var current).ShouldBeTrue();
|
||||
current.ShouldBeSameAs(second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_Rebuild_PreservesArmedFlag()
|
||||
public void GetOrCreate_Rebuild_ProducesDisarmedCapture_AndReconcileReArms()
|
||||
{
|
||||
// The rebuilt capture is intentionally disarmed: ReconcileArmed re-arms it within
|
||||
// one push cycle if the PLC still has a viewer, so arm state is never carried
|
||||
// across the rebuild — which removes any arm-vs-rebuild race.
|
||||
var registry = new TagCaptureRegistry();
|
||||
var capture = registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
capture.Arm();
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.ReconcileArmed(["plc-1"]);
|
||||
registry.TryGet("plc-1", out var armed).ShouldBeTrue();
|
||||
armed.IsArmed.ShouldBeTrue();
|
||||
|
||||
// Hot-reload reseat: same PLC, changed tag set.
|
||||
var rebuilt = registry.GetOrCreate("plc-1", Map((100, 16), (200, 32)));
|
||||
|
||||
rebuilt.ShouldNotBeSameAs(capture);
|
||||
rebuilt.IsArmed.ShouldBeTrue("a rebuilt capture must keep capturing for an open detail page");
|
||||
rebuilt.ShouldNotBeSameAs(armed);
|
||||
rebuilt.IsArmed.ShouldBeFalse("a rebuilt capture starts disarmed");
|
||||
rebuilt.TagCount.ShouldBe(2);
|
||||
|
||||
// The next reconcile re-arms it because the PLC is still viewed.
|
||||
registry.ReconcileArmed(["plc-1"]);
|
||||
rebuilt.IsArmed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Arm_And_Disarm_ReachTheRightCapture()
|
||||
public void ReconcileArmed_ArmsActivePlcs_DisarmsTheRest()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.GetOrCreate("plc-2", Map((100, 16)));
|
||||
|
||||
registry.Arm("plc-1");
|
||||
|
||||
registry.ReconcileArmed(["plc-1"]);
|
||||
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
|
||||
registry.TryGet("plc-2", out var c2).ShouldBeTrue();
|
||||
c1.IsArmed.ShouldBeTrue();
|
||||
c2.IsArmed.ShouldBeFalse();
|
||||
|
||||
registry.Disarm("plc-1");
|
||||
// plc-1's viewer leaves, plc-2 gains one.
|
||||
registry.ReconcileArmed(["plc-2"]);
|
||||
c1.IsArmed.ShouldBeFalse();
|
||||
c2.IsArmed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconcileArmed_EmptyActiveSet_DisarmsEverything()
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.ReconcileArmed(["plc-1"]);
|
||||
|
||||
registry.ReconcileArmed(Array.Empty<string>());
|
||||
|
||||
registry.TryGet("plc-1", out var c1).ShouldBeTrue();
|
||||
c1.IsArmed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -76,8 +98,7 @@ public sealed class TagCaptureRegistryTests
|
||||
var registry = new TagCaptureRegistry();
|
||||
registry.GetOrCreate("plc-1", Map((100, 16)));
|
||||
registry.GetOrCreate("plc-2", Map((100, 16)));
|
||||
registry.Arm("plc-1");
|
||||
registry.Arm("plc-2");
|
||||
registry.ReconcileArmed(["plc-1", "plc-2"]);
|
||||
|
||||
registry.DisarmAll();
|
||||
|
||||
@@ -92,8 +113,7 @@ public sealed class TagCaptureRegistryTests
|
||||
{
|
||||
var registry = new TagCaptureRegistry();
|
||||
|
||||
Should.NotThrow(() => registry.Arm("ghost"));
|
||||
Should.NotThrow(() => registry.Disarm("ghost"));
|
||||
Should.NotThrow(() => registry.ReconcileArmed(["ghost"]));
|
||||
Should.NotThrow(() => registry.Remove("ghost"));
|
||||
registry.TryGet("ghost", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -174,4 +174,38 @@ public sealed class TagValueCaptureTests
|
||||
await Task.WhenAll([.. writers, reader]);
|
||||
tornObserved.ShouldBeFalse("Snapshot must never observe a torn (half-updated) slot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentRecordAndDisarm_LeavesNoStaleObservation()
|
||||
{
|
||||
// M7 regression: Record() checks _armed then writes; Disarm() flips _armed then
|
||||
// clears the slots. A Record that passes the check while armed, then has Disarm
|
||||
// run, then writes, would strand a stale observation on a disarmed capture —
|
||||
// breaking the "reopened page shows no stale data" contract. Record's re-check
|
||||
// after the write must undo that. The capture ends disarmed (the toggler's last
|
||||
// op is Disarm), so a clean Snapshot is a deterministic post-condition of the fix.
|
||||
var capture = Make((100, 16));
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var recorder = Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < 400_000; i++)
|
||||
capture.Record(100, 0x1234, 0, 1234, CaptureDirection.Read);
|
||||
}, ct);
|
||||
|
||||
var toggler = Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < 80_000; i++)
|
||||
{
|
||||
capture.Arm();
|
||||
capture.Disarm();
|
||||
}
|
||||
}, ct);
|
||||
|
||||
await Task.WhenAll(recorder, toggler);
|
||||
|
||||
capture.IsArmed.ShouldBeFalse();
|
||||
capture.Snapshot().ShouldAllBe(s => s.UpdatedAtUtc == null,
|
||||
"a disarmed capture must never retain a recorded observation");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user