Skip to content

Commit 28d917d

Browse files
fix(live): single-poll gate — paused /live makes ONE call until a run starts
Root cause: a paused /live page fanned out to /stats, /runs/last, /events (SSE), /status, /live-state and /logs on every load whenever broadcast was on, regardless of whether a run was actually in flight. The DOMContentLoaded handler unconditionally connected every work endpoint. Fix (server + client): - GET /api/broadcast now returns { broadcastLive, activeRun }, where activeRun = broadcastLive && publicRunActive() — true only for a genuinely in-flight run (continuous loop, an active batch row, or status running/paused*; covers paused_balance/paused_internet mid-run pauses). - live.html polls ONLY /api/broadcast. checkBroadcastState() fans out via connectLiveWork() (SSE + logs + live-state + batch + stats) exactly once on a paused→active transition, tears the stream down on active→paused, and makes no other call while paused. Poll repeats every 10s so a run that starts while paused flips the page live promptly. - connectSSE() now gates on _activeRun (not just _broadcastLive) so a server-initiated reconnect can't reopen an idle stream while paused. - DOMContentLoaded keeps only the synchronous _cachedRunActive() optimistic reveal (localStorage, no network); the old fan-out block is removed so the page never double-connects. Tests: new test/live-single-poll-gate.test.js (19 assertions) locks the one-call-while-paused contract and the once-only fan-out; full suite green.
1 parent cea0710 commit 28d917d

4 files changed

Lines changed: 213 additions & 46 deletions

File tree

live.html

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -857,31 +857,52 @@ <h1 class="live-page-title">
857857

858858
// ─── Boot / theme ───
859859
let _broadcastLive = false;
860+
// True once /api/broadcast reports a live, broadcasting run. While this is
861+
// false the page is PAUSED and the ONLY network traffic is the poll of
862+
// /api/broadcast itself — we don't connect SSE or hit any work endpoint.
863+
let _activeRun = false;
864+
let _liveWorkConnected = false;
865+
866+
// Fan out to the live-work endpoints — runs EXACTLY ONCE per paused→active
867+
// transition. This is the "if active, then make the necessary calls" half of
868+
// the single-poll gate: SSE stream, log backlog, full live-state, current
869+
// batch, and header stats. While paused none of these fire.
870+
async function connectLiveWork() {
871+
if (_liveWorkConnected) return;
872+
_liveWorkConnected = true;
873+
rehydrateFromCache(); // instant cache paint (active-gated internally)
874+
renderTable();
875+
renderLiveStats();
876+
connectSSE();
877+
seedLogsFromRest();
878+
await seedLiveStateFromRest();
879+
loadCurrentBatch();
880+
loadHeaderStats();
881+
}
860882

883+
// Single authoritative poll. /api/broadcast returns { broadcastLive,
884+
// activeRun }; activeRun gates ALL of /live's other traffic so a paused page
885+
// makes just this one call (plus its 10s repeat) until a run actually starts.
861886
async function checkBroadcastState() {
862887
try {
863888
const r = await fetch('/api/broadcast', { cache: 'no-store' });
864889
if (!r.ok) return;
865890
const d = await r.json();
866-
const wasLive = _broadcastLive;
891+
const wasActive = _activeRun;
867892
_broadcastLive = !!d.broadcastLive;
893+
_activeRun = !!d.activeRun;
868894
const notice = document.getElementById('broadcastOffNotice');
869-
if (_broadcastLive) {
870-
if (notice) notice.style.display = 'none';
871-
// Reconcile the overlay against admin status. On the cold-load pass
872-
// this no-ops until _liveStateResolved (set by the REST seed / SSE)
873-
// so we don't flash "paused" before the real run status is known.
895+
if (notice) notice.style.display = 'none';
896+
if (_activeRun) {
897+
// A run is live → connect the work endpoints once, then let the
898+
// authoritative status drop the overlay and reveal the live page.
899+
if (!wasActive) await connectLiveWork();
874900
applyPauseFromState();
875-
if (!wasLive) {
876-
connectSSE();
877-
seedLogsFromRest();
878-
}
879901
} else {
880-
// Broadcast off: show the full pause overlay, not just the inline notice.
881-
// Public visitors must see "Testing Has Been Paused" — last-completed
882-
// run snapshot still loads behind the overlay for context.
883-
if (notice) notice.style.display = 'none';
902+
// Paused / no active run: overlay up, tear down the stream, and make
903+
// NO further calls. Re-arm so the next active poll reconnects.
884904
showPaused(true);
905+
_liveWorkConnected = false;
885906
if (_sse) { try { _sse.close(); } catch (_) {} _sse = null; }
886907
}
887908
} catch {}
@@ -938,41 +959,20 @@ <h1 class="live-page-title">
938959
// that fetch is in flight. The overlay ships SHOWN in the markup, so a
939960
// stopped page stays fully covered from the very first paint. Only when the
940961
// cached snapshot was an active run (operator refreshing mid-run) do we
941-
// paint it and reveal the work up-front; the authoritative check below
942-
// confirms or corrects.
943-
let _paintedFromCache = false;
962+
// paint it and reveal the work up-front; the single poll below confirms or
963+
// corrects, and fans out to the work endpoints only if a run is live.
944964
if (_cachedRunActive()) {
945965
rehydrateFromCache();
946966
renderTable();
947967
renderLiveStats();
948968
showPaused(false);
949-
_paintedFromCache = true;
950969
}
951-
// Resolve broadcast/run state. When stopped (broadcast off) the overlay
952-
// stays up and we skip every paint path below — the page shows ONLY the
953-
// overlay, never a flash of the last dataset behind it. When live we paint
954-
// the cached snapshot, seed the real data, and show the work; the overlay
955-
// is dropped once the authoritative running status lands.
970+
// One call. If a run is live it fans out (SSE + live-state + logs + runs +
971+
// stats); if paused it does nothing else and the page stays on the overlay.
956972
await checkBroadcastState();
957-
if (_broadcastLive) {
958-
if (!_paintedFromCache) {
959-
// Cached snapshot first for an instant refresh, then authoritative REST.
960-
rehydrateFromCache();
961-
renderTable();
962-
renderLiveStats();
963-
}
964-
// Seeds full live state + results so refresh paints the same dashboard
965-
// the operator sees, even before the SSE init message lands.
966-
await seedLiveStateFromRest();
967-
loadCurrentBatch();
968-
loadHeaderStats();
969-
}
970-
// The authoritative round-trip is done (or broadcast is off). Even if it
971-
// carried no state (genuinely no active run), let the final reconcile
972-
// below decide the overlay rather than leaving it pending forever.
973-
_liveStateResolved = true;
974-
applyPauseFromState();
975-
setInterval(checkBroadcastState, 30000);
973+
// Re-poll the single endpoint so a run that STARTS while we're paused flips
974+
// the page to live within ~10s — without any traffic in the meantime.
975+
setInterval(checkBroadcastState, 10000);
976976
document.addEventListener('visibilitychange', () => {
977977
if (document.visibilityState === 'visible') checkBroadcastState();
978978
});
@@ -2513,7 +2513,10 @@ <h1 class="live-page-title">
25132513

25142514
let _sseEverConnected = false;
25152515
function connectSSE() {
2516-
if (!_broadcastLive) return;
2516+
// Only ever hold a stream open for a live, active run. Gating on _activeRun
2517+
// (not just _broadcastLive) keeps a paused page on its single /api/broadcast
2518+
// poll — no idle SSE connection, even on a server-initiated reconnect.
2519+
if (!_broadcastLive || !_activeRun) return;
25172520
if (_sse) { try { _sse.close(); } catch (_) {} }
25182521
try {
25192522
_sse = new EventSource('/api/public/events');
@@ -2535,7 +2538,7 @@ <h1 class="live-page-title">
25352538
_sse.onerror = () => {
25362539
try { _sse.close(); } catch (_) {}
25372540
_sse = null;
2538-
if (_broadcastLive) {
2541+
if (_broadcastLive && _activeRun) {
25392542
setTimeout(connectSSE, _sseRetry);
25402543
_sseRetry = Math.min(_sseRetry * 2, SSE_MAX_RETRY);
25412544
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",

server.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2229,7 +2229,13 @@ app.post('/api/broadcast', adminOnly, (req, res) => {
22292229
});
22302230

22312231
app.get('/api/broadcast', (req, res) => {
2232-
res.json({ broadcastLive: state.broadcastLive });
2232+
// `activeRun` lets /live gate ALL of its work calls behind this single poll:
2233+
// while paused it hits only this endpoint, and fans out (SSE, live-state,
2234+
// logs, runs, stats) only once there's a run to actually show.
2235+
res.json({
2236+
broadcastLive: state.broadcastLive,
2237+
activeRun: state.broadcastLive && publicRunActive(),
2238+
});
22332239
});
22342240

22352241
// ─── Audit settings (P2P payment tunables) ───────────────────────────────────

test/live-single-poll-gate.test.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* /live single-poll gate — paused page makes ONE network call
3+
*
4+
* Request: "When showing paused it should only make one network call to check
5+
* whether there is an active run or no. If [active] then make the necessary
6+
* calls and show the live page."
7+
*
8+
* Before: while paused, /live fired /stats, /runs/last, /events (SSE), /status,
9+
* /live-state and /logs on every load — six requests for a page showing nothing
10+
* but the paused overlay. Root cause: DOMContentLoaded fanned out to every work
11+
* endpoint whenever broadcast was on, regardless of whether a run was in flight.
12+
*
13+
* Fix: /api/broadcast now returns { broadcastLive, activeRun }. checkBroadcastState
14+
* polls ONLY that endpoint; it calls connectLiveWork() (SSE + logs + live-state +
15+
* batch + stats) exactly once on a paused→active transition, and nothing else
16+
* while paused. This runs the REAL checkBroadcastState + connectLiveWork extracted
17+
* from live.html against a fake fetch/DOM and counts the calls.
18+
*
19+
* Run: node test/live-single-poll-gate.test.js
20+
*/
21+
22+
import { readFileSync } from 'node:fs';
23+
import { fileURLToPath } from 'node:url';
24+
import { dirname, join } from 'node:path';
25+
import vm from 'node:vm';
26+
27+
const out = { pass: 0, fail: 0, errors: [] };
28+
function ok(cond, name) {
29+
if (cond) { out.pass++; console.log(` PASS ${name}`); }
30+
else { out.fail++; out.errors.push(name); console.log(` FAIL ${name}`); }
31+
}
32+
33+
const __dirname = dirname(fileURLToPath(import.meta.url));
34+
const html = readFileSync(join(__dirname, '..', 'live.html'), 'utf8');
35+
36+
function extractFn(src, name) {
37+
const m = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`).exec(src);
38+
if (!m) throw new Error(`function ${name} not found in live.html`);
39+
let depth = 0, started = false, j = m.index;
40+
for (; j < src.length; j++) {
41+
const c = src[j];
42+
if (c === '{') { depth++; started = true; }
43+
else if (c === '}') { depth--; if (started && depth === 0) { j++; break; } }
44+
}
45+
return src.slice(m.index, j);
46+
}
47+
48+
const extracted = ['connectLiveWork', 'checkBroadcastState']
49+
.map(n => extractFn(html, n)).join('\n\n');
50+
51+
function makeSandbox(broadcastResponses) {
52+
// broadcastResponses: array of { broadcastLive, activeRun } returned in order.
53+
const calls = { fetchUrls: [], work: 0, sseConnect: 0, sseClose: 0, paused: [] };
54+
let idx = 0;
55+
const sandbox = {
56+
console,
57+
_broadcastLive: false,
58+
_activeRun: false,
59+
_liveWorkConnected: false,
60+
_sse: null,
61+
// The single allowed poll endpoint:
62+
fetch: (url) => {
63+
calls.fetchUrls.push(url);
64+
const body = broadcastResponses[Math.min(idx, broadcastResponses.length - 1)];
65+
idx++;
66+
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
67+
},
68+
document: { getElementById: () => null },
69+
// Work-endpoint spies — every one of these is a network call we must NOT
70+
// make while paused.
71+
rehydrateFromCache: () => {},
72+
renderTable: () => {},
73+
renderLiveStats: () => {},
74+
connectSSE: () => { calls.sseConnect++; sandbox._sse = { close: () => { calls.sseClose++; } }; },
75+
seedLogsFromRest: () => { calls.work++; },
76+
seedLiveStateFromRest: () => { calls.work++; return Promise.resolve(); },
77+
loadCurrentBatch: () => { calls.work++; },
78+
loadHeaderStats: () => { calls.work++; },
79+
applyPauseFromState: () => {},
80+
showPaused: (on) => { calls.paused.push(on); },
81+
};
82+
vm.createContext(sandbox);
83+
vm.runInContext(extracted, sandbox);
84+
return { sandbox, calls };
85+
}
86+
87+
console.log('\n/live single-poll gate — paused page makes ONE network call\n');
88+
89+
// ─── 1. Paused: exactly one call to /api/broadcast, no work endpoints ────────
90+
console.log('[1] paused (activeRun=false) → only /api/broadcast');
91+
{
92+
const { sandbox, calls } = makeSandbox([{ broadcastLive: true, activeRun: false }]);
93+
await vm.runInContext('checkBroadcastState()', sandbox);
94+
ok(calls.fetchUrls.length === 1 && /\/api\/broadcast/.test(calls.fetchUrls[0]),
95+
`one fetch, to /api/broadcast (got ${calls.fetchUrls.length}: ${calls.fetchUrls.join(',')})`);
96+
ok(calls.work === 0, `no work-endpoint calls while paused (got ${calls.work})`);
97+
ok(calls.sseConnect === 0, 'no SSE connection while paused');
98+
ok(calls.paused.length && calls.paused[calls.paused.length - 1] === true,
99+
'overlay shown while paused');
100+
ok(sandbox._liveWorkConnected === false, 'work stays disconnected while paused');
101+
}
102+
103+
// ─── 2. Broadcast off entirely → still only the one poll, overlay up ─────────
104+
console.log('[2] broadcast off → one poll, overlay up, no work');
105+
{
106+
const { sandbox, calls } = makeSandbox([{ broadcastLive: false, activeRun: false }]);
107+
await vm.runInContext('checkBroadcastState()', sandbox);
108+
ok(calls.fetchUrls.length === 1, 'exactly one fetch when broadcast off');
109+
ok(calls.work === 0 && calls.sseConnect === 0, 'no work / SSE when broadcast off');
110+
ok(sandbox._broadcastLive === false, '_broadcastLive reflects off');
111+
}
112+
113+
// ─── 3. paused→active transition fans out EXACTLY once ───────────────────────
114+
console.log('[3] paused→active → connectLiveWork fires the full fan-out once');
115+
{
116+
const { sandbox, calls } = makeSandbox([{ broadcastLive: true, activeRun: true }]);
117+
await vm.runInContext('checkBroadcastState()', sandbox);
118+
ok(calls.fetchUrls.length === 1, 'broadcast poll itself is still one call');
119+
ok(calls.sseConnect === 1, 'SSE connected once on activation');
120+
ok(calls.work === 4, `all four work endpoints hit once (logs+live-state+batch+stats) (got ${calls.work})`);
121+
ok(sandbox._liveWorkConnected === true, 'work marked connected');
122+
}
123+
124+
// ─── 4. staying active across polls does NOT re-fan-out ──────────────────────
125+
console.log('[4] active→active second poll does not reconnect');
126+
{
127+
const { sandbox, calls } = makeSandbox([
128+
{ broadcastLive: true, activeRun: true },
129+
{ broadcastLive: true, activeRun: true },
130+
]);
131+
await vm.runInContext('checkBroadcastState()', sandbox);
132+
await vm.runInContext('checkBroadcastState()', sandbox);
133+
ok(calls.fetchUrls.length === 2, 'two broadcast polls');
134+
ok(calls.sseConnect === 1, 'SSE connected only once across both polls');
135+
ok(calls.work === 4, `work endpoints hit only once total (got ${calls.work})`);
136+
}
137+
138+
// ─── 5. active→paused tears down the stream and re-arms ──────────────────────
139+
console.log('[5] active→paused closes SSE and re-arms for the next run');
140+
{
141+
const { sandbox, calls } = makeSandbox([
142+
{ broadcastLive: true, activeRun: true },
143+
{ broadcastLive: true, activeRun: false },
144+
{ broadcastLive: true, activeRun: true },
145+
]);
146+
await vm.runInContext('checkBroadcastState()', sandbox); // active: connect
147+
await vm.runInContext('checkBroadcastState()', sandbox); // paused: teardown
148+
ok(calls.sseClose === 1, 'SSE closed on active→paused');
149+
ok(sandbox._liveWorkConnected === false, 're-armed (disconnected) while paused');
150+
await vm.runInContext('checkBroadcastState()', sandbox); // active again: reconnect
151+
ok(calls.sseConnect === 2, 'SSE reconnects on the next activation');
152+
ok(calls.work === 8, `full fan-out runs again on re-activation (got ${calls.work})`);
153+
}
154+
155+
console.log(`\n${'='.repeat(60)}\nRESULTS: ${out.pass} passed, ${out.fail} failed (${out.pass + out.fail} total)`);
156+
if (out.errors.length) for (const e of out.errors) console.log(` FAIL: ${e}`);
157+
console.log('='.repeat(60));
158+
process.exit(out.fail ? 1 : 0);

0 commit comments

Comments
 (0)