mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix: cancel orphaned queued URL media probes (client-reliability-4)
This commit is contained in:
1 parent
d9bd2df6e8
commit
ce1381f500
7 files changed
+686
-44
No files matched your search
@@ -760,3 +760,80 @@ test_integrity_note:
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## client-reliability-4 — queued URL media ownership
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: ["new_tests"]
|
||||
scenarios:
|
||||
- id: SCN-url-last-owner
|
||||
given: "four cards, three active media and one queued"
|
||||
when: "last registration is disposed and an active slot frees"
|
||||
then: "queued promise settles null, no cache entry or obsolete media start"
|
||||
- id: SCN-url-shared-owner
|
||||
given: "two feeds share a queued event/source"
|
||||
when: "one feed disposes"
|
||||
then: "exactly one media probe serves the live badge; disposed connected badge stays checking"
|
||||
- id: SCN-url-last-shared-owner
|
||||
given: "two feeds share a queued event/source"
|
||||
when: "both feeds dispose"
|
||||
then: "queued promise settles null and never starts media"
|
||||
- id: SCN-url-cancel-rerequest
|
||||
given: "cancelled queued event/source has pending promise continuations"
|
||||
when: "a new feed requests the same source immediately"
|
||||
then: "new inflight record survives old settlement and live badge becomes healthy"
|
||||
- id: SCN-url-cancel-mirror
|
||||
given: "queued primary has a mirror"
|
||||
when: "last owner disposes"
|
||||
then: "cancelled work settles without mirror HEAD/media or stale UI/cache"
|
||||
- id: SCN-url-registration-reuse
|
||||
given: "hidden old feed queues an eager callback"
|
||||
when: "dispose, reuse container, deliver old callback and old disposer"
|
||||
then: "only the new event reaches onCheck and starts IO"
|
||||
- id: SCN-url-active-cache
|
||||
given: "active media belongs to a connected card"
|
||||
when: "dispose its registration and complete active media"
|
||||
then: "active result caches healthy, disposed badge stays checking, new card uses cache"
|
||||
- id: SCN-url-hidden-reveal
|
||||
given: "URL-only card is hidden pending health"
|
||||
when: "eager media confirmation succeeds"
|
||||
then: "card becomes visible with healthy badge"
|
||||
- id: SCN-url-media-limit
|
||||
given: "three active media probes plus FIFO queued work"
|
||||
when: "slot releases while a new card enters"
|
||||
then: "existing queued source runs first; active media stays three"
|
||||
- id: SCN-url-mirror-order
|
||||
given: "primary and two mirrors exist"
|
||||
when: "primary errors then first mirror succeeds"
|
||||
then: "primary then first mirror are requested; second mirror is untouched"
|
||||
- id: SCN-url-legacy-callback
|
||||
given: "connected badge lacks observer ownership"
|
||||
when: "legacy caller invokes controller"
|
||||
then: "media settles and healthy badge paints"
|
||||
- id: SCN-url-cross-controller
|
||||
given: "shared real inflight cache and two controllers"
|
||||
when: "creator feed disposes while other feed remains"
|
||||
then: "queued shared source starts once and other controller paints healthy"
|
||||
observable_outcomes:
|
||||
- "HEAD and native media IO starts, promise settlement, real cache/inflight records, connected badge and card visibility"
|
||||
- "Observer callback emissions after disposal and container reuse"
|
||||
determinism_controls:
|
||||
- "jsdom cards, controllable IntersectionObserver, explicit loadeddata/error events and stubbed fetch boundary; real observer/controller/cache"
|
||||
- "Unique event/source keys and task-turn drain; no live traffic, arbitrary sleeps, retries or relaxed assertions"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "hard-coded healthy or cancelled results"
|
||||
- "testing a copied queue instead of real controller and observer"
|
||||
- "cancelling shared live work or caching cancellation as offline"
|
||||
- "stale connected DOM callbacks hidden by isConnected guards"
|
||||
- "retry/sleep-based flake masking"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
All existing assertions preserved. `tests/url-health-lifecycle.test.mjs` adds12 scenarios. Mutations are temporary edits restored before gates. `python3 /tmp/item4-mutants.py` final run:13/13 variants naturally exit1 with named assertion failures (no timeouts); logs `/tmp/item4-mutant-*.log`, results `/tmp/item4-mutants.json`. No production test runner/gate changes.
|
||||
|
||||
Coverage grouping: no-unregister→last owner; cancel-shared→shared owner/cross-controller; reuse-cancelled-inflight→immediate retry/new inflight; cache-cancellation→last/shared owner/retry/mirror cache outcomes; mirror-fallthrough→cancelled primary; grouped stale guards→shared/active disposed UI (remove both redundant owner guards together; two equivalent variants); eager-callback-stale→registration reuse; no-eager-reveal→hidden reveal; concurrency-four→three-media cap/FIFO; reverse-mirrors→mirror ordering; break-legacy-consumer→callback compatibility; controller-local-records→shared cache ownership across controllers. Initial mutation pass exposed callback-emission and first-source gaps; new assertions were strengthened, then all mutants rejected. Initial no-unregister full-file run failed assertions but hung in another scenario awaiting its intentionally broken promise; final targeted last-owner variant naturally exits1. No timeout is counted as successful mutant evidence.
|
||||
@@ -0,0 +1,7 @@
|
||||
# client-reliability-4 implementation
|
||||
|
||||
- Existing observer unregister boundary owns per-badge lifecycle tokens, read by controller via WeakMap. Existing app/feed callback arguments unchanged; direct legacy controller consumers remain live until settlement.
|
||||
- Module-scoped promise→ownership WeakMap mirrors shared real inflight cache across controllers. Last-owner release only cancels queued media jobs. Already-running media/cache behavior remains; no active HEAD/media/HLS/torrent abort.
|
||||
- Queue resumes reserve a slot synchronously to preserve three-media cap. Cancellation settles with an explicit internal outcome propagated through mirrors and omitted from health cache. Existing state-cache promise identity guard preserves immediate re-request.
|
||||
- Guards bind UI/RAF work to captured registration; late observer callbacks/eager microtasks and stale disposer cannot affect reused registrations.
|
||||
- No app/shared observer/cache-module or policy/TTL edits. Scope/read-only predecessor evidence at77d486fb; dispatch implementation routing merged before code.
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
## client-reliability
|
||||
|
||||
- 2026-10-20: item4 routed implementation in URL observer/controller complete locally;12 lifecycle scenarios and13 reverted mutants, focused/lint/build pass. Queued last-owner work settles without cache/mirror/stale UI; shared owners, immediate retry and hidden reveal covered. Remaining: native browser smoke/full merged-tree gate/normal landing/closure; active abort/fallback/CPU unverified.
|
||||
|
||||
- 2026-10-20: client-reliability-4 investigation at4c54858c reproduces queued URL media start after feed disposal: active3/queued1 before and after disposal, then1 obsolete start(expected0); shared live-owner control deduplicates and receives healthy badge. Strict assertion exits1, diagnostic/control naturally exit0. No product/tests changed; awaiting dispatch fix routing. Active abort/browser/fallback/CPU unverified. Evidence: test_logs/TEST_LOG_2026-10-20_03-10-00_client-reliability-4.md.
|
||||
|
||||
- 2026-10-19: client-reliability-1 assigned: reproduce/classify and fix profile-modal suite hang without weakening assertions. Initial evidence: three isolated 60s timeouts (one clean environment), full baseline stopped at file 220/425; read docs/agents/prompts/client-reliability.md and claim the item. Scope excludes app.js and unrelated subsystems.
|
||||
|
||||
+117
-39
@@ -1,17 +1,21 @@
|
||||
import { getUrlHealthBadgeOwner } from "../urlHealthObserver.js";
|
||||
import { SHORT_TIMEOUT_MS } from "../constants.js";
|
||||
import {
|
||||
isHlsUrl,
|
||||
prefersNativeHls,
|
||||
probeHlsManifest,
|
||||
probeHlsManifest
|
||||
} from "../services/hlsPlayback.js";
|
||||
|
||||
// Inflight cache is shared, so ownership also spans controller instances.
|
||||
const urlProbeRecords = new WeakMap();
|
||||
|
||||
export default class UrlHealthController {
|
||||
constructor({
|
||||
state = {},
|
||||
utils = {},
|
||||
logger = {},
|
||||
constants = {},
|
||||
callbacks = {},
|
||||
callbacks = {}
|
||||
} = {}) {
|
||||
this.state = state;
|
||||
this.utils = utils;
|
||||
@@ -44,7 +48,13 @@ export default class UrlHealthController {
|
||||
`;
|
||||
}
|
||||
|
||||
updateUrlHealthBadge(badgeEl, state, videoId) {
|
||||
updateUrlHealthBadge(
|
||||
badgeEl,
|
||||
state,
|
||||
videoId,
|
||||
owner = getUrlHealthBadgeOwner(badgeEl)
|
||||
) {
|
||||
if (owner && !owner.active) return;
|
||||
if (!badgeEl) {
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +71,7 @@ export default class UrlHealthController {
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
if (badgeEl.isConnected) {
|
||||
this.updateUrlHealthBadge(badgeEl, state, videoId);
|
||||
this.updateUrlHealthBadge(badgeEl, state, videoId, owner);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -74,18 +84,19 @@ export default class UrlHealthController {
|
||||
offline: "❌ CDN",
|
||||
unknown: "⚠️ CDN",
|
||||
timeout: "⚠️ CDN timed out",
|
||||
checking: "⏳ CDN",
|
||||
checking: "⏳ CDN"
|
||||
};
|
||||
const message =
|
||||
state?.message ||
|
||||
fallbackMessages[status] ||
|
||||
fallbackMessages.checking;
|
||||
state?.message || fallbackMessages[status] || fallbackMessages.checking;
|
||||
|
||||
const hadCompactMargin =
|
||||
badgeEl.classList.contains("mt-sm") || badgeEl.classList.contains("mt-3");
|
||||
badgeEl.dataset.urlHealthState = status;
|
||||
const cardEl = badgeEl.closest(".card[data-video-id]");
|
||||
if (cardEl && typeof this.utils.updateVideoCardSourceVisibility === "function") {
|
||||
if (
|
||||
cardEl &&
|
||||
typeof this.utils.updateVideoCardSourceVisibility === "function"
|
||||
) {
|
||||
cardEl.dataset.urlHealthState = status;
|
||||
this.utils.updateVideoCardSourceVisibility(cardEl);
|
||||
}
|
||||
@@ -104,7 +115,7 @@ export default class UrlHealthController {
|
||||
offline: "critical",
|
||||
unknown: "neutral",
|
||||
timeout: "neutral",
|
||||
checking: "neutral",
|
||||
checking: "neutral"
|
||||
};
|
||||
const variant = variantMap[status];
|
||||
if (variant) {
|
||||
@@ -129,7 +140,7 @@ export default class UrlHealthController {
|
||||
message,
|
||||
lastCheckedAt: Number.isFinite(state?.lastCheckedAt)
|
||||
? state.lastCheckedAt
|
||||
: undefined,
|
||||
: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -139,6 +150,8 @@ export default class UrlHealthController {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = getUrlHealthBadgeOwner(badgeEl) || { active: true };
|
||||
if (!owner.active) return;
|
||||
const eventId = video.id;
|
||||
const trimmedUrl = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmedUrl) {
|
||||
@@ -157,6 +170,8 @@ export default class UrlHealthController {
|
||||
|
||||
this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId);
|
||||
|
||||
if (!owner.active) return;
|
||||
|
||||
// Try the primary url plus any additional NIP-71 source mirrors, so one dead
|
||||
// host doesn't mark a video offline when an alternate plays.
|
||||
const sources = Array.isArray(video?.sources) ? video.sources : [];
|
||||
@@ -164,7 +179,9 @@ export default class UrlHealthController {
|
||||
const seenUrls = new Set();
|
||||
for (const candidate of [
|
||||
trimmedUrl,
|
||||
...sources.map((s) => (s && typeof s.url === "string" ? s.url.trim() : "")),
|
||||
...sources.map((s) =>
|
||||
s && typeof s.url === "string" ? s.url.trim() : ""
|
||||
)
|
||||
]) {
|
||||
if (candidate && !seenUrls.has(candidate)) {
|
||||
seenUrls.add(candidate);
|
||||
@@ -179,14 +196,21 @@ export default class UrlHealthController {
|
||||
? getInFlight(eventId, trimmedUrl, probeOptions)
|
||||
: null;
|
||||
|
||||
if (existingProbe) {
|
||||
const existingRecord = existingProbe && urlProbeRecords.get(existingProbe);
|
||||
if (existingProbe && !existingRecord?.cancelled) {
|
||||
const release = existingRecord
|
||||
? this.retainUrlProbe(existingRecord, owner)
|
||||
: () => {};
|
||||
|
||||
existingProbe
|
||||
.then((entry) => {
|
||||
if (entry) {
|
||||
this.updateUrlHealthBadge(badgeEl, entry, eventId);
|
||||
release();
|
||||
if (entry && owner.active) {
|
||||
this.updateUrlHealthBadge(badgeEl, entry, eventId, owner);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
release();
|
||||
this.logger.warn(
|
||||
`[urlHealth] cached probe promise rejected for ${trimmedUrl}:`,
|
||||
err
|
||||
@@ -195,8 +219,14 @@ export default class UrlHealthController {
|
||||
return;
|
||||
}
|
||||
|
||||
const probePromise = this.probeUrlList(candidateUrls, probeOptions)
|
||||
const record = { owners: new Set(), cancelled: false, cancelQueued: null };
|
||||
const release = this.retainUrlProbe(record, owner);
|
||||
const probePromise = this.probeUrlList(candidateUrls, {
|
||||
...probeOptions,
|
||||
lifecycle: record
|
||||
})
|
||||
.then((result) => {
|
||||
if (record.cancelled || result?.outcome === "cancelled") return null;
|
||||
const outcome = result?.outcome || "error";
|
||||
let entry;
|
||||
|
||||
@@ -205,17 +235,17 @@ export default class UrlHealthController {
|
||||
} else if (outcome === "opaque" || outcome === "unknown") {
|
||||
entry = {
|
||||
status: "unknown",
|
||||
message: "⚠️ CDN",
|
||||
message: "⚠️ CDN"
|
||||
};
|
||||
} else if (outcome === "timeout") {
|
||||
entry = {
|
||||
status: "timeout",
|
||||
message: "⚠️ CDN timed out",
|
||||
message: "⚠️ CDN timed out"
|
||||
};
|
||||
} else {
|
||||
entry = {
|
||||
status: "offline",
|
||||
message: "❌ CDN",
|
||||
message: "❌ CDN"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,10 +266,11 @@ export default class UrlHealthController {
|
||||
return entry;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (record.cancelled) return null;
|
||||
this.logger.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err);
|
||||
const entry = {
|
||||
status: "offline",
|
||||
message: "❌ CDN",
|
||||
message: "❌ CDN"
|
||||
};
|
||||
if (typeof this.state.storeUrlHealth === "function") {
|
||||
return this.state.storeUrlHealth(eventId, trimmedUrl, entry);
|
||||
@@ -247,6 +278,7 @@ export default class UrlHealthController {
|
||||
return entry;
|
||||
});
|
||||
|
||||
urlProbeRecords.set(probePromise, record);
|
||||
if (typeof this.state.setInFlightUrlProbe === "function") {
|
||||
this.state.setInFlightUrlProbe(
|
||||
eventId,
|
||||
@@ -258,11 +290,13 @@ export default class UrlHealthController {
|
||||
|
||||
probePromise
|
||||
.then((entry) => {
|
||||
if (entry) {
|
||||
this.updateUrlHealthBadge(badgeEl, entry, eventId);
|
||||
release();
|
||||
if (entry && owner.active) {
|
||||
this.updateUrlHealthBadge(badgeEl, entry, eventId, owner);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
release();
|
||||
this.logger.warn(
|
||||
`[urlHealth] probe promise rejected post-cache for ${trimmedUrl}:`,
|
||||
err
|
||||
@@ -270,8 +304,22 @@ export default class UrlHealthController {
|
||||
});
|
||||
}
|
||||
|
||||
async probeUrlWithVideoElement(url, timeoutMs) {
|
||||
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
|
||||
retainUrlProbe(record, owner) {
|
||||
record.owners.add(owner);
|
||||
const onDispose = () => {
|
||||
record.owners.delete(owner);
|
||||
if (!record.owners.size) record.cancelQueued?.();
|
||||
};
|
||||
owner.releases?.add(onDispose);
|
||||
return () => {
|
||||
record.owners.delete(owner);
|
||||
owner.releases?.delete(onDispose);
|
||||
};
|
||||
}
|
||||
|
||||
async probeUrlWithVideoElement(url, timeoutMs, lifecycle) {
|
||||
const defaultTimeout =
|
||||
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
|
||||
const effectiveTimeout =
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
|
||||
|
||||
@@ -290,20 +338,49 @@ export default class UrlHealthController {
|
||||
const defaultTimeout =
|
||||
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
|
||||
const effective =
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: defaultTimeout;
|
||||
const outcome = await probeHlsManifest(trimmed, effective);
|
||||
return { outcome };
|
||||
}
|
||||
|
||||
if (lifecycle && !lifecycle.owners.size) {
|
||||
lifecycle.cancelled = true;
|
||||
return { outcome: "cancelled" };
|
||||
}
|
||||
if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) {
|
||||
await new Promise((resolve) => {
|
||||
this.videoProbeQueue.push(resolve);
|
||||
const acquired = await new Promise((resolve) => {
|
||||
const job = {
|
||||
resume: () => {
|
||||
if (lifecycle) lifecycle.cancelQueued = null;
|
||||
// Reserve the slot synchronously, before another caller can use it.
|
||||
this.activeVideoProbes++;
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
this.videoProbeQueue.push(job);
|
||||
if (lifecycle) {
|
||||
lifecycle.cancelQueued = () => {
|
||||
const index = this.videoProbeQueue.indexOf(job);
|
||||
if (index < 0) return;
|
||||
this.videoProbeQueue.splice(index, 1);
|
||||
lifecycle.cancelled = true;
|
||||
lifecycle.cancelQueued = null;
|
||||
resolve(false);
|
||||
};
|
||||
}
|
||||
});
|
||||
if (!acquired) return { outcome: "cancelled" };
|
||||
} else {
|
||||
this.activeVideoProbes++;
|
||||
}
|
||||
|
||||
this.activeVideoProbes++;
|
||||
|
||||
try {
|
||||
if (lifecycle && !lifecycle.owners.size) {
|
||||
lifecycle.cancelled = true;
|
||||
return { outcome: "cancelled" };
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
let settled = false;
|
||||
@@ -369,9 +446,7 @@ export default class UrlHealthController {
|
||||
this.activeVideoProbes--;
|
||||
if (this.videoProbeQueue.length > 0) {
|
||||
const next = this.videoProbeQueue.shift();
|
||||
if (typeof next === "function") {
|
||||
next();
|
||||
}
|
||||
next.resume();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,6 +471,7 @@ export default class UrlHealthController {
|
||||
continue;
|
||||
}
|
||||
const outcome = result?.outcome;
|
||||
if (outcome === "cancelled") return result;
|
||||
if (outcome === "ok") {
|
||||
return { outcome: "ok", url };
|
||||
}
|
||||
@@ -413,7 +489,8 @@ export default class UrlHealthController {
|
||||
}
|
||||
|
||||
const confirmPlayable = options?.confirmPlayable === true;
|
||||
const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
|
||||
const defaultTimeout =
|
||||
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
|
||||
const urlHealthConstants = this.constants.urlHealthConstants || {};
|
||||
|
||||
const confirmWithVideoElement = async () => {
|
||||
@@ -431,7 +508,8 @@ export default class UrlHealthController {
|
||||
try {
|
||||
const result = await this.probeUrlWithVideoElement(
|
||||
trimmed,
|
||||
timeoutMs
|
||||
timeoutMs,
|
||||
options.lifecycle
|
||||
);
|
||||
if (result && result.outcome) {
|
||||
return result;
|
||||
@@ -473,8 +551,8 @@ export default class UrlHealthController {
|
||||
method: "HEAD",
|
||||
mode: "no-cors",
|
||||
cache: "no-store",
|
||||
signal: controller ? controller.signal : undefined,
|
||||
}),
|
||||
signal: controller ? controller.signal : undefined
|
||||
})
|
||||
];
|
||||
|
||||
if (Number.isFinite(defaultTimeout) && defaultTimeout > 0) {
|
||||
@@ -543,7 +621,7 @@ export default class UrlHealthController {
|
||||
}
|
||||
return {
|
||||
outcome: "bad",
|
||||
status: response.status,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
|
||||
@@ -552,7 +630,7 @@ export default class UrlHealthController {
|
||||
if (playbackCheck.outcome === "ok") {
|
||||
return {
|
||||
...playbackCheck,
|
||||
status: response.status,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
return playbackCheck;
|
||||
@@ -560,7 +638,7 @@ export default class UrlHealthController {
|
||||
|
||||
return {
|
||||
outcome: "ok",
|
||||
status: response.status,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
}
|
||||
+36
-5
@@ -9,6 +9,13 @@ import { cardNeedsEagerLivenessProbe } from "./utils/cardSourceVisibility.js";
|
||||
const ROOT_MARGIN = getLivenessProbePrefetchMargin();
|
||||
const THRESHOLD = 0.25;
|
||||
|
||||
// Badge ownership crosses the existing callback boundary without requiring
|
||||
// callers to forward a new argument through the app/feed controllers.
|
||||
const badgeOwners = new WeakMap();
|
||||
export function getUrlHealthBadgeOwner(badgeEl) {
|
||||
return badgeOwners.get(badgeEl);
|
||||
}
|
||||
|
||||
const urlCardObserver = createCardObserver({
|
||||
rootMargin: ROOT_MARGIN,
|
||||
threshold: THRESHOLD,
|
||||
@@ -17,27 +24,44 @@ const urlCardObserver = 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;
|
||||
},
|
||||
createState: () => ({ onCheck: null }),
|
||||
createState: () => ({ onCheck: null, active: true, owners: new Map() }),
|
||||
// Hide-until-verified cards start `display:none`, so the IntersectionObserver
|
||||
// never fires for them — kick the URL probe eagerly on register so they can be
|
||||
// verified and revealed (the alternative is they stay hidden forever).
|
||||
onCardRegister: ({ card, state }) => {
|
||||
const badge = card.querySelector("[data-url-health-state]");
|
||||
const owner = { active: true, releases: new Set() };
|
||||
state.owners.set(card, owner);
|
||||
if (badge) badgeOwners.set(badge, owner);
|
||||
if (cardNeedsEagerLivenessProbe(card)) {
|
||||
// attachUrlHealthBadges sets state.onCheck right AFTER observe() returns, so
|
||||
// defer one microtask to let that wiring land before we probe.
|
||||
queueMicrotask(() => triggerUrlCheck(card, state));
|
||||
}
|
||||
},
|
||||
onCardUnregister: ({ card, state }) => {
|
||||
const owner = state.owners.get(card);
|
||||
if (!owner) return;
|
||||
owner.active = false;
|
||||
for (const release of owner.releases) release();
|
||||
owner.releases.clear();
|
||||
state.owners.delete(card);
|
||||
},
|
||||
onCardVisible: ({ card, state }) => {
|
||||
triggerUrlCheck(card, state);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
function triggerUrlCheck(card, state) {
|
||||
if (!state || typeof state.onCheck !== "function") {
|
||||
if (
|
||||
!state?.active ||
|
||||
!state.owners.get(card)?.active ||
|
||||
typeof state.onCheck !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,5 +106,12 @@ export function attachUrlHealthBadges(container, onCheck) {
|
||||
if (state && typeof onCheck === "function") {
|
||||
state.onCheck = onCheck;
|
||||
}
|
||||
return () => urlCardObserver.disconnect(container);
|
||||
return () => {
|
||||
if (!state?.active) return;
|
||||
state.active = false;
|
||||
// A stale disposer must never disconnect a newer registration.
|
||||
if (urlCardObserver.getState(container) === state) {
|
||||
urlCardObserver.disconnect(container);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# client-reliability-4 precommit gates
|
||||
|
||||
- `env -u NODE_TEST_CONTEXT node tests/url-health-lifecycle.test.mjs`: exit0,12/12 scenarios,0 failures/cancellations/skips. Inherited NODE_TEST_CONTEXT=child-v8 makes --test output misleading; direct self-executing test file yields named scenarios. No runner edit.
|
||||
- `env -u NODE_TEST_CONTEXT node scripts/run-unit-tests.mjs url-health health-disposal`: exit0,4 files. New lifecycle12, URL controller4, multi-source4, view health disposal2 =22 scenarios.
|
||||
- `python3 /tmp/item4-mutants.py`: final13/13 restored mutants naturally exit1 with named assertion failures, no timeout counted. Coverage in TEST_INTEGRITY.md; initial surviving callback/mirror variants led to stronger assertions before final run.
|
||||
- `npm run lint`: sandbox exit2 at git grep EPERM; same command with approved subprocess access exits0,11 stages. No lint relaxation.
|
||||
- `npm run build`: exit0, including deploy-artifact verification; pre-existing Browserslist age warning, dependencies unchanged.
|
||||
- Browser ephemeral server initially denied listen EPERM; `node /tmp/item4-browser.mjs` with approved local access exits0: Chromium+Firefox each0 obsolete starts, healthy shared badge/disposed badge checking, hidden reveal, hosted/torrent source-health visibility pass,0 external requests/page errors. Owned loopback server/browser closed; preview3000 untouched. Full merged-tree suite and normal queue/durable ancestry still required.
|
||||
- No CPU/RSS or actual playback/live peers/fallback qualification. Only external IO virtualized; browser cache boundary uses in-memory service virtualization, unit regression uses actual state/cache module.
|
||||
@@ -0,0 +1,438 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
// Resolve real cache dependencies with installed Node packages before DOM setup.
|
||||
import * as cache from "../js/state/cache.js";
|
||||
import UrlHealthController from "../js/ui/urlHealthController.js";
|
||||
import { attachUrlHealthBadges } from "../js/urlHealthObserver.js";
|
||||
import {
|
||||
setCardLivenessPolicy,
|
||||
getCardLivenessPolicy
|
||||
} from "../js/constants.js";
|
||||
import { updateVideoCardSourceVisibility } from "../js/utils/cardSourceVisibility.js";
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
let sequence = 0;
|
||||
function fixture(t) {
|
||||
const dom = new JSDOM("<!doctype html><body></body>", {
|
||||
url: "https://example.invalid"
|
||||
});
|
||||
const names = [
|
||||
"window",
|
||||
"document",
|
||||
"HTMLElement",
|
||||
"localStorage",
|
||||
"fetch",
|
||||
"IntersectionObserver",
|
||||
"requestAnimationFrame"
|
||||
];
|
||||
const saved = names.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 policy = getCardLivenessPolicy();
|
||||
setCardLivenessPolicy("show-pending");
|
||||
const observers = [],
|
||||
media = [],
|
||||
heads = [],
|
||||
promises = [],
|
||||
warnings = [],
|
||||
registrations = [],
|
||||
visibility = [],
|
||||
checks = [];
|
||||
const id = (name) => `url-lifecycle-${++sequence}-${name}`;
|
||||
const url = (name) => `https://media.invalid/${name}.mp4`;
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.cards = [];
|
||||
observers.push(this);
|
||||
}
|
||||
observe(card) {
|
||||
this.cards.push(card);
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
disconnect() {
|
||||
this.disconnected = true;
|
||||
}
|
||||
show() {
|
||||
this.callback(
|
||||
this.cards.map((target) => ({
|
||||
target,
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
globalThis.fetch = async (source, options) => {
|
||||
heads.push({ source, options });
|
||||
return { ok: true, status: 200, type: "basic" };
|
||||
};
|
||||
dom.window.HTMLMediaElement.prototype.load = function () {
|
||||
if (this.getAttribute("src") && !media.includes(this)) media.push(this);
|
||||
};
|
||||
dom.window.HTMLMediaElement.prototype.pause = function () {};
|
||||
const controller = new UrlHealthController({
|
||||
state: {
|
||||
...cache,
|
||||
setInFlightUrlProbe(...args) {
|
||||
promises.push(args[2]);
|
||||
return cache.setInFlightUrlProbe(...args);
|
||||
}
|
||||
},
|
||||
logger: {
|
||||
warn(...args) {
|
||||
warnings.push(args);
|
||||
}
|
||||
},
|
||||
utils: {
|
||||
updateVideoCardSourceVisibility(card) {
|
||||
visibility.push(card);
|
||||
updateVideoCardSourceVisibility(card);
|
||||
}
|
||||
}
|
||||
});
|
||||
function feed(
|
||||
videos,
|
||||
{ container = document.createElement("section") } = {}
|
||||
) {
|
||||
container.replaceChildren();
|
||||
for (const video of videos) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "card";
|
||||
card.dataset.videoId = video.id;
|
||||
card.dataset.urlHealthEventId = video.id;
|
||||
card.dataset.urlHealthUrl = encodeURIComponent(video.url);
|
||||
card.dataset.urlHealthState = "checking";
|
||||
card.dataset.streamHealthState = "unsupported";
|
||||
const badge = document.createElement("span");
|
||||
badge.dataset.urlHealthState = "checking";
|
||||
card.append(badge);
|
||||
container.append(card);
|
||||
updateVideoCardSourceVisibility(card);
|
||||
}
|
||||
document.body.append(container);
|
||||
const registration = { container, videos };
|
||||
registration.dispose = attachUrlHealthBadges(
|
||||
container,
|
||||
({ badgeEl, url, eventId }) => {
|
||||
checks.push(eventId);
|
||||
controller.handleUrlHealthBadge({
|
||||
video: videos.find((v) => v.id === eventId),
|
||||
url,
|
||||
badgeEl
|
||||
});
|
||||
}
|
||||
);
|
||||
registration.observer = observers.at(-1);
|
||||
registration.cards = [...container.children];
|
||||
registration.badges = registration.cards.map(
|
||||
(card) => card.firstElementChild
|
||||
);
|
||||
registrations.push(registration);
|
||||
return registration;
|
||||
}
|
||||
const video = (name) => {
|
||||
const key = id(name);
|
||||
return { id: key, url: url(key) };
|
||||
};
|
||||
const done = (element, event = "loadeddata") =>
|
||||
element.dispatchEvent(new dom.window.Event(event));
|
||||
async function finishAll() {
|
||||
// Event-driven drain: each iteration completes the currently created media.
|
||||
// No wall-clock delay or retry of a failed assertion.
|
||||
let handled = 0;
|
||||
while (handled < media.length) {
|
||||
for (const element of media.slice(handled)) {
|
||||
done(element);
|
||||
handled++;
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await flush();
|
||||
}
|
||||
t.after(async () => {
|
||||
for (const registration of registrations) registration.dispose();
|
||||
await finishAll();
|
||||
dom.window.close();
|
||||
setCardLivenessPolicy(policy);
|
||||
for (const [key, descriptor] of saved) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
dom,
|
||||
controller,
|
||||
media,
|
||||
heads,
|
||||
promises,
|
||||
warnings,
|
||||
visibility,
|
||||
checks,
|
||||
feed,
|
||||
video,
|
||||
done,
|
||||
finishAll
|
||||
};
|
||||
}
|
||||
async function saturated(f) {
|
||||
const videos = ["a", "b", "c", "queued"].map(f.video);
|
||||
const feed = f.feed(videos);
|
||||
feed.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.media.length, 3);
|
||||
assert.equal(f.heads.length, 4);
|
||||
return { ...feed, videos };
|
||||
}
|
||||
|
||||
test("last feed disposal settles queued URL work without an obsolete media start or negative cache", async (t) => {
|
||||
const f = fixture(t),
|
||||
old = await saturated(f),
|
||||
queuedPromise = f.promises[3];
|
||||
old.dispose();
|
||||
old.container.remove();
|
||||
await flush();
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(f.media.length, 3);
|
||||
assert.equal(await queuedPromise, null);
|
||||
assert.equal(
|
||||
cache.getCachedUrlHealth(old.videos[3].id, old.videos[3].url),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
cache.getInFlightUrlProbe(old.videos[3].id, old.videos[3].url, {
|
||||
confirmPlayable: true
|
||||
}),
|
||||
null
|
||||
);
|
||||
await f.finishAll();
|
||||
assert.equal(f.controller.activeVideoProbes, 0);
|
||||
assert.equal(f.warnings.length, 0);
|
||||
});
|
||||
|
||||
test("a queued shared URL survives disposal of one owner and updates only the live badge", async (t) => {
|
||||
const f = fixture(t),
|
||||
old = await saturated(f),
|
||||
live = f.feed([old.videos[3]]);
|
||||
live.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.promises.length, 4);
|
||||
assert.equal(f.heads.length, 4);
|
||||
old.dispose(); // Keep DOM connected: stale badge guard must not rely on isConnected.
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(f.media.length, 4);
|
||||
f.done(f.media[3]);
|
||||
await flush();
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
assert.equal(old.badges[3].dataset.urlHealthState, "checking");
|
||||
});
|
||||
|
||||
test("disposing the final shared owner cancels queued work", async (t) => {
|
||||
const f = fixture(t),
|
||||
old = await saturated(f),
|
||||
live = f.feed([old.videos[3]]);
|
||||
live.observer.show();
|
||||
await flush();
|
||||
old.dispose();
|
||||
live.dispose();
|
||||
await flush();
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(f.media.length, 3);
|
||||
assert.equal(await f.promises[3], null);
|
||||
});
|
||||
|
||||
test("same source can re-request immediately after cancellation without old settlement deleting its inflight record", async (t) => {
|
||||
const f = fixture(t),
|
||||
old = await saturated(f),
|
||||
v = old.videos[3];
|
||||
old.dispose();
|
||||
const live = f.feed([v]);
|
||||
live.observer.show(); // Deliberately before cancelled promise continuations.
|
||||
const replacement = f.promises[4];
|
||||
assert.ok(replacement);
|
||||
assert.notEqual(replacement, f.promises[3]);
|
||||
await flush();
|
||||
assert.equal(
|
||||
cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true }),
|
||||
replacement
|
||||
);
|
||||
assert.equal(cache.getCachedUrlHealth(v.id, v.url), null);
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(f.media.length, 4);
|
||||
f.done(f.media[3]);
|
||||
await flush();
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
});
|
||||
|
||||
test("cancelled queued primary does not fall through to a mirror or update a connected disposed badge", async (t) => {
|
||||
const f = fixture(t),
|
||||
busy = f.feed(["a", "b", "c"].map(f.video));
|
||||
busy.observer.show();
|
||||
await flush();
|
||||
const v = f.video("mirrored");
|
||||
v.sources = [{ url: "https://mirror.invalid/alternate.mp4" }];
|
||||
const old = f.feed([v]);
|
||||
old.observer.show();
|
||||
await flush();
|
||||
old.dispose();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 4);
|
||||
assert.equal(await f.promises[3], null);
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
await f.finishAll();
|
||||
assert.equal(f.media.length, 3);
|
||||
assert.equal(f.heads.length, 4);
|
||||
});
|
||||
|
||||
test("disposal blocks queued eager microtasks and late observer callbacks, including after container reuse", async (t) => {
|
||||
const f = fixture(t);
|
||||
setCardLivenessPolicy("hide-all");
|
||||
const old = f.feed([f.video("old")]);
|
||||
old.dispose();
|
||||
const live = f.feed([f.video("new")], { container: old.container });
|
||||
old.dispose();
|
||||
old.observer.show();
|
||||
await flush();
|
||||
assert.deepEqual(f.checks, [live.videos[0].id]);
|
||||
assert.equal(f.heads.length, 1);
|
||||
assert.equal(f.heads[0].source, live.videos[0].url);
|
||||
assert.equal(f.media.length, 1);
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
});
|
||||
|
||||
test("active media retain prior result/cache behavior while disposed connected badges stay unchanged", async (t) => {
|
||||
const f = fixture(t),
|
||||
v = f.video("active"),
|
||||
old = f.feed([v]);
|
||||
old.observer.show();
|
||||
await flush();
|
||||
old.dispose();
|
||||
assert.equal(f.media[0].src, v.url);
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(cache.getCachedUrlHealth(v.id, v.url).status, "healthy");
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
const live = f.feed([v]);
|
||||
live.observer.show();
|
||||
await flush();
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
assert.equal(f.heads.length, 1);
|
||||
});
|
||||
|
||||
test("hidden URL-only card probes eagerly and reveals after healthy media confirmation", async (t) => {
|
||||
const f = fixture(t);
|
||||
setCardLivenessPolicy("hide-all");
|
||||
const live = f.feed([f.video("hidden")]);
|
||||
assert.equal(live.cards[0].hidden, true);
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 1);
|
||||
assert.equal(f.media.length, 1);
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(live.cards[0].hidden, false);
|
||||
assert.equal(live.cards[0].dataset.sourceVisibility, "visible");
|
||||
});
|
||||
|
||||
test("three-media FIFO limit holds while a freed slot and a newly visible card compete", async (t) => {
|
||||
const f = fixture(t),
|
||||
old = await saturated(f);
|
||||
const extra = f.feed([f.video("extra")]);
|
||||
f.done(f.media[0]);
|
||||
extra.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.media.length, 4);
|
||||
assert.equal(f.media[3].src, old.videos[3].url);
|
||||
assert.equal(f.controller.activeVideoProbes, 3);
|
||||
f.done(f.media[1]);
|
||||
await flush();
|
||||
assert.equal(f.media.length, 5);
|
||||
assert.equal(f.media[4].src, extra.videos[0].url);
|
||||
assert.equal(f.controller.activeVideoProbes, 3);
|
||||
});
|
||||
|
||||
test("live mirrors stay ordered and stop after first playable source", async (t) => {
|
||||
const f = fixture(t),
|
||||
v = f.video("primary");
|
||||
v.sources = [
|
||||
{ url: "https://mirror.invalid/one.mp4" },
|
||||
{ url: "https://mirror.invalid/two.mp4" }
|
||||
];
|
||||
const live = f.feed([v]);
|
||||
live.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 1);
|
||||
assert.equal(f.heads[0].source, v.url);
|
||||
f.done(f.media[0], "error");
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 2);
|
||||
assert.equal(f.heads[1].source, v.sources[0].url);
|
||||
f.done(f.media[1]);
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 2);
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
});
|
||||
|
||||
test("legacy direct callback consumers without observer ownership still settle and paint", async (t) => {
|
||||
const f = fixture(t),
|
||||
v = f.video("legacy"),
|
||||
badge = document.createElement("span");
|
||||
document.body.append(badge);
|
||||
f.controller.handleUrlHealthBadge({ video: v, url: v.url, badgeEl: badge });
|
||||
await flush();
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(badge.dataset.urlHealthState, "healthy");
|
||||
});
|
||||
|
||||
test("shared inflight ownership also survives a second controller using the same cache", async (t) => {
|
||||
const f = fixture(t),
|
||||
old = await saturated(f),
|
||||
v = old.videos[3];
|
||||
const controller = new UrlHealthController({
|
||||
state: cache,
|
||||
logger: { warn() {} }
|
||||
});
|
||||
// Preserve the production observer callback shape while selecting another controller.
|
||||
const container = document.createElement("section"),
|
||||
card = document.createElement("article"),
|
||||
badge = document.createElement("span");
|
||||
card.className = "card";
|
||||
card.dataset.videoId = v.id;
|
||||
card.dataset.urlHealthEventId = v.id;
|
||||
card.dataset.urlHealthUrl = encodeURIComponent(v.url);
|
||||
badge.dataset.urlHealthState = "checking";
|
||||
card.append(badge);
|
||||
container.append(card);
|
||||
document.body.append(container);
|
||||
const dispose = attachUrlHealthBadges(container, ({ badgeEl, url }) =>
|
||||
controller.handleUrlHealthBadge({ video: v, url, badgeEl })
|
||||
);
|
||||
t.after(dispose);
|
||||
// The fixture observer class records new observations on the returned object.
|
||||
controller.handleUrlHealthBadge({ video: v, url: v.url, badgeEl: badge });
|
||||
old.dispose();
|
||||
f.done(f.media[0]);
|
||||
await flush();
|
||||
assert.equal(f.media.length, 4);
|
||||
f.done(f.media[3]);
|
||||
await flush();
|
||||
assert.equal(badge.dataset.urlHealthState, "healthy");
|
||||
assert.equal(f.heads.length, 4);
|
||||
});
|
||||
Reference in new issue
Block a user