mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 17:02:28 +00:00
land: area/client-reliability 280f043a66 (client-reliability)
This commit is contained in:
commit
83fd2495b4
10 files changed
+706
-16
No files matched your search
@@ -0,0 +1 @@
|
||||
HLS-only implementation perdispatch20:55:13Z/confirmed newestRULE. Controller passes optional lifecycle; manifesthelper cancellation covers fetch+body, synchronous invalidation/promptcancelled/abort/identitysafehook+timercleanup. No engine/player/session/HEAD/media/torrent/RangeGET changes. New35tests+adjacent94=129pass/6files;19isolatedmutantsfailnaturally/cancelled0. Final lint pending; record then commit+merge currentunstable,READY for serialized full/browser/build/normal queue. No expensive stage/submission without grant.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Explicit dispatch20:55:13Z Stage3 HLS scope extension and helper/controller ownership; keep broaderitem9OPEN. Existing HEAD/media/engine/player/nativepreference behavior and standalone manifest callers preserved.
|
||||
- Pass optional lifecycle as third helper argument; helper owns its own hook/controller/timer through fetch AND response.text, races logical cancellation independently of AbortController cooperation. Native media remains on existing path.
|
||||
- Test actual observer/controller/cache/helper with controlled IO/clock boundaries, new unique suite only; existing tests/expectations unchanged. Baseline35newcases13pass/22fail before fix (isolated copies); original12mode5fail/7pass retained.
|
||||
- Mutants isolate copies in/tmp,never mutate worktree/shared modules. Initial retainedtimer mutant hit engineclearTimeout outside manifesthelper;corrected exacthelper suffix and final19allfail naturally. Formatting outside manifesthelper restored to keep engine read-only.
|
||||
@@ -7,5 +7,5 @@
|
||||
"addedOn": "2026-10-22",
|
||||
"filedAt": "fb54cea3a2ba99b4860421724a5050af6b97c293",
|
||||
"brief": "docs/agents/CAMPAIGN-2026-10-22.md",
|
||||
"evidence": "HEAD-only fix e9c960ed/merged09f52bdd durably landed f41ac656 normalgateexit0,435discovered/435unique starts missingextra0,ancestry0;build/artifactexit0;controlled native Chromium+Firefox lifecycle PASS with synthetic loadeddata/publicdeny. Prior60focused/16mutants/lint11. Resources released. HLS/torrent/RangeGET separately OPEN; no wholeStage3 claim. test_logs/TEST_LOG_2026-10-22_20-52-00_client-reliability-9-browser.md HLS read-only atd04d501e:12modes/7controls pass/5cancellationfail/0cancelled/naturalexit1; GET/body finalowner retains timer/unsettled,rejoin old,latecache. Await explicit controller+HLShelper routing. test_logs/TEST_LOG_2026-10-22_20-54-00_client-reliability-9-hls-investigation.md"
|
||||
"evidence": "HEAD-only fix e9c960ed/merged09f52bdd durably landed f41ac656 normalgateexit0,435discovered/435unique starts missingextra0,ancestry0;build/artifactexit0;controlled native Chromium+Firefox lifecycle PASS with synthetic loadeddata/publicdeny. Prior60focused/16mutants/lint11. Resources released. HLS/torrent/RangeGET separately OPEN; no wholeStage3 claim. test_logs/TEST_LOG_2026-10-22_20-52-00_client-reliability-9-browser.md HLS read-only atd04d501e:12modes/7controls pass/5cancellationfail/0cancelled/naturalexit1; GET/body finalowner retains timer/unsettled,rejoin old,latecache. Await explicit controller+HLShelper routing. test_logs/TEST_LOG_2026-10-22_20-54-00_client-reliability-9-hls-investigation.md HLS explicitlyrouted20:55:13Z;minimal controllerhandoff+helperfetch/bodycancel implemented;new35+existing94=129pass/6files,19isolatedmutantsnaturallyfail/cancelled0,lint11exit0;noexistingtestsmodified. READY for serialized full/build/browser/normal slot after candidatecommit/merge; notsubmitted. test_logs/TEST_LOG_2026-10-22_21-03-00_client-reliability-9-hls-ready.md"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Active HLS manifest owner cancellation
|
||||
|
||||
Source: docs/performance-efficiency-plan.md Stage3, PROGRAM campaign priority2, explicit dispatch mailbox2026-10-22T20:55:13Z and newest RULE-CHANGES HLS routing. Dispatch independently reproduced actual12mode baseline7pass/5fail atd04d501e.
|
||||
|
||||
Old scoped behavior: queued/media/HEAD ownership cancellation does not reach the HLS manifest helper. Final-owner disposal retains manifest GET/body-read/timer and unsettled observer work; same-source rejoin inherits the old request and late success can cache.
|
||||
|
||||
New HLS health-probe contract: last registered owner disposal synchronously invalidates that work, requests transport abort where available and promptly settles the observer chain null even if fetch/body ignore abort or AbortController is absent. Release manifest timer and only that probe's hook once. Pre-cancelled work starts no GET. Fresh same-turn/later same-source owners start fresh identity; old responses/errors/timers/finalizers cannot change replacement/cache/badges/media/mirrors.
|
||||
|
||||
Preserve shared/cross-controller and ownerless legacy consumers, live success/error/bad-body/timeout, existing helper calls without lifecycle, native-HLS preference and existing media/HEAD/FIFO behavior. No existing assertion changed;35 new cases tighten uncovered HLS ownership behavior. This is a scope extension, not retroactive qualification by prior HEAD/media fixes.
|
||||
|
||||
Only controller HLS lifecycle argument and manifest-probe optional cancellation plumbing change. HLS engine/player/session/config, app, torrent and RangeGET stay outside this slice. No real playback/network-byte/browser/CPU/wholeStage3 qualification from unit evidence.
|
||||
+41
-14
@@ -378,16 +378,33 @@ export async function attachHlsSource(
|
||||
* native playback needs no CORS, so the caller should keep using the element
|
||||
* probe there.
|
||||
*
|
||||
* @returns {Promise<"ok"|"error"|"timeout">}
|
||||
* Optional card-probe lifecycle owns cancellation through both fetch and body
|
||||
* reading. Callers without a lifecycle retain the standalone probe behavior.
|
||||
* @returns {Promise<"ok"|"error"|"timeout"|"cancelled">}
|
||||
*/
|
||||
export async function probeHlsManifest(url, timeoutMs = 4000) {
|
||||
export async function probeHlsManifest(url, timeoutMs = 4000, lifecycle) {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed || typeof fetch !== "function") {
|
||||
return "error";
|
||||
}
|
||||
if (lifecycle && (lifecycle.cancelled || !lifecycle.owners.size)) {
|
||||
lifecycle.cancelled = true;
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
const controller =
|
||||
typeof AbortController === "function" ? new AbortController() : null;
|
||||
let resolveCancellation;
|
||||
const cancellation = new Promise((resolve) => {
|
||||
resolveCancellation = resolve;
|
||||
});
|
||||
const cancelActive = () => {
|
||||
// Invalidate synchronously so a same-turn owner starts a fresh probe.
|
||||
lifecycle.cancelled = true;
|
||||
resolveCancellation("cancelled");
|
||||
controller?.abort();
|
||||
};
|
||||
if (lifecycle) lifecycle.cancelActive = cancelActive;
|
||||
let timedOut = false;
|
||||
const timeoutId =
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
@@ -397,22 +414,32 @@ export async function probeHlsManifest(url, timeoutMs = 4000) {
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const response = await fetch(trimmed, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: controller?.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return "error";
|
||||
const request = async () => {
|
||||
try {
|
||||
const response = await fetch(trimmed, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: controller?.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
return "error";
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
|
||||
} catch (err) {
|
||||
return timedOut ? "timeout" : "error";
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
|
||||
} catch (err) {
|
||||
return timedOut ? "timeout" : "error";
|
||||
};
|
||||
try {
|
||||
// Race the whole request, including response.text(): a transport or body
|
||||
// reader may ignore abort, but obsolete card work must still settle.
|
||||
return await Promise.race([request(), cancellation]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (lifecycle?.cancelActive === cancelActive) {
|
||||
lifecycle.cancelActive = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ export default class UrlHealthController {
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: defaultTimeout;
|
||||
const outcome = await probeHlsManifest(trimmed, effective);
|
||||
const outcome = await probeHlsManifest(trimmed, effective, lifecycle);
|
||||
return { outcome };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# HLS health lifecycle integrity
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: [new_tests, spec_correction]
|
||||
scenarios:
|
||||
- id: SCN-hls-owner-cancel
|
||||
given: "Actual observer/controller/helper with pending manifest fetch or body read"
|
||||
when: "Final owner disposes, including duplicate disposal and ignored/missing abort"
|
||||
then: "Prompt null, aborted when supported, timer cleanup once, no obsolete cache or mirror or badge"
|
||||
- id: SCN-hls-replacement
|
||||
given: "Cancelled HLS work and new same-source observer in same turn or later"
|
||||
when: "Old success, error or timer fires"
|
||||
then: "Fresh request/inflight survives; only current owner receives healthy cache and badge"
|
||||
- id: SCN-hls-shared-legacy
|
||||
given: "Two owners, cross-controller owners or ownerless legacy consumer in fetch/body phase"
|
||||
when: "One registered owner disposes"
|
||||
then: "One live GET, no abort, live healthy cache/badge and no disposed badge write"
|
||||
- id: SCN-hls-live-contract
|
||||
given: "Live HLS card with controlled response or timeout"
|
||||
when: "Good manifest, HTTP error, invalid body or timeout completes"
|
||||
then: "Existing healthy/offline/timeout and cleanup semantics preserved"
|
||||
- id: SCN-hls-boundary
|
||||
given: "Cancellation at HEAD handoff, native-support check or helper prestart"
|
||||
when: "Ownership disappears"
|
||||
then: "No manifest starts; native preference retains existing cancellable media behavior"
|
||||
- id: SCN-hls-hook-cleanup
|
||||
given: "Helper hook active or superseded by successor"
|
||||
when: "Cancellation or completion finalizes"
|
||||
then: "Own timer/hook removed once; successor hook preserved"
|
||||
observable_outcomes:
|
||||
- "Fetch count, abort signal, promise settlement, timers, cache, inflight identity and visible badges"
|
||||
- "No obsolete mirror/media start; active media slots and queue return to zero"
|
||||
determinism_controls:
|
||||
- "Real observer/controller/cache/helper with JSDOM; controlled external fetch/body/viewport/timers"
|
||||
- "Microtask/event-loop continuation drain only, no arbitrary sleeps, retries, force exits or public network"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Abort signal without prompt settlement"
|
||||
- "Fetch-only cancellation that leaves body reader pending"
|
||||
- "Same-source fresh observer inheriting obsolete work"
|
||||
- "Cancelling shared or legacy live owners"
|
||||
- "Late cache or badge writes and replacement deletion"
|
||||
- "Unconditional healthy response and lost live timeout/native branch"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
Spec extension documented in docs/spec_changes/2026-10-22-client-reliability-9-active-hls.md. No existing tests/expectations changed. Original actual12mode baseline and initial fixture corrections are retained in20-54 investigation note; new35case suite against original isolated modules produces13pass/22fail/cancelled0.
|
||||
|
||||
Grouped mutant coverage: six final-owner cases reject hook/abort/prompt/timer/cache failures; twelve replacement cases reject sync invalidation and stale cache finalizer; six shared/cross/legacy cases reject lost shared owner; four live controls reject corrupted success/status/body/timeout; handoff/native/prestart cases reject missing handoff/guard/native branch; hook cases reject absent cleanup and deleting successor. Each group has failing mutants; all35 cases covered without changing expectations.
|
||||
|
||||
Final19 isolated variants below naturally exit1 with assertion failures and0cancelled. Source copies live in/tmp; actual worktree modules never mutated. Initial timer mutant accidentally targeted engine cleanup, survived, corrected to manifest-helper suffix; final result rejects timer retention. Baseline variant includes absent API TypeErrors in direct-hook cases as well as22 total failures; primary lifecycle cases fail assertions.
|
||||
|
||||
- baseline: exit1, fail22, cancelled0
|
||||
- no-handoff: exit1, fail19, cancelled0
|
||||
- no-helper-hook: exit1, fail19, cancelled0
|
||||
- no-cancel-abort: exit1, fail5, cancelled0
|
||||
- no-prompt-settlement: exit1, fail18, cancelled0
|
||||
- no-sync-invalidation: exit1, fail7, cancelled0
|
||||
- retained-timer: exit1, fail30, cancelled0
|
||||
- no-hook-cleanup: exit1, fail1, cancelled0
|
||||
- successor-hook-deleted: exit1, fail1, cancelled0
|
||||
- body-hook-lost: exit1, fail10, cancelled0
|
||||
- no-prestart-guard: exit1, fail3, cancelled0
|
||||
- cache-on-cancel: exit1, fail21, cancelled0
|
||||
- lost-shared-owner: exit1, fail6, cancelled0
|
||||
- native-branch-lost: exit1, fail2, cancelled0
|
||||
- healthy-cache-corrupted: exit1, fail19, cancelled0
|
||||
- bad-status-healthy: exit1, fail1, cancelled0
|
||||
- bad-body-healthy: exit1, fail1, cancelled0
|
||||
- no-timeout-abort: exit1, fail1, cancelled0
|
||||
- stale-inflight-finalizer: exit1, fail6, cancelled0
|
||||
@@ -0,0 +1,9 @@
|
||||
# HLS-only cancellation candidate readiness
|
||||
|
||||
- Parent HEADfb0e671d4da534a52d834cb761c285b862d86889 contains docs-only investigation over runtime d04d501e. Dispatch20:55:13Z explicitly routed helper/controller changes after independently reproducing12cases7pass/5fail/cancelled0/naturalexit1. Original and fixture-correction provenance in20-54 investigation note.
|
||||
- Product delta: controller one argument; helper optional lifecycle, cancellation hook and raced fetch+response.text, synchronous invalidation, transport abort where available, timer and identity-safe hook cleanup. Existing standalone caller and engine/player/native selection unchanged. Prettier formatting outside helper was restored to avoid engine edits. No existing tests modified.
|
||||
- New unique tests/url-health-hls-lifecycle.test.mjs35scenarios; original isolated modules yield13pass/22fail/cancelled0 (baseline mutant). Fixed original12case diagnostic12pass/0fail/0cancelled/naturalexit0. No baseline failure erased.
|
||||
- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node --test tests/url-health-hls-lifecycle.test.mjs tests/url-health-head-lifecycle.test.mjs tests/url-health-lifecycle.test.mjs tests/url-health-multisource.test.mjs tests/ui/url-health-controller.test.mjs tests/services/hlsPlayback.test.mjs > /tmp/client9-hls-focused-final.tap 2>&1`: approvedhost naturalexit0;6files/129pass/0fail/0cancelled/1042.269662ms. New35+existing94. Initial equivalent candidate with redundant controller preguard also129pass; removed redundant guard then verified final again.
|
||||
- `python3 /tmp/client9-hls-mutants.py > /tmp/client9-hls-mutants-final.log 2>&1`: runnerexit0;19isolated module/cache variants each naturalexit1/fail>0/cancelled0. Results/tmp/client9-hls-mutants.json. Initial retainedtimer variant hit engineclearTimeout and survived; corrected exacthelper suffix, rejected30scenarios, reran final19 on final candidate. No worktree source mutated for experiments.
|
||||
- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD npm run lint > /tmp/client9-hls-lint-final.log 2>&1`: approvedhost naturalexit0/all11checks. Earlier candidate lint alsoexit0. `git diff --check` exit0. No lint baseline/config change.
|
||||
- Full/normal gate, build/artifact and browser HLS lifecycle UNRUN for this follow-on; no product submission. Storage4 owns heavy slot. Await serialized slot after commit/currentunstable merge. No decoder/realplayback/network-byte/CPU/livepeer/realcloud/signer/production/wholeStage3 proof; torrent/RangeGET remain open.
|
||||
@@ -0,0 +1,559 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { probeHlsManifest } from "../js/services/hlsPlayback.js";
|
||||
// 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, { ignoreAbort = false, missingAbort = false } = {}) {
|
||||
const dom = new JSDOM("<!doctype html><body></body>", {
|
||||
url: "https://example.invalid"
|
||||
});
|
||||
const names = [
|
||||
"window",
|
||||
"document",
|
||||
"HTMLElement",
|
||||
"localStorage",
|
||||
"fetch",
|
||||
"IntersectionObserver",
|
||||
"requestAnimationFrame",
|
||||
"setTimeout",
|
||||
"clearTimeout",
|
||||
"AbortController"
|
||||
];
|
||||
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
|
||||
});
|
||||
if (missingAbort) globalThis.AbortController = undefined;
|
||||
const policy = getCardLivenessPolicy();
|
||||
setCardLivenessPolicy("show-pending");
|
||||
const resources = new Set(),
|
||||
listeners = new Map(),
|
||||
handlers = new Map(),
|
||||
pauses = [];
|
||||
const observers = [],
|
||||
media = [],
|
||||
heads = [],
|
||||
promises = [],
|
||||
warnings = [],
|
||||
registrations = [],
|
||||
visibility = [],
|
||||
checks = [];
|
||||
const id = (name) => `url-lifecycle-${++sequence}-${name}`;
|
||||
const url = (name) => `https://media.invalid/${name}.m3u8`;
|
||||
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
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
const bodies = [],
|
||||
clears = [];
|
||||
const timers = new Map(),
|
||||
nativeSetTimeout = globalThis.setTimeout,
|
||||
nativeClearTimeout = globalThis.clearTimeout;
|
||||
globalThis.setTimeout = (fn, ms) => {
|
||||
if (!ms) return nativeSetTimeout(fn, ms);
|
||||
const handle = {};
|
||||
timers.set(handle, { fn, ms });
|
||||
return handle;
|
||||
};
|
||||
globalThis.clearTimeout = (handle) => {
|
||||
clears.push(handle);
|
||||
if (!timers.delete(handle)) nativeClearTimeout(handle);
|
||||
};
|
||||
globalThis.fetch = (source, options) =>
|
||||
options.method === "HEAD"
|
||||
? Promise.resolve({ ok: true, status: 200, type: "basic" })
|
||||
: new Promise((resolve, reject) => {
|
||||
const head = { source, options, resolve, reject, aborted: 0 };
|
||||
heads.push(head);
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
head.aborted++;
|
||||
if (!ignoreAbort)
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
const proto = dom.window.HTMLMediaElement.prototype;
|
||||
const addListener = proto.addEventListener,
|
||||
removeListener = proto.removeEventListener;
|
||||
proto.addEventListener = function (type, fn, options) {
|
||||
if (["loadeddata", "canplay", "error"].includes(type)) {
|
||||
if (!listeners.has(this)) listeners.set(this, new Map());
|
||||
if (!handlers.has(this)) handlers.set(this, new Map());
|
||||
listeners.get(this).set(type, fn);
|
||||
handlers.get(this).set(type, fn);
|
||||
}
|
||||
return addListener.call(this, type, fn, options);
|
||||
};
|
||||
proto.removeEventListener = function (type, fn, options) {
|
||||
listeners.get(this)?.delete(type);
|
||||
return removeListener.call(this, type, fn, options);
|
||||
};
|
||||
proto.load = function () {
|
||||
if (this.getAttribute("src")) {
|
||||
if (!media.includes(this)) media.push(this);
|
||||
resources.add(this);
|
||||
} else resources.delete(this);
|
||||
};
|
||||
proto.pause = function () {
|
||||
pauses.push(this);
|
||||
};
|
||||
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"),
|
||||
consumer = controller
|
||||
} = {}
|
||||
) {
|
||||
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);
|
||||
consumer.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.
|
||||
for (const head of heads)
|
||||
head.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
type: "basic",
|
||||
text: async () => "#EXTM3U\nsegment.ts"
|
||||
});
|
||||
for (const body of bodies) body.resolve("#EXTM3U\nsegment.ts");
|
||||
await flush();
|
||||
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,
|
||||
bodies,
|
||||
clears,
|
||||
resources,
|
||||
listeners,
|
||||
handlers,
|
||||
pauses,
|
||||
controller,
|
||||
media,
|
||||
heads,
|
||||
timers,
|
||||
promises,
|
||||
warnings,
|
||||
visibility,
|
||||
checks,
|
||||
feed,
|
||||
video,
|
||||
done,
|
||||
finishAll
|
||||
};
|
||||
}
|
||||
|
||||
// Actual observer/controller/cache/manifest helper; only IO, clock and viewport
|
||||
// boundaries are controlled. No engine/decoder/public network is involved.
|
||||
const inFlight = (v) =>
|
||||
cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true });
|
||||
const cached = (v) => cache.getCachedUrlHealth(v.id, v.url);
|
||||
const manifest = {
|
||||
ok: true,
|
||||
text: async () => "#EXTM3U\n#EXTINF:2\nsegment.ts"
|
||||
};
|
||||
function tracking(promise) {
|
||||
const result = { settled: false };
|
||||
promise.then((value) => Object.assign(result, { settled: true, value }));
|
||||
return result;
|
||||
}
|
||||
function bodyReader(f) {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
const body = { promise, resolve, reject };
|
||||
f.bodies.push(body);
|
||||
return body;
|
||||
}
|
||||
async function start(f, { phase = "fetch", mirror = false } = {}) {
|
||||
const v = f.video(phase);
|
||||
if (mirror) v.sources = [{ url: `${v.url}?mirror` }];
|
||||
const old = f.feed([v]);
|
||||
old.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 1);
|
||||
assert.equal(f.heads[0].options.method, "GET");
|
||||
assert.equal(f.media.length, 0);
|
||||
let body;
|
||||
if (phase === "body") {
|
||||
body = bodyReader(f);
|
||||
f.heads[0].resolve({ ok: true, text: () => body.promise });
|
||||
await flush();
|
||||
}
|
||||
return {
|
||||
v,
|
||||
old,
|
||||
body,
|
||||
promise: f.promises[0],
|
||||
result: tracking(f.promises[0])
|
||||
};
|
||||
}
|
||||
for (const phase of ["fetch", "body"]) {
|
||||
for (const transport of ["abortable", "ignores-abort", "missing-abort"]) {
|
||||
test(`HLS ${phase} final owner releases resources with ${transport}`, async (t) => {
|
||||
const f = fixture(t, {
|
||||
ignoreAbort: transport === "ignores-abort",
|
||||
missingAbort: transport === "missing-abort"
|
||||
});
|
||||
const { v, old, result } = await start(f, { phase, mirror: true });
|
||||
const timer = [...f.timers.keys()][0];
|
||||
old.dispose();
|
||||
old.dispose();
|
||||
await flush();
|
||||
assert.equal(
|
||||
result.settled,
|
||||
true,
|
||||
"settle independently of fetch/body cooperation"
|
||||
);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.heads[0].aborted, transport === "missing-abort" ? 0 : 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.clears.filter((x) => x === timer).length, 1);
|
||||
assert.equal(cached(v), null);
|
||||
assert.equal(inFlight(v), null);
|
||||
assert.equal(f.heads.length, 1, "no obsolete mirror");
|
||||
assert.equal(f.media.length, 0);
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
assert.equal(f.controller.activeVideoProbes, 0);
|
||||
assert.equal(f.controller.videoProbeQueue.length, 0);
|
||||
await f.finishAll();
|
||||
assert.equal(cached(v), null, "late completion cannot cache");
|
||||
assert.equal(f.clears.filter((x) => x === timer).length, 1);
|
||||
});
|
||||
}
|
||||
for (const timing of ["same-turn", "after-flush"])
|
||||
for (const late of ["success", "error", "timer"]) {
|
||||
test(`HLS ${phase} ${timing} replacement survives old ${late}`, async (t) => {
|
||||
const f = fixture(t, { ignoreAbort: true });
|
||||
const { v, old, body, promise, result } = await start(f, { phase });
|
||||
const staleTimer = [...f.timers.values()][0].fn;
|
||||
old.dispose();
|
||||
if (timing === "after-flush") await flush();
|
||||
const live = f.feed([v]);
|
||||
live.observer.show();
|
||||
const replacement = inFlight(v);
|
||||
assert.notEqual(
|
||||
replacement,
|
||||
promise,
|
||||
"new owner requires fresh inflight identity"
|
||||
);
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 2);
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(inFlight(v), replacement);
|
||||
assert.equal(f.timers.size, 1);
|
||||
if (late === "timer") staleTimer();
|
||||
else if (phase === "body") {
|
||||
if (late === "success") body.resolve("#EXTM3U\nold.ts");
|
||||
else body.reject(Error("late body failure"));
|
||||
} else if (late === "success") f.heads[0].resolve(manifest);
|
||||
else f.heads[0].reject(Error("late fetch failure"));
|
||||
await flush();
|
||||
assert.equal(inFlight(v), replacement);
|
||||
assert.equal(cached(v), null);
|
||||
assert.equal(f.timers.size, 1);
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
f.heads[1].resolve(manifest);
|
||||
await replacement;
|
||||
await flush();
|
||||
assert.equal(cached(v).status, "healthy");
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
assert.equal(inFlight(v), null);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
}
|
||||
for (const kind of ["shared", "cross-controller", "legacy"]) {
|
||||
test(`HLS ${phase} cancellation preserves ${kind} survivor`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const { v, old, result } = await start(f, { phase });
|
||||
const consumer =
|
||||
kind === "cross-controller"
|
||||
? new UrlHealthController({ state: cache, logger: { warn() {} } })
|
||||
: f.controller;
|
||||
let live;
|
||||
if (kind === "legacy") {
|
||||
const badge = document.createElement("span");
|
||||
document.body.append(badge);
|
||||
consumer.handleUrlHealthBadge({ video: v, url: v.url, badgeEl: badge });
|
||||
live = { badges: [badge] };
|
||||
} else {
|
||||
live = f.feed([v], { consumer });
|
||||
live.observer.show();
|
||||
}
|
||||
await flush();
|
||||
old.dispose();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 1);
|
||||
assert.equal(f.heads[0].aborted, 0);
|
||||
assert.equal(result.settled, false);
|
||||
assert.equal(f.timers.size, 1);
|
||||
await f.finishAll();
|
||||
assert.equal(result.value.status, "healthy");
|
||||
assert.equal(cached(v).status, "healthy");
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const outcome of ["success", "error", "bad-body", "timeout"]) {
|
||||
test(`HLS live ${outcome} preserves health/cache semantics`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const { v, old, result } = await start(f);
|
||||
if (outcome === "timeout") [...f.timers.values()][0].fn();
|
||||
else
|
||||
f.heads[0].resolve(
|
||||
outcome === "success"
|
||||
? manifest
|
||||
: outcome === "error"
|
||||
? { ok: false, status: 500 }
|
||||
: { ok: true, text: async () => "<html>not a manifest</html>" }
|
||||
);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
const status =
|
||||
outcome === "success"
|
||||
? "healthy"
|
||||
: outcome === "timeout"
|
||||
? "timeout"
|
||||
: "offline";
|
||||
assert.equal(result.value.status, status);
|
||||
assert.equal(cached(v).status, status);
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, status);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.heads[0].aborted, outcome === "timeout" ? 1 : 0);
|
||||
});
|
||||
}
|
||||
test("HLS disposal during HEAD handoff prevents manifest GET", async (t) => {
|
||||
const f = fixture(t);
|
||||
const v = f.video("handoff"),
|
||||
old = f.feed([v]);
|
||||
old.observer.show();
|
||||
old.dispose();
|
||||
await flush();
|
||||
const result = tracking(f.promises[0]);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(cached(v), null);
|
||||
});
|
||||
test("HLS native preference keeps cancellable media path", async (t) => {
|
||||
const f = fixture(t);
|
||||
f.dom.window.HTMLMediaElement.prototype.canPlayType = () => "probably";
|
||||
const v = f.video("native"),
|
||||
old = f.feed([v]);
|
||||
old.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.media.length, 1);
|
||||
assert.equal(f.controller.activeVideoProbes, 1);
|
||||
old.dispose();
|
||||
await flush();
|
||||
const result = tracking(f.promises[0]);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.controller.activeVideoProbes, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.resources.size, 0);
|
||||
assert.equal(cached(v), null);
|
||||
});
|
||||
for (const preCancelled of [false, true])
|
||||
test(`manifest helper skips cancelled lifecycle before start ${preCancelled}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const lifecycle = {
|
||||
owners: new Set(preCancelled ? [{}] : []),
|
||||
cancelled: preCancelled
|
||||
};
|
||||
const result = tracking(
|
||||
probeHlsManifest(f.video("prestart").url, 5000, lifecycle)
|
||||
);
|
||||
await flush();
|
||||
assert.equal(
|
||||
result.settled,
|
||||
true,
|
||||
"pre-cancelled helper settles without IO"
|
||||
);
|
||||
assert.equal(result.value, "cancelled");
|
||||
assert.equal(lifecycle.cancelled, true);
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
test("manifest helper clears its hook exactly once on cancellation", async (t) => {
|
||||
const f = fixture(t, { ignoreAbort: true });
|
||||
const lifecycle = { owners: new Set([{}]) };
|
||||
const promise = probeHlsManifest(f.video("hook").url, 5000, lifecycle);
|
||||
const cancel = lifecycle.cancelActive;
|
||||
const timer = [...f.timers.keys()][0];
|
||||
cancel();
|
||||
cancel();
|
||||
assert.equal(lifecycle.cancelled, true);
|
||||
const result = tracking(promise);
|
||||
await flush();
|
||||
assert.equal(
|
||||
result.settled,
|
||||
true,
|
||||
"cancelled helper settles without cooperative IO"
|
||||
);
|
||||
assert.equal(result.value, "cancelled");
|
||||
assert.equal(lifecycle.cancelActive, null);
|
||||
assert.equal(f.heads[0].aborted, 1);
|
||||
assert.equal(f.clears.filter((x) => x === timer).length, 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
test("manifest helper finalizer preserves a successor cancellation hook", async (t) => {
|
||||
const f = fixture(t);
|
||||
const lifecycle = { owners: new Set([{}]) };
|
||||
const promise = probeHlsManifest(f.video("successor").url, 5000, lifecycle);
|
||||
const successor = () => {};
|
||||
lifecycle.cancelActive = successor;
|
||||
f.heads[0].resolve(manifest);
|
||||
assert.equal(await promise, "ok");
|
||||
assert.equal(lifecycle.cancelActive, successor);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test("HLS cancellation during native-support check prevents manifest start", async (t) => {
|
||||
const f = fixture(t);
|
||||
const v = f.video("support-handoff"),
|
||||
old = f.feed([v]);
|
||||
f.dom.window.HTMLMediaElement.prototype.canPlayType = () => {
|
||||
old.dispose();
|
||||
return "";
|
||||
};
|
||||
old.observer.show();
|
||||
await flush();
|
||||
const result = tracking(f.promises[0]);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(cached(v), null);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
- [x] Preserve original actual12mode baseline5fail/7pass and fixture corrections.
|
||||
- [x] Minimal optional HLS cancellation plumbing and35 new lifecycle/helper scenarios.
|
||||
- [x] Focused129/6files and19 failing isolated mutants,cancelled0.
|
||||
- [ ] Final lint11,commit/merge currentunstable and READY/resource request.
|
||||
- [ ] Allocated full/normal landing/build/nativebrowser; no wider HLSplayback/torrent/RangeGET/Stage3 claim.
|
||||
Reference in new issue
Block a user