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:
Joseph Doherty
2026-05-16 16:12:43 -04:00
parent 554b05d28c
commit 374eecd205
16 changed files with 580 additions and 212 deletions
+63 -22
View File
@@ -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);