diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index 90ba84e7..809aeb65 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -663,3 +663,71 @@ test_integrity_note: Original concurrent-promise, pool-ready and post-init no-throw assertions remain. The old empty connection result accidentally modeled failure; init correctly scheduled manager-owned retries that bypassed its client-level stub. The fixture now models successful init at the manager boundary and releases its feed subscription. This is harness cleanup, not a Nostr product change. Mutants: a test-local historical early-return init variant fails the pending-barrier assertion (exit1, 1 failed); a connection-failure fixture variant fails the no-retry assertion (exit1, 1 failed). Both exit naturally and are reverted. No product file was edited for either mutant. + +--- + +# client-reliability-3 + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-last-probe-owner-disposal + given: "five real DOM cards register through actual gridHealth/cardObserver; four probes are active and one queued" + when: "the feed is disposed/removed and active probes finish" + then: "zero obsolete probes start" + - id: SCN-shared-queued-owner + given: "two cards share one queued torrent source" + when: "one registration disposes while its DOM remains connected" + then: "the live owner still probes and becomes healthy; disposed owner receives no stale UI/callback" + - id: SCN-cancelled-replacement-inflight + given: "last owner cancels queued work then a replacement requests the same source before old settlement" + when: "old completion drains and another owner joins before a slot frees" + then: "no unhealthy cancellation cache is written, only one new probe starts, and both replacements become healthy/cacheable" + - id: SCN-probe-priority-concurrency + given: "four active probes, queued mixed priorities and a cancelled source" + when: "slots free one at a time" + then: "four-slot maximum and stable priority order remain; cancelled work never starts" + - id: SCN-hidden-healthy-reveal + given: "a hide-all card is hidden pending liveness" + when: "registration eagerly probes and returns healthy" + then: "raw magnet/webseed bytes reach probe IO and the card reveals without an intersection" + - id: SCN-active-shared-late-entry + given: "an active source loses its owner, receives a late observer entry and gains a live owner" + when: "the unchanged active probe completes" + then: "no duplicate starts or disposed result writes; live owner becomes healthy" + - id: SCN-repeat-disposal-reattach + given: "a disposed container has a new queued registration" + when: "the old disposer is called again" + then: "new owner survives and becomes healthy" + - id: SCN-ownerless-active-cache + given: "an already active probe has no remaining owner" + when: "it finishes before a later live card requests that source" + then: "the active result remains cached; no new probe or stale UI write occurs" + - id: SCN-existing-probe-cache-ttl + given: "completed healthy and negative results" + when: "cards request before, at, and after the existing TTL boundary" + then: "both cached outcomes reuse until the existing inclusive TTL expires, then both probe again" + observable_outcomes: + - "actual probePeers start count/order, arguments and maximum active count" + - "real DOM badge health/hidden state, observer disposal and callback counts" + - "storage cache absence for cancellation and reuse/expiry for completed probes" + determinism_controls: + - "actual gridHealth/cardObserver and DOM, controllable observer and deferred probePeers IO" + - "explicit viewport geometry, unique sources and isolated storage; fake Date.now only for TTL scenario" + - "task-turn promise draining without wall-clock sleeps, polling or retries" + - "native Chromium/Firefox observer smoke against built dist, external traffic blocked" + anti_cheat_rationale: + prevents: + - "unconditional queue cancellation: shared-owner mutant fails" + - "no-op cancellation, stale inflight deletion or cancelled cache poisoning" + - "changed concurrency/priority or hidden eager path" + - "stale result/listener/observer effects and repeated old disposal" + - "cancelling or dropping cache from already active work" + - "timeout/retry/sleep masking or hard-coded healthy results" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +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. diff --git a/context/CONTEXT_2026-10-19_21-52-00_client-reliability-3.md b/context/CONTEXT_2026-10-19_21-52-00_client-reliability-3.md new file mode 100644 index 00000000..8492bb2d --- /dev/null +++ b/context/CONTEXT_2026-10-19_21-52-00_client-reliability-3.md @@ -0,0 +1,4 @@ +- Goal: cancel unstarted torrent health probes when last feed/card owner unregisters, preserving live shared owners and active probes. +- Scope: gridHealth.js, optional queue helper and focused tests/integrity/area notes. Excludes app, URL health, webtorrent implementation, playback/magnet helpers, cache TTLs/concurrency, runner/kit. Dispatch completion mailbox required; no scheduler. +- Revalidated unchanged gridHealth/cardObserver from filedAtf9adfc8f through startup66832dc3. Adapted actual dispatch five-card reproduction to own imports:4 start,1 queues, dispose/remove then release ->1 obsolete start; assertion exit1. +- Plan: introduce cancellable pending queue records and per-card leases; release at existing onCardUnregister; preserve active completion and cache, identity-check inflight deletion; reject stale observer/UI effects; scenario tests for shared owners, cancellation/re-request race, priority/concurrency, hidden reveal; grouped mutants; focused/full60s-file/lint/build/browser; commit/merge/normal landq/ancestry then close and report. diff --git a/decisions/DECISIONS_2026-10-19_21-55-00_client-reliability-3.md b/decisions/DECISIONS_2026-10-19_21-55-00_client-reliability-3.md new file mode 100644 index 00000000..05ac3678 --- /dev/null +++ b/decisions/DECISIONS_2026-10-19_21-55-00_client-reliability-3.md @@ -0,0 +1,9 @@ +# client-reliability-3: queued probe ownership + +- Actual baseline at66832dc3 (source unchanged from filedAtf9adfc8f): actual attachHealthBadges + DOM + observer, four deferred probePeers calls active and fifth queued; disposing/removing feed then releasing active work starts one obsolete probe (expected0), assertion exit1. +- Queue records own a Set of live card requests. onCardUnregister releases the matching request; only an unstarted job with no remaining owner can cancel. Cancellation resolves null, skips normalization/cache, and removes inflight synchronously. Promise finalization checks record identity so an old cancellation cannot delete replacement inflight work. +- Active jobs are never aborted or removed from inflight on disposal; their normal cache result remains usable. Existing raw magnets/webseeds, four-slot concurrency, stable priority ordering, healthy/negative TTL remain unchanged. +- Live-card membership rejects late observer entries. Each request also guards result writes by ownership/identity even while disposed DOM remains connected. Per-registration badge listener removal and idempotent disposer prevent stale callbacks or an old disposer unregistering a newly attached feed. +- Existing cardObserver unregister hook is sufficient; no shared helper, URL health, WebTorrent implementation, app, cache policy, concurrency, styling, runner or kit edits. +- Browser smoke uses actual built modules/native observers and fake external probe IO only; both Chromium and Firefox pass. No external request attempted. Hosted-only/torrent-only source visibility checked; real playback fallback, live torrent peers, CPU/RSS and active abort behavior unmeasured/out of scope. +- Fixture correction before acceptance: use explicit viewport geometry so observer priorities are deterministic; initial unpinned JSDOM viewport reversed two fixture assumptions. No existing assertion changed. Eleven mutants independently kill the nine grouped lifecycle scenarios (details in integrity note). diff --git a/docs/TODO.md b/docs/TODO.md index b9acc40d..455adcb7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -14,3 +14,5 @@ - 2026-10-19: client-reliability-1 and client-reliability-2 ready for normal landing at181eae95: profile27/27 and race1/1 natural exit; all425 unit files passed with60s/file bound; lint11/11. Four deliberately broken variants fail and are reverted. Dispatch routing unblocked the second item; no product Nostr/shared-runner changes. Remaining: durable normal queue landing, unstable verification, then backlog close. Browser/build and real IO unverified (test-only scope). See test_logs/TEST_LOG_2026-10-19_20-35-00_client-reliability-both.md. - 2026-10-19: client-reliability-1 and client-reliability-2 finished: durable normal-gate landing de6918ce (submission683cedf0), gate exit0 and unstable ancestry verified. Profile27/27, race1/1 natural exit, lint11/11, full425/425 file exits0; all four mutants rejected. Backlog closed after landing. No unfinished product work for these items; browser/build/real IO unverified (test-only scope). Closure metadata queued separately; see test_logs/TEST_LOG_2026-10-19_20-34-50_client-reliability-landed.md. + +- 2026-10-19: client-reliability-3 implemented at current worktree: actual five-card obsolete starts1→0 after last-owner disposal; shared queued/live owners and active probe cache preserved; inflight identity and stale registration guards. Focused25/25 across4 files,11 mutants rejected, lint11/11, build/artifact verification pass, native Chromium/Firefox built-dist lifecycle smoke pass with0 external requests/page errors. Remaining: committed merged-tree full426-file gate, normal landq, durable ancestry/close and dispatch events. Active abort, URL probes, actual media fallback/peers and CPU/RSS unverified/out of scope; entire Stage3 performance plan is not complete. diff --git a/docs/agents/backlog/client-reliability-3.json b/docs/agents/backlog/client-reliability-3.json index 5f74ed38..73eb0841 100644 --- a/docs/agents/backlog/client-reliability-3.json +++ b/docs/agents/backlog/client-reliability-3.json @@ -1,7 +1,7 @@ { "id": "client-reliability-3", "area": "client-reliability", - "status": "open", + "status": "in-progress", "title": "Cancel queued torrent health probes when their last feed owner is disposed", "blockedOn": null, "addedOn": "2026-10-19", diff --git a/js/gridHealth.js b/js/gridHealth.js index 3e6e39fe..bbe57fc4 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -183,41 +183,47 @@ function normalizeResult(result) { }; } -function queueProbe(magnet, cacheKey, priority = 0, webSeeds = []) { +function queueProbe(magnet, cacheKey, priority = 0, webSeeds = [], owner) { if (!magnet) { - return Promise.resolve(null); + return { promise: Promise.resolve(null), owners: new Set() }; } const cached = getCacheEntry(cacheKey); if (cached) { - return Promise.resolve(cached); + return { promise: Promise.resolve(cached), owners: new Set() }; } if (probeInflight.has(cacheKey)) { - return probeInflight.get(cacheKey); + const record = probeInflight.get(cacheKey); + record.owners.add(owner); + return record; } - const job = probeQueue - .run( - () => - torrentClient - .probePeers(magnet, { - timeoutMs: PROBE_TIMEOUT_MS, - maxWebConns: 2, - polls: PROBE_POLL_COUNT, - urlList: webSeeds, - }) - .catch((err) => ({ - healthy: false, - peers: 0, - reason: "error", - error: err, - appendedTrackers: false, - hasProbeTrackers: false, - })), - priority - ) + const record = { owners: new Set([owner]), cancelled: false }; + record.job = probeQueue.run( + () => + torrentClient + .probePeers(magnet, { + timeoutMs: PROBE_TIMEOUT_MS, + maxWebConns: 2, + polls: PROBE_POLL_COUNT, + urlList: webSeeds, + }) + .catch((err) => ({ + healthy: false, + peers: 0, + reason: "error", + error: err, + appendedTrackers: false, + hasProbeTrackers: false, + })), + priority + ); + record.promise = record.job.promise .then((result) => { + if (record.cancelled) { + return null; + } const normalized = normalizeResult(result); if (cacheKey) { setCacheEntry(cacheKey, normalized); @@ -225,16 +231,29 @@ function queueProbe(magnet, cacheKey, priority = 0, webSeeds = []) { return normalized; }) .finally(() => { - if (cacheKey) { + if (cacheKey && probeInflight.get(cacheKey) === record) { probeInflight.delete(cacheKey); } }); if (cacheKey) { - probeInflight.set(cacheKey, job); + probeInflight.set(cacheKey, record); } - return job; + return record; +} + +function releaseProbe(record, owner, cacheKey) { + owner.active = false; + record.owners.delete(owner); + // A running probe keeps its existing lifecycle and cache behavior. Only work + // still waiting for a slot can be cancelled when its last card unregisters. + if (!record.owners.size && record.job?.cancel()) { + record.cancelled = true; + if (probeInflight.get(cacheKey) === record) { + probeInflight.delete(cacheKey); + } + } } const PROBE_CACHE_TTL_MS = FIVE_MINUTES_MS; @@ -255,9 +274,10 @@ class ProbeQueue { } run(task, priority = 0) { - return new Promise((resolve, reject) => { + let job; + const promise = new Promise((resolve, reject) => { const normalizedPriority = Number.isFinite(priority) ? priority : 0; - const job = { + job = { task, resolve, reject, @@ -266,6 +286,18 @@ class ProbeQueue { this.enqueue(job); this.drain(); }); + return { + promise, + cancel: () => { + const index = this.queue.indexOf(job); + if (index < 0) { + return false; + } + this.queue.splice(index, 1); + job.resolve(null); + return true; + }, + }; } enqueue(job) { @@ -432,10 +464,13 @@ function setBadge(card, state, details) { card, state: normalizedState, peers: hasPeerCount ? peersValue : null, - reason: details && typeof details.reason === "string" ? details.reason : null, + reason: + details && typeof details.reason === "string" ? details.reason : null, webseedOnly, checkedAt: - details && Number.isFinite(details.checkedAt) ? Number(details.checkedAt) : null, + details && Number.isFinite(details.checkedAt) + ? Number(details.checkedAt) + : null, text: badge.textContent, tooltip, role: entry.role, @@ -473,8 +508,8 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { const cachedState = cached.healthy ? "healthy" : cached.hasProbeTrackers - ? "unhealthy" - : "unknown"; + ? "unhealthy" + : "unknown"; setBadge(card, cachedState, cached); return; } @@ -485,13 +520,19 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { const webSeed = encodedUrl ? decodeURIComponent(encodedUrl) : ""; const webSeeds = webSeed ? [webSeed] : []; - const probePromise = queueProbe(magnet, infoHash, priority, webSeeds); + const owner = { active: true }; + const record = queueProbe(magnet, infoHash, priority, webSeeds, owner); + owner.release = () => releaseProbe(record, owner, infoHash); - pendingByCard.set(card, probePromise); + pendingByCard.set(card, owner); - probePromise + record.promise .then((result) => { + if (!owner.active || pendingByCard.get(card) !== owner) { + return; + } pendingByCard.delete(card); + record.owners.delete(owner); if (!card.isConnected) { return; } @@ -510,11 +551,15 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { setBadge(card, "unhealthy", result); }) .catch((err) => { - userLogger.warn("[gridHealth] probe failed", err); + if (!owner.active || pendingByCard.get(card) !== owner) { + return; + } pendingByCard.delete(card); + record.owners.delete(owner); if (!card.isConnected) { return; } + userLogger.warn("[gridHealth] probe failed", err); setBadge(card, "unhealthy"); }); } @@ -522,7 +567,10 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { const gridCardObserver = createCardObserver({ rootMargin: ROOT_MARGIN, threshold: 0.01, - createState: () => ({ pendingByCard: new WeakMap() }), + createState: () => ({ + pendingByCard: new WeakMap(), + liveCards: new WeakSet(), + }), prepareEntries: (entries) => { const viewportCenter = getViewportCenter(); return prioritizeEntries(entries, viewportCenter); @@ -532,10 +580,12 @@ const gridCardObserver = createCardObserver({ return false; } const isIntersecting = Boolean(entry.isIntersecting); - const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + const ratio = + typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; return isIntersecting && ratio > 0; }, onCardRegister: ({ card, state }) => { + state.liveCards.add(card); if (!card.dataset.magnet) { setBadge(card, "unhealthy", { reason: "missing-source" }); return; @@ -555,8 +605,16 @@ const gridCardObserver = createCardObserver({ }); } }, + onCardUnregister: ({ card, state }) => { + state.liveCards.delete(card); + const owner = state.pendingByCard.get(card); + if (owner) { + owner.release(); + state.pendingByCard.delete(card); + } + }, onCardVisible: ({ card, meta, state }) => { - if (!state || !(state.pendingByCard instanceof WeakMap)) { + if (!state?.liveCards.has(card)) { return; } const priority = @@ -575,9 +633,17 @@ export function attachHealthBadges(container, options = {}) { if (!(container instanceof HTMLElement)) { return () => {}; } - if (options && typeof options.onUpdate === "function") { - badgeUpdateListeners.add(options.onUpdate); - } + const listener = + typeof options?.onUpdate === "function" + ? (payload) => options.onUpdate(payload) + : null; + if (listener) badgeUpdateListeners.add(listener); gridCardObserver.observe(container); - return () => gridCardObserver.disconnect(container); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + if (listener) badgeUpdateListeners.delete(listener); + gridCardObserver.disconnect(container); + }; } diff --git a/test_logs/TEST_LOG_2026-10-19_21-55-00_client-reliability-3.md b/test_logs/TEST_LOG_2026-10-19_21-55-00_client-reliability-3.md new file mode 100644 index 00000000..83b6e1ae --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-19_21-55-00_client-reliability-3.md @@ -0,0 +1,14 @@ +# client-reliability-3 pre-commit evidence (startup66832dc3) + +- Adapted /tmp/bitvid-dispatch-obsolete-probe-scenario.mjs imports to this worktree. Baseline: four initial active probes, observer disconnected,1 obsolete start after feed disposal/removal, exit1 assertion1!==0. Source unchanged since filedAtf9adfc8f. +- Fixed same actual badge/observer scenario: four initial active,0 obsolete starts, exit0. No live torrent traffic; only probePeers boundary deferred. +- Initial focused draft:5/7 pass;2 fixture expectations exposed default viewport ordering. Explicit viewport geometry controls priority; expectations unchanged. Direct final focused logs record9 lifecycle and6 priority scenarios, all exit0 naturally; related source visibility and card observer suites also exit0 (counts below). +- Intermediate node --test invocation reported two file wrappers, not scenario totals; use direct per-file readable reporters for authoritative scenario counts. +- Eleven mutations: cancel, shared-owner, inflight identity, priority, hidden eager path, stale result, TTL/cache write, repeated disposer, late observer entry, cancelled-cache normalization, ownerless-active cache. Each selected scenario exits1 naturally,1 failed/0 passed/cancelled. Restored source after all variants; no product files outside gridHealth.js touched. +- `npm run lint` unrestricted: exit0,11 stages. Sandbox git subprocess restrictions are known; no gate change. +- `npm run build`: exit0 including deployment artifact verification. Existing Browserslist data-age warning; dependency update not attempted. +- Initial browser smoke sandbox attempt: exit1, loopback listen EPERM; authorized unrestricted smoke exits0, closes its ephemeral server/browsers, does not touch port3000. +- Chromium and Firefox, built dist modules with native observers and virtual probe IO: each4 active/0 obsolete starts, live shared owner healthy/disposed owner checking/0 stale updates, hidden eager probe and reveal, torrent-only and hosted-only source visibility true,0 page errors and0 attempted external requests. Script /tmp/client-reliability-3-browser.mjs, log /tmp/client-reliability-3-browser-unrestricted.log. +- GitHub overlap API check: exit6 DNS unavailable; no PR created and dispatch reserves this scope. +- No CPU/RSS benefit or real media/torrent fallback claimed. Active probe cancellation, live peers, URL checks, signer/wallet/production/deployment unverified/out of scope. Full merged-tree unit/landing gates follow. +- Final direct per-file focused runs: lifecycle9/9 (225.86ms), existing priority6/6 (136.30ms), source visibility9/9 (211.62ms), observer disposal1/1 (135.19ms); all exit0 naturally,0 failed/cancelled/skipped. Total25 scenarios across4 files. Final source restored after all mutants. diff --git a/tests/grid-health-lifecycle.test.mjs b/tests/grid-health-lifecycle.test.mjs new file mode 100644 index 00000000..563e1340 --- /dev/null +++ b/tests/grid-health-lifecycle.test.mjs @@ -0,0 +1,412 @@ +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { torrentClient } from "../js/webtorrent.js"; +import { attachHealthBadges } from "../js/gridHealth.js"; +import { + FIVE_MINUTES_MS, + setCardLivenessPolicy, + getCardLivenessPolicy +} from "../js/constants.js"; +import { readTorrentProbeFromStorage } from "../js/utils/storage.js"; + +const healthy = { + healthy: true, + peers: 1, + reason: "peer", + hasProbeTrackers: true +}; +let nextHash = 1; +const magnet = () => + "magnet:?xt=urn:btih:" + (nextHash++).toString(16).padStart(40, "0"); +// A task-turn boundary drains the queue's promise continuations and badge writes; +// no wall-clock delay, polling or live network is involved. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function fixture(t) { + const dom = new JSDOM("", { + url: "https://example.invalid" + }); + Object.defineProperty(dom.window, "innerHeight", { value: 20 }); + const previousPolicy = getCardLivenessPolicy(); + const saved = new Map( + [ + "window", + "document", + "HTMLElement", + "localStorage", + "IntersectionObserver", + "__BITVID_CARD_LIVENESS_POLICY__" + ].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]) + ); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + localStorage: dom.window.localStorage + }); + const observers = []; + class Observer { + constructor(callback) { + this.callback = callback; + this.cards = []; + observers.push(this); + } + observe(card) { + this.cards.push(card); + } + takeRecords() { + return []; + } + disconnect() { + this.disconnected = true; + } + show() { + // Intentionally allow late delivery after disconnect to test stale entries. + this.callback( + this.cards.map((target, index) => ({ + target, + isIntersecting: true, + intersectionRatio: 1, + intersectionRect: { top: index * 20, width: 100, height: 20 } + })) + ); + } + } + globalThis.IntersectionObserver = Observer; + const calls = []; + const disposals = []; + let active = 0; + let peakActive = 0; + t.mock.method(torrentClient, "probePeers", (source, options) => { + active += 1; + peakActive = Math.max(peakActive, active); + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + const call = { + source, + options, + settled: false, + finish(result = healthy) { + if (call.settled) return; + call.settled = true; + active -= 1; + resolve(result); + } + }; + calls.push(call); + return promise; + }); + setCardLivenessPolicy("show-pending"); + t.after(async () => { + disposals.forEach((dispose) => dispose()); + // Disposing removes queued owners; only the controlled active calls remain. + calls.forEach((call) => call.finish()); + await flush(); + setCardLivenessPolicy(previousPolicy); + dom.window.close(); + for (const [key, descriptor] of saved) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + function feed(sources, options = {}) { + const container = document.createElement("section"); + const cards = sources.map((source) => { + const card = document.createElement("article"); + card.className = "card"; + card.dataset.videoId = source; + card.dataset.magnet = source; + if (options.hidden) { + card.hidden = true; + card.dataset.urlHealthUrl = encodeURIComponent( + "https://media.example.invalid/video.mp4" + ); + card.dataset.urlHealthState = "checking"; + } + const badge = document.createElement("span"); + badge.className = "torrent-health-badge"; + card.appendChild(badge); + container.appendChild(card); + return card; + }); + document.body.appendChild(container); + const dispose = attachHealthBadges(container, options); + disposals.push(dispose); + return { container, cards, dispose, observer: observers.at(-1) }; + } + function saturate() { + const blockers = feed(Array.from({ length: 4 }, magnet)); + blockers.observer.show(); + assert.equal( + calls.length, + 4, + "current four-probe concurrency must be saturated" + ); + return blockers; + } + function reattach(feed) { + const dispose = attachHealthBadges(feed.container); + disposals.push(dispose); + return { ...feed, dispose, observer: observers.at(-1) }; + } + return { + feed, + reattach, + saturate, + calls, + get peakActive() { + return peakActive; + } + }; +} + +test("last feed owner disposal prevents the fifth queued probe from starting", async (t) => { + const f = fixture(t); + const feed = f.feed(Array.from({ length: 5 }, magnet)); + feed.observer.show(); + assert.equal(f.calls.length, 4); + feed.dispose(); + feed.container.remove(); + assert.equal(feed.observer.disconnected, true); + f.calls.forEach((call) => call.finish()); + await flush(); + assert.equal(f.calls.length, 4, "obsolete queued source must never start"); +}); + +test("disposing one of two queued source owners preserves the live owner", async (t) => { + const f = fixture(t); + f.saturate(); + const source = magnet(); + const disposedUpdates = []; + const first = f.feed([source], { + onUpdate: (event) => disposedUpdates.push(event.state) + }); + const second = f.feed([source]); + first.observer.show(); + second.observer.show(); + assert.equal(f.calls.length, 4); + first.dispose(); // Keep DOM connected: stale writes must be guarded by ownership. + disposedUpdates.length = 0; + f.calls[0].finish(); + await flush(); + assert.equal( + f.calls.length, + 5, + "remaining live owner must retain the shared queued probe" + ); + assert.equal(f.calls[4].source, source); + f.calls[4].finish(); + await flush(); + assert.equal(second.cards[0].dataset.streamHealthState, "healthy"); + assert.equal(first.cards[0].dataset.streamHealthState, "checking"); + assert.deepEqual( + disposedUpdates, + [], + "disposed registration must receive no later badge 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(); + const source = magnet(); + const first = f.feed([source]); + first.observer.show(); + first.dispose(); + const replacement = f.feed([source]); + replacement.observer.show(); // New inflight record before cancelled promise settles. + await flush(); + assert.equal( + readTorrentProbeFromStorage(source.slice(-40)), + null, + "cancelled job must not cache an unhealthy result" + ); + assert.equal(replacement.cards[0].dataset.streamHealthState, "checking"); + const sharedReplacement = f.feed([source]); + sharedReplacement.observer.show(); // Must still find the replacement inflight record. + f.calls.forEach((call) => call.finish()); + await flush(); + assert.equal( + f.calls.filter((call) => call.source === source).length, + 1, + "old finalizer must not delete newer inflight work" + ); + f.calls.at(-1).finish(); + await flush(); + for (const feed of [replacement, sharedReplacement]) { + assert.equal(feed.cards[0].dataset.streamHealthState, "healthy"); + } + const cached = f.feed([source]); + cached.observer.show(); + assert.equal(cached.cards[0].dataset.streamHealthState, "healthy"); + assert.equal( + f.calls.length, + 5, + "successful replacement must retain normal cache reuse" + ); +}); + +test("queue cancellation preserves stable priority and concurrency of four", async (t) => { + const f = fixture(t); + f.saturate(); + const [first, low, high, cancelled] = Array.from({ length: 4 }, magnet); + const batch = f.feed([first, low]); + batch.observer.show(); // First gets baseline, low gets baseline minus one. + const newer = f.feed([high]); + newer.observer.show(); // Baseline: follows the earlier equal-priority first. + const obsolete = f.feed([cancelled]); + obsolete.observer.show(); + obsolete.dispose(); + for (const [slot, expected] of [ + [0, first], + [4, high], + [5, low] + ]) { + f.calls[slot].finish(); + await flush(); + assert.equal(f.calls.at(-1).source, expected); + } + assert.equal( + f.peakActive, + 4, + "cancellation must not free active slots prematurely" + ); + assert.deepEqual( + f.calls.slice(4).map((call) => call.source), + [first, high, low] + ); + assert.equal( + f.calls.some((call) => call.source === cancelled), + false + ); +}); + +test("healthy hidden card probes eagerly and reveals without an intersection", async (t) => { + const f = fixture(t); + setCardLivenessPolicy("hide-all"); + const source = magnet() + "&tr=wss%3A%2F%2Ftracker.example.invalid"; + const feed = f.feed([source], { hidden: true }); + assert.equal( + f.calls.length, + 1, + "hidden card must eagerly probe on registration" + ); + assert.equal(feed.cards[0].hidden, true); + assert.deepEqual(f.calls[0].options.urlList, [ + "https://media.example.invalid/video.mp4" + ]); + assert.equal( + f.calls[0].source, + source, + "magnet bytes must reach the probe unchanged" + ); + f.calls[0].finish(); + await flush(); + assert.equal(feed.cards[0].dataset.streamHealthState, "healthy"); + assert.equal(feed.cards[0].hidden, false); + assert.equal(feed.cards[0].dataset.sourceVisibility, "visible"); +}); + +test("active probe remains shared while disposed owners and late observer entries stay inert", async (t) => { + const f = fixture(t); + const source = magnet(); + const first = f.feed([source]); + first.observer.show(); + first.dispose(); + first.observer.show(); // An already-delivered entry may arrive after disconnect. + const live = f.feed([source]); + live.observer.show(); + assert.equal( + f.calls.length, + 1, + "already active probe must remain deduplicated" + ); + f.calls[0].finish(); + await flush(); + assert.equal(first.cards[0].dataset.streamHealthState, "checking"); + assert.equal(live.cards[0].dataset.streamHealthState, "healthy"); +}); + +test("repeated old disposal cannot cancel a reattached live registration", async (t) => { + const f = fixture(t); + f.saturate(); + const source = magnet(); + const old = f.feed([source]); + old.observer.show(); + old.dispose(); + const renewed = f.reattach(old); + renewed.observer.show(); + old.dispose(); + f.calls[0].finish(); + await flush(); + assert.equal( + f.calls.length, + 5, + "old disposer must not unregister the new queued owner" + ); + f.calls[4].finish(); + await flush(); + assert.equal(renewed.cards[0].dataset.streamHealthState, "healthy"); +}); + +test("disposed active completion remains cached for a later live owner", async (t) => { + const f = fixture(t); + const source = magnet(); + const old = f.feed([source]); + old.observer.show(); + old.dispose(); + f.calls[0].finish(); + await flush(); + assert.equal(old.cards[0].dataset.streamHealthState, "checking"); + const live = f.feed([source]); + live.observer.show(); + assert.equal( + f.calls.length, + 1, + "ownerless active completion must retain its healthy cache result" + ); + assert.equal(live.cards[0].dataset.streamHealthState, "healthy"); +}); + +test("completed healthy and negative probes retain existing cache TTL behavior", async (t) => { + const f = fixture(t); + let time = 1_000_000; + t.mock.method(Date, "now", () => time); + const sources = [magnet(), magnet()]; + const first = f.feed(sources); + first.observer.show(); + f.calls[0].finish(); + f.calls[1].finish({ + healthy: false, + peers: 0, + hasProbeTrackers: true, + reason: "timeout" + }); + await flush(); + const cached = f.feed(sources); + cached.observer.show(); + assert.deepEqual( + cached.cards.map((card) => card.dataset.streamHealthState), + ["healthy", "unhealthy"] + ); + assert.equal(f.calls.length, 2); + time += FIVE_MINUTES_MS; + cached.observer.show(); + assert.equal( + f.calls.length, + 2, + "existing inclusive TTL boundary must remain cached" + ); + time += 1; + const expired = f.feed(sources); + expired.observer.show(); + assert.equal( + f.calls.length, + 4, + "both healthy and negative entries must expire under the existing TTL" + ); +}); diff --git a/todo/TODO_2026-10-19_21-55-00_client-reliability-3.md b/todo/TODO_2026-10-19_21-55-00_client-reliability-3.md new file mode 100644 index 00000000..f026182c --- /dev/null +++ b/todo/TODO_2026-10-19_21-55-00_client-reliability-3.md @@ -0,0 +1,6 @@ +- [x] Rules/mailbox/claim/revalidate; actual five-card baseline reproduces1 obsolete start. +- [x] Last-owner queued cancellation, shared owners, cancellation settlement/cache/inflight identity; active unchanged and stale registrations inert. +- [x] Nine focused lifecycle scenarios; six existing priority scenarios; related source visibility/observer suites. Eleven deliberately broken variants fail and are reverted. +- [x] Lint11/11, build/artifact verification; Chromium/Firefox built-dist/native-observer local smoke0 obsolete starts/0 stale updates/hidden reveal/source health visibility. +- [ ] Commit, merge unstable, full npm test60s/file and final lint; normal landing, durable status/ancestry, dispatch landing event, close, closure records and final mailbox event. +- Not verified: real media fallback or peers, active abort, CPU/RSS, URL checks, owner preview refresh/deploy. Dispatch owns preview rebuild.