[client-reliability] client-reliability-8 cancel active URL media after final owner disposal

This commit is contained in:
thePR0M3TH3AN committed 2026-10-20 16:56:07 -04:00
1 parent 3dac0afb8d
commit 3474668a83
12 files changed
+480 -47

No files matched your search

@@ -0,0 +1,4 @@
- Goal: measure active URL-media cancellation at integrated HEAD5b77fd20 before proposing minimal fix.
- Scope: real observer/controller/cache with controlled external HEAD/media IO; scratch/evidence only.
- Done: resource/listener/timer/promise/slot/FIFO/cache/inflight/stale-write measurements; shared/rejoin/cross-controller/legacy controls; exact reproduction and scoped proposal to dispatch.
- No product/committed-test changes or full/build/browser without routing/grant. Preserve prior queued-only assertions pending explicit contract correction.
@@ -0,0 +1,4 @@
- Implement only controller retainUrlProbe final-owner hook and active media settle/cleanup. Existing shared WeakMap owners and cancelled result paths suffice; no AbortController or observer/cache edit needed for video-element cleanup.
- Mark cancelled synchronously before promise continuations so same-turn rejoin cannot attach to obsolete work. Clear active cancellation hook on completion; reuse existing finally for exactly-once slot/FIFO release.
- Strict spec correction authorized at3dac0afb and recorded in docs/spec_changes/2026-10-20-client-reliability-8-active-media.md; retain live-owner cache/timeout/mirror contracts independently.
- No full/build/browser before dispatch allocation; one unchanged normal full landing gate can be authoritative after merged focused/lint.
@@ -1,10 +1,10 @@
{
"id": "client-reliability-8",
"area": "client-reliability",
"status": "open",
"title": "Measure and scope active URL-media last-owner cancellation with shared/rejoin controls",
"blockedOn": null,
"addedOn": "2026-10-20",
"filedAt": "6609269ea11aaff753e6d9f3d8b91eb04e219a20",
"evidence": "Owner continuation relayed20:44:41UTC. Stage3 still requires active owner-aware cancellation. Current lifecycle tests intentionally retain active resources/cache under previous queued-only contract. Read-only actual observer/controller controlled-IO reproduction first; explicit fix/test-contract routing required after measurements. See current area brief."
"id": "client-reliability-8",
"area": "client-reliability",
"status": "in-progress",
"title": "Measure and scope active URL-media last-owner cancellation with shared/rejoin controls",
"blockedOn": null,
"addedOn": "2026-10-20",
"filedAt": "6609269ea11aaff753e6d9f3d8b91eb04e219a20",
"evidence": "At5b77fd20 actual observer/controller controlled media last-owner retains1resource/3listeners/1timer/1slot/unsettled1;strict disposed/fifo checks naturally fail after cleanup. Queue3active/2waiting unchanged until controlled completion;shared/cross/legacy/rejoin/timeout/error/replacement7controls exit0,existing lifecycle18pass. Proposal controller retain/active-media settle cleanup only,explicit queued-only cache/rejoin test contract correction pending dispatch independent confirmation/routing. Evidence test_logs/TEST_LOG_2026-10-20_20-55-00_client-reliability-8-investigation.md. No product/tests/full/build/browser changes."
}
@@ -0,0 +1,13 @@
# Active URL-media final-owner cancellation
Source: docs/performance-efficiency-plan.md Stage3 requires removed cards not retain probes and shared visible cards not cancel each other's work. PROGRAM records owner continuation "Then continue with the active-probe/URL cancellation work." Dispatch explicitly routed this correction at3dac0afb after independent nine-mode confirmation.
The prior queued-only lifecycle tests deliberately allowed an active probe to finish and cache healthy/offline after its final observer owner was disposed. That was the contract for items4/5, not evidence of a regression. This item implements the next planned boundary: last-owner disposal during active video-element probing now immediately pauses/removes src/reloads, removes media listeners and the timeout, and settles cancelled/null without writing health cache or starting mirrors. Existing finally releases the slot once and advances FIFO. Its cancelled marker is synchronous, so a fresh owner even in the same turn obtains a new promise; late old callbacks must not cache, paint, remove newer inflight or release slots again.
Explicit corrections in tests/url-health-lifecycle.test.mjs:
- Disposed active-success result/cache retention -> immediate cleanup/null/no-cache and stale success/error callback immunity, followed by a fresh successful request.
- Disposed final failure/offline cache -> separately retained live-owner offline cache check; disposed late error now strict null/no-cache.
- Disposed timeout-driven cleanup -> cancellation before any time tick, plus separate live-owner retry-success and retry-timeout/cache scenarios.
- Rejoin after final-owner disposal -> new promise/new primary; sharing before final owner leaves still uses the same promise/ordered mirrors.
Separate live-owner healthy/offline/timeout cache, timeout retry, shared/cross-controller/legacy ownerless consumers, bounded concurrency/FIFO, queued cancellation and ordered mirrors remain required. No observer/cache/HEAD/HLS/torrent/playback policy change. Browser/network/CPU/RSS evidence remains a later gate.
+17 -3
View File
@@ -308,7 +308,10 @@ export default class UrlHealthController {
record.owners.add(owner);
const onDispose = () => {
record.owners.delete(owner);
if (!record.owners.size) record.cancelQueued?.();
if (!record.owners.size) {
record.cancelQueued?.();
record.cancelActive?.();
}
};
owner.releases?.add(onDispose);
return () => {
@@ -387,6 +390,9 @@ export default class UrlHealthController {
let timeoutId = null;
const cleanup = () => {
if (lifecycle?.cancelActive === cancelActive) {
lifecycle.cancelActive = null;
}
if (timeoutId) {
clearTimeout(timeoutId);
}
@@ -423,6 +429,14 @@ export default class UrlHealthController {
settle({ outcome: "error" });
};
const cancelActive = () => {
// Mark synchronously: a same-turn owner must start fresh work rather
// than join the cancelled promise before its continuations run.
lifecycle.cancelled = true;
settle({ outcome: "cancelled" });
};
if (lifecycle) lifecycle.cancelActive = cancelActive;
if (Number.isFinite(effectiveTimeout) && effectiveTimeout > 0) {
timeoutId = setTimeout(() => {
settle({ outcome: "timeout" });
@@ -463,8 +477,8 @@ export default class UrlHealthController {
}
let sawTimeout = false;
for (const url of list) {
// Completed active work may still cache its result, but starting another
// source requires a live owner (including a card that rejoined meanwhile).
// Starting another source requires a live owner. Cancelled active media
// settles without caching or falling through to another mirror.
if (options.lifecycle && !options.lifecycle.owners.size) {
options.lifecycle.cancelled = true;
return { outcome: "cancelled" };
@@ -0,0 +1,51 @@
test_integrity_note:
change_type: ["spec_correction", "new_tests"]
scenarios:
- id: SCN-active-media-last-owner
given: "Actual observer/controller/cache has active video media and last registered owner"
when: "Owner disposes; retained success/error/timeout callbacks arrive later"
then: "Immediate media/listener/timer cleanup, null settlement, no cache or mirror or stale badge; cleanup once"
- id: SCN-active-media-replacement
given: "Final owner cancels active media"
when: "Same-turn or after-flush fresh owner requests same source"
then: "Fresh promise; old callbacks/finalizer cannot remove/cache/paint/release over replacement"
- id: SCN-active-media-shared
given: "Same/cross-controller or legacy ownerless consumer shares active media"
when: "Old observer owner disposes"
then: "One shared media/promise survives, live consumer gets healthy cache; final registered shared owner cancels exactly once"
- id: SCN-active-media-fifo
given: "Three active media, two live FIFO waiters"
when: "Target last owner disposes while newcomer requests"
then: "First waiter advances without completion/timeout, later waiter precedes newcomer, max3slots and final0"
- id: SCN-active-media-live-timeout
given: "Live owner needs media confirmation"
when: "Initial timeout then retry succeeds or times out"
then: "Normal retry/media cleanup and healthy/timeout cache reuse remain; live final error caches offline"
observable_outcomes:
- "Real media src/pause/load/listener/timer boundaries, promise result, FIFO source starts, badge/cache/inflight identity"
determinism_controls:
- "Real observer/controller/cache; fake IntersectionObserver, HEAD/media IO boundary, controlled timers; no public network"
- "setImmediate event-loop flush, explicit media events and retained callback invocation; fixture restores globals/policy/DOM"
anti_cheat_rationale:
prevents:
- "Skipping work for live shared consumers"
- "Marking cancelled without releasing resources or settling"
- "Caching cancelled results, retaining timers/listeners, double releasing slots"
- "Joining cancelled records or overwriting replacements"
- "FIFO disruption or suppressing live timeout retries"
relaxation:
did_relax_any_assertion: true
if_true_explain_spec_basis: "Explicit authorized spec correction at3dac0afb replaces prior disposed active cache/rejoin allowances with stricter immediate cancellation/null/no-cache/new-record requirements, per Stage3 and owner continuation. Old cache expectations no longer apply after final-owner cancellation; separate live healthy/offline/timeout/cache controls retained. No weaker retries/sleeps/skips/timeouts. See docs/spec_changes/2026-10-20-client-reliability-8-active-media.md."
Mutant mapping (all restored, natural exit1,0cancelled):
- disable-active-cancel -> cleanup/same-turn/FIFO/timer/final-shared scenarios
- retain-src,retain-listener,retain-timer,skip-cleanup-load,skip-pause -> strict cleanup boundaries
- missing-sync-marker -> same-turn replacement/rejoin
- cache-on-cancel -> null/no-cache/fresh request
- cancel-shared-owner -> shared/cross/legacy/final-shared controls
- fifo-pop -> queued competition order
- double-cleanup -> late success/error/timeout and replacement slot/pause assertions
- obsolete-inflight-write -> same-turn/after-flush identity assertions (controller-local simulated obsolete cache write; cache module unchanged)
- skip-retry,force-offline-healthy -> live-owner retry/offline contract
14/14 rejected; no surviving mutants. /tmp/client8-mutants.json and /tmp/client8-mutant-*.log retain exact failures. Restored suite29/29 passes.
@@ -0,0 +1,12 @@
# client-reliability-8 current-HEAD active media investigation
- HEAD `5b77fd2029cffe016e1cf582322eac35ba4022a1`; merged unstable fast-forward after sandbox read-only ORIG_HEAD failure via approved escalated git merge. Item filedAt6609269e; coordination-only delta, active controller unchanged. No product/test edits.
- Repro `/tmp/bitvid-client-reliability-8-active-media.mjs`: actual attachUrlHealthBadges -> controller -> shared cache -> real document.createElement video; controlled HEAD and HTMLMediaElement boundary only, mock timer4000ms. No public IO/browser. Counters track media src resources,3 named media listener registrations, positive probe timers only (exclude unrelated jsdom0ms timer), actual slots/queue/promises/cache/inflight. Source names captured at media start before cleanup erases src.
- `env -u NODE_TEST_CONTEXT node /tmp/bitvid-client-reliability-8-active-media.mjs disposed --expect-cancel` exit1 NATURALLY AFTER cleanup: before/after last-owner disposal resources1/listeners3/timer1/slot1/inflight1/settled0. Expected resources0/settled1. Controlled loadeddata clears all resources/listeners/timer/slots and settles1, but writes healthy cache after owner loss. Disposed connected badge stays checking. Later request reuses healthy cache,0new HEAD.
- Same command with `fifo --expect-cancel` exit1 after cleanup:3active resources/9listeners/3timers/3slots,2queued unchanged on disposal;settled0 expected1. On controlled target completion,first queued starts(media total3->4,queue2->1);all5 complete in original FIFO order,target/0/1/2/3,final resources/listeners/timers/slots/queue0,settled5. Proposed cancellation would replace target with first waiting live work promptly, so total resources still3 while target itself is removed.
- Modes `shared`, `cross`, `legacy`, `rejoin`, `timeout`, `error`, `replacement` each naturally exit0. Shared/cross-controller/legacy direct badge retain1HEAD/1media/1promise through old-owner disposal; live badge becomes healthy,old stays checking,final cleanup0. Fresh owner after disposal currently rejoins same original promise and receives healthy with no second HEAD; future immediate cancellation should instead start fresh and never revive obsolete record. Timeout tick4000 releases all,settles1,no cache/no retry resources,subsequent request starts1HEAD and becomes healthy. Final error currently writes offline after owner loss;later request reuses offline0HEAD. Replacement inflight sentinel survives old completion,demonstrating existing identity-safe cache finalizer;old result still writes healthy cache.
- Logs `/tmp/client-reliability-8-{disposed,fifo,shared,cross,legacy,rejoin,timeout,error,replacement}.log`. Initial scratch instrumentation failed because unrelated jsdom0ms timer counted; corrected measurement scope,not a product bug. Initial error mode incorrectly assumed later cache always healthy; corrected scratch to assert observed prior-contract offline reuse. Neither involved changing repo tests.
- `env -u NODE_TEST_CONTEXT node tests/url-health-lifecycle.test.mjs` natural exit0:18pass/0fail/0cancel/0skip;log `/tmp/client-reliability-8-baseline-lifecycle.log`. Existing tests unchanged. No lint/full/build/browser at investigation-only stage.
- Smallest proposed product boundary: js/ui/urlHealthController.js retainUrlProbe last-owner hook plus probeUrlWithVideoElement's settle/cleanup lifecycle, record cancellation marker. Settle cancelled synchronously enough to mark old record unavailable before rejoin; cleanup video/timer/listeners, existing finally releases slot/FIFO; suppress cache/mirror/stale UI using existing cancelled paths. Shared/cross-controller/ownerless live consumers preserve record. No observer/cache/HEAD/HLS/torrent changes proposed.
- Existing lifecycle tests at320(active success cache),566(active final failure cache) express superseded queued-only scope and would require explicit strict spec correction: immediate media cleanup/settlement and no cache after last-owner cancellation; separate live-owner success/offline/timeout controls retain cache contracts. Rejoin tests473 must distinguish joining before final owner leaves from fresh replacement after cancelled record. Timeout534 remains meaningful for live owner timeout plus separately strict cancellation-without-clock-tick. No changes before dispatch independent confirmation/routing.
- Unverified: actual browser resource release/network byte savings/CPU/RSS,real media playback/fallback/live peers; HEAD/HLS/torrent cancellation not assessed or completed. Replacement sentinel is controlled cache IO identity check,not a real fresh-owner cancellation run. Scratch counters are controlled IO evidence,not production leak proof.
@@ -0,0 +1,9 @@
# Item8 focused-ready
- Base/integrated HEAD3dac0afb8d8e9afe62c01c482d6f4ac5ccc2a581. Product delta only js/ui/urlHealthController.js:18added/2removed; final-owner active media cancellation+sync cancelled marker+cleanup hook. Tests only tests/url-health-lifecycle.test.mjs; new spec/integrity/decision/evidence notes. No observer/cache/HEAD/HLS/torrent/app/runner/config changes.
- Before product edit, corrected19-scenario suite naturalexit1:15pass/4fail/0cancel/0skip. `/tmp/client8-spec-baseline.log`. First fix19pass;expanded28pass;final restored29pass.
- Final focused `env -u NODE_TEST_CONTEXT node <file>`: tests/url-health-lifecycle.test.mjs29/29,tests/url-health-multisource.test.mjs4/4,tests/ui/url-health-controller.test.mjs4/4. All3processes naturalexit0,37scenarios/0fail/0cancel/0skip. `/tmp/client8-focused-{0,1,2}.log`.
-14isolated mutants each naturalexit1/0cancelled,all restored by finally;no timeout or force exit. Mapping in TEST_INTEGRITY_2026-10-20_client-reliability-8.md;exact results /tmp/client8-mutants.json,logs /tmp/client8-mutant-*.log. Final focused ran after restoration.
- `env -u NODE_TEST_CONTEXT npm run lint` first sandbox run exit2 at lint:hex because read-only git grep subprocess spawn EPERM;not assertion failure. Approved same command outside sandbox exit0/all11stages in `/tmp/client8-lint-approved.log`;unchanged gate/checks. Product content restored after mutants. Prettier on owned files and git diff --check exit0.
- Former disposed-active cache/rejoin contract explicitly corrected with strict cleanup/null/no-cache/new-record tests. Separate live success/offline/timeout+retry/cache,shared/cross/legacy,queued/mirror/FIFO preserved. No test removed to pass;see unique spec+integrity notes for honest expectation-change disclosure.
- Full/build/browser UNRUN pending grant. No release,actual network bytes,CPU/RSS,real media playback/fallback/livepeer qualification; HEAD/HLS/torrent cancellation not implemented. No landing claimed yet.
+347 -36
View File
@@ -38,6 +38,10 @@ function fixture(t) {
});
const policy = getCardLivenessPolicy();
setCardLivenessPolicy("show-pending");
const resources = new Set(),
listeners = new Map(),
handlers = new Map(),
pauses = [];
const observers = [],
media = [],
heads = [],
@@ -77,10 +81,31 @@ function fixture(t) {
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);
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);
};
dom.window.HTMLMediaElement.prototype.pause = function () {};
const controller = new UrlHealthController({
state: {
...cache,
@@ -103,7 +128,10 @@ function fixture(t) {
});
function feed(
videos,
{ container = document.createElement("section") } = {}
{
container = document.createElement("section"),
consumer = controller
} = {}
) {
container.replaceChildren();
for (const video of videos) {
@@ -126,7 +154,7 @@ function fixture(t) {
container,
({ badgeEl, url, eventId }) => {
checks.push(eventId);
controller.handleUrlHealthBadge({
consumer.handleUrlHealthBadge({
video: videos.find((v) => v.id === eventId),
url,
badgeEl
@@ -173,6 +201,10 @@ function fixture(t) {
});
return {
dom,
resources,
listeners,
handlers,
pauses,
controller,
media,
heads,
@@ -317,24 +349,45 @@ test("disposal blocks queued eager microtasks and late observer callbacks, inclu
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);
});
for (const lateEvent of ["loadeddata", "error"]) {
test(`last-owner active media cancellation cleans immediately and ignores late ${lateEvent}`, async (t) => {
const f = fixture(t),
v = f.video("active"),
old = f.feed([v]);
old.observer.show();
await flush();
const media = f.media[0];
assert.equal(f.resources.size, 1);
assert.equal(f.listeners.get(media).size, 3);
old.dispose();
assert.equal(media.getAttribute("src"), null);
assert.equal(f.resources.size, 0);
assert.equal(f.listeners.get(media).size, 0);
assert.deepEqual(f.pauses, [media]);
await flush();
assert.equal(f.controller.activeVideoProbes, 0);
assert.equal(await f.promises[0], null);
f.handlers.get(media).get(lateEvent)(); // Simulate an already queued browser callback.
f.done(media, lateEvent);
old.dispose();
await flush();
assert.equal(cache.getCachedUrlHealth(v.id, v.url), null);
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
assert.deepEqual(
f.pauses,
[media],
"cleanup runs once despite late callbacks"
);
const live = f.feed([v]);
live.observer.show();
await flush();
assert.equal(f.heads.length, 2);
f.done(f.media[1]);
await flush();
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
assert.equal(cache.getCachedUrlHealth(v.id, v.url).status, "healthy");
});
}
test("hidden URL-only card probes eagerly and reveals after healthy media confirmation", async (t) => {
const f = fixture(t);
@@ -486,23 +539,29 @@ for (const timing of ["shared", "rejoin"]) {
live.observer.show();
await flush();
if (timing === "shared") old.dispose();
assert.equal(f.heads.length, 1);
assert.equal(f.promises.length, 1);
f.done(f.media[0], "error");
const primary = timing === "shared" ? 0 : 1;
assert.equal(f.heads.length, primary + 1);
assert.equal(f.promises.length, primary + 1);
if (timing === "rejoin") assert.equal(await f.promises[0], null);
f.done(f.media[primary], "error");
await flush();
assert.equal(f.heads.length, 2);
assert.equal(f.heads[1].source, v.sources[0].url);
assert.equal(f.media.length, 2);
f.done(f.media[1]);
assert.equal(f.heads.length, primary + 2);
assert.equal(f.heads[primary + 1].source, v.sources[0].url);
assert.equal(f.media.length, primary + 2);
f.done(f.media[primary + 1]);
await flush();
assert.equal(f.heads.length, 2, "healthy first mirror must stop fallback");
assert.equal(
f.heads.length,
primary + 2,
"healthy first mirror must stop fallback"
);
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
assert.equal(f.controller.activeVideoProbes, 0);
const cached = f.feed([v]);
cached.observer.show();
await flush();
assert.equal(f.heads.length, 2);
assert.equal(f.heads.length, primary + 2);
assert.equal(cached.badges[0].dataset.urlHealthState, "healthy");
});
}
@@ -531,7 +590,7 @@ test("active first mirror error after disposal does not start the next mirror",
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
});
test("disposed active media timeout releases its slot without retry resources and later requests remain playable", async (t) => {
test("disposed active media cancels before timeout and later requests remain playable", async (t) => {
const f = fixture(t),
v = f.video("active-timeout");
f.controller.constants = {
@@ -543,6 +602,9 @@ test("disposed active media timeout releases its slot without retry resources an
old.observer.show();
await flush();
old.dispose();
assert.equal(f.media[0].getAttribute("src"), null);
await flush();
assert.equal(f.controller.activeVideoProbes, 0);
t.mock.timers.tick(cache.URL_PROBE_TIMEOUT_MS);
await flush();
assert.equal(f.heads.length, 1);
@@ -563,17 +625,266 @@ test("disposed active media timeout releases its slot without retry resources an
assert.equal(f.controller.activeVideoProbes, 0);
});
test("completed active failure without another candidate preserves its existing offline cache result", async (t) => {
test("live active failure without another candidate preserves offline cache result", async (t) => {
const f = fixture(t),
v = f.video("final-active-failure"),
old = f.feed([v]);
old.observer.show();
await flush();
old.dispose();
f.done(f.media[0], "error");
await flush();
assert.equal(f.heads.length, 1);
assert.equal(f.controller.activeVideoProbes, 0);
assert.equal(cache.getCachedUrlHealth(v.id, v.url).status, "offline");
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
assert.equal(old.badges[0].dataset.urlHealthState, "offline");
});
for (const timing of ["same-turn", "after-flush"]) {
test(`active cancellation permits ${timing} replacement immune to old callbacks and finalizer`, async (t) => {
const f = fixture(t),
v = f.video(timing),
old = f.feed([v]);
old.observer.show();
await flush();
const oldMedia = f.media[0],
original = f.promises[0];
old.dispose();
if (timing === "after-flush") await flush();
const fresh = f.feed([v]);
fresh.observer.show();
const replacement = f.promises[1];
assert.ok(replacement);
assert.notEqual(replacement, original);
await flush();
assert.equal(await original, null);
assert.equal(f.media.length, 2);
assert.equal(
cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true }),
replacement
);
for (const callback of f.handlers.get(oldMedia).values()) callback();
await flush();
assert.equal(f.resources.has(f.media[1]), true);
assert.equal(f.controller.activeVideoProbes, 1);
assert.equal(
cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true }),
replacement
);
assert.equal(cache.getCachedUrlHealth(v.id, v.url), null);
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
assert.equal(fresh.badges[0].dataset.urlHealthState, "checking");
f.done(f.media[1]);
await flush();
assert.equal(fresh.badges[0].dataset.urlHealthState, "healthy");
assert.equal(
cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true }),
null
);
assert.equal(f.controller.activeVideoProbes, 0);
assert.equal(f.pauses.length, 2);
});
}
for (const consumerKind of ["same-controller", "cross-controller", "legacy"]) {
test(`active probe survives old-owner disposal for a ${consumerKind} live consumer`, async (t) => {
const f = fixture(t),
v = f.video(consumerKind),
old = f.feed([v]);
old.observer.show();
await flush();
const consumer =
consumerKind === "cross-controller"
? new UrlHealthController({ state: cache, logger: { warn() {} } })
: f.controller;
let badge, live;
if (consumerKind === "legacy") {
badge = document.createElement("span");
document.body.append(badge);
consumer.handleUrlHealthBadge({ video: v, url: v.url, badgeEl: badge });
} else {
live = f.feed([v], { consumer });
live.observer.show();
badge = live.badges[0];
}
old.dispose();
await flush();
assert.equal(f.heads.length, 1);
assert.equal(f.promises.length, 1);
assert.equal(f.resources.size, 1);
assert.equal(f.listeners.get(f.media[0]).size, 3);
assert.equal(f.controller.activeVideoProbes, 1);
assert.equal(
consumerKind === "cross-controller" ? consumer.activeVideoProbes : 0,
0
);
f.done(f.media[0]);
await flush();
assert.equal(badge.dataset.urlHealthState, "healthy");
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
assert.equal(cache.getCachedUrlHealth(v.id, v.url).status, "healthy");
assert.equal(f.resources.size, 0);
assert.equal(f.controller.activeVideoProbes, 0);
});
}
test("last-owner active cancellation advances waiting live media in FIFO order under same-turn competition", async (t) => {
const f = fixture(t),
v = f.video("cancel-target"),
old = f.feed([v]);
old.observer.show();
const busy = f.feed(
["busy-a", "busy-b", "first-waiter", "second-waiter"].map(f.video)
);
busy.observer.show();
await flush();
assert.equal(f.resources.size, 3);
assert.equal(f.controller.videoProbeQueue.length, 2);
old.dispose();
const newcomer = f.feed([f.video("newcomer")]);
newcomer.observer.show();
await flush();
assert.equal(await f.promises[0], null);
assert.equal(f.media[0].getAttribute("src"), null);
assert.equal(f.media.length, 4);
assert.equal(f.media[3].src, busy.videos[2].url);
assert.equal(f.controller.activeVideoProbes, 3);
assert.equal(f.resources.size, 3);
assert.equal(f.controller.videoProbeQueue.length, 2);
f.done(f.media[1]);
await flush();
assert.equal(f.media[4].src, busy.videos[3].url);
f.done(f.media[2]);
await flush();
assert.equal(f.media[5].src, newcomer.videos[0].url);
assert.equal(f.controller.activeVideoProbes, 3);
await f.finishAll();
assert.equal(newcomer.badges[0].dataset.urlHealthState, "healthy");
assert.equal(f.controller.activeVideoProbes, 0);
assert.equal(f.controller.videoProbeQueue.length, 0);
assert.equal(f.resources.size, 0);
assert.equal(cache.getCachedUrlHealth(v.id, v.url), null);
});
// Observe the real timer boundary while controlling time; retain callbacks to
// simulate browser work already queued when cancellation removes the timer.
function trackProbeTimers(t) {
t.mock.timers.enable({ apis: ["setTimeout"] });
const pending = new Map(),
callbacks = [];
const set = globalThis.setTimeout,
clear = globalThis.clearTimeout;
t.mock.method(globalThis, "setTimeout", (fn, ms, ...args) => {
let id;
id = set(() => {
pending.delete(id);
fn(...args);
}, ms);
if (
ms === cache.URL_PROBE_TIMEOUT_MS ||
ms === cache.urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS
) {
pending.set(id, fn);
callbacks.push(fn);
}
return id;
});
t.mock.method(globalThis, "clearTimeout", (id) => {
pending.delete(id);
return clear(id);
});
return { pending, callbacks };
}
test("active cancellation clears timer and late timeout cannot clear or cache over replacement", async (t) => {
const f = fixture(t),
timers = trackProbeTimers(t),
v = f.video("timer"),
old = f.feed([v]);
f.controller.constants = {
URL_PROBE_TIMEOUT_MS: cache.URL_PROBE_TIMEOUT_MS,
urlHealthConstants: cache.urlHealthConstants
};
old.observer.show();
await flush();
assert.equal(timers.pending.size, 1);
const lateTimeout = [...timers.pending.values()][0];
old.dispose();
assert.equal(timers.pending.size, 0);
const fresh = f.feed([v]);
fresh.observer.show();
await flush();
assert.equal(await f.promises[0], null);
assert.equal(timers.pending.size, 1);
lateTimeout();
await flush();
assert.equal(f.media.length, 2);
assert.equal(f.resources.size, 1);
assert.equal(f.controller.activeVideoProbes, 1);
assert.equal(cache.getCachedUrlHealth(v.id, v.url), null);
assert.equal(
cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true }),
f.promises[1]
);
f.done(f.media[1]);
await flush();
assert.equal(fresh.badges[0].dataset.urlHealthState, "healthy");
assert.equal(timers.pending.size, 0);
assert.equal(f.pauses.length, 2);
});
for (const retryOutcome of ["success", "timeout"]) {
test(`live-owner media timeout retains normal retry and ${retryOutcome} cache behavior`, async (t) => {
const f = fixture(t),
timers = trackProbeTimers(t),
v = f.video(retryOutcome),
live = f.feed([v]);
f.controller.constants = {
URL_PROBE_TIMEOUT_MS: cache.URL_PROBE_TIMEOUT_MS,
urlHealthConstants: cache.urlHealthConstants
};
live.observer.show();
await flush();
t.mock.timers.tick(cache.URL_PROBE_TIMEOUT_MS);
await flush();
assert.equal(f.media.length, 2);
assert.equal(f.media[0].getAttribute("src"), null);
assert.equal(f.resources.size, 1);
assert.equal(timers.pending.size, 1);
if (retryOutcome === "success") f.done(f.media[1]);
else
t.mock.timers.tick(cache.urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS);
await flush();
const status = retryOutcome === "success" ? "healthy" : "timeout";
assert.equal(live.badges[0].dataset.urlHealthState, status);
assert.equal(cache.getCachedUrlHealth(v.id, v.url).status, status);
assert.equal(f.resources.size, 0);
assert.equal(timers.pending.size, 0);
assert.equal(f.controller.activeVideoProbes, 0);
assert.equal(f.heads.length, 1);
const cached = f.feed([v]);
cached.observer.show();
await flush();
assert.equal(cached.badges[0].dataset.urlHealthState, status);
assert.equal(f.heads.length, 1);
});
}
test("disposing the final shared active owner cancels exactly once", async (t) => {
const f = fixture(t),
v = f.video("final-shared"),
first = f.feed([v]),
second = f.feed([v]);
first.observer.show();
second.observer.show();
await flush();
assert.equal(f.media.length, 1);
first.dispose();
assert.equal(f.resources.size, 1);
second.dispose();
assert.equal(f.resources.size, 0);
await flush();
assert.equal(await f.promises[0], null);
assert.equal(f.controller.activeVideoProbes, 0);
assert.equal(cache.getCachedUrlHealth(v.id, v.url), null);
assert.equal(f.pauses.length, 1);
});
@@ -0,0 +1,4 @@
- [x] Read mailbox/new rules, merge unstable, claim8.
- [ ] Reproduce current active-media last-owner behavior and controls.
- [ ] Record evidence and propose boundary/contract correction to dispatch.
- [ ] Await explicit minimal-fix routing; no implementation authority yet.
@@ -0,0 +1,5 @@
- [x] Read mailbox/current rules, merge unstable, claim8 at5b77fd20.
- [x] Actual active URL-media disposal reproduction:2strict failures after cleanup;7controls pass;existing lifecycle18pass.
- [x] Record exact counts and minimal controller-only proposal with explicit old-contract test corrections.
- [ ] Dispatch independent confirmation and explicit minimal-fix/test-contract routing required.
- No product/committed-test changes;no expensive checks/browser.
@@ -0,0 +1,6 @@
- [x] Investigation independently confirmed; explicit controller-only/spec-correction routing3dac0afb.
- [x] Strict failing regression before product edits; minimal controller active cleanup.
- [x] Final focused37/37 across3files;14restored mutants rejected;lint11pass.
- [x] Unique spec/integrity/decision/test evidence.
- [ ] Commit/merge verification;READY and expensive slot request.
- [ ] Granted validation,unchanged normal full gate,durable ancestry before close.