From cdc398b0280346d19fb5d7a61025168a7d55de5e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 22 Oct 2026 17:02:48 -0400 Subject: [PATCH 1/4] [client-reliability] client-reliability-9 cancel ownerless HLS manifest probes --- ...10-22_21-01-00_client-reliability-9-hls.md | 1 + ...10-22_21-01-00_client-reliability-9-hls.md | 4 + docs/agents/backlog/client-reliability-9.json | 2 +- ...6-10-22-client-reliability-9-active-hls.md | 11 + js/services/hlsPlayback.js | 55 +- js/ui/urlHealthController.js | 2 +- ...ITY_2026-10-22_client-reliability-9-hls.md | 74 +++ ...21-03-00_client-reliability-9-hls-ready.md | 9 + tests/url-health-hls-lifecycle.test.mjs | 559 ++++++++++++++++++ ...10-22_21-01-00_client-reliability-9-hls.md | 5 + 10 files changed, 706 insertions(+), 16 deletions(-) create mode 100644 context/CONTEXT_2026-10-22_21-01-00_client-reliability-9-hls.md create mode 100644 decisions/DECISIONS_2026-10-22_21-01-00_client-reliability-9-hls.md create mode 100644 docs/spec_changes/2026-10-22-client-reliability-9-active-hls.md create mode 100644 test_logs/TEST_INTEGRITY_2026-10-22_client-reliability-9-hls.md create mode 100644 test_logs/TEST_LOG_2026-10-22_21-03-00_client-reliability-9-hls-ready.md create mode 100644 tests/url-health-hls-lifecycle.test.mjs create mode 100644 todo/TODO_2026-10-22_21-01-00_client-reliability-9-hls.md diff --git a/context/CONTEXT_2026-10-22_21-01-00_client-reliability-9-hls.md b/context/CONTEXT_2026-10-22_21-01-00_client-reliability-9-hls.md new file mode 100644 index 00000000..85169ee8 --- /dev/null +++ b/context/CONTEXT_2026-10-22_21-01-00_client-reliability-9-hls.md @@ -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. diff --git a/decisions/DECISIONS_2026-10-22_21-01-00_client-reliability-9-hls.md b/decisions/DECISIONS_2026-10-22_21-01-00_client-reliability-9-hls.md new file mode 100644 index 00000000..c5db8999 --- /dev/null +++ b/decisions/DECISIONS_2026-10-22_21-01-00_client-reliability-9-hls.md @@ -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. diff --git a/docs/agents/backlog/client-reliability-9.json b/docs/agents/backlog/client-reliability-9.json index e378fc34..5b8f7972 100644 --- a/docs/agents/backlog/client-reliability-9.json +++ b/docs/agents/backlog/client-reliability-9.json @@ -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" } diff --git a/docs/spec_changes/2026-10-22-client-reliability-9-active-hls.md b/docs/spec_changes/2026-10-22-client-reliability-9-active-hls.md new file mode 100644 index 00000000..b139980b --- /dev/null +++ b/docs/spec_changes/2026-10-22-client-reliability-9-active-hls.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. diff --git a/js/services/hlsPlayback.js b/js/services/hlsPlayback.js index ce38093c..005ee6de 100644 --- a/js/services/hlsPlayback.js +++ b/js/services/hlsPlayback.js @@ -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; + } } } diff --git a/js/ui/urlHealthController.js b/js/ui/urlHealthController.js index 6af13135..0702dd32 100644 --- a/js/ui/urlHealthController.js +++ b/js/ui/urlHealthController.js @@ -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 }; } diff --git a/test_logs/TEST_INTEGRITY_2026-10-22_client-reliability-9-hls.md b/test_logs/TEST_INTEGRITY_2026-10-22_client-reliability-9-hls.md new file mode 100644 index 00000000..4a166e6e --- /dev/null +++ b/test_logs/TEST_INTEGRITY_2026-10-22_client-reliability-9-hls.md @@ -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 diff --git a/test_logs/TEST_LOG_2026-10-22_21-03-00_client-reliability-9-hls-ready.md b/test_logs/TEST_LOG_2026-10-22_21-03-00_client-reliability-9-hls-ready.md new file mode 100644 index 00000000..75783dd9 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_21-03-00_client-reliability-9-hls-ready.md @@ -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. diff --git a/tests/url-health-hls-lifecycle.test.mjs b/tests/url-health-hls-lifecycle.test.mjs new file mode 100644 index 00000000..999760c4 --- /dev/null +++ b/tests/url-health-hls-lifecycle.test.mjs @@ -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("", { + 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 () => "not a manifest" } + ); + 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); +}); diff --git a/todo/TODO_2026-10-22_21-01-00_client-reliability-9-hls.md b/todo/TODO_2026-10-22_21-01-00_client-reliability-9-hls.md new file mode 100644 index 00000000..36b67bf4 --- /dev/null +++ b/todo/TODO_2026-10-22_21-01-00_client-reliability-9-hls.md @@ -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. From 3a89abd05b264d157b7526de02b125636bca56a2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Fri, 23 Oct 2026 08:37:56 -0400 Subject: [PATCH 2/4] fleet init --- docs/agents/BITVID-SCHEDULER.md | 15 +++++ tools/fleet/bin/bitvid-schedule-guard.mjs | 56 ++++++++++++++++ .../fleet/test/bitvid-schedule-guard.test.mjs | 67 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 docs/agents/BITVID-SCHEDULER.md create mode 100644 tools/fleet/bin/bitvid-schedule-guard.mjs create mode 100644 tools/fleet/test/bitvid-schedule-guard.test.mjs diff --git a/docs/agents/BITVID-SCHEDULER.md b/docs/agents/BITVID-SCHEDULER.md new file mode 100644 index 00000000..1b60e1ee --- /dev/null +++ b/docs/agents/BITVID-SCHEDULER.md @@ -0,0 +1,15 @@ +# Bitvid scheduled-job policy + +Owner direction, 2026-10-23: "I would like to not have extra processes like this running when the fleet is down or paused". + +All seven installed Bitvid cron jobs enter through `tools/fleet/bin/bitvid-schedule-guard.mjs`, not directly through `sched.sh`. + +- A missing/dead/stopped interactive fleet means no scheduled headless launch. At least one configured interactive area must have a running record and a live PID. +- `fleet/PAUSED` is represented by the existing state-directory `PAUSED` marker. It unconditionally blocks every job, including the weekly restart job; `ignorePause` does not bypass this outer guard. +- A running scheduled job is checked every second. On pause/down, the guard sends TERM only to the process group it created. Fleet's nested process runners forward termination to their own agent group. A bounded KILL fallback handles a group that does not exit. +- A cron tick while down or paused starts only the brief guard check, which exits without an AI session or persistent watcher. There is no always-on supervisor added. +- Unpausing and bringing the fleet up lets the next normal cron tick run; this policy does not automatically resume a paused fleet. +- Existing quiet hours, per-job non-overlap locks and time limits still apply inside `sched.sh`. The one-heavy-job convention still applies. +- This is a Bitvid-specific file, separate from the kit-managed scheduler. Kit upgrades should retain it. `fleet crontab` prints generic, unguarded commands: do not overwrite the installed guarded entries without preserving this policy. + +Validation: `env -u NODE_TEST_CONTEXT node --test tools/fleet/test/bitvid-schedule-guard.test.mjs` passed 8 tests with natural exit; fixtures only, no real fleet shutdown/pause or headless model run. Test integrity: new behavioral coverage, no existing assertions/gates changed. Process stop tests use a disposable sleep child and verify the unrelated test parent remains alive. Nested production runner cleanup relies on the kit's existing TERM forwarding; this is not a real-model stop qualification. diff --git a/tools/fleet/bin/bitvid-schedule-guard.mjs b/tools/fleet/bin/bitvid-schedule-guard.mjs new file mode 100644 index 00000000..66d51c59 --- /dev/null +++ b/tools/fleet/bin/bitvid-schedule-guard.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Bitvid policy: cron may work only while an interactive fleet is alive and unpaused. +// Kept separate from sched.sh so kit updates cannot restore ignorePause behavior. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig, pidAlive, killProcessGroup, runProcessGroup } from './lib.mjs'; + +export function inactiveReason(c) { + if (fs.existsSync(path.join(c.stateDir, 'PAUSED'))) return 'fleet paused'; + for (const area of c.areas) { + try { + const run = JSON.parse(fs.readFileSync(path.join(c.stateDir, 'runs', `${area.id}.json`), 'utf8')); + if (run.status === 'running' && Number.isInteger(run.pid) && run.pid > 1 && pidAlive(run.pid)) return null; + } catch { /* Missing or invalid state is not permission to launch. */ } + } + return 'fleet down'; +} + +export async function guardSchedule(c, job, { scheduler = path.join(path.dirname(fileURLToPath(import.meta.url)), 'sched.sh'), pollMs = 1000 } = {}) { + if (!c.schedules.some((entry) => entry.name === job)) throw new Error(`Unknown scheduled job: ${job}`); + const reason = inactiveReason(c); + if (reason) return { status: 0, reason }; + let poll; + let force; + let stopped; + const result = await runProcessGroup({ + cmd: '/bin/bash', args: [scheduler, job], cwd: c.repo, env: process.env, + onSpawn(pid) { + poll = setInterval(() => { + const why = inactiveReason(c); + if (!why || stopped) return; + stopped = why; + // Only the process group this invocation created. Nested fleet runners + // receive TERM and forward it to their own agent process groups. + killProcessGroup(pid, 'SIGTERM'); + force = setTimeout(() => killProcessGroup(pid, 'SIGKILL'), 3000); + }, pollMs); + }, + }).finally(() => { + clearInterval(poll); + clearTimeout(force); + }); + return { status: stopped ? 0 : (result.status ?? 1), reason: stopped ?? null }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const c = loadConfig(); + const result = await guardSchedule(c, process.argv[2]); + process.exitCode = result.status; + } catch (error) { + console.error(`Bitvid schedule guard: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/tools/fleet/test/bitvid-schedule-guard.test.mjs b/tools/fleet/test/bitvid-schedule-guard.test.mjs new file mode 100644 index 00000000..6a54c452 --- /dev/null +++ b/tools/fleet/test/bitvid-schedule-guard.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { guardSchedule } from '../bin/bitvid-schedule-guard.mjs'; + +const moduleUrl = new URL('../bin/bitvid-schedule-guard.mjs', import.meta.url).href; +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bitvid-schedule-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const c = { repo: root, stateDir: root, areas: [{ id: 'dispatch' }], schedules: [{ name: 'resources' }, { name: 'restart' }] }; + fs.mkdirSync(path.join(root, 'runs')); + const record = path.join(root, 'runs', 'dispatch.json'); + const scheduler = path.join(root, 'schedule.sh'); + fs.writeFileSync(scheduler, 'echo started\nprintf yes > "'+root+'/launched"\n'); + const live = () => fs.writeFileSync(record, JSON.stringify({ status: 'running', pid: process.pid })); + return { c, root, record, scheduler, live }; +} + +for (const job of ['resources', 'restart']) { + test(`${job} does not launch while down`, async (t) => { + const f = fixture(t); + assert.deepEqual(await guardSchedule(f.c, job, { scheduler: f.scheduler }), { status: 0, reason: 'fleet down' }); + assert.equal(fs.existsSync(path.join(f.root, 'launched')), false); + }); + test(`${job} cannot bypass pause even with a live fleet`, async (t) => { + const f = fixture(t); f.live(); fs.writeFileSync(path.join(f.root, 'PAUSED'), 'owner pause'); + assert.deepEqual(await guardSchedule(f.c, job, { scheduler: f.scheduler }), { status: 0, reason: 'fleet paused' }); + assert.equal(fs.existsSync(path.join(f.root, 'launched')), false); + }); +} + +test('stopped record pointing at a live unrelated process cannot start a job', async (t) => { + const f = fixture(t); + fs.writeFileSync(f.record, JSON.stringify({ status: 'stopped', pid: process.pid })); + assert.equal((await guardSchedule(f.c, 'resources', { scheduler: f.scheduler })).reason, 'fleet down'); +}); + +test('live unpaused fleet launches a short job and exits naturally', async (t) => { + const f = fixture(t); f.live(); + assert.deepEqual(await guardSchedule(f.c, 'resources', { scheduler: f.scheduler }), { status: 0, reason: null }); + assert.equal(fs.readFileSync(path.join(f.root, 'launched'), 'utf8'), 'yes'); +}); + +for (const transition of ['pause', 'down']) { + test(`already-running scheduled child is stopped on ${transition}`, { timeout: 5000 }, async (t) => { + const f = fixture(t); f.live(); + fs.writeFileSync(f.scheduler, 'echo started\nexec sleep 600\n'); + const code = `import {guardSchedule} from ${JSON.stringify(moduleUrl)}; const r=await guardSchedule(${JSON.stringify(f.c)},'resources',${JSON.stringify({scheduler:f.scheduler,pollMs:25})}); console.log(JSON.stringify(r)); process.exitCode=r.status;`; + const child = spawn(process.execPath, ['--input-type=module', '-e', code], { stdio: ['ignore','pipe','pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); }); + let output = ''; + const exited = new Promise((resolve, reject) => { child.once('error',reject); child.once('close',(status)=>resolve(status)); }); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.stdout.on('data', chunk => { output += chunk; if (output.includes('started\n')) resolve(); }); + child.once('exit', () => { if (!output.includes('started\n')) reject(new Error('child exited before ready')); }); + }); + if (transition === 'pause') fs.writeFileSync(path.join(f.root,'PAUSED'),'pause'); + else fs.writeFileSync(f.record, JSON.stringify({status:'stopped',pid:process.pid})); + assert.equal(await exited, 0); + assert.match(output, transition === 'pause' ? /fleet paused/ : /fleet down/); + assert.doesNotThrow(() => process.kill(process.pid, 0)); + }); +} From 144ff557b14efbbdf7a96b62c1a681d11d1b8a04 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Fri, 23 Oct 2026 08:54:43 -0400 Subject: [PATCH 3/4] storage-cors: record hosted Range probe audit --- docs/testing/storage-cors-range-audit-2026-10-23.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/testing/storage-cors-range-audit-2026-10-23.md diff --git a/docs/testing/storage-cors-range-audit-2026-10-23.md b/docs/testing/storage-cors-range-audit-2026-10-23.md new file mode 100644 index 00000000..e47d00b0 --- /dev/null +++ b/docs/testing/storage-cors-range-audit-2026-10-23.md @@ -0,0 +1,12 @@ +# Hosted URL health Range fallback audit — 2026-10-23 + +Inspected clean storage-cors HEAD 265ce4261f7c42f653ce0ac6a49da0eea16f0f66. Source and retained tests show the public playback promise for a bounded Range GET fallback is not implemented in the hosted health probe. + +The card-health path reaches UrlHealthController.handleUrlHealthBadge via feedCoordinator and probes candidate URLs in probeUrlList; playback calls the same controller through playbackCoordinator.probeUrl (js/app.js:5302-5305, js/app/playbackCoordinator.js:384-401,685, js/app/feedCoordinator.js:1319-1323, js/ui/urlHealthController.js:471-503). The controller sends fetch(trimmed, { method: "HEAD", mode: "no-cors", cache: "no-store", signal }) (urlHealthController.js:592-603). On network error, timeout, opaque response, or non-OK status such as 405, confirmPlayable falls back to a timed