From 1f65d7811b493449b033851696efda3717b1ba4e Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 5 Jun 2026 03:44:31 -0700 Subject: [PATCH] fix(#1599): replay handoff no longer freezes the map (suppressLive flag) (#1603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .github/workflows/deploy.yml | 1 + public/live.js | 18 +++- test-issue-1599-replay-freeze-e2e.js | 141 +++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 test-issue-1599-replay-freeze-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cf6be744..540deb86 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/public/live.js b/public/live.js index 15cc6a04..82fbfa54 100644 --- a/public/live.js +++ b/public/live.js @@ -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 diff --git a/test-issue-1599-replay-freeze-e2e.js b/test-issue-1599-replay-freeze-e2e.js new file mode 100644 index 00000000..40b45c36 --- /dev/null +++ b/test-issue-1599-replay-freeze-e2e.js @@ -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); });