Skip to content

Commit 1aed0ae

Browse files
fix(live): never paint from cache on paused load — data only when run is active
Root cause: the synchronous optimistic reveal in DOMContentLoaded trusted the localStorage snapshot (_cachedRunActive → rehydrateFromCache). But a snapshot persisted mid-run keeps state.status:'running' after the run is stopped — persistence halts on stop, so the cache is never updated to a terminal state. On a paused hard-refresh that stale 'running' cache made the page: - paint the last run's rows + restore the TESTED/progress counters behind the opaque paused overlay (visible if you delete the overlay in devtools), and - set _liveState to the stale running state, so applyLiveModeBadge saw active=true and fired /api/public/sdk-info — all with no network proof that a run was actually live. The cache can't know the run stopped without asking the server, so the cache-only synchronous decision is unsound. Remove it entirely: nothing paints until checkBroadcastState polls /api/broadcast and gets activeRun=true, which runs connectLiveWork (the sole paint path: cache rehydrate → render → SSE → live-state → batch → stats). The paused overlay ships shown in markup, so a paused page stays covered AND data-free from first paint. Deleted the now-dead _cachedRunActive(). Cost: a genuine mid-run refresh shows the overlay for one /api/broadcast round-trip before connectLiveWork paints — correct over stale. Tests: live-load-gating [0] now asserts _cachedRunActive is gone and there's no optimistic reveal (with a hard regression guard that exits non-zero if either returns); rehydrate/seed active-gating tests unchanged. Full suite green.
1 parent f79a364 commit 1aed0ae

2 files changed

Lines changed: 38 additions & 51 deletions

File tree

live.html

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -964,21 +964,18 @@ <h1 class="live-page-title">
964964
if (_liveResultsNext) _liveResultsNext.addEventListener('click', () => liveGoPage(1));
965965
const _liveSearchInput = document.getElementById('liveSearchInput');
966966
if (_liveSearchInput) _liveSearchInput.addEventListener('input', (e) => setLiveSearch(e.target.value));
967-
// Decide the overlay SYNCHRONOUSLY from the last-known snapshot — BEFORE the
968-
// async broadcast check — so the page never flashes the live shell while
969-
// that fetch is in flight. The overlay ships SHOWN in the markup, so a
970-
// stopped page stays fully covered from the very first paint. Only when the
971-
// cached snapshot was an active run (operator refreshing mid-run) do we
972-
// paint it and reveal the work up-front; the single poll below confirms or
973-
// corrects, and fans out to the work endpoints only if a run is live.
974-
if (_cachedRunActive()) {
975-
rehydrateFromCache();
976-
renderTable();
977-
renderLiveStats();
978-
showPaused(false);
979-
}
980-
// One call. If a run is live it fans out (SSE + live-state + logs + runs +
981-
// stats); if paused it does nothing else and the page stays on the overlay.
967+
// NOTHING is painted before the server confirms an active run. We do NOT
968+
// optimistically rehydrate from the localStorage snapshot: a snapshot
969+
// persisted mid-run keeps status:'running' after the run is stopped
970+
// (persistence halts on stop, so the cache never updates to a terminal
971+
// state). Trusting it flashed the last run's rows behind the paused
972+
// overlay AND triggered a /sdk-info fetch while paused. The overlay ships
973+
// SHOWN in the markup, so a paused page stays fully covered and data-free
974+
// from the very first paint until the poll below proves a run is live.
975+
// One call. If a run is live, checkBroadcastState fans out via
976+
// connectLiveWork (cache rehydrate → SSE + live-state + logs + batch +
977+
// stats) and drops the overlay; if paused it paints nothing and the page
978+
// stays on the overlay.
982979
await checkBroadcastState();
983980
// Re-poll the single endpoint so a run that STARTS while we're paused flips
984981
// the page to live within ~10s — without any traffic in the meantime.
@@ -1976,17 +1973,6 @@ <h1 class="live-page-title">
19761973
// session leave an ACTIVE, broadcasting run? Used at load to decide the
19771974
// overlay before the async broadcast check, so a stopped page never flashes
19781975
// the live shell and an operator's mid-run refresh reveals the work instantly.
1979-
function _cachedRunActive() {
1980-
try {
1981-
const raw = localStorage.getItem(LIVE_CACHE_KEY);
1982-
if (!raw) return false;
1983-
const snap = JSON.parse(raw);
1984-
if (!snap || !snap.ts || (Date.now() - snap.ts) > LIVE_CACHE_TTL_MS) return false;
1985-
if (!snap.broadcastLive) return false;
1986-
const s = snap.state && snap.state.status;
1987-
return s === 'running' || s === 'paused' || !!(snap.cb && snap.cb.batchId);
1988-
} catch { return false; }
1989-
}
19901976
function rehydrateFromCache() {
19911977
try {
19921978
const raw = localStorage.getItem(LIVE_CACHE_KEY);

test/live-load-gating.test.js

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@
99
* broadcast left on after a run finished, live-state still returns the last
1010
* run's results, so seed/rehydrate flashed a stale snapshot behind the overlay.
1111
*
12-
* Fix: gate every paint path on there being an ACTIVE run to show —
12+
* Fix: NOTHING paints before the server confirms an active run. DOMContentLoaded
13+
* no longer optimistically rehydrates from the localStorage snapshot at all — a
14+
* snapshot persisted mid-run keeps status:'running' after the run is stopped, so
15+
* trusting it flashed the last run's rows (and fired /sdk-info) while paused.
16+
* Painting happens ONLY inside connectLiveWork, which runs only once the
17+
* /api/broadcast poll reports activeRun. As defense-in-depth the two paint
18+
* functions still self-gate on an active run:
1319
* - rehydrateFromCache repaints cached rows ONLY when the cached snapshot was
1420
* an active run (status running/paused or a live batchId).
1521
* - seedLiveStateFromRest paints rows ONLY when the seeded status is active.
16-
* - (DOMContentLoaded resolves broadcast before any paint — covered by the
17-
* pause-overlay suite; this suite locks the two paint-gate functions.)
22+
* - (The paused-overlay decision is covered by the pause-overlay suite, and
23+
* the single-poll gate by live-single-poll-gate; this suite locks the two
24+
* paint-gate functions.)
1825
*
1926
* This runs the REAL rehydrateFromCache + seedLiveStateFromRest extracted from
2027
* live.html against a fake localStorage / fetch / DOM and counts paints.
@@ -48,9 +55,19 @@ function extractFn(src, name) {
4855
return src.slice(m.index, j);
4956
}
5057

51-
const extracted = ['_cachedRunActive', 'rehydrateFromCache', 'seedLiveStateFromRest']
58+
const extracted = ['rehydrateFromCache', 'seedLiveStateFromRest']
5259
.map(n => extractFn(html, n)).join('\n\n');
5360

61+
// Guard: the speculative pre-broadcast reveal must stay gone. If a future edit
62+
// reintroduces a _cachedRunActive() optimistic paint in DOMContentLoaded, a
63+
// paused hard-refresh would flash stale cached rows behind the overlay again.
64+
if (/function\s+_cachedRunActive\s*\(/.test(html)) {
65+
console.error('REGRESSION: _cachedRunActive() is back in live.html — the ' +
66+
'speculative pre-broadcast cache reveal was removed on purpose (it flashed ' +
67+
'stale rows + fired /sdk-info while paused). Paint only via connectLiveWork.');
68+
process.exit(1);
69+
}
70+
5471
// ─── Fake environment ────────────────────────────────────────────────────────
5572
let addSingleRowCount = 0; // rehydrate row paints
5673
let upsertCount = 0; // seed row paints
@@ -110,27 +127,11 @@ const idleSnap = {
110127
};
111128

112129
console.log('\n/live load-sequence gating — stopped-flash regression\n');
113-
114-
// ─── 0. _cachedRunActive: synchronous overlay decision (no network) ──────────
115-
console.log('[0] _cachedRunActive drives the synchronous overlay decision');
116-
function cachedActive(snap) {
117-
const sb = makeSandbox();
118-
sb.localStorage = fakeLocalStorage(snap);
119-
return vm.runInContext('_cachedRunActive()', sb);
120-
}
121-
ok(cachedActive(activeSnap('running')) === true,
122-
'broadcasting + running cache → active (reveal work synchronously)');
123-
ok(cachedActive({ ts: NOW, broadcastLive: true, state: {}, cb: { batchId: 9 }, results: [] }) === true,
124-
'broadcasting + live batchId → active');
125-
ok(cachedActive(idleSnap) === false,
126-
'broadcasting + done status → NOT active (overlay stays up)');
127-
ok(cachedActive({ ...activeSnap('running'), broadcastLive: false }) === false,
128-
'last session not broadcasting → NOT active even if status running');
129-
ok(cachedActive(null) === false, 'no cache → not active (default to overlay)');
130-
{
131-
const stale = activeSnap('running'); stale.ts = NOW - (2 * 60 * 60 * 1000);
132-
ok(cachedActive(stale) === false, 'expired cache → not active');
133-
}
130+
console.log('[0] _cachedRunActive removed — no speculative pre-broadcast paint');
131+
ok(!/function\s+_cachedRunActive\s*\(/.test(html),
132+
'live.html has no _cachedRunActive (paint only via connectLiveWork)');
133+
ok(!/if\s*\(\s*_cachedRunActive\(\)\s*\)/.test(html),
134+
'DOMContentLoaded does not optimistically reveal from cache');
134135

135136
// ─── 1. rehydrateFromCache: active cache repaints, idle cache does NOT ────────
136137
console.log('[1] rehydrateFromCache gates row paint on a cached ACTIVE run');

0 commit comments

Comments
 (0)