diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index 809aeb65..9e29df29 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -731,3 +731,32 @@ test_integrity_note: ``` Grouped mutation coverage: last-owner cancellation mutant kills SCN-last-probe-owner-disposal; unconditional cancel kills SCN-shared-queued-owner; unconditional inflight deletion and cancelled-normalization mutants each kill SCN-cancelled-replacement-inflight; reversed queue priority kills SCN-probe-priority-concurrency; disabled eager path kills SCN-hidden-healthy-reveal; stale result guard and late-entry membership mutants each kill SCN-active-shared-late-entry; non-idempotent disposer kills SCN-repeat-disposal-reattach; owner-dependent active caching kills SCN-ownerless-active-cache; missing cache writes kills SCN-existing-probe-cache-ttl. All11 variants exit1 with1 failed scenario,0 cancelled, and are reverted. Browser checks corroborate the same disposal/shared-owner/hidden-reveal/source-visibility scenarios with native observers; no browser CPU or actual media fallback claim. + +Additional client-reliability-3 scenarios (same invariant and controls): + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-shared-badge-listener-lifetime + given: "two feed registrations share the app callback" + when: "badge updates occur and registrations dispose one at a time" + then: "each event notifies once, one surviving owner retains the callback, last disposal stops notifications" + - id: SCN-checking-callback-disposal + given: "a live card's checking callback synchronously disposes the feed" + when: "badge notification returns to probe submission" + then: "no probe is submitted after unregister" + observable_outcomes: + - "callback event count/state sequence and external probe start count" + determinism_controls: + - "real badge/observer path, synchronous disposal callback and deferred probe IO" + anti_cheat_rationale: + prevents: + - "duplicated shared callbacks or dropping live callback owners" + - "post-disposal work submitted after synchronous reentrant teardown" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +Duplicate wrapper and unconditional listener release variants each fail SCN-shared-badge-listener-lifetime. Removing the post-checking membership guard fails SCN-checking-callback-disposal (actual starts1 vs expected0). These3 additional variants exit1 with1 failed/0 cancelled and are reverted; total14 killed variants for11 lifecycle scenarios. Original source/priority/observer expectations remain unchanged. diff --git a/decisions/DECISIONS_2026-10-19_22-02-00_client-reliability-3.md b/decisions/DECISIONS_2026-10-19_22-02-00_client-reliability-3.md new file mode 100644 index 00000000..419b6663 --- /dev/null +++ b/decisions/DECISIONS_2026-10-19_22-02-00_client-reliability-3.md @@ -0,0 +1,7 @@ +# Final callback ownership corrections + +- Caller review shows app.boundStreamHealthBadgeHandler is shared across feeds; preserve global callback dedup with registration reference counts. Disposing one owner must retain the handler for another owner; final disposal removes it. Wrapping each registration separately would duplicate callbacks. +- Synchronous checking notifications can dispose/rerender the feed before queue submission. Measured1 start after disposal, expected0; check live membership after notification before creating queued work. +- Late observer scenario also asserts no disposed-card notifications reach a remaining registration, not just final badge state. This rejects a removed observer guard even when the reentrant guard still prevents queue submission. +- Final source has11 lifecycle scenarios;14 independent mutants were rerun against this exact product tree, all exit1 with1 failed/0 cancelled, then restored. Source outside gridHealth.js remains untouched. +- Corrected build and Chromium/Firefox built-dist/native-observer smoke both pass; no external requests or page errors. Actual media playback fallback and CPU/RSS remain unverified. diff --git a/js/gridHealth.js b/js/gridHealth.js index bbe57fc4..8d59c06c 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -21,14 +21,14 @@ import { getLivenessProbePrefetchMargin, } from "./constants.js"; -const badgeUpdateListeners = new Set(); +const badgeUpdateListeners = new Map(); function notifyBadgeUpdate(payload) { if (!payload) { return; } - badgeUpdateListeners.forEach((listener) => { + badgeUpdateListeners.forEach((_owners, listener) => { if (typeof listener !== "function") { return; } @@ -479,7 +479,7 @@ function setBadge(card, state, details) { notifyBadgeUpdate(payload); } -function handleCardVisible({ card, pendingByCard, priority = 0 }) { +function handleCardVisible({ card, pendingByCard, liveCards, priority = 0 }) { if (!(card instanceof HTMLElement)) { return; } @@ -515,6 +515,9 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { } setBadge(card, "checking"); + // Badge callbacks can synchronously dispose/rerender the feed. Do not create + // a new request after its unregister hook has already run. + if (!liveCards.has(card)) return; const encodedUrl = card.dataset.urlHealthUrl || ""; const webSeed = encodedUrl ? decodeURIComponent(encodedUrl) : ""; @@ -601,6 +604,7 @@ const gridCardObserver = createCardObserver({ handleCardVisible({ card, pendingByCard: state.pendingByCard, + liveCards: state.liveCards, priority: PRIORITY_BASELINE, }); } @@ -624,6 +628,7 @@ const gridCardObserver = createCardObserver({ handleCardVisible({ card, pendingByCard: state.pendingByCard, + liveCards: state.liveCards, priority, }); }, @@ -634,16 +639,23 @@ export function attachHealthBadges(container, options = {}) { return () => {}; } const listener = - typeof options?.onUpdate === "function" - ? (payload) => options.onUpdate(payload) - : null; - if (listener) badgeUpdateListeners.add(listener); + typeof options?.onUpdate === "function" ? options.onUpdate : null; + if (listener) { + badgeUpdateListeners.set( + listener, + (badgeUpdateListeners.get(listener) || 0) + 1 + ); + } gridCardObserver.observe(container); let disposed = false; return () => { if (disposed) return; disposed = true; - if (listener) badgeUpdateListeners.delete(listener); + if (listener) { + const remaining = badgeUpdateListeners.get(listener) - 1; + if (remaining > 0) badgeUpdateListeners.set(listener, remaining); + else badgeUpdateListeners.delete(listener); + } gridCardObserver.disconnect(container); }; } diff --git a/test_logs/TEST_LOG_2026-10-19_21-59-00_client-reliability-3-review.md b/test_logs/TEST_LOG_2026-10-19_21-59-00_client-reliability-3-review.md new file mode 100644 index 00000000..29579d14 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-19_21-59-00_client-reliability-3-review.md @@ -0,0 +1,10 @@ +# client-reliability-3 final lifecycle review + +- At712b05cf, final lint11/11 and build/artifact verification passed; full426-file run was interrupted130 after133 file exits, before file134 completed. No full green claimed for that partial run. +- Final caller review found app.boundStreamHealthBadgeHandler is shared across feed registrations. Preserve the existing global dedup contract with per-callback registration counts rather than per-registration callback wrappers. New shared-listener scenario checks one notification, survival after one owner disposes, and no notifications after the last owner disposes. Both duplicate-listener and unconditional-release mutants fail naturally (exit1,1 failed,0 cancelled). +- New actual callback-disposal reproduction: onUpdate disposes feed at checking, but old code still submits1 probe after unregister. Added post-notification live-card guard; missing-guard mutant fails naturally (exit1,1 failed,0 cancelled). No shared observer/VideoListView code edits needed; all callers read only. +- Corrected lifecycle suite11/11 passes naturally; total focused count now27 scenarios across4 files. New scoped correction does not change queue/cache/concurrency policy or active probes. Existing11 mutants plus these3 variants total14 rejected/restored variants. +- Browser/build/lint and full gates rerun below on final correction; earlier built-dist browser evidence belongs to the prior product tree and is not substituted for the corrected build. +- Final14-variant rerun against corrected listener/reentrant tree: all14 killed (each exit1/1 failed/0 cancelled), source restored. Late-entry case additionally asserts no stale disposed-card badge event reaches a remaining live listener. +- Corrected build/artifact verification exit0 and lint11/11 exit0. Corrected Chromium/Firefox native-observer smoke exit0: each4 active/0 obsolete starts, shared live healthy/disposed checking/0 stale updates, healthy hidden reveal, both source visibility fallbacks true,0 page errors/0 attempted external requests. Final log /tmp/client-reliability-3-browser-corrected.log; server/browsers closed and browser reservation released. +- Final direct focused runs after full mutant restoration: lifecycle11/11 (513.51ms), priority6/6 (381.59ms), visibility9/9 (394.34ms), observer1/1 (225.61ms), each exit0 naturally with0 failed/cancelled/skipped. Total27 scenarios across4 files. No source changes follow these focused checks. diff --git a/tests/grid-health-lifecycle.test.mjs b/tests/grid-health-lifecycle.test.mjs index 563e1340..f6bd78bb 100644 --- a/tests/grid-health-lifecycle.test.mjs +++ b/tests/grid-health-lifecycle.test.mjs @@ -210,6 +210,59 @@ test("disposing one of two queued source owners preserves the live owner", async ); }); +test("disposal from a checking callback prevents probe submission", async (t) => { + const f = fixture(t); + const feed = f.feed([magnet()], { + onUpdate: ({ state }) => { + if (state === "checking") feed.dispose(); + } + }); + feed.observer.show(); + await flush(); + assert.equal( + f.calls.length, + 0, + "checking notification must not submit work after synchronous disposal" + ); +}); + +test("shared badge listener stays deduplicated until its last registration disposes", async (t) => { + const f = fixture(t); + const updates = []; + const listener = (event) => updates.push(event); + const first = f.feed([magnet()], { onUpdate: listener }); + const live = f.feed([magnet()], { onUpdate: listener }); + first.observer.show(); + assert.equal( + updates.length, + 1, + "same callback must receive each update once across registrations" + ); + first.dispose(); + updates.length = 0; + live.observer.show(); + assert.equal( + updates.length, + 1, + "disposing one registration must retain the shared callback" + ); + f.calls[1].finish(); + await flush(); + assert.deepEqual( + updates.map((event) => event.state), + ["checking", "healthy"] + ); + live.dispose(); + updates.length = 0; + const other = f.feed([magnet()]); + other.observer.show(); + assert.equal( + updates.length, + 0, + "last listener owner disposal must stop later notifications" + ); +}); + test("cancelled source can be re-requested before old settlement without cache poison or duplicate work", async (t) => { const f = fixture(t); f.saturate(); @@ -317,9 +370,17 @@ test("active probe remains shared while disposed owners and late observer entrie const first = f.feed([source]); first.observer.show(); first.dispose(); + const liveUpdates = []; + const live = f.feed([source], { + onUpdate: (event) => liveUpdates.push(event) + }); first.observer.show(); // An already-delivered entry may arrive after disconnect. - const live = f.feed([source]); live.observer.show(); + assert.equal( + liveUpdates.some((event) => event.card === first.cards[0]), + false, + "late observer entries must not emit disposed-card badge events" + ); assert.equal( f.calls.length, 1, diff --git a/todo/TODO_2026-10-19_22-02-00_client-reliability-3.md b/todo/TODO_2026-10-19_22-02-00_client-reliability-3.md new file mode 100644 index 00000000..e604cb54 --- /dev/null +++ b/todo/TODO_2026-10-19_22-02-00_client-reliability-3.md @@ -0,0 +1,6 @@ +- [x] Actual disposal reproduction1→0 obsolete starts, queued/shared/active lifecycle preservation. +- [x] Caller review preserves shared-handler dedup and checks synchronous checking-callback disposal1→0 starts. +- [x] Focused27/27 across4 files (11 lifecycle+6 priority+9 visibility+1 observer),14 reverted mutants killed. +- [x] Corrected product lint11/11, build/artifact verification and Chromium/Firefox built-dist/native-observer smoke pass; browser released. +- [ ] Commit corrected tree, merge unstable, restart full426-file60s gate and final lint; normal queue/durable landing/ancestry, dispatch landing event, close/final report. +- Not qualified: actual media/torrent fallback, live peers, CPU/RSS, URL cancellation or active abort. Dispatch owns preview refresh. No new item without routing.