fix(#1599): replay handoff no longer freezes the map (suppressLive flag) (#1603)

## Summary

Partial fix for #1599 — replay from packets sidebar no longer freezes
the live map.

Clicking **Replay** on a packets-page row wrote the packet to
`sessionStorage['replay-packet']` and navigated to `/#/live`. On init,
`live.js` called `vcrPause()` to silence live WS traffic during the
replay. But `vcrPause()` sets `VCR.mode = 'PAUSED'`, and
`renderAnimations()` gates `anim.progress` advancement on `!isPaused` —
so the replayed animation never advanced and the map appeared frozen.

## Fix

Introduce a module-level `suppressLive` flag dedicated to muting live WS
traffic without entering `PAUSED`. The WS handler's `LIVE` branch honors
the flag (still ticking `updateTimeline` so the UI keeps reflecting
traffic). The replay handoff sets the flag for ~12 s — long enough for
the animation to play out — then clears it.

Files changed:
- `public/live.js` — module flag (`~145`), replay handoff (`~1502`), WS
LIVE branch (`~897`)
- `test-issue-1599-replay-freeze-e2e.js` — new Playwright E2E (seeds
`sessionStorage['replay-packet']`, asserts `activeAnimations` drains
after the handoff)
- `.github/workflows/deploy.yml` — wire the new E2E into the deploy E2E
block

## TDD trail

| Commit | Role |
| --- | --- |
| `8a0add00` | Red — failing E2E (asserts the queued animation drains;
pre-fix it never does → `FAIL: activeAnimations did NOT drain after
replay handoff (count=1) — replay freeze regression`) |
| `8069210d` | Green — `suppressLive` flag replaces `vcrPause()` in the
handoff |
| `c2a84a3e` | CI wiring |

Locally reproduced both states against the e2e-fixture DB (Chromium via
`CHROMIUM_PATH=/usr/bin/chromium`):
- HEAD red commit: `2 pass, 1 fail` (assertion-shaped, not compile)
- HEAD green commit: `3 pass, 0 fail`

Browser verified: local Chromium against `corescope-server -port 13581
-db /tmp/e2e-fixture.db -public public` — `replay-packet` key is
consumed by the init path, animation queues, and drains post-fix.

E2E assertion added: `test-issue-1599-replay-freeze-e2e.js:111`
(`activeAnimations drained to 0`).

## What this PR does NOT do

The reporter explicitly called out a second, separable problem on the
same issue: `renderPacketTree(packets, true)` runs with `isReplay =
true`, which skips `addFeedItem` (`public/live.js:3155`), so the
bottom-left feed shows "Waiting for packets…" even once the map
animates. That is a UX decision (should the replayed packet appear in
the feed?) and is intentionally **not** addressed here. Leaving #1599
open so the operator can decide.

Hence: **"Partial fix for #1599"** — no `Fixes #` keyword.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all hard gates , no warnings.

---------

Co-authored-by: corescope-bot <bot@corescope>
This commit is contained in:
Kpa-clawbot
2026-06-05 03:44:31 -07:00
committed by GitHub
parent ac6415eca6
commit 1f65d7811b
3 changed files with 159 additions and 1 deletions
+1
View File
@@ -392,6 +392,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1510-live-nav-pin-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-fullscreen-1572-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1599-replay-freeze-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
+17 -1
View File
@@ -137,6 +137,11 @@
timelineFetchedScope: 0, // last fetched scope to avoid redundant fetches
replayGen: 0, // generation counter — incremented on each replay/rewind to discard stale async results
};
// #1599 — drop live WS packets during a manual replay handoff without
// entering VCR PAUSED (which freezes the canvas engine and kills the very
// animation we want to play). The handoff sets this true, then clears it
// after the replay window has elapsed.
let suppressLive = false;
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
@@ -899,6 +904,10 @@
if (_tabHidden) {
return;
}
// #1599 — manual replay handoff sets suppressLive=true so incoming live
// WS packets don't clutter the replay animation. We still want the
// timeline to tick so the UI shows traffic continuing.
if (suppressLive) { updateTimeline(); return; }
if (realisticPropagation && pkt.hash) {
const hash = pkt.hash;
if (propagationBuffer.has(hash)) {
@@ -1499,8 +1508,15 @@
try {
const parsed = JSON.parse(replayData);
const packets = Array.isArray(parsed) ? parsed : [parsed];
vcrPause(); // suppress live packets
// #1599 — mute live WS traffic but keep VCR in LIVE so the canvas
// engine keeps advancing animation progress. Using vcrPause() here
// set VCR.mode='PAUSED' which froze anim.progress in
// renderAnimations(), turning the replay into a blank, motionless map.
suppressLive = true;
setTimeout(() => renderPacketTree(packets, true), 1500);
// Clear the suppression after the replay window has elapsed (the
// longest animation duration plus a margin) so live traffic resumes.
setTimeout(() => { suppressLive = false; }, 12000);
} catch { }
} else {
// replayRecent(); // disabled — live page starts empty, fills from WS
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/* Issue #1599 Replay from packets sidebar must not freeze the live map.
*
* Reproduces the freeze caused by the replay handoff calling vcrPause() to
* silence live WS traffic. vcrPause() sets VCR.mode='PAUSED', and
* renderAnimations() gates `anim.progress` advancement on `!isPaused`, so the
* replayed animation never advances and the map appears frozen.
*
* Repro:
* 1. Seed sessionStorage['replay-packet'] with a synthetic packet (the
* packets-sidebar "Replay" button does this before navigating to /#/live).
* 2. Load /#/live. live.js init reads the key and (currently) calls
* vcrPause().
* 3. Push an animation via the existing _liveDrawAnimatedLine test seam
* AFTER the replay handoff has run.
* 4. Wait 2x the canvas-engine duration (660ms base 1500ms total).
*
* Expected behaviour after fix: the animation drains to 0 (engine still
* running because VCR stays in LIVE mode; live WS suppression is handled by a
* dedicated flag, not by entering PAUSED).
*
* Pre-fix behaviour: VCR.mode is 'PAUSED' animation progress stays 0
* activeAnimations never drains test fails on the drain assertion.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-issue-1599-replay-freeze-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-issue-1599-replay-freeze-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0, passes = 0;
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
// Seed sessionStorage BEFORE the page script runs (mirrors the packets
// sidebar "Replay" button which sets the key then navigates to /#/live).
await page.addInitScript(() => {
try {
const syntheticPacket = {
hash: 'test1599deadbeef',
decoded: {
header: { payloadTypeName: 'ADVERT' },
payload: {},
path: { hops: [] },
},
};
sessionStorage.setItem('replay-packet', JSON.stringify([syntheticPacket]));
} catch (_) {}
});
try {
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveMap', { timeout: 15000 });
await page.waitForFunction(() => !!(window._liveTestSeams && window._liveDrawAnimatedLine), null, { timeout: 15000 });
// Wait past the 1500ms setTimeout in the replay handoff so VCR mode is
// settled into whatever state the handoff leaves it in.
await page.waitForTimeout(1700);
// Sanity: confirm the handoff actually consumed the sessionStorage key
// (proves the init path ran the replay branch).
const replayKeyConsumed = await page.evaluate(() => sessionStorage.getItem('replay-packet') === null);
if (replayKeyConsumed) pass('replay-packet sessionStorage key was consumed by live.js init');
else fail('replay-packet sessionStorage key still present — handoff did not run');
// Push a synthetic animation onto the canvas engine post-handoff.
await page.evaluate(() => {
window._liveDrawAnimatedLine(
[37.4, -122.0],
[37.5, -122.1],
'#00ff00',
null,
null,
'00AA',
'test-1599-anim'
);
});
const initialCount = await page.evaluate(() => window._liveTestSeams.getAnimCount());
if (initialCount >= 1) pass(`animation queued (count=${initialCount})`);
else fail(`animation did not queue (count=${initialCount})`);
// Core assertion: with the replay handoff leaving VCR in LIVE mode
// (post-fix), the canvas engine advances progress and the animation
// drains within ~2× the 660ms base duration. Pre-fix the handoff sets
// VCR.mode='PAUSED' which freezes progress, so the engine never drains.
//
// Headless Chromium throttles requestAnimationFrame when no compositing
// is happening, so we pump rAF callbacks from inside the page to give
// the engine a deterministic chance to advance.
const drainedTo = await page.evaluate(async () => {
const seam = window._liveTestSeams;
// ~30 frames at ~16ms each ≈ 480ms of simulated time, well over the
// 660ms / 60fps it takes one animation to drain, but the rAF callback
// bridge advances by real timestamps so we keep pumping until either
// the queue drains or we hit a hard cap (90 frames ≈ 1.5s wall-clock).
for (let i = 0; i < 90; i++) {
if (seam.getAnimCount() === 0) break;
await new Promise((r) => requestAnimationFrame(r));
}
return seam.getAnimCount();
});
if (drainedTo === 0) {
pass('activeAnimations drained to 0 — canvas engine advanced progress during replay');
} else {
fail(`activeAnimations did NOT drain after replay handoff (count=${drainedTo}) — replay freeze regression`);
}
} catch (e) {
fail(`unexpected error: ${e && e.stack || e}`);
} finally {
try { await browser.close(); } catch (_) {}
}
console.log(`\ntest-issue-1599-replay-freeze-e2e.js: ${passes} pass, ${failures} fail`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(1); });