mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
client-reliability-3: cancel queued torrent probes after last card owner disposes
This commit is contained in:
1 parent
66832dc3a9
commit
712b05cf87
9 files changed
+626
-45
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
+110
-44
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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("<!doctype html><body></body>", {
|
||||
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"
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user