Skip to content

fix(ui): stop compare/channels/live leaking handlers on #app (#428) - #437

Merged
dborup merged 3 commits into
masterfrom
codex/issue-428-page-listener-leaks
Oct 13, 2026
Merged

dborup merged 3 commits into
masterfrom
codex/issue-428-page-listener-leaks

Conversation

@dborup

@dborup dborup commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Relates to #428

Remainder of #405: the #419 sweep found 15 page-module handlers bound to
the shared #app/document/window and never removed in destroy() —
compare.js (1), channels.js (6), live.js (8), listed in #419's
description under "Same-class leaks found but NOT fixed". This PR fixes
all 15, following the same pattern #419 used for observers.js.

Fix

  • compare.js — the row keydown handler (fix(store): charge the path_json fallback and pin the hash-migration accounting (#202) #209) is bound to
    .compare-page (its own container) instead of #app.
  • channels.js — the click delegation (ch-back/ch-close-node)
    is bound to .ch-layout instead of #app. The other 5 (Add/Share modal
    Escape, the mc-channels-show-encrypted-changed window event, and the
    sidebar-resize mousemove/mouseup) are genuinely global — they're
    stored as module refs and removed in destroy().
  • live.js — all 8 are global (window's mc-tile-provider-changed
    and the DragManager resize clamp; document's feed-resize drag, the VCR
    "more" overflow-menu outside-click, and the timeline-scrub drag), stored
    and removed the same way.
    • orientationchange was a ref-mismatch bug on top of the leak:
      destroy() called window.removeEventListener('orientationchange', _onResize), but the listener actually bound there was a different,
      anonymous arrow function — so it was never removed at all, even though
      the code looked like it handled it.

Page behaviour (keyboard shortcuts, clicks, VCR, channel selection,
modals, sidebar/feed/timeline drag) is unchanged — each E2E below drives
the real DOM interaction, not just the listener count.

Live init/teardown races

Live's init() is async and can still be resuming past an await
after the router has already switched pages. Traced every one of the 8
bind sites against the two awaits in init()'s main flow
(fetch('/api/config/map'), loadNodes()) and the generation !== liveGeneration guards immediately after each: every handler is bound in
a block that runs synchronously once a guard has passed, so there's no
window where a stale init() can bind a listener after its own
destroy() already ran. A dedicated E2E case (enter/leave Live 3x
quickly, not waiting for it to settle) plus the existing
test-issue-125-live-toggles-early-e2e.js (which already specifically
covers "leaving Live while init awaits loadNodes()") both stay green.

Tests

Each page gets an E2E regression test (real browser + Go server), with a
mutant per page (reverting that file to its pre-#428 origin/master
version). Listener counting uses the same "patch addEventListener and
count" technique #419's E2E used for #app, extended to document/
window with a net (add minus remove) count filtered by the
handler's own function name — not by raw event type. That isolates
exactly the handler under test: other pages bind/unbind their own
keydown/etc. listeners too (confirmed while building this: nodes.js's
own Escape binds already drift independently across repeated visits),
so a type-only count would be contaminated by unrelated pages.

File Test (red before → green after) Mutant → red
test-issue-428-compare-listener-leak-e2e.js #app gains 0 keydown listeners visiting Compare; row Enter still navigates; after Compare→Nodes a sentinel keydown on #app is inert; 3 visits don't stack. origin/master compare.js → 3/4 red (#app gains 1, sentinel navigates, 3 visits stack to 3).
test-issue-428-channels-listener-leak-e2e.js #app gains 0 click; all 5 global handlers net to exactly 1 while mounted and back to 0 after leaving / after 3 visits; after leaving, a ch-back click on #app is inert; behaviour: Add/Share modal Escape still closes each, sidebar drag still resizes + persists, the show-encrypted event still triggers a reload, ch-back delegation still navigates. origin/master channels.js → 4/10 red (#app click leak, both net-while-mounted and net-stacking cases, the inert-click case).
test-issue-428-live-listener-leak-e2e.js All 8 net to 1 while mounted, 0 after leaving, 0 after 3 visits; _onOrientationChange specifically checked; rapid 3x enter/leave doesn't leak or throw; a normal visit right after rapid cycling still mounts cleanly (WS beacon present). origin/master live.js → red (handlers never bound under their #428 names, so "net while mounted" and the post-race "still binds" checks fail — e.g. _onOrientationChange net while mounted: 0).

Two pre-existing test harnesses needed updating for the container
rebinds (not behaviour bugs — their fake DOM didn't have the new
querySelector call):

  • test-frontend-helpers.js: the fake #app.querySelector('.ch-layout')
    stub lacked addEventListener; merged it into the branch that already
    returns a full element stub.
  • test-xss-escape-sinks.js: same gap for #app.querySelector('.compare-page')
    in its loadComparePage() harness.

Verification

Against a local Go server with test-fixtures/e2e-fixture.db prepared as
in CI (freshen → migrate → seed Kpa-clawbot#2073/#199/#245/Kpa-clawbot#1486/Kpa-clawbot#1791), port 13950,
-public public:

  • The 3 new E2E files above: 4/4, 10/10, 6/6 (0 failed), and each one
    shown red against its file's origin/master mutant.
  • sh test-all.sh → 242/242 (registry meta-test green: unit tests in
    test-all.sh, the 3 new E2E in deploy.yml).
  • node test-frontend-helpers.js → 713/0.
  • cmd/server go test ./... → ok (no server change; read-only invariant
    intact).
  • Broader regression sweep (unaffected by this change, confirmed still
    green): test-channels-ws-batch-e2e, test-channels-ws-race-1498-e2e,
    test-channels-client-state-152-e2e/-decrypt-e2e,
    test-channels-selection-flow-e2e, test-channels-add-modal-e2e,
    test-channels-share-color-e2e, test-channel-modal-e2e,
    test-channel-color-picker-e2e, test-channels-list-render-e2e,
    test-channel-issue-1111-e2e, test-channels-154-155-e2e,
    test-channel-proposals-e2e, test-channel-qr-e2e,
    test-channels-observed-path-hash-size-e2e,
    test-issue-1367-channels-chat-app-e2e,
    test-issue-1224-channels-mobile-ux-e2e, test-live-mql-leak-1180-e2e,
    test-live-fullscreen-1572-e2e, test-issue-1205-live-controls-anchor-e2e,
    test-issue-1206-resize-observer-leak-e2e, test-live-layout-1178-1179-e2e,
    test-issue-1510-live-nav-pin-e2e, test-issue-1234-live-chrome-pass2-e2e,
    test-issue-1244-live-vcr-row-hints-e2e,
    test-issue-125-live-toggles-early-e2e (the existing Live
    init/teardown-race test), test-audio-live-1297-e2e,
    test-issue-1136-live-region-e2e, test-issue-220-live-filter-readiness-e2e,
    test-pr-1490-live-map-gpu-animations-e2e, test-live-pulse-ring-weight-e2e,
    test-live-multibyte-only-e2e, test-issue-1640-compare-discovery-e2e,
    test-issue-1639-observers-sort-e2e, test-issue-199-inactive-observer-e2e,
    test-xss-333-pages-e2e, test-node-liveness-e2e.
  • scripts/check-xss-sinks.sh --diff origin/master → exit 0.
  • Fork-guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.
  • No new map[string]interface{} outside tests; no hardcoded colours;
    cmd/server read-only (diff touches only public/*.js, 3 new test
    files, 2 test-harness fixes, and the 3 new deploy.yml E2E lines).

Left / not done

🤖 Generated with Claude Code

Remainder of #405: the 15 page-module handlers #419 found bound to the
shared #app/document/window and never removed in destroy() — compare.js
(1), channels.js (6), live.js (8). Each is now bound to the page's own
container (click delegation in compare.js/channels.js) or stored as a
module ref and removed in destroy() (the global document/window binds:
modal Escape handlers, sidebar/feed/timeline drag tracking, the
show-encrypted-channels and tile-provider-changed events, the VCR
more-menu outside-click, and live.js's DragManager resize clamp).

live.js's orientationchange listener was a ref-mismatch bug on top of
the leak: destroy() removed `_onResize` under the 'orientationchange'
name, which never matched the actual anonymous listener bound there, so
it was never removed at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dborup

dborup commented Oct 13, 2026

Copy link
Copy Markdown
Owner Author

Rapport — CS-Minimax PR#437 #428 — head 28c88a2

CI is fully resolved: every job on the PR head has finished. The only test failure is
test-issue-1692-packets-init-parallel-e2e.js, which fails on current origin/master too
and is tracked in #439. Nothing was re-run (it is not #271 or #301).

Evidence tags: [T] run locally, [A] analysis/code reading, [K] CI.

CI — run 38415434009 (pull_request, attempt 1)

Job Result
✅ Go Build & Test success
🧱 Validate Docker Image success
🎭 Playwright E2E Tests failure — only test-issue-1692-packets-init-parallel-e2e.js: first table row appears < 3000ms despite 4000ms /api/observers stub: page.waitForSelector: Timeout 10000ms exceeded. Every E2E before it in the fail-fast list passed, including the three new #428 files.
🏗️ Build & Publish Docker Image failure, its gate only: Required CI job did not pass with GO_RESULT: success, E2E_RESULT: failure, IMAGE_RESULT: success
📦 Release / 🚀 Deploy Staging / 📝 Badges skipped (PR)

The failure is not from this PR.

  • [K] Current origin/master (8665b0c7, push run 38426328005) fails the same test with the
    identical message.
  • [K] master at 4c7a1ca5 failed it on attempt 1 of run 38409905842 and only went green on
    attempt 2, so that run's "success" is a re-run, not a pass of the test.
  • [A] This PR touches no file the test exercises: public/packets.js, the router and every Go
    file are untouched. Its public/ delta is channels.js, compare.js and live.js only.
  • [T] Locally the test is green on the merged tree, 4 runs out of 4, against a local Go server
    with the CI-prepared fixture.
  • [T] One hypothesis checked and falsified: that the test needs a freshly freshened fixture
    (in CI it runs ~15 min into the Playwright step). With every observations.timestamp shifted
    back 20 minutes — zero observations inside the page's default 15-minute window — the test
    still passed in 58 ms. So fixture age via that column is not the mechanism. Root cause is left
    to ci/packets: test-issue-1692 packets-init-parallel E2E fails repeatedly — regression or flake? #439.

What the change is

One commit, 28c88a26, author dborup <kontakt@meshview.dk> [T]. The remainder of #405: the 15
page-module handlers #419 found bound to the shared #app/document/window and never removed.

  • compare.js — the fix(store): charge the path_json fallback and pin the hash-migration accounting (#202) #209 row keydown moves from #app to .compare-page, the container this
    init() writes, following the pageEl pattern observers.js already uses [A].
  • channels.js — the data-action click delegation moves from #app to .ch-layout; the 5
    genuinely global ones (two modal Escapes, the mc-channels-show-encrypted-changed window
    event, sidebar-resize mousemove/mouseup) are stored in module refs and removed in destroy().
  • live.js — all 8 are global and get the same treatment. orientationchange was a ref-mismatch
    bug as well: destroy() removed _onResize under that event name, which was never the function
    bound there, so the listener was never removed at all [A].
  • .github/workflows/deploy.yml — the three new E2E files are added to the fail-fast list,
    next to the observers.js leaks delegated click/keydown handlers on the shared #app container #405 one. Nothing else in the workflow changes [T].
  • test-frontend-helpers.js, test-xss-escape-sinks.js — their fake #app needed a
    querySelector returning an element stub for .ch-layout / .compare-page. Harness gaps, not
    behaviour changes [A].

[A] Re-audited both files for anything missed: live.js still binds two listeners that are never
removed, and both are correct to leave. The visibilitychange one is at module scope, outside
init(), so it binds once per script load. The fullscreen keydown is inside init() but behind
if (!window.__liveFullscreenKeyBound), a one-shot global guard, so it cannot stack either.
Everything in channels.js and compare.js is now either on the page's own container or removed
in destroy().

Tests

On the merged tree (git merge-tree --write-tree origin/master 28c88a26 → 46c12bfc, clean),
against a local Go server built from it, fixture prepared as CI does (freshen → the Kpa-clawbot#1486/Kpa-clawbot#1791
seed SQL from deploy.yml → corescope-migrate → seeds 2073/199/245), port 13700, -public public.
The server was stopped by its listening port, not $!.

  • [T] The 3 new E2E files: 4/4, 10/10, 6/6, 0 failed.
  • [T] Every other channels/live/compare/observers E2E in the CI fail-fast list that runs against a
    plain BASE_URL — 39 files, all green, 0 failed (includes
    test-issue-125-live-toggles-early-e2e.js 17/17, which covers leaving Live while init() awaits
    loadNodes(), and test-issue-405-observers-listener-leak-e2e.js 6/6).
  • [T] test-e2e-playwright.js: 151/154 passed, 3 skipped, 0 failed.
  • [T] sh test-all.sh: 242 passed, 0 failed (242 files).
  • [T] node test-frontend-helpers.js: 713 passed, 0 failed.
  • [T] go test ./...: cmd/server ok 48.5 s, cmd/ingestor ok 111.4 s. Run on the sibling merged
    tree whose cmd/ is byte-identical to this one (git diff between the two merged trees over
    cmd/ is empty), since this PR changes no Go file.

Mutants

Two of my own, applied to the merged tree and run against the new E2Es on the same server:

Mutant Result
M1 — compare.js: bind the row keydown back onto #app killed, 3 of 4 red: #app gained 1 keydown listener, the post-navigation sentinel keydown navigated, and 3 visits stacked to 3. The one that stayed green is the behaviour case (Enter on a row still navigates) — correct, the mutant does not break that.
M2 — live.js: drop only the _onOrientationChange removal from destroy() killed, 3 of 6 red: net-after-leaving 1, 3 visits accumulated 3, and the dedicated ref-mismatch case. The other three stayed green — correct, they do not touch orientationchange.

Always-rules

  • [T] scripts/check-xss-sinks.sh --diff origin/master clean, run in an isolated clone with
    origin/master pinned to 8665b0c7 (the working copy here is stale, so running it in place
    would be meaningless).
  • [T] No hardcoded colours. Every #... in the added lines is an issue reference (#428, #405,
    #419, #209, #app selectors).
  • [A] No Go change at all: cmd/ is untouched, so cmd/server stays read-only and no new
    map[string]interface{} is introduced.
  • [T] Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.
  • [T] No closing keywords in the PR body or the commit message; the body says "Relates to Page modules leak delegated handlers on #app/document (compare, channels, live) — remainder of #405 #428".
  • [T] Commit author dborup <kontakt@meshview.dk>.
  • [T] git ls-remote on the head before and after: 28c88a26 unchanged. master moved from
    d34b228a to 8665b0c7 during the run; everything above is against 8665b0c7.

Left for the owner

  1. CI stays red until ci/packets: test-issue-1692 packets-init-parallel E2E fails repeatedly — regression or flake? #439 is resolved (or the job is re-run). The PR stays a draft.
  2. No null guard on the new container lookups. app.querySelector('.compare-page') and
    app.querySelector('.ch-layout') would throw if that markup ever stopped being written by the
    same init(). observers.js has had exactly this shape since observers.js leaks delegated click/keydown handlers on the shared #app container #405, so this follows the house
    pattern rather than introducing a new risk — worth knowing it is the pattern's shape, not an
    oversight.
  3. The two deliberately-kept global listeners in live.js described above. Neither stacks, but
    both outlive the page by design.

Not verified

  • A real multi-hour browsing session: the leak claims are verified by net add/remove counting over
    up to 3 visits per page, not by heap measurement.
  • The orientationchange handler firing on real hardware; only its binding and removal are checked.
  • busybox/Linux-only paths and the Docker image: CI covers those [K].
  • No staging or production access, no server API key used, no write to the repository other than
    this comment.

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Review — CS-Macmini PR#437 — head 28c88a2

Dom: APPROVE med nits

Independent, read-only review of PR #437 against issue #428 and the #419 PR body's "Same-class leaks found but NOT fixed" table (the authoritative list of the 15 sites this PR is scoped to close). Evidence tags: [T] run locally, [A] analysis/code reading, [K] CI.

Findings

# Severity Finding
1 Low (test-gap) live.js's "rapid enter/leave race" E2E case does not independently kill a mutant on _moreMenuDocClickHandler's destroy() removal — in that fast-cycling scenario the VCR "more" menu code path never binds the handler before the page is already left, so the assertion passes trivially either way. The other two live.js cases ("leaving Live removes all 8" and "3x does not stack") catch this mutant solidly. Not a correctness defect, just a narrower blind spot in one specific case.
2 Informational No null-guard on the new app.querySelector('.compare-page') / app.querySelector('.ch-layout') lookups — would throw if that markup were ever not written by the page's own init(). Verified this follows the exact shape observers.js has had since #405 (same house pattern, not a new risk). Correctly flagged by the author as "left for the owner" rather than silently accepted.
3 Informational Two global live.js listeners are deliberately left unfixed: document.addEventListener('visibilitychange', …) at module scope (line ~948, outside init(), so it binds once per script load — cannot stack) and the fullscreen keydown listener guarded by the one-shot window.__liveFullscreenKeyBound flag inside init() (cannot re-bind). [A] Independently re-read both sites in the merged tree and confirmed neither can stack; leaving them out is correct.

No findings above informational/low — nothing here warrants REQUEST CHANGES.

Issue #428 checklist — point by point

Source of truth: #419's sweep table listed exactly 15 leaking sites — compare.js (1), channels.js (6), live.js (8).

  • compare.js:114 keydown — [A] rebound from #app to .compare-page. ✅ fixed.
  • channels.js — 6 sites: app click delegation → .ch-layout (✅); document keydown ×2 (Add/Share modal Escape) → stored refs, removed in destroy() (✅); window mc-channels-show-encrypted-changed → stored ref, removed (✅); document mousemove/mouseup (sidebar-resize) → stored refs, removed (✅). All 6 accounted for. ✅ fixed.
  • live.js — 8 sites: window orientationchange (ref-mismatch bug: destroy() removed _onResize under that name, never the actual anonymous listener) → fixed with a real stored ref (✅); window mc-tile-provider-changed (✅); window resize (DragManager) (✅); document mousemove/mouseup (feed-resize) ×2 (✅); document click (more-menu outside-click) (✅); document mousemove/mouseup (timeline-scrub) ×2 (✅). All 8 accounted for. ✅ fixed.
  • Each page gets a red/green regression test, N visits + another page, asserting no stacking/firing — all 3 new E2E files do exactly this (see Tests below), with per-page mutants.
  • Live's map/WebSocket init/teardown races not regressed — [A] traced all 8 bind sites against the two awaits in init() and their generation !== liveGeneration guards: every bind happens synchronously after a guard passes, so no window for a stale init() to bind after its own destroy(). [T] Dedicated rapid-cycle E2E case plus the pre-existing test-issue-125-live-toggles-early-e2e.js (which specifically covers leaving Live while init() awaits loadNodes()) both pass.

No scope creep: the diff touches only the 15 sites plus the 3 new E2E test files, two mechanical test-harness stub fixes (test-frontend-helpers.js, test-xss-escape-sinks.js — their fake #app needed a querySelector for .ch-layout/.compare-page), and 3 new lines in deploy.yml registering the new E2E files. Nothing requiring a product/config decision was implemented.

Tests

All run on the merge of head 28c88a26 onto current origin/master (dd712bc9), via git merge-tree --write-tree → clean tree 307132af… extracted with git archive, no conflicts.

  • [T] cmd/server go test ./... → ok, 52.999s.
  • [T] cmd/ingestor go test ./... → ok, 117.105s.
  • [T] node test-frontend-helpers.js → 713 passed, 0 failed.
  • [T] sh test-all.sh → 243 passed, 0 failed (243 files) (one more file than the PR body's stated 242 — master has advanced with other merged PRs since the author wrote that number; not a discrepancy in this PR).
  • [T] node test-xss-escape-sinks.js → 121 passed, 0 failed.
  • [T] The 3 new E2E files, against a local Go server + test-fixtures/e2e-fixture.db prepared as in CI (freshen → seed SQL from deploy.yml → corescope-migrate), local port:
    • test-issue-428-compare-listener-leak-e2e.js → 4/4.
    • test-issue-428-channels-listener-leak-e2e.js → 10/10.
    • test-issue-428-live-listener-leak-e2e.js → 6/6.
  • [T] Broad regression sweep — every channels/live/compare/observers E2E file on the branch (37 files total, including test-issue-405-observers-listener-leak-e2e.js, test-issue-125-live-toggles-early-e2e.js, and both PSK-decrypt/channel-proposal tests that needed a locally built corescope-ingestor) → all green, 0 failed.
  • [K] CI run 38415434009: Go Build & Test and Validate Docker Image green. Playwright E2E Tests failure, but the only failing test is test-issue-1692-packets-init-parallel-e2e.js (Timeout 10000ms exceeded waiting on a table row). [K] Verified current origin/master (8665b0c7, run 38426328005) fails the identical test with the identical message — this is a pre-existing flake unrelated to this PR (tracked separately as ci/packets: test-issue-1692 packets-init-parallel E2E fails repeatedly — regression or flake? #439), not a regression. This PR's public/ delta touches only channels.js, compare.js, live.js — none of which the bug: /packets init() serializes loadObservers→loadPackets — 25-40s time-to-first-row on slow links (root cause of #1662 flakes) Kpa-clawbot/CoreScope#1692 test exercises. "Build & Publish Docker Image" fails only because its gate requires E2E_RESULT: success.
  • [T] git ls-remote on the head before and after this review: 28c88a26 unchanged, confirming no concurrent push.

Mutants (2 of my own, independent of the author's)

Mutant Site Result
M1 channels.js: suppressed the destroy() removal of _chShowEncryptedChangedHandler only (left everything else intact) Killed: 2/10 red — leaving Channels removes all 5… (_chShowEncryptedChangedHandler net after leaving: 1) and visiting Channels 3x does not stack… (accumulated over 3 visits: 3). The other 8 cases correctly stayed green — the mutant isolates exactly the one handler, and the test isolates exactly that handler back.
M2 live.js: suppressed the destroy() removal of _moreMenuDocClickHandler only Killed: 2/6 red — leaving Live removes all 8… and 3x does not stack… both flagged _moreMenuDocClickHandler. The "rapid race" case stayed green (see Finding 1 — a coverage gap, not a false negative masking the bug).

Both mutants restored afterward; re-ran the clean suites to confirm restoration (all green again).

Always-rules

  • [T] Commit author: dborup <kontakt@meshview.dk>. ✅
  • [T] No closing keywords in the commit message or PR body (PR body says "Relates to Page modules leak delegated handlers on #app/document (compare, channels, live) — remainder of #405 #428"). ✅
  • [T] Fork-guards: 9 occurrences in deploy.yml, 1 in release-fast-path.yml — unchanged. ✅
  • [A] No .go files touched at all — cmd/server stays read-only, and no new map[string]interface{} anywhere. ✅
  • [T] No hardcoded colours: every #… token in the added diff lines is an issue reference (#428, #405, #419, #209, #app/#chList selectors), not a hex colour. ✅
  • [T] scripts/check-xss-sinks.sh --diff origin/master → exit 0, run in a fresh isolated (non-shallow) clone at the PR head commit, merge-base confirmed against the real current origin/master (dd712bc9). ✅

Not verified

  • A real multi-hour browsing session / heap profiling — leak-freedom is checked by net add/remove listener counting over up to 3 visits, same methodology as fix(ui): stop observers.js leaking delegated handlers on #app (#405) #419, not by memory measurement.
  • The orientationchange handler firing on real mobile hardware — only its binding/removal under the fix is exercised.
  • The full test-e2e-playwright.js suite (151/154 in the author's report) and the axe-core/a11y gate were not independently re-run locally; CI's own green result for those steps (prior to the unrelated ci/packets: test-issue-1692 packets-init-parallel E2E fails repeatedly — regression or flake? #439 failure) was treated as sufficient given the scope of this PR's changes and the extensive targeted + regression E2E coverage already run directly against the affected files.
  • No staging/production access, no server API key used, no writes to the repository beyond this comment.

@dborup

dborup commented Oct 13, 2026

Copy link
Copy Markdown
Owner Author

Rapport — CS-Minimax PR#437 master-sync — head 2da5f06

Review feedback addressed (commit 2da5f06b)

Merged origin/master (e67d36f3) into codex/issue-428-page-listener-leaks (28c88a26) with an ordinary merge commit, 2da5f06b. Evidence tags: [T] run locally, [A] analysis/code reading, [K] CI.

Review findings — "Review — CS-Macmini PR#437" (APPROVE with nits)

  1. Low (test-gap): live.js rapid race case doesn't independently kill the _moreMenuDocClickHandler removal mutant. Not fixed — kept under Rester, see below. [T] Instrumented a standalone probe mirroring the test's own addEventListener/removeEventListener patch, restricted to _moreMenuDocClickHandler, and ran the exact 3x rapid-cycle sequence the race test uses: zero bind/unbind events fired over all three cycles (final net: { _moreMenuDocClickHandler: 0 }). This confirms the reviewer's mechanism exactly — wireVcrScopeMore() (the function that binds this handler) sits past an await in Live's async init(), and the no-wait rapid cycling never gives that await a chance to resolve before the next navigation fires, so the handler never binds at all in this scenario (0→0 regardless of whether destroy()'s removal code exists). Disagreeing only on remedy, not on the finding: closing this inside the same race test would require waiting long enough for the bind to land, which just turns it into a duplicate of "visiting Live 3x does not stack" (already 300ms-waited and already confirmed solidly killing this exact mutant per the review's own M2). A genuinely new case would need to synthetically force the overflow menu open mid-race, which risks coupling the race assertion to viewport-dependent DOM (the "More ▾" control) it was never about. Left as the documented blind spot the review already scoped correctly.
  2. Informational: no null-guard on app.querySelector('.compare-page') / app.querySelector('.ch-layout'). No change — [A] re-confirmed this matches the exact shape observers.js has used since observers.js leaks delegated click/keydown handlers on the shared #app container #405 (public/observers.js's own container lookups are equally unguarded). House pattern, not a new risk, as the review itself concluded. Not a defect to fix.
  3. Informational: two global live.js listeners deliberately left unfixed (visibilitychange at module scope, outside init(); fullscreen keydown behind the one-shot window.__liveFullscreenKeyBound guard). No change — [A] re-read both sites in the merged tree; neither can stack (module-scope binds once per script load; the guard prevents re-binding). Correct as shipped.

No blocking findings to re-fix; all three were nits/informational in the source review and none required a code change in compare.js/channels.js/live.js.

Tests (post-merge, on 2da5f06b)

Mutants

No code change was made for any finding, so no new mutant was authored this round — the original two mutants (M1 on compare.js's keydown, M2 on live.js's _moreMenuDocClickHandler removal) are unaffected by the merge and already verified killed in the source review. Finding 1's diagnostic above (a targeted bind/unbind probe, not a source mutation) is the verification evidence for that finding specifically.

CI — run 38459384042 (pull_request, head 2da5f06b)

Job Result
✅ Go Build & Test success
🧱 Validate Docker Image success
🎭 Playwright E2E Tests failure — test-touch-gestures-coverage-e2e.js only, reproduced identically on both the initial run and one re-run
🏗️ Build & Publish Docker Image failure, gate only (Require tests and image validation, no further steps run)
📦 Release / 🚀 Deploy Staging / 📝 Badges skipped (PR)

This failure is not caused by this PR's diff, and is not the #1692 timing bug the task expected to be the sole remaining redness. It is a sibling of the same root cause as #439/#1692, in a different, not-yet-patched file:

  • [A] #428's diff touches only compare.js, channels.js, live.js, two test-harness stub fixes, and 3 new lines in deploy.yml. test-touch-gestures-coverage-e2e.js and public/packets.js are untouched by this PR.
  • [K] test-touch-gestures-coverage-e2e.js fails identically both times: FAIL: no packets row available — fixture/setup problem, then page.waitForSelector: Timeout 10000ms exceeded on #pktBody tr[data-hash], at openRowOverlay/main — the same stack both runs.
  • [T] Reproduced locally in isolation: against the long-running local server from this session's own earlier test pass (fixture freshened ~1h earlier), the test fails with the exact same message. Against a freshly rebuilt fixture + freshly started server, it passes immediately (12/12). This isolates the cause to fixture-timestamp age vs. the packets page's default 15-minute view window — the identical mechanism e5663475's commit message describes for #1692 ("the test timed out whenever the E2E suite reached it more than 15 minutes after the fixture was freshened").
  • [A] e5663475's fix only seeded a wider localStorage time window inside test-issue-1692-packets-init-parallel-e2e.js itself. It did not change packets.js's actual default window, and no other E2E file received the same seeding. test-touch-gestures-coverage-e2e.js has no such seed and simply waits on the default-window row.
  • [A] This PR's 3 new #428 E2E files run immediately before test-touch-gestures-coverage-e2e.js in deploy.yml's fail-fast order (lines 451-453 vs. 466) and add roughly 20-30s of cumulative runtime that wasn't there pre-merge, which is enough to tip an already-thin margin against the 15-minute fixture-freshness window later in the same sequential run.
  • Re-ran the CI run once (gh run rerun --failed) to rule out ordinary non-determinism; it failed identically both times at the same step, which argues this is a deterministic timing threshold rather than one-off flakiness, so a further re-run would not be expected to help. This test is not on the known-flaky list (#271, #301).
  • Build & Publish Docker Image fails only on its gate (E2E_RESULT: failure), same conditional behavior as the prior report.

Always-rules

  • [T] Commit author on the merge: dborup <kontakt@meshview.dk>.
  • [T] No closing keywords used anywhere in this comment or in the merge commit message.
  • [T] git ls-remote on the branch before writing this report: 2da5f06b, matching what was pushed — no concurrent push.
  • Ordinary merge commit pushed, no rebase/amend/force-push. PR left as draft.

Rester (left for the owner)

  1. CI stays red on Playwright E2E Tests / Build & Publish Docker Image's gate, caused by test-touch-gestures-coverage-e2e.js's own fixture-freshness fragility (the same class as #439, a different file), not by anything in this PR's diff. Recommend tracking it as its own issue and applying the same remedy #439 used (seed a wider time window via an init script before packets.js evaluates) to test-touch-gestures-coverage-e2e.js, or moving it earlier in deploy.yml's fail-fast order. Out of scope here per "no other changes."
  2. Finding 1 (rapid-race case doesn't independently kill the _moreMenuDocClickHandler mutant) — left open with reasoning above; the other two live.js mutant-killing cases already cover it solidly.
  3. Findings 2 and 3 from the source review are informational/confirmed-correct and need no action.

Not verified: a real multi-hour browsing session (leak-freedom still checked by net add/remove counting, not heap measurement); the orientationchange handler on real mobile hardware; no staging/production access, no API key used, no writes to the repository beyond this comment and the merge commit already pushed.

# Conflicts:
#	.github/workflows/deploy.yml
@dborup

dborup commented Oct 13, 2026

Copy link
Copy Markdown
Owner Author

Rapport — CS-Minimax PR#437 runde-3 — head c9e453e

Review feedback addressed (commit c9e453e8)

Evidence tags: [T] run locally, [A] analysis/code reading, [K] CI.

1. CI failure on test-touch-gestures-coverage-e2e.js — is it a regression from this PR's listener changes?

Conclusion: no. Not a regression, no code change made. The fix is the origin/master merge (below) alone.

  • [A] This PR's diff is compare.js, channels.js, live.js only. grep -l "TouchGestures\|touch-gestures" across public/*.js shows only packets.js references the shared gesture module — compare.js/channels.js/live.js never do. The PR's edits to those three files are strictly 1:1 replacements of anonymous document/window listeners (keydown, mousemove, mouseup, click, resize, orientationchange) with named, stored-ref equivalents bound to the exact same targets/events — disjoint from touch-gestures.js's pointerdown/pointermove/pointerup/pointercancel/lostpointercapture handling on #pktBody/#nodesTable.
  • [A] Under scripts/e2e-shards.json (shard 2, the shard test-touch-gestures-coverage-e2e.js lives in), none of the three live.js-related suites (test-live-multibyte-only-e2e.js, test-issue-125-live-toggles-early-e2e.js, test-issue-220-live-filter-readiness-e2e.js) run before it — they come later in that shard's order. So a live.js init/teardown race was never a candidate mechanism for this specific symptom either.
  • [T] Reproduced CI's exact conditions locally for both branches: built corescope-server/corescope-migrate, prepared test-fixtures/e2e-fixture.db identically to deploy.yml (freshen → seed Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486/"Group Data" message type missing from packet view window's "message type" filter Kpa-clawbot/CoreScope#1791 grouped/GRP_DATA rows → corescope-migrate → seed-2073/199/245), then ran shard 2's real suite prefix (the 11 suites preceding test-touch-gestures-coverage-e2e.js plus the test itself, each with its manifest-pinned CHROMIUM_REQUIRE/BASE_URL) against a phone viewport (375×812, hasTouch, isMobile — baked into the test itself, same as CI):
    • PR branch (merged tree, port 13790): all 12 suites pass; test-touch-gestures-coverage-e2e.js reached at 142s total elapsed, exits 0 in 13s.
    • plain origin/master (822cd34, separate worktree/fixture/server, port 13791): identical — 142s elapsed, exits 0 in 13s.
  • Root cause of the original failure (runs 38415434009 / 38426328005 / 38459384042, all pre-ci: measure and shard Playwright E2E suites (#434) #436-merge): the single monolithic Playwright job ran all ~150 E2E files sequentially against one fixture/server. test-touch-gestures-coverage-e2e.js sat late enough in that list (and got pushed a few files later still once the 3 new Page modules leak delegated handlers on #app/document (compare, channels, live) — remainder of #405 #428 tests were inserted ahead of it) that it crossed the packets page's 15-minute default-window relative to when the fixture was freshened at job start — the same mechanism already tracked for #1692/#439, just surfacing in a different, as-yet-unpatched file. #436's sharding (merged below) eliminates this by construction: shard 2 now runs only 58 of ~158 suites against its own freshly-prepared fixture/server, reaching this test ~142s in — nowhere near any staleness threshold, confirmed green on both the PR branch and plain master above. No change to compare.js/channels.js/live.js/touch-gestures.js/packets.js was needed or made.

2. Merge origin/master (E2E sharding, #436)

Merged origin/master (822cd34c) into codex/issue-428-page-listener-leaks (2da5f06b) with an ordinary merge commit, c9e453e8.

  • [A] One conflict, in .github/workflows/deploy.yml: HEAD still had the old monolithic "Run Playwright E2E tests (fail-fast)" inline-script step (with this PR's 3 #428 lines inserted into it); origin/master had replaced that entire step with node scripts/run-e2e-shard.js as part of ci: measure and shard Playwright E2E suites (#434) #436. Resolved by taking master's sharded-runner step — the inline list is gone, so the 3 new lines aren't re-added there.
  • [A] Because the 3 new #428 test files didn't exist when #436's manifest (scripts/e2e-shards.json) was generated, node scripts/run-e2e-shard.js --validate failed with unassigned E2E suite: test-issue-428-channels-listener-leak-e2e.js (the manifest's validateManifest asserts every discovered test-*.js E2E file is assigned or excluded). Registered all three in scripts/e2e-shards.json, shard 3, immediately after their sibling test-issue-405-observers-listener-leak-e2e.js (same fix(ui): stop observers.js leaking delegated handlers on #app (#405) #419/Page modules leak delegated handlers on #app/document (compare, channels, live) — remainder of #405 #428 listener-leak family), each with real meanSeconds/secondsByExecution from 3 local runs against the merged tree (compare: 2.404/2.441/2.365s; channels: 6.753/6.842/6.81s; live: 5.56/5.528/5.422s) — matching the convention every other one of the manifest's 155 pre-existing entries already follows (none omit this field).
    • [T] node scripts/run-e2e-shard.js --validate → E2E assignments: 158 suites across three non-empty shards.
    • [T] node test-ci-shards.js → 9/9 pass (manifest-guard, fail-closed-report, and real-child-process runner tests all still hold with the new entries in place).
    • [A] Estimated per-shard total runtime shifts from a tight 505–506s/shard 3-way split to 506.0 / 506.2 / 520.1s (shard 3 now ~3% heavier) — informational only, not a gate; left for the owner if tighter rebalancing is ever wanted (see Rester).
  • No other file in the merge needed manual resolution; everything else (scripts/ci/e2e-manifest.js, scripts/run-e2e-shard.js, scripts/report-e2e-shards.js, test-ci-shards.js, the test-all.sh/test-test-all.js/test-ci-parallel-gates.js updates, cmd/server/fork_guard_workflow_test.go) auto-merged cleanly as pure additions from master.

Review findings from earlier rounds ("Review — CS-Macmini PR#437", APPROVE with nits)

No change from the round-2 report — all three were nits/informational, already addressed there with no code change, and nothing in this round's master merge touches compare.js/channels.js/live.js to reopen them:

  1. Low (test-gap): live.js rapid-race case doesn't independently kill the _moreMenuDocClickHandler removal mutant — left open per round-2's reasoning (the other two live.js mutant-killing cases cover it solidly).
  2. Informational: no null-guard on app.querySelector('.compare-page')/.ch-layout' — confirmed-correct house pattern, no action.
  3. Informational: two global live.js listeners deliberately left unfixed (visibilitychange module-scope, fullscreen keydown one-shot guard) — confirmed-correct, no action.

Tests (post-merge, on c9e453e8)

  • [T] cmd/server go test ./... → ok, 50.2s.
  • [T] cmd/ingestor go test ./... → ok, 110.3s.
  • [T] node test-frontend-helpers.js → 713 passed, 0 failed.
  • [T] sh test-all.sh → 245 passed, 0 failed (245 files; one more than round-2's 244 since master advanced further — includes test-ci-shards.js, new from ci: measure and shard Playwright E2E suites (#434) #436).
  • [T] All 3 #428 E2E files, against a local Go server + fixture prepared exactly as deploy.yml's shard jobs now do (freshen → seed → migrate → seed-2073/199/245), port 13790, -public public: test-issue-428-compare-listener-leak-e2e.js 4/4, test-issue-428-channels-listener-leak-e2e.js 10/10, test-issue-428-live-listener-leak-e2e.js 6/6.
  • [T] test-issue-125-live-toggles-early-e2e.js (existing Live init/teardown-race test) → 17/17.
  • [T] test-issue-405-observers-listener-leak-e2e.js (sibling fix(ui): stop observers.js leaking delegated handlers on #app (#405) #419 test) → 6/6.
  • [T] test-issue-1692-packets-init-parallel-e2e.js → 2/2 (confirms its own prior fixture-timing fix, ci/packets: test-issue-1692 packets-init-parallel E2E fails repeatedly — regression or flake? #439, still holds post-merge).
  • [T] test-touch-gestures-coverage-e2e.js itself, both standalone and as part of the shard-2 prefix reproduction above → pass on both the PR branch and plain master (see Finding 1).

Mutants

No new mutant this round: Finding 1 resolved as "not a regression, no code change," so there is no fix to mutation-test. Its verification evidence is the dual PR-branch-vs-master timing/ordering reproduction above, not a source mutation. The two pre-existing mutants (M1 on compare.js's keydown, M2 on live.js's _moreMenuDocClickHandler removal) are unaffected by this merge and remain verified killed from earlier rounds.

CI — run 38471996789 (pull_request, head c9e453e8, new sharded pipeline from #436)

Job Result
✅ Go Build & Test success (26m29s)
🧱 Validate Docker Image success (51s)
🎭 Playwright E2E Tests (1/3) success (10m31s)
🎭 Playwright E2E Tests (2/3) success (11m2s) — contains test-touch-gestures-coverage-e2e.js
🎭 Playwright E2E Tests (3/3) success (11m0s) — contains the 3 new #428 suites (shard 3)
📊 Verify E2E Shards & Coverage success (16s)
🏗️ Build & Publish Docker Image success (15s)
📦 Release / 🚀 Deploy Staging / 📝 Badges skipped (PR, as before)

CI is fully green. Neither known-flaky test (#271, #301) was encountered; no re-run was necessary.

Always-rules

  • [T] Commit author on the merge: dborup <kontakt@meshview.dk>.
  • [T] No closing keywords used anywhere in this comment or in the merge commit message.
  • [T] git ls-remote on the branch before writing this report: c9e453e8, matching what was pushed — no concurrent push.
  • Ordinary merge commit pushed, no rebase/amend/force-push. PR left as draft.

Rester (left for the owner)

  1. Shard 3's estimated total runtime is now ~520s vs shards 1/2's ~506s (~3% heavier) after adding the 3 new #428 suites there. Purely informational — not a CI gate, and well within the sharding's own tolerance — but worth a look if #436's balance is ever revisited by a different mechanism than manual placement.
  2. All three nits/informational findings from "Review — CS-Macmini PR#437" remain open exactly as scoped in round 2 (see above) — no new information this round changes that assessment.

Not verified: a real multi-hour browsing session (leak-freedom still checked by net add/remove counting, not heap measurement); the orientationchange handler on real mobile hardware; no staging/production access, no API key used, no writes to the repository beyond this comment and the merge commit already pushed.

@dborup
dborup marked this pull request as ready for review October 13, 2026 22:44
@dborup
dborup merged commit bcf263e into master Oct 13, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants