diff --git a/context/CONTEXT_2026-10-22_14-10-00_client-reliability-9.md b/context/CONTEXT_2026-10-22_14-10-00_client-reliability-9.md new file mode 100644 index 00000000..2339cc57 --- /dev/null +++ b/context/CONTEXT_2026-10-22_14-10-00_client-reliability-9.md @@ -0,0 +1 @@ +Client9 at c5b678f7; investigate HEAD owner cancellation only via actual observer/controller and controlled fetch. No product/retained tests/full/build/browser. HLS/torrent remain separate. Success: counts for disposal/shared/rejoin/cache/timers/media and scoped proposal to dispatch. diff --git a/context/CONTEXT_2026-10-22_14-25-00_client-reliability-9.md b/context/CONTEXT_2026-10-22_14-25-00_client-reliability-9.md new file mode 100644 index 00000000..60ef49a3 --- /dev/null +++ b/context/CONTEXT_2026-10-22_14-25-00_client-reliability-9.md @@ -0,0 +1 @@ +Client9 HEAD-only candidate ready: controller cancel hook/abort+raced prompt settlement/timer identity cleanup;23new HEAD+37existing scenarios pass,16failing isolated mutants/cancelled0,lint11 pass. No existing test expectation edits. Explicit strict contract/integrity notes. Next commit candidate, merge current unstable, request full/browser slot; do not submit or run expensive jobs without grant. HLS/torrent separately open. diff --git a/context/CONTEXT_2026-10-22_20-45-00_storage-cors-4.md b/context/CONTEXT_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..6101f46e --- /dev/null +++ b/context/CONTEXT_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,5 @@ +- storage-cors-4: prevent obsolete registered feed results repainting deleted/replaced DOM. +- Baseline26e9a787; storage3 durable63e2b519 normalgateexit0, ancestry verified; close3 after durable lookup. +- Own coordinator only plus unique tests/notes. No app/view/engine/service/buffer edits. +- Plan: retain actual registered pipeline/JSDOM baseline; guard refresh/load ownership; cover async runtime/engine success+error, disposal/remount, telemetry and current controls; kill restored mutants; focused/lint; request serialized normal gate; verify durable landing. +- Definition of done: strict regressions+mutants, existing focused/lint, merged normal gate, durable status+ancestry. No live/browser qualification inferred. diff --git a/context/CONTEXT_2026-10-22_20-48-00_client-reliability-9.md b/context/CONTEXT_2026-10-22_20-48-00_client-reliability-9.md new file mode 100644 index 00000000..ebb3c5e7 --- /dev/null +++ b/context/CONTEXT_2026-10-22_20-48-00_client-reliability-9.md @@ -0,0 +1 @@ +Client9 HEAD-only fix durably landed f41ac656 after normal435-filegate exit0, ancestry0; sequential build/artifact exit0. Heavy slot released. Await separate bounded native browser slot; no browser started. HLS/torrent/RangeGET and wholeStage3 remain open. No additional product changes. diff --git a/context/CONTEXT_2026-10-22_20-52-00_client-reliability-9.md b/context/CONTEXT_2026-10-22_20-52-00_client-reliability-9.md new file mode 100644 index 00000000..688e6234 --- /dev/null +++ b/context/CONTEXT_2026-10-22_20-52-00_client-reliability-9.md @@ -0,0 +1 @@ +HEAD slice complete locally: durablef41ac656 normal435unique/exit0/ancestry0; build/artifact0; controlled native Chromium+Firefox lifecycle pass with synthetic media completion/public-deny. Browser/heavy resources released. Pending docs-only evidence landing; HLS/torrent/RangeGET remain open, await bounded routing/HEAD closure disposition. diff --git a/context/CONTEXT_2026-10-22_20-54-00_client-reliability-9.md b/context/CONTEXT_2026-10-22_20-54-00_client-reliability-9.md new file mode 100644 index 00000000..6e093d04 --- /dev/null +++ b/context/CONTEXT_2026-10-22_20-54-00_client-reliability-9.md @@ -0,0 +1 @@ +Item9 HEAD slice done/landedf41ac656. Read-only HLS diagnostic atd04d501e actualobserver/controller/helper reproduces5cancellationfailures/7controls pass/0cancelled/naturalexit1. Pending manifest GET/body retains timer and promise after final-owner disposal; rejoin original,late cache. Need dispatch explicit controller+HLShelper ownership/contract before edits. No browser/full/build/product/retainedtest changes. diff --git a/decisions/DECISIONS_2026-10-22_14-12-00_client-reliability-9.md b/decisions/DECISIONS_2026-10-22_14-12-00_client-reliability-9.md new file mode 100644 index 00000000..00ae39b5 --- /dev/null +++ b/decisions/DECISIONS_2026-10-22_14-12-00_client-reliability-9.md @@ -0,0 +1,3 @@ +- HEAD slice only. Current media cancellation hook does not cover fetch; reproduce real ownership flow before proposing hook reuse. +- Do not change product/tests without dispatch contract routing. Keep HLS/torrent and wide Stage3 completion open. +- No redundant full/lint/build/browser for scratch investigation; QA slot untouched. diff --git a/decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md b/decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..9ae26f90 --- /dev/null +++ b/decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,6 @@ +- Per-app WeakMap request and loader tokens avoid exposing a public API. Capture actual view, mounted container and telemetry context; only eligible completion may publish metadata/map/render/telemetry/impressions/similar content. +- Capture mounted view.container (not mount adapter return); existing lightweight adapters need not expose a container. Real destroyed view loses its container and cannot be resurrected by completion. +- Preserve started engine caller result: stale success resolves original videos/metadata; stale engine error resolves existing fallback/error metadata. loadFeedVideos still resolves undefined. +- A request superseded while awaiting history preload never starts engine: resolve {videos: fallback, metadata: {reason, engine: "superseded"}}. This explicitly distinguishes a skipped run while preserving the resolved payload shape, and avoids new obsolete engine-stage subscriptions. No changes to current valid failure/unavailable fallback or content policies. +- Old service callbacks and recommendation activation must also match the original load/view/context, including a same-feed replacement. Loader continuations recheck after each await before state writes. +- No existing assertions changed. New failure injection wraps actual engine execution and rejects only after production stages, since moderation subscription rejection itself is deliberately caught by the production stage. diff --git a/docs/agents/backlog/client-reliability-9.json b/docs/agents/backlog/client-reliability-9.json index fcbc3233..e378fc34 100644 --- a/docs/agents/backlog/client-reliability-9.json +++ b/docs/agents/backlog/client-reliability-9.json @@ -1,10 +1,11 @@ { "id": "client-reliability-9", "area": "client-reliability", - "status": "open", + "status": "in-progress", "title": "Measure remaining active HEAD HLS torrent cancellation at current HEAD", "blockedOn": null, "addedOn": "2026-10-22", "filedAt": "fb54cea3a2ba99b4860421724a5050af6b97c293", - "brief": "docs/agents/CAMPAIGN-2026-10-22.md" + "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" } diff --git a/docs/agents/backlog/storage-cors-3.json b/docs/agents/backlog/storage-cors-3.json index d70583e6..9f8a1beb 100644 --- a/docs/agents/backlog/storage-cors-3.json +++ b/docs/agents/backlog/storage-cors-3.json @@ -1,11 +1,12 @@ { "id": "storage-cors-3", "area": "storage-cors", - "status": "in-progress", + "status": "done", "title": "Audit feed/view tombstone stale-refresh regression gap with offline integration", "blockedOn": null, "addedOn": "2026-10-20", "filedAt": "7f99c0456931c917082d6a6680c79655221dcc82", "resumedOn": "2026-10-22", - "brief": "docs/agents/CAMPAIGN-2026-10-22.md" + "brief": "docs/agents/CAMPAIGN-2026-10-22.md", + "closedOn": "2026-10-22" } diff --git a/docs/agents/backlog/storage-cors-4.json b/docs/agents/backlog/storage-cors-4.json index 4efb3f56..67a08cdd 100644 --- a/docs/agents/backlog/storage-cors-4.json +++ b/docs/agents/backlog/storage-cors-4.json @@ -1,10 +1,12 @@ { "id": "storage-cors-4", "area": "storage-cors", - "status": "open", + "status": "done", "title": "Prevent obsolete registered feed completions restoring deleted or replaced DOM", - "blockedOn": "Implement separately only after existing storage3 candidate is frozen and submitted", + "blockedOn": null, "addedOn": "2026-10-22", "filedAt": "89f8923c6e83b79a2b8874f811cdff2af6a809d0", - "brief": "docs/agents/prompts/storage-cors.md" + "brief": "docs/agents/prompts/storage-cors.md", + "closedOn": "2026-10-22", + "landedSha": "7edc7ea86f33508bb790a12d802daa3af012f933" } diff --git a/docs/spec_changes/2026-10-22-client-reliability-9-active-head.md b/docs/spec_changes/2026-10-22-client-reliability-9-active-head.md new file mode 100644 index 00000000..2d7e2793 --- /dev/null +++ b/docs/spec_changes/2026-10-22-client-reliability-9-active-head.md @@ -0,0 +1,11 @@ +# Active HEAD owner cancellation + +Source: docs/performance-efficiency-plan.md Stage3, campaign2026-10-22 priority2, fleet/RULE-CHANGES.md2026-10-22 and explicit dispatch routing56417d11 durably landed e04549f8. + +Old scoped behavior: queued work and active media cancel on final-owner disposal, but active HEAD fetch remains until response/error/timeout. A fresh observer may join that obsolete inflight record. Controlled11-mode reproduction independently confirmed3 failures/8 controls. + +New contract: final registered owner loss invalidates active HEAD synchronously, requests transport abort when supported and promptly settles the observer chain null independently of transport cooperation. HEAD timer and cancellation hook are released. Same-turn/later consumers start fresh work; obsolete response/error/timer/finalizer cannot affect replacement, cache, badges, media or mirrors. Unsupported AbortController still permits logical settlement and cleanup, but cannot promise transport abort. + +Preserve shared and cross-controller ownership, legacy consumers, live HEAD success/error/timeout/opaque/bad-status media fallback and cache, media-hook handoff and FIFO/cap. Existing29 media lifecycle scenarios and4+4 adjacent controller/multisource scenarios remain unchanged. No retained assertion was relaxed or removed;23 new HEAD scenarios tighten previously uncovered behavior. This is a scope extension, not retroactive proof of HEAD cancellation by item8. + +Only js/ui/urlHealthController.js HEAD phase changes. Observer, cache, app, HLS, torrent, RangeGET and limits/TTLs unchanged. HLS/torrent remain separately open. No browser/real network bytes/fullStage3 qualification. diff --git a/docs/testing/storage-cors-4-integrity.md b/docs/testing/storage-cors-4-integrity.md new file mode 100644 index 00000000..00e48d5b --- /dev/null +++ b/docs/testing/storage-cors-4-integrity.md @@ -0,0 +1,38 @@ +# storage-cors-4 registered lifecycle integrity + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-storage4-registered-completion + given: "Real registered Recent/Explore/For You pipelines and real JSDOM VideoListView/Card, with controlled moderation subscription IO" + when: "Older success or rejection resolves after a newer refresh, deletion, empty result, feed replacement, or disposal" + then: "Only eligible current request changes DOM, metadata, telemetry and presentation effects; stale caller still settles" + - id: SCN-storage4-current-controls + given: "A current registered pipeline or fallback and a live view" + when: "History or moderation IO is delayed, engine fails, or engine is unavailable" + then: "Live cards/fallback and metadata remain observable with natural completion" + - id: SCN-storage4-obsolete-load + given: "Retained onVideos callback or history preload belongs to replaced load" + when: "Same-feed replacement or different-feed load wins" + then: "Old callback starts no refresh; obsolete runtime starts no engine or presentation effects" + observable_outcomes: + - "Card DOM IDs, view container ownership, payloads, metadata identity, render/impression counts and telemetry calls" + determinism_controls: + - "Explicit deferred IO; synthetic offline events, fake subscription/storage adapters; no sleeps/retries/timeouts" + anti_cheat_rationale: + prevents: + - "hard-coded empty output via delayed/current/fallback controls" + - "decorator-only pipeline replacement via actual registered production stages" + - "success-only guards via delayed rejected engine with telemetry assertions" + - "per-feed counters ignoring view replacement via same-feed remount controls" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +16 retained scenarios in tests/storage-feed-lifecycle.test.mjs. Auto-discovered by unchanged normal runner; no registration bypass. Uses existing actual client/buffer/service fixture, synthetic signatures and controlled adapters. Source, filters, moderation, timestamps, scoring/sorting and actual DOM rendering execute. First moderation setActiveEventIds containing original pauses; no custom decorator stage. Engine error tests wrap real run and reject after its production stages return. History preload exercises actual For You await. + +Nine isolated runtime mutants all exit1 naturally, cancelled0; restored in finally. Original coordinator fails12/16; ignoring request order fails4; ignoring view eligibility fails2; ignoring stale errors fails2; ignoring stale runtime fails1; suppressing current rendering fails9; obsolete load callbacks enabled fails1; allowing orphan completion fails11; dropping fallback fails3. Each retained scenario rejects original or a relevant broken control variant. No assertion relaxation or existing-test edit. + +Live network/signatures/moderation timing, full application router/auth teardown, native browser and manual release not verified. No new source/cache/deletion policy. Return choice and pre-engine supersession are documented in decisions/DECISIONS_2026-10-22_20-45-00_storage-cors-4.md. diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 5281941d..9bdfaf8d 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -56,6 +56,14 @@ export function createFeedCoordinator(deps) { registerWatchHistoryFeed: registerWatchHistoryFeedFn, } = deps; + // Each app owns one presentation target. Requests may finish for their caller + // after losing that target, but must not publish into a newer feed/view. + const refreshRequests = new WeakMap(); + const feedLoads = new WeakMap(); + const ownsView = (app, view, container) => + app.videoListView === view && + (!container || view?.container === container); + return { registerRecentFeed() { if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { @@ -830,6 +838,17 @@ export function createFeedCoordinator(deps) { }, async refreshFeed(feedType, { reason, fallbackVideos } = {}) { + const request = {}; + refreshRequests.set(this, request); + const view = this.videoListView; + const container = view?.container; + const load = feedLoads.get(this); + const activeFeed = this.feedTelemetryState?.activeFeed; + const isCurrent = () => + refreshRequests.get(this) === request && + feedLoads.get(this) === load && + this.feedTelemetryState?.activeFeed === activeFeed && + ownsView(this, view, container); const normalizedReason = typeof reason === "string" ? reason : undefined; const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; let runtime = {}; @@ -902,6 +921,11 @@ export function createFeedCoordinator(deps) { ); } + // A runtime preload can outlive its view before the engine even starts. + if (!isCurrent()) { + return { videos: fallback, metadata: { reason: normalizedReason, engine: "superseded" } }; + } + // 2. Check Engine if (!this.feedEngine || typeof this.feedEngine.run !== "function") { const metadata = metadataModifier({ @@ -930,6 +954,9 @@ export function createFeedCoordinator(deps) { if (normalizedReason) metadata.reason = normalizedReason; metadata = metadataModifier(metadata); + const payload = { videos, metadata }; + if (!isCurrent()) return payload; + this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { @@ -941,7 +968,6 @@ export function createFeedCoordinator(deps) { this.updateFeedTelemetryMetadata(feedType, items, metadata); } - const payload = { videos, metadata }; this.renderVideoList(payload); return payload; }) @@ -955,6 +981,8 @@ export function createFeedCoordinator(deps) { error: true, }; metadata = metadataModifier(metadata); + const payload = { videos: fallback, metadata }; + if (!isCurrent()) return payload; this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); @@ -966,7 +994,6 @@ export function createFeedCoordinator(deps) { this.updateFeedTelemetryMetadata(feedType, [], metadata); } - const payload = { videos: fallback, metadata }; this.renderVideoList(payload); return payload; }); @@ -1012,6 +1039,8 @@ export function createFeedCoordinator(deps) { */ async loadFeedVideos(feedType, forceFetch = false) { devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")"); + const load = {}; + feedLoads.set(this, load); this.setFeedTelemetryContext(feedType); const recommendationFeed = @@ -1102,6 +1131,13 @@ export function createFeedCoordinator(deps) { } } + const view = this.videoListView; + const viewContainer = view?.container; + const activeFeed = this.feedTelemetryState?.activeFeed; + const isCurrentLoad = () => + feedLoads.get(this) === load && + ownsView(this, view, viewContainer) && + this.feedTelemetryState?.activeFeed === activeFeed; let initialRefreshPromise = null; const videos = await this.nostrService.loadVideos({ @@ -1109,6 +1145,7 @@ export function createFeedCoordinator(deps) { blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), onVideos: (payload, detail = {}) => { + if (!isCurrentLoad()) return; const promise = refreshMethod({ reason: detail?.reason, fallbackVideos: payload, @@ -1119,12 +1156,14 @@ export function createFeedCoordinator(deps) { }, }); + if (!isCurrentLoad()) return; if (initialRefreshPromise) { await initialRefreshPromise; } else if (!Array.isArray(videos) || videos.length === 0) { await refreshMethod({ reason: "initial", fallbackVideos: [] }); } + if (!isCurrentLoad()) return; this.videoSubscription = this.nostrService.getVideoSubscription() || null; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { @@ -1136,7 +1175,7 @@ export function createFeedCoordinator(deps) { // deliberately cache-first and must not wait for worker startup. if (exploreActivation) { void exploreActivation.then(() => { - if (!this.isFeedActive(feedType)) { + if (!isCurrentLoad()) { return; } return this.refreshFeed(feedType, { reason: "recommendation-data-ready" }); diff --git a/js/ui/urlHealthController.js b/js/ui/urlHealthController.js index e2f66a9f..6af13135 100644 --- a/js/ui/urlHealthController.js +++ b/js/ui/urlHealthController.js @@ -562,16 +562,44 @@ export default class UrlHealthController { return result; }; + const lifecycle = options.lifecycle; + if (lifecycle && (lifecycle.cancelled || !lifecycle.owners.size)) { + lifecycle.cancelled = true; + return { outcome: "cancelled" }; + } const supportsAbort = typeof AbortController !== "undefined"; const controller = supportsAbort ? new AbortController() : null; let timeoutId = null; + let resolveCancellation; + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve; + }); + const cancelActive = () => { + // Invalidate before resolving: same-turn rejoin must start fresh work. + lifecycle.cancelled = true; + resolveCancellation({ outcome: "cancelled" }); + // Settlement must not depend on the transport observing this signal. + controller?.abort(); + }; + if (lifecycle) lifecycle.cancelActive = cancelActive; + const cleanupHead = () => { + if (timeoutId) clearTimeout(timeoutId); + if (lifecycle?.cancelActive === cancelActive) { + lifecycle.cancelActive = null; + } + }; const racers = [ - fetch(trimmed, { - method: "HEAD", - mode: "no-cors", - cache: "no-store", - signal: controller ? controller.signal : undefined + cancellation, + new Promise((resolve) => { + resolve( + fetch(trimmed, { + method: "HEAD", + mode: "no-cors", + cache: "no-store", + signal: controller ? controller.signal : undefined + }) + ); }) ]; @@ -596,9 +624,8 @@ export default class UrlHealthController { try { responseOrTimeout = await Promise.race(racers); } catch (err) { - if (timeoutId) { - clearTimeout(timeoutId); - } + cleanupHead(); + if (lifecycle?.cancelled) return { outcome: "cancelled" }; this.logger.warn(`[probeUrl] HEAD request failed for ${trimmed}:`, err); const fallback = await confirmWithVideoElement(); if (fallback) { @@ -609,8 +636,9 @@ export default class UrlHealthController { : { outcome: "unknown", error: err }; } - if (timeoutId) { - clearTimeout(timeoutId); + cleanupHead(); + if (lifecycle?.cancelled || responseOrTimeout?.outcome === "cancelled") { + return { outcome: "cancelled" }; } if (responseOrTimeout && responseOrTimeout.outcome === "timeout") { diff --git a/test_logs/TEST_INTEGRITY_2026-10-22_client-reliability-9.md b/test_logs/TEST_INTEGRITY_2026-10-22_client-reliability-9.md new file mode 100644 index 00000000..bd24989f --- /dev/null +++ b/test_logs/TEST_INTEGRITY_2026-10-22_client-reliability-9.md @@ -0,0 +1,73 @@ +# HEAD lifecycle integrity + +```yaml +test_integrity_note: + change_type: ["new_tests", "spec_correction"] + scenarios: + - id: SCN-head-final-owner + given: "actual observer/controller HEAD with abortable, ignoring-abort or missing AbortController transport" + when: "final owner disposes, including duplicate disposal and already-completed HEAD boundary" + then: "prompt null settlement, timer cleanup, no cache/media/mirror/stale badge; abort if supported" + - id: SCN-head-replacement + given: "same-turn or later rejoin after disposal" + when: "obsolete success, error or timeout callback runs" + then: "fresh HEAD and inflight identity preserved, fresh healthy result only reaches live badge" + - id: SCN-head-sharing + given: "same-controller, cross-controller or legacy live survivor" + when: "other observer disposes then HEAD/media completes" + then: "one un-aborted HEAD, survivor gets healthy cache/badge, disposed badge unchanged" + - id: SCN-head-live-fallback + given: "live HEAD success/error/timeout/opaque/405 or failing media and mirror" + when: "controlled transport/media settles" + then: "healthy fallback or honest offline cache, timers/media slots released" + - id: SCN-head-handoff-fifo + given: "HEAD transitioning to media or saturated real media queue" + when: "owner leaves or live media releases slots" + then: "new media cancellation hook works, live FIFO and cap3 remain intact" + - id: SCN-head-before-start + given: "lifecycle already cancelled or without owners" + when: "real probeUrl entry is called" + then: "cancelled result, no fetch/media/timer" + observable_outcomes: + - "fetch count/AbortSignal, settled value, timer/source/listener cleanup" + - "real cache/inflight identity, live/disposed badges and media queue/FIFO" + determinism_controls: + - "controlled fetch with native AbortSignal and optional ignored abort" + - "manually driven positive-delay timers, native zero-delay JSDOM tasks" + - "actual observer/controller/cache/DOM media events; external network never used" + - "cleanup completes controlled transports/media and restores globals before natural exit" + anti_cheat_rationale: + prevents: + - "abort signal without prompt settlement" + - "null-only fake fix retaining transport or timer" + - "reuse of obsolete request or cache-on-cancel" + - "dropping shared/legacy owners or fallback/FIFO behavior" + - "stale inflight finalizer deleting fresh work" + - "retry/sleep/assertion relaxation" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +Spec scope extension: docs/spec_changes/2026-10-22-client-reliability-9-active-head.md. No existing test was changed. Original controller fails12/23 assertions and passes11 controls, cancelled0. Fixed controller23/23. All16 isolated mutants exit1 with actual failed assertions/cancelled0; production source never contains a mutant. Isolated cache-finalizer mutant changes a scratch copy only. + +| Mutant | Failed scenarios | Pass | Exit | +|---|---:|---:|---:| +| baseline | 12 | 11 | 1 | +| no-head-hook | 10 | 13 | 1 | +| no-head-abort | 2 | 21 | 1 | +| no-prompt-settlement | 8 | 15 | 1 | +| no-sync-invalidation | 3 | 20 | 1 | +| retained-head-timer | 21 | 2 | 1 | +| no-prestart-guard | 2 | 21 | 1 | +| lost-shared-owner | 3 | 20 | 1 | +| cache-on-cancel | 12 | 11 | 1 | +| no-live-error-fallback | 1 | 22 | 1 | +| lost-media-hook | 1 | 22 | 1 | +| healthy-cache-corrupted | 14 | 9 | 1 | +| no-live-timeout-abort | 1 | 22 | 1 | +| no-mirror-fallback | 1 | 22 | 1 | +| fifo-reversed | 1 | 22 | 1 | +| stale-inflight-finalizer | 3 | 20 | 1 | + +Mutant generation/reproduction: /tmp/client9-run-mutants.py; per-mutant source/test/TAP under /tmp/client9-mutant-*.mjs/.tap; structured /tmp/client9-mutants.json. Changes disable HEAD hook/abort/prompt settlement/synchronous marker/timer cleanup/prestart guard, lose shared owner, cache on cancellation, lose live error fallback/media hook/timeout abort/mirror fallback, corrupt healthy cache, reverse FIFO and remove cache finalizer identity guard. Baseline uses pre-change git controller. Every scenario is rejected by at least one mutant (grouped coverage); none survived. No forced termination, retries or waits for real timeout. diff --git a/test_logs/TEST_LOG_2026-10-22_14-12-00_client-reliability-9-head.md b/test_logs/TEST_LOG_2026-10-22_14-12-00_client-reliability-9-head.md new file mode 100644 index 00000000..90e52fdd --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_14-12-00_client-reliability-9-head.md @@ -0,0 +1,13 @@ +# client-reliability-9 HEAD investigation + +- HEAD: c5b678f7c9d0b92fbf786b16ae831f1be1d0b899; filedAt fb54cea3. Controller/observer/cache diff between filedAt and HEAD is empty. Item still applies; claimed. +- Scratch: /tmp/client9-head-repro.mjs, derived from real lifecycle fixture; imports actual observer/controller/cache. Controlled fetch and AbortSignal; manually driven positive-delay timeout callbacks; actual DOM/media events. Zero-delay JSDOM tasks use native timers. No public IO, sleep, retry, forced exit or internal probe override. +- Command per mode: `env -u NODE_TEST_CONTEXT node /tmp/client9-head-repro.mjs `; /tmp/client9-.tap; structured /tmp/client9-results.json. +- Three natural failures (1 scenario each, exit1, cancelled0): disposed, same-turn, later. Last owner leaves HEAD1/abort0/timer1(5000ms)/unsettled/cache0/media0/video slots0/queued0/original inflight retained. Rejoin starts1 HEAD total, expected2. This is retained network work, not media-slot retention: HEAD has no shared slot cap in this controller. +- Eight natural passes (exit0, 1 scenario each, cancelled0): shared, cross, legacy, live-success, live-error, live-timeout, late-success, late-error. Shared survivor remains un-aborted and receives healthy result; disposed badge checking. Live HEAD success/error/timeout proceed to successful controlled media and healthy cache; timeout abort1. Completed probe timers0, video slots0. Late HEAD success/error after disposal settles null/cache0/media0/mirror0; existing guards work eventually, but not promptly. +- Cleanup resolves all controlled outstanding HEAD requests, completes created media, disposes registrations, restores globals and closes JSDOM before natural exit. Initial diagnostic intercepted a JSDOM0ms timer; corrected capture to delegate0ms tasks, repeated all11 modes above. Final pass timer counts exclude no real probe timer. +- Inspection: retainUrlProbe calls lifecycle.cancelActive on final owner loss, but probeUrl HEAD phase installs none. Existing hook only covers active media. Proposed smallest product boundary: js/ui/urlHealthController.js probeUrl HEAD phase only; register identity-checked lifecycle cancellation hook, mark cancelled synchronously, abort/race-settle cancelled, clear timer and hook on every completion; explicitly skip fallback/mirrors/cache on cancel. Existing shared-owner release logic/cache identity guards stay unchanged. +- Proposed retained test boundary: new focused HEAD lifecycle suite or tests/url-health-lifecycle.test.mjs plus unique integrity/spec notes. Preserve current29 media scenarios and live HEAD error/timeout fallback. Add both rejoin timings, shared/cross/legacy controls, cancellation with abort-unresponsive transport, late callbacks/replacement identity and transition-to-media/FIFO checks before readiness. Need explicit routing/spec contract before writes. +- Unverified: HLS/torrent active cancellation, browser/network cancellation bytes, FIFO competition/replacement late callbacks (not yet reproduced in this HEAD scratch), no-AbortController behavior, whole Stage3, real playback/CPU, full suite/lint/build/browser. QA expensive slot untouched. +- Open PR check attempted prescribed GitHub API via curl: exit6 DNS could not resolve api.github.com. No product changes/PR creation; overlap not remotely qualified. +- No product/retained test changes. Investigation evidence only; waiting for independent reproduction and minimal implementation routing. diff --git a/test_logs/TEST_LOG_2026-10-22_14-25-00_client-reliability-9-ready.md b/test_logs/TEST_LOG_2026-10-22_14-25-00_client-reliability-9-ready.md new file mode 100644 index 00000000..e0fd96ec --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_14-25-00_client-reliability-9-ready.md @@ -0,0 +1,12 @@ +# HEAD-only candidate verification + +- Test parent e21be27f34402bb274fa698b721dc01a0fa12623 (merge of notes145c09f7 and routing unstablee04549f8). Candidate source/tests subsequently committed byte-for-byte; no full/build/browser/product submission yet. +- `node_modules/.bin/prettier --write tests/url-health-head-lifecycle.test.mjs js/ui/urlHealthController.js`: formatting complete; product38+/10-, focused new suite23 scenarios. `git diff --check` exit0. +- Initial `env -u NODE_TEST_CONTEXT node --test tests/url-health-head-lifecycle.test.mjs` baseline/fixed produced file-level1 summary only (exit1/0); not used for scenario counts. Switched to direct Node per repo runner. Initial before-start baseline awaited blocked transport and cancelled3 tests; changed test to first assert no transport after draining continuations so broken variants fail assertions and clean naturally. No expectation relaxed. +- Final original-controller reproduction: /tmp/client9-baseline-tests.mjs imports isolated pre-change controller with absolute dependency imports. `env -u NODE_TEST_CONTEXT node /tmp/client9-baseline-tests.mjs`: exit1,23 total/11pass/12fail/0cancelled. /tmp/client9-direct-baseline.tap. Fixed direct23/23 exit0, /tmp/client9-direct-fixed.tap (before last test-only no-start assertion order adjustment); authoritative final focused run below covers final bytes. +- `env -u NODE_TEST_CONTEXT node scripts/run-targeted-tests.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`: /tmp/client9-focused.tap shows23+29+4+4 pass but initial concurrent tool completion metadata exit2 was inconsistent with all child results and runner success. Retained that observation; authoritative host rerun clears shard context too. +- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node scripts/run-targeted-tests.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`: approved host exit0,4files/60scenarios pass,0failed/0cancelled. /tmp/client9-focused-host.tap. Existing29+4+4 unchanged. +- `env -u NODE_TEST_CONTEXT npm run lint`: initial sandbox /tmp/client9-lint.log failed at git spawnEPERM. Approved identical host run /tmp/client9-lint-host.log exit0,11lint checks completed. No suppression/config/baseline edit. +- `python3 /tmp/client9-run-mutants.py`: final16 isolated mutants, each exit1/assertionfail>0/cancelled0, all natural exits. /tmp/client9-mutants.json and per-mutant TAP. Initial no-head-hook mutant accidentally targeted media due substring indentation; corrected to HEAD suffix and reran all16. Coverage and counts in integrity note. No product file mutated for experiments. +- Measured new contract: cancelled HEAD prompt null despite uncooperative/noAbort transport; timer0; fresh same-turn and later request; late success/error/timer/replacement identity; shared/cross/legacy live continuation; live fallback/cache; handoff cancellation; media cap3/FIFO preserved. Controller HEAD-only; no observer/cache/app/HLS/torrent edits. +- Unverified: unchanged full normal gate, build/artifact/browser/native abort/network bytes, real playback/CPU, HLS/torrent active cancellation/wholeStage3, remote open PR overlap (prior DNS exit6). No product queue submit or expensive-slot acquisition. Dispatch routing required for next gates. diff --git a/test_logs/TEST_LOG_2026-10-22_14-27-00_client-reliability-9-merged.md b/test_logs/TEST_LOG_2026-10-22_14-27-00_client-reliability-9-merged.md new file mode 100644 index 00000000..71af01bd --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_14-27-00_client-reliability-9-merged.md @@ -0,0 +1 @@ +Product/test/integrity/spec commit e9c960edcf9f4ffd820f17dd6cf9dfe081e6bd29; merged current unstable4de357e4 as e887ceafdae9eb06a05f7cf6e8a2c50ab6f0c004. git diff --exit-code e9c960ed HEAD -- js tests package.json package-lock.json scripts config exit0; merge docs/evidence-only, prior60focused/16failingmutants/lint11 apply unchanged. git diff --check exit0;treeclean before new readiness metadata. Full/build/browser/productqueue not run;await slot. diff --git a/test_logs/TEST_LOG_2026-10-22_20-45-00_storage-cors-4.md b/test_logs/TEST_LOG_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..171bb573 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,9 @@ +- Startup20:39UTC; clean984c219c, mailbox unread0. Existing storage3 submission1792679488327-984c219cd2 durably63e2b519 with normalgateexit0; git merge-base --is-ancestor63e2b519 unstable exit0. Closed3 after durable proof; claimed4 after sync26e9a787. +- Fleet health20:40:39UTC: memory available64.4%, diskfree26.3%, CPU70C, swap52% (tight). No new heavy job. Client9 exclusive heavy slot from20:41:26Z; dispatch permits storage4 focused-only. +- Initial retained four-scenario baseline: `env -u NODE_TEST_CONTEXT node tests/storage-feed-lifecycle.test.mjs` exit1,2pass/2fail/cancelled0/199.723627ms at26e9a787 coordinator. Sameview deleted-card resurrection and remounted Explore overwrite retained. +- New final regression16pass/0fail/0cancelled naturalexit0/302.555724ms before strengthened absent-view effect assertions; final normal focused run below includes those strict assertions. +- Nine restored mutants: original pass4/fail12,ignore-order12/4,ignore-view14/2,stale-errors14/2,stale-runtime15/1,no-current-render7/9,obsolete-load15/1,allow-orphan5/11,drop-fallback13/3. Every variant exit1,cancelled0. Script /tmp/storage4-mutants.py, summary /tmp/storage4-mutants.log, individual /tmp/storage4-mutant-*.log; source restored in finally. +- `env -u NODE_TEST_CONTEXT node scripts/run-unit-tests.mjs tests/storage-feed-lifecycle.test.mjs tests/app/feedCoordinator.test.mjs tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs tests/feed-engine.test.mjs tests/nostr-service-live-feed-notification.test.mjs` finalexit0,5files, new16scenarios; unchanged runner/discovery. /tmp/storage4-focused-final.log. +- `env -u NODE_TEST_CONTEXT npm run lint` finalexit0,11stages,read-only git-grep sandbox exception approved; /tmp/storage4-lint-final.log. git diff --check exit0. +- Fixture/setup development: `node --test` suppresses nested output under existing fixture behavior, use normal direct runner; initial direct baseline strict2fail retained. Two new error controls initially failed because actual moderation stage intentionally catches adapter errors; corrected controlled error injection to wrap real engine run and reject after actual production stages. Did not change expected contract or production stage. First broader run failed existing loadForYouVideos test because its mount adapter has no view.container; guard now captures actual view.container rather than return value, preserving adapter contract. Existing assertions untouched. Initial lint failed EPERM git grep; approved outside-sandbox read-only execution passes. +- No full/build/browser/submission for4 yet. Native browser/full router/auth teardown, live relay/signatures/cloud/manual release unverified. No extra full repetition of storage3. Storage3 closure and4 ownership metadata accompany separate storage4 candidate. diff --git a/test_logs/TEST_LOG_2026-10-22_20-48-00_client-reliability-9-landed.md b/test_logs/TEST_LOG_2026-10-22_20-48-00_client-reliability-9-landed.md new file mode 100644 index 00000000..49babfdf --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_20-48-00_client-reliability-9-landed.md @@ -0,0 +1,8 @@ +# Client9 HEAD cancellation landing + +- Candidate product `e9c960edcf9f4ffd820f17dd6cf9dfe081e6bd29`; merged current unstable `26e9a787cfdc31b77ac35417a5f10e218e2b9662` safely as `09f52bdd5024d8b691500fc8d7181e6fe2a4336f`. Clean tree; `git diff --exit-code e9c960ed HEAD -- js/ui/urlHealthController.js tests/url-health-head-lifecycle.test.mjs` and `git diff --check` exit0 before submission. Initial sandbox merge could not write ORIG_HEAD; identical approved host merge succeeded. +- Dispatch20:41:26Z and interpreter20:40:54Z granted exclusive normal gate, then sequential build. No parallel heavy job/browser launched. Fleet health checkpoints20:42/43/44/45/47Z: available RAM65-66%, swap51%, maximum observed81.8C; final63.9C. Swap occupancy guidance assessed with dispatch; no system setting/process changes. +- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node tools/fleet/bin/landq.mjs submit` approved host exit0; submission `09f52bdd5024d8b691500fc8d7181e6fe2a4336f`. Unchanged normal install/npm-test gate exit0, landed20:47:18Z as `f41ac656f6d994616ff54b5877934311beecc5e4`. Durable result JSON and host `landq status ` agree. Sandbox PID visibility incorrectly reported dead during gating; host status confirmed alive, no second drain started. +- Independent discovery/log comparison:435discovered/435starts/435unique, missing0/extra0 in lander artifacts/test_unit.log. Gate result exit0 proves completion. `git merge-base --is-ancestor 09f52bdd5024d8b691500fc8d7181e6fe2a4336f unstable` exit0; printed unstable tip f41ac656. +- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD npm run build > /tmp/client9-build-20261022.log 2>&1` naturalexit0; deployment artifact verifier passed version markup and hashed references. Own worktree clean after build. Heavy slot released20:48Z; browser slot requested, not started. +- Earlier focused60/4files,16killed isolated mutants/cancelled0 and lint11 remain recorded evidence; not redundantly rerun. Native browser HEAD lifecycle, actual network bytes/decoder/playback, HLS/torrent/RangeGET, CPU benefit and wholeStage3 remain unverified/open. No remote push/deploy/cloud/signer/credential/scheduler action. diff --git a/test_logs/TEST_LOG_2026-10-22_20-52-00_client-reliability-9-browser.md b/test_logs/TEST_LOG_2026-10-22_20-52-00_client-reliability-9-browser.md new file mode 100644 index 00000000..e7e33da4 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_20-52-00_client-reliability-9-browser.md @@ -0,0 +1,7 @@ +# Client9 controlled native browser lifecycle + +- On built candidate09f52bdd (runtime equal landedf41ac656), scratch `/tmp/client9-native-smoke.mjs` serves own dist on temporary localhost port; denies every nonlocal request. Actual controller, observer and cache modules; native DOM/IntersectionObserver/fetch/AbortController/media load and pause. No product/test/config edits. +- Initial sandbox listener EPERM, identical host launch allowed. Initial scratch assertion expected0 attempted public requests but dependencies attempted4, all denied; corrected accounting to report denied attempts, not claim no attempts. Initial fresh control used a different URL; tightened to identical eventID/source and reran final matrix. These are scratch fixture corrections; retained tests/assertions unchanged, no retry/sleep/timeout relaxation. +- Final `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node /tmp/client9-native-smoke.mjs > /tmp/client9-native-smoke-final.log 2>&1` approved host naturalexit0. Chromium and Firefox each: HEAD3, native abortsignal1, cancellednull1, healthy2, cachewrites2, obsoletebadge writes0, media2 src cleared, slots0/queue0, pageerrors0, dependencyCDNattempts4 all denied. Cancellation prevents cache/badge writes, same-source fresh request succeeds, shared owner keeps HEAD alive and receives healthy badge while disposed badge stays checking. +- Native media lifecycle but synthetic loadeddata completion. No actual decoder/playback, universal transport/network-byte cancellation, live relay/signer/cloud/peer, CPU improvement, HLS/torrent/RangeGET or wholeStage3 qualification. Native signal count proves signal abortion, not server-observed bytes saved. Ignored-abort/missing-AbortController/timer/late-callback controls are recorded unit evidence, not this browser matrix. +- Browsers and temporary server closed naturally in finally; all heavy/browser resources released. Preserved preview3000 and peer processes. diff --git a/test_logs/TEST_LOG_2026-10-22_20-54-00_client-reliability-9-hls-investigation.md b/test_logs/TEST_LOG_2026-10-22_20-54-00_client-reliability-9-hls-investigation.md new file mode 100644 index 00000000..49ba02ed --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_20-54-00_client-reliability-9-hls-investigation.md @@ -0,0 +1,11 @@ +# Read-only HLS manifest cancellation measurement + +- Current HEAD `d04d501ee2a44271397c3af933a1655cf16e49af`, clean before investigation. HEAD slice already landedf41ac656/normal435files/buildartifact/native-controlled browser pass. Dispatch20:52:04Z authorized read-only HLS last-owner/shared/rejoin measurements only; no product or retained-test writes, browser/full/build. +- Actual callpath: `attachUrlHealthBadges` ownership -> `UrlHealthController.handleUrlHealthBadge` -> `probeUrlList` -> `probeUrl` -> HEAD completion -> `probeUrlWithVideoElement` HLS early branch -> `probeHlsManifest`. HLS branch precedes media ownership hook/queue; helper owns GET controller/timer through fetch and response.text but receives no lifecycle. Source locations controller323-349 and hlsPlayback383+ at this SHA. No media slot leak claim. +- Scratch `/tmp/client9-hls-repro.mjs` reuses real observer/controller/cache/HLS helper with JSDOM, controlled IntersectionObserver, deferred fetch/body boundaries, native AbortController, controlled timers; no public network, wall-clock sleep or force exit. Flush only drains continuations. All resources completed via controlled IO before strict failed assertions, then fixture cleanup restores globals. +- Initial9-mode fixture produced7fail/2pass: legacy badge was detached (update intentionally skips disconnected DOM), and automatic healthy mirror contaminated live-error/timeout controls. Retained `/tmp/client9-hls-initial-fixture.tap`; corrected scratch to append legacy badge and use single-source live controls. Corrected9 modes4fail/5pass. Expanded body/cross-controller controls, no product/assertion weakening. +- Final exact command `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node /tmp/client9-hls-repro.mjs > /tmp/client9-hls-repro-final.tap 2>&1`: naturalexit1,12tests/7pass/5fail/0cancelled/167.265191ms suite. +- Five failures: final-owner disposal retains GET1/abort0/timer1/unsettled/original inflight; same-turn and later same-source owners rejoin original; late success caches healthy after disposal; pending response.text disposal also retains abort0/timer1/unsettled. Media slots0/queue0, disposed badge stayschecking; no stale badge write observed. +- Seven passing controls: shared live owner, cross-controller shared owner, ownerless legacy, live success, live error, live timeout, shared owner during pending body read. All controlled completions leave timers0/slots0/queue0. +- Proposed routing, NOT implemented: controller HLS lifecycle handoff plus hlsPlayback helper optional cancellation API across fetch/body; synchronous cancellation identity, prompt settlement despite ignored abort/missing controller, timer/hook cleanup and no obsolete cache/badge/mirror; preserve all live/shared/legacy/native-media behavior. Helper ownership/spec must be explicitly routed by dispatch before product/test edits. +- HLS playback engine/decoder, real transport bytes, browser HLS behavior, torrent, RangeGET and wholeStage3 remain unverified. No CPU benefit claim. Storage owns heavy slot; no heavy jobs launched. diff --git a/test_logs/TEST_LOG_2026-10-22_20-58-00_storage-cors-4-landed.md b/test_logs/TEST_LOG_2026-10-22_20-58-00_storage-cors-4-landed.md new file mode 100644 index 00000000..a2a95796 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-22_20-58-00_storage-cors-4-landed.md @@ -0,0 +1,9 @@ +- START exclusive full/normal slot20:52:16Z after dispatch grant20:52:02Z. Merged current unstabled04d501e into cleanb21ce86c; merged candidate888807baf6d91da37b9abcadc7d81b29f4814eca. +- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD -u UNIT_TEST_TIMEOUT_MS node tools/fleet/bin/landq.mjs submit --area storage-cors` exit0,submission888807ba. Sandbox autodraindied before processing; durable statusqueued/alivefalse. Approved env-cleared host `fleet.mjs lander start` launched same submission20:52:39Z; no resubmit/gate bypass. +- Durable land_status state landed/landedSha7edc7ea86f33508bb790a12d802daa3af012f933 at20:57:23.749Z; saved result confirms unchanged normal gateexit0. Normal discovery436unique files,new suite included,quarantineempty. Full per-test-start transcript is not retained by lander (only combined output tail); do not claim independent436start audit. Gate result is authoritative unchanged discovery/runner success. +- `git log -1 unstable` printedd9424fb7 (docs-only client9 descendant of7edc7ea8); `git merge-base --is-ancestor888807ba unstable` exit0. Fast-forward own branch tod9424fb7; product/tests/scripts/package diff vs7edc7ea8 empty. +- Health20:53:34 max74C/memoryavailable65.9%;20:57:42 max60.9C/memoryavailable65.8%,swap51% advisory. No competing heavy/browser work started. Slot wait20:46:48→20:52:02=5m14s; no resource threshold pause. +- Sequential `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD -u UNIT_TEST_TIMEOUT_MS npm run build` exit0 after gate,source d9424fb7; deployment artifact verifierPASS. Artifact version29cbe6f0 is not Git SHA. Log /tmp/storage4-build.log. No server/preview replacement/deploy performed. +- Focused5files/new16pass,9restored killedmutants,lint11exit0 retained in20:45log/integrity. No duplicate local full. All normal assertions/discovery/gates preserved. +- Closedstorage4 after durable status+ancestry. Heavy slot released~20:58UTC; no native browser held. No further storage item routed; IDLE. Dispatch integrates shared TODO status. +- Unverified: native browser/full app navigation/auth teardown/live relay/signature/moderation timing/cloud/manual release. JSDOM lifecycle and controlled IO are not production qualification. Existing coordinator caller behavior and skipped-history-runtime choice in unique decisions note. diff --git a/tests/storage-feed-lifecycle.test.mjs b/tests/storage-feed-lifecycle.test.mjs new file mode 100644 index 00000000..19fa50ca --- /dev/null +++ b/tests/storage-feed-lifecycle.test.mjs @@ -0,0 +1,421 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { + tombstoneFixture, + videoEvent, + ids +} from "./test-helpers/storage-tombstone-fixture.mjs"; +import * as feeds from "../js/feedEngine/index.js"; +import { createFeedCoordinator } from "../js/app/feedCoordinator.js"; +import { createPlaybackCoordinator } from "../js/app/playbackCoordinator.js"; +import { VideoListView } from "../js/ui/views/VideoListView.js"; +import { dedupeVideos } from "../js/utils/videoDeduper.js"; +import * as timestamps from "../js/utils/videoTimestamps.js"; +import { FEED_TYPES } from "../js/constants.js"; +const logger = { log() {}, warn() {}, error() {} }; +async function fixture(t, { failDelayed = false } = {}) { + const f = tombstoneFixture(t); + await f.load(); + await f.ingest( + videoEvent("original", 100), + videoEvent("unrelated", 110, { root: "other" }) + ); + const dom = new JSDOM( + '
', + { url: "http://fixture.invalid" } + ); + const root = dom.window.document.getElementById("videoList"); + const previousHTMLElement = globalThis.HTMLElement; + globalThis.HTMLElement = dom.window.HTMLElement; + t.after(() => { + if (previousHTMLElement === undefined) delete globalThis.HTMLElement; + else globalThis.HTMLElement = previousHTMLElement; + }); + const history = { loadLatest: async () => [] }; + const methods = createFeedCoordinator({ + ...feeds, + watchHistoryService: history, + devLogger: logger, + userLogger: logger, + nostrClient: f.client + }); + const playback = createPlaybackCoordinator({ + ...timestamps, + devLogger: logger, + userLogger: logger, + nostrClient: f.client + }); + let release, entered; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (release = r)); + let hold = true; + // Defer actual moderation-stage report-subscription IO, not a custom stage. + f.service.moderationService = { + async setActiveEventIds(activeIds) { + if (hold && activeIds.has("original")) { + hold = false; + entered(); + await gate; + } + } + }; + f.client.hydrateVideoHistory = async (video) => [ + { created_at: video.created_at, deleted: false } + ]; + const effects = []; + const app = { + ...methods, + nostrService: f.service, + feedEngine: feeds.createFeedEngine(), + videosMap: f.service.getVideosMap(), + blacklistedEventIds: new Set(), + buildRecentFeedRuntime: () => ({}), + buildExploreFeedRuntime: () => ({}), + buildForYouFeedRuntime: () => ({}), + isAuthorBlocked: () => false, + dedupeVideosByRoot: dedupeVideos, + getKnownVideoPostedAt: playback.getKnownVideoPostedAt, + resolveVideoPostedAt: playback.resolveVideoPostedAt, + cacheVideoRootCreatedAt: playback.cacheVideoRootCreatedAt, + decorateVideoModeration: (v) => v, + decorateVideoCreatorIdentity: (v) => v, + emitFeedImpressions(videos) { + effects.push(ids(videos)); + }, + updateModalSimilarContent() {}, + checkRelayHealthWarning() {}, + mountVideoListView() { + if (!this.videoListView) return null; + return this.videoListView.mount(root); + } + }; + const makeView = () => + new VideoListView({ + document: dom.window.document, + utils: { + dedupeVideos, + getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video) + } + }); + if (failDelayed) { + const run = app.feedEngine.run.bind(app.feedEngine); + let first = true; + app.feedEngine.run = async (...args) => { + const fail = first; + first = false; + const result = await run(...args); + if (fail) + throw new Error("controlled engine rejection after production stages"); + return result; + }; + } + app.videoListView = makeView(); + // View-count/zap subscription transport is disabled at its own UI adapter boundary. + app.videoListView.syncViewCountSubscriptions = () => {}; + assert.ok(app.registerRecentFeed()); + assert.ok(app.registerExploreFeed()); + const renders = []; + const originalRender = app.renderVideoList; + app.renderVideoList = function (payload) { + renders.push({ + active: this.feedTelemetryState?.activeFeed, + reason: payload.metadata?.reason, + ids: ids(payload.videos), + deleted: payload.videos.filter((v) => v.deleted).map((v) => v.id) + }); + return originalRender.call(this, payload); + }; + const runs = []; + const refresh = app.refreshFeed; + app.refreshFeed = function (...args) { + const p = refresh.apply(this, args); + runs.push(p); + return p; + }; + t.after(() => { + release(); + app.videoListView?.destroy(); + dom.window.close(); + }); + return { + ...f, + app, + root, + reached, + release, + runs, + renders, + makeView, + history, + effects, + domIds: () => + Array.from( + root.querySelectorAll('[data-component="video-card"]'), + (node) => node.dataset.videoId + ).sort() + }; +} +test("control: valid delayed registered recent pipeline renders live cards", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + f.release(); + await loading; + assert.deepEqual(f.domIds(), ["original", "unrelated"]); + assert.equal(f.app.isFeedActive("recent"), true); +}); +test("registered recent pipeline must not restore deleted DOM card after a fresher subscription render", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + await f.ingest(videoEvent("delete", 200, { deleted: true })); + await f.runs.at(-1); + assert.deepEqual(f.domIds(), ["unrelated"]); + f.release(); + await loading; + assert.deepEqual(f.domIds(), ["unrelated"]); +}); +test("registered recent result must not overwrite replacement Explore view after destroy/remount", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + await f.ingest(videoEvent("delete", 200, { deleted: true })); + await f.runs.at(-1); + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos(FEED_TYPES.EXPLORE); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.app.isFeedActive(FEED_TYPES.EXPLORE), true); + f.release(); + await loading; + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal( + f.app.videoListView.state.feedMetadata.sortOrder, + FEED_TYPES.EXPLORE + ); +}); +test("control: fully disposed view is not remounted when delayed pipeline completes", async (t) => { + const f = await fixture(t); + const loading = f.app.loadFeedVideos("recent"); + await f.reached; + const view = f.app.videoListView; + view.destroy(); + f.app.videoListView = null; + f.release(); + await loading; + assert.equal(view.container, null); + assert.equal(f.app.videoListView, null); + assert.equal(f.renders.length, 0); + assert.equal(f.effects.length, 0); + assert.deepEqual(f.domIds(), []); +}); + +test("late old failure returns its fallback without painting over the newer success", async (t) => { + const f = await fixture(t, { failDelayed: true }); + f.app.mountVideoListView(); + const old = f.app.refreshFeed("recent", { + fallbackVideos: [f.app.videosMap.get("original")] + }); + await f.reached; + await f.ingest(videoEvent("delete", 200, { deleted: true })); + await f.app.refreshFeed("recent", { reason: "newest" }); + const metadata = f.app.latestFeedMetadata; + const count = f.effects.length; + f.release(); + const result = await old; + assert.equal(result.metadata.error, true); + assert.deepEqual(ids(result.videos), ["original"]); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.app.latestFeedMetadata, metadata); + assert.equal(f.effects.length, count); +}); +test("reverse completion order never paints the older result even while newest is pending", async (t) => { + const f = await fixture(t); + f.app.mountVideoListView(); + const old = f.app.refreshFeed("recent"); + await f.reached; + let releaseNew, entered; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (releaseNew = r)); + f.service.moderationService.setActiveEventIds = async () => { + entered(); + await gate; + }; + t.after(() => releaseNew()); + const newest = f.app.refreshFeed("recent", { reason: "newest" }); + await reached; + f.release(); + const oldResult = await old; + assert.deepEqual(ids(oldResult.videos), ["original", "unrelated"]); + assert.equal(f.renders.length, 0); + releaseNew(); + await newest; + assert.equal(f.renders.length, 1); + assert.equal(f.app.latestFeedMetadata.reason, "newest"); +}); +test("same feed remount preserves replacement metadata and suppresses old side effects", async (t) => { + const f = await fixture(t); + const old = f.app.loadFeedVideos("recent"); + await f.reached; + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos("recent"); + const metadata = f.app.latestFeedMetadata; + const count = f.effects.length; + f.release(); + await old; + assert.equal(f.app.latestFeedMetadata, metadata); + assert.equal(f.effects.length, count); + assert.deepEqual(f.domIds(), ["original", "unrelated"]); +}); +test("destroyed view still referenced by app cannot be remounted by completion", async (t) => { + const f = await fixture(t); + const old = f.app.loadFeedVideos("recent"); + await f.reached; + const view = f.app.videoListView; + view.destroy(); + f.release(); + await old; + assert.equal(view.container, null); + assert.equal(f.renders.length, 0); + assert.equal(f.effects.length, 0); +}); +test("new empty result remains empty after older populated result", async (t) => { + const f = await fixture(t); + const old = f.app.loadFeedVideos("recent"); + await f.reached; + await f.ingest( + videoEvent("delete", 200, { deleted: true }), + videoEvent("delete-other", 210, { deleted: true, root: "other" }) + ); + await f.runs.at(-1); + assert.deepEqual(f.domIds(), []); + const count = f.effects.length; + f.release(); + await old; + assert.deepEqual(f.domIds(), []); + assert.equal(f.effects.length, count); +}); +test("current engine failure paints supplied live fallback and error metadata", async (t) => { + const f = await fixture(t, { failDelayed: true }); + f.app.mountVideoListView(); + const current = f.app.refreshFeed("recent", { + fallbackVideos: [f.app.videosMap.get("unrelated")] + }); + await f.reached; + f.release(); + const payload = await current; + assert.equal(payload.metadata.error, true); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.effects.length, 1); +}); +test("current unavailable engine paints fallback", async (t) => { + const f = await fixture(t); + f.app.mountVideoListView(); + f.app.feedEngine = null; + const result = await f.app.refreshFeed("recent", { + fallbackVideos: [f.app.videosMap.get("unrelated")] + }); + assert.equal(result.metadata.engine, "unavailable"); + assert.deepEqual(f.domIds(), ["unrelated"]); + assert.equal(f.effects.length, 1); +}); +test("obsolete awaited runtime cannot start engine or alter current telemetry", async (t) => { + const f = await fixture(t); + f.app.mountVideoListView(); + f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); + let release, entered; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (release = r)); + f.history.loadLatest = async () => { + entered(); + await gate; + return []; + }; + t.after(() => release()); + const old = f.app.refreshFeed(FEED_TYPES.FOR_YOU, { fallbackVideos: [] }); + await reached; + // A different feed becomes current before history IO completes. + f.release(); + await f.app.loadFeedVideos(FEED_TYPES.EXPLORE); + const metadata = f.app.latestFeedMetadata; + const count = f.effects.length; + release(); + const result = await old; + assert.equal(result.metadata.engine, "superseded"); + assert.equal(f.app.latestFeedMetadata, metadata); + assert.equal(f.effects.length, count); + assert.equal(f.app.isFeedActive(FEED_TYPES.EXPLORE), true); +}); + +test("current awaited history runtime completes registered For You rendering and telemetry", async (t) => { + const f = await fixture(t); + assert.ok(f.app.registerForYouFeed()); + f.app.mountVideoListView(); + f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); + let entered, release; + const reached = new Promise((r) => (entered = r)); + const gate = new Promise((r) => (release = r)); + f.history.loadLatest = async () => { + entered(); + await gate; + return []; + }; + t.after(() => release()); + const current = f.app.refreshFeed(FEED_TYPES.FOR_YOU); + await reached; + release(); + await f.reached; + f.release(); + const result = await current; + assert.deepEqual(ids(result.videos), ["original", "unrelated"]); + assert.deepEqual(f.domIds(), ["original", "unrelated"]); + assert.equal(result.metadata.preserveOrder, true); +}); +for (const failDelayed of [false, true]) + test(`stale For You ${failDelayed ? "failure" : "success"} cannot update telemetry or impressions`, async (t) => { + const f = await fixture(t, { failDelayed }); + assert.ok(f.app.registerForYouFeed()); + f.app.mountVideoListView(); + f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); + const updates = []; + f.app.updateFeedTelemetryMetadata = (...args) => updates.push(args); + const old = f.app.refreshFeed(FEED_TYPES.FOR_YOU); + await f.reached; + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos(FEED_TYPES.EXPLORE); + const count = f.effects.length; + const metadata = f.app.latestFeedMetadata; + f.release(); + await old; + assert.equal(updates.length, 0); + assert.equal(f.effects.length, count); + assert.equal(f.app.latestFeedMetadata, metadata); + }); +test("retained obsolete load callback cannot start refresh after a same-feed remount", async (t) => { + const f = await fixture(t); + const load = f.service.loadVideos.bind(f.service); + const callbacks = []; + f.service.loadVideos = (opts) => { + callbacks.push(opts.onVideos); + return load(opts); + }; + const old = f.app.loadFeedVideos("recent"); + await f.reached; + f.app.videoListView.destroy(); + f.app.videoListView = f.makeView(); + f.app.videoListView.syncViewCountSubscriptions = () => {}; + await f.app.loadFeedVideos("recent"); + const count = f.runs.length; + const effects = f.effects.length; + callbacks[0]([], { reason: "obsolete" }); + assert.equal(f.runs.length, count); + assert.equal(f.effects.length, effects); + f.release(); + await old; +}); diff --git a/tests/url-health-head-lifecycle.test.mjs b/tests/url-health-head-lifecycle.test.mjs new file mode 100644 index 00000000..bbb6eb6e --- /dev/null +++ b/tests/url-health-head-lifecycle.test.mjs @@ -0,0 +1,513 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +// Resolve real cache dependencies with installed Node packages before DOM setup. +import * as cache from "../js/state/cache.js"; +import UrlHealthController from "../js/ui/urlHealthController.js"; +import { attachUrlHealthBadges } from "../js/urlHealthObserver.js"; +import { + setCardLivenessPolicy, + getCardLivenessPolicy +} from "../js/constants.js"; +import { updateVideoCardSourceVisibility } from "../js/utils/cardSourceVisibility.js"; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +let sequence = 0; +function fixture(t, { 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}.mp4`; + globalThis.IntersectionObserver = class { + constructor(callback) { + this.callback = callback; + this.cards = []; + observers.push(this); + } + observe(card) { + this.cards.push(card); + } + takeRecords() { + return []; + } + disconnect() { + this.disconnected = true; + } + show() { + this.callback( + this.cards.map((target) => ({ + target, + isIntersecting: true, + intersectionRatio: 1 + })) + ); + } + }; + 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) => { + if (!timers.delete(handle)) nativeClearTimeout(handle); + }; + globalThis.fetch = (source, options) => + 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" }); + 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, + resources, + listeners, + handlers, + pauses, + controller, + media, + heads, + timers, + promises, + warnings, + visibility, + checks, + feed, + video, + done, + finishAll + }; +} +// All transport and time boundaries are controlled; ownership, cache, observer, +// controller, DOM events and media queue are real. Flush drains continuations, +// never a wall-clock wait or assertion retry. +const inFlight = (v) => + cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true }); +const cached = (v) => cache.getCachedUrlHealth(v.id, v.url); +function tracking(promise) { + const result = { settled: false }; + promise.then((value) => Object.assign(result, { settled: true, value })); + return result; +} +async function start(f, name = "head") { + const v = f.video(name); + 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.media.length, 0); + return { v, old, promise: f.promises[0], result: tracking(f.promises[0]) }; +} +function cleanCancelled(f, v, old, result) { + assert.equal(result.settled, true, "cancelled HEAD must settle promptly"); + assert.equal(result.value, null); + assert.equal(cached(v), null); + assert.equal(inFlight(v), null); + assert.equal(f.timers.size, 0, "no HEAD or media timer retained"); + assert.equal(f.heads.length, 1, "no obsolete mirror"); + assert.equal(f.media.length, 0, "no obsolete media fallback"); + assert.equal(f.controller.activeVideoProbes, 0); + assert.equal(old.badges[0].dataset.urlHealthState, "checking"); +} +for (const transport of ["abortable", "ignores-abort", "missing-abort"]) { + test(`last HEAD owner disposal releases promise/timer with ${transport} transport`, async (t) => { + const f = fixture(t, { + ignoreAbort: transport === "ignores-abort", + missingAbort: transport === "missing-abort" + }); + const { v, old, result } = await start(f); + assert.equal(f.timers.size, 1); + old.dispose(); + old.dispose(); + await flush(); + assert.equal(f.heads[0].aborted, transport === "missing-abort" ? 0 : 1); + cleanCancelled(f, v, old, result); + // Even a transport that never settles its own promise cannot block release. + }); +} +for (const timing of ["same-turn", "after-flush"]) { + for (const late of ["success", "error", "timeout"]) { + test(`HEAD ${timing} rejoin has fresh identity despite late ${late}`, async (t) => { + const f = fixture(t, { ignoreAbort: true }); + const { v, old, promise, result } = await start(f); + const oldTimer = [...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, "same-turn owner must start fresh"); + await flush(); + assert.equal(f.heads.length, 2); + assert.equal(result.settled, true); + assert.equal(result.value, null); + assert.equal( + inFlight(v), + replacement, + "old finalizer preserves replacement" + ); + if (late === "success") + f.heads[0].resolve({ ok: true, status: 200, type: "basic" }); + if (late === "error") f.heads[0].reject(new Error("obsolete HEAD error")); + if (late === "timeout") oldTimer(); + await flush(); + assert.equal(inFlight(v), replacement); + assert.equal(cached(v), null); + assert.equal(f.media.length, 0); + assert.equal(f.timers.size, 1, "only replacement timer remains"); + assert.equal(old.badges[0].dataset.urlHealthState, "checking"); + f.heads[1].resolve({ ok: true, status: 200, type: "basic" }); + await flush(); + assert.equal(f.media.length, 1); + f.done(f.media[0]); + 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(f.timers.size, 0); + assert.equal(f.controller.activeVideoProbes, 0); + }); + } +} +for (const kind of ["shared", "cross-controller", "legacy"]) { + test(`HEAD disposal preserves ${kind} survivor and live media handoff`, async (t) => { + const f = fixture(t); + const { v, old, result } = await start(f); + 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); + f.heads[0].resolve({ ok: true, status: 200, type: "basic" }); + await flush(); + assert.equal(f.media.length, 1); + f.done(f.media[0]); + 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(f.timers.size, 0); + }); +} +for (const response of [ + "success", + "error", + "timeout", + "opaque", + "bad-status" +]) { + test(`live HEAD ${response} retains playable media fallback/cache`, async (t) => { + const f = fixture(t); + const { v, old, result } = await start(f); + if (response === "error") + f.heads[0].reject(new Error("controlled HEAD error")); + else if (response === "timeout") [...f.timers.values()][0].fn(); + else + f.heads[0].resolve({ + ok: response !== "bad-status", + status: response === "bad-status" ? 405 : 200, + type: response === "opaque" ? "opaque" : "basic" + }); + await flush(); + assert.equal(f.media.length, 1); + assert.equal(f.timers.size, 1); + f.done(f.media[0]); + await flush(); + assert.equal(result.settled, true); + assert.equal(cached(v).status, "healthy"); + assert.equal(old.badges[0].dataset.urlHealthState, "healthy"); + assert.equal(f.heads[0].aborted, response === "timeout" ? 1 : 0); + assert.equal(f.timers.size, 0); + assert.equal(f.controller.activeVideoProbes, 0); + }); +} +test("HEAD completion immediately before disposal never starts media or mirrors", async (t) => { + const f = fixture(t); + const { v, old, result } = await start(f); + f.heads[0].resolve({ ok: true, status: 200, type: "basic" }); + old.dispose(); + await flush(); + cleanCancelled(f, v, old, result); +}); +test("HEAD to media handoff retains the new cancellation hook", async (t) => { + const f = fixture(t); + const { v, old, result } = await start(f); + f.heads[0].resolve({ ok: true, status: 200, type: "basic" }); + await flush(); + assert.equal(f.media.length, 1); + assert.equal(f.controller.activeVideoProbes, 1); + old.dispose(); + await flush(); + assert.equal(result.settled, true); + assert.equal(result.value, null); + assert.equal(f.resources.size, 0); + assert.equal(f.listeners.get(f.media[0]).size, 0); + assert.equal(f.timers.size, 0); + assert.equal(f.controller.activeVideoProbes, 0); + assert.equal(cached(v), null); + assert.equal(f.heads.length, 1); +}); +test("HEAD cancellation during saturated media queue preserves live FIFO and cap", async (t) => { + const f = fixture(t), + videos = ["a", "b", "c", "queued1", "queued2"].map(f.video); + const live = f.feed(videos); + live.observer.show(); + await flush(); + for (const head of f.heads) + head.resolve({ ok: true, status: 200, type: "basic" }); + await flush(); + assert.equal(f.media.length, 3); + assert.equal(f.controller.activeVideoProbes, 3); + assert.equal(f.controller.videoProbeQueue.length, 2); + const obsolete = f.video("obsolete"), + old = f.feed([obsolete]); + old.observer.show(); + await flush(); + const cancelled = tracking(f.promises.at(-1)); + old.dispose(); + await flush(); + assert.equal(cancelled.settled, true); + assert.equal(cancelled.value, null); + assert.equal(f.controller.activeVideoProbes, 3); + assert.equal(f.controller.videoProbeQueue.length, 2); + f.done(f.media[0]); + await flush(); + assert.equal(f.media.length, 4); + assert.equal(f.media[3].getAttribute("src"), videos[3].url); + assert.equal(f.controller.activeVideoProbes, 3); + f.done(f.media[1]); + await flush(); + assert.equal(f.media.length, 5); + assert.equal(f.media[4].getAttribute("src"), videos[4].url); + await f.finishAll(); + assert.equal(f.controller.activeVideoProbes, 0); + assert.equal(cached(obsolete), null); + assert.equal(f.timers.size, 0); +}); +for (const state of ["no-owners", "already-cancelled"]) { + test(`HEAD ${state} lifecycle starts no fetch or timer`, async (t) => { + const f = fixture(t), + lifecycle = { + owners: new Set(), + cancelled: state === "already-cancelled" + }; + if (state === "already-cancelled") lifecycle.owners.add({ active: true }); + const promise = f.controller.probeUrl("https://media.invalid/before.mp4", { + confirmPlayable: true, + lifecycle + }); + f.promises.push(promise); + await flush(); + assert.equal(f.heads.length, 0); + assert.equal(f.timers.size, 0); + assert.equal(f.media.length, 0); + assert.deepEqual(await promise, { outcome: "cancelled" }); + }); +} +test("live HEAD and failed media still try one mirror then cache offline", async (t) => { + const f = fixture(t); + const { v, old, result } = await start(f); + f.heads[0].resolve({ ok: false, status: 405, type: "basic" }); + await flush(); + assert.equal(f.media.length, 1); + f.done(f.media[0], "error"); + await flush(); + assert.equal(f.heads.length, 2); + assert.equal(f.heads[1].source, v.sources[0].url); + f.heads[1].resolve({ ok: false, status: 503, type: "basic" }); + await flush(); + assert.equal(f.media.length, 2); + f.done(f.media[1], "error"); + await flush(); + assert.equal(result.settled, true); + assert.equal(cached(v).status, "offline"); + assert.equal(old.badges[0].dataset.urlHealthState, "offline"); + assert.equal(f.timers.size, 0); + assert.equal(f.controller.activeVideoProbes, 0); +}); diff --git a/todo/TODO_2026-10-22_14-10-00_client-reliability-9.md b/todo/TODO_2026-10-22_14-10-00_client-reliability-9.md new file mode 100644 index 00000000..8a2207e6 --- /dev/null +++ b/todo/TODO_2026-10-22_14-10-00_client-reliability-9.md @@ -0,0 +1,3 @@ +- [x] Read rules/campaign/mail; clean fast-forward; claim9; ack dispatch. +- [ ] Reproduce active HEAD final-owner disposal and controls. +- [ ] Report evidence, proposed files and contract; await routing before implementation. diff --git a/todo/TODO_2026-10-22_14-12-00_client-reliability-9.md b/todo/TODO_2026-10-22_14-12-00_client-reliability-9.md new file mode 100644 index 00000000..1f6d4afd --- /dev/null +++ b/todo/TODO_2026-10-22_14-12-00_client-reliability-9.md @@ -0,0 +1,6 @@ +- [x] Safe sync/claim/ack and source review. +- [x] HEAD disposal/rejoin3 natural failures;8 controls pass. +- [x] Record exact commands/counts/limits and scoped proposal. +- [ ] Dispatch independent reproduction + explicit implementation/spec routing. +- [ ] Later: retained HEAD regression/mutants/focused/lint and allocated normal gates. +- [ ] HLS/torrent separately measured/routed. diff --git a/todo/TODO_2026-10-22_14-25-00_client-reliability-9.md b/todo/TODO_2026-10-22_14-25-00_client-reliability-9.md new file mode 100644 index 00000000..af16fcb2 --- /dev/null +++ b/todo/TODO_2026-10-22_14-25-00_client-reliability-9.md @@ -0,0 +1,6 @@ +- [x] Independent evidence and explicit HEAD implementation routing verified landed e04549f8. +- [x] Minimal controller HEAD fix,23 real lifecycle scenarios and strict integrity/spec notes. +- [x] Focused60/4files,16naturally failing mutants,cancelled0,lint11. +- [ ] Commit/merge candidate and send READY + slot request. +- [ ] Allocated full/normal gate, durable landing/ancestry, final closure. +- [ ] HLS/torrent separately routed/measured; no wider Stage3 claim. diff --git a/todo/TODO_2026-10-22_20-45-00_storage-cors-4.md b/todo/TODO_2026-10-22_20-45-00_storage-cors-4.md new file mode 100644 index 00000000..b39ec70e --- /dev/null +++ b/todo/TODO_2026-10-22_20-45-00_storage-cors-4.md @@ -0,0 +1,5 @@ +- [x] Recover routing/mailbox/health; ff clean branch26e9a787; confirm storage3 durable normal landing63e2b519; close3, claim4. +- [x] Registered baseline2pass/2fail; smallest coordinator-only guard;16 scenarios pass. +- [x] Nine restored failing mutants; five-file focused and lint checks (final results in test log). +- [ ] Serialized heavy/full/normal slot, commit/merged-tree gate and durable landing. +- [ ] Close4 only after durable status and unstable ancestry; report no native/live/manual qualification. diff --git a/todo/TODO_2026-10-22_20-48-00_client-reliability-9.md b/todo/TODO_2026-10-22_20-48-00_client-reliability-9.md new file mode 100644 index 00000000..e0c5fb7b --- /dev/null +++ b/todo/TODO_2026-10-22_20-48-00_client-reliability-9.md @@ -0,0 +1,4 @@ +- [x] HEAD candidate merged, unchanged normal gate435unique/missing0/extra0 and durable landing f41ac656, ancestry0. +- [x] Sequential build/artifact exit0; heavy resources released. +- [ ] Separate allocated native browser HEAD lifecycle smoke and honest limitations. +- [ ] HEAD slice closure disposition; HLS/torrent separately routed and still open. diff --git a/todo/TODO_2026-10-22_20-52-00_client-reliability-9.md b/todo/TODO_2026-10-22_20-52-00_client-reliability-9.md new file mode 100644 index 00000000..e2877e3d --- /dev/null +++ b/todo/TODO_2026-10-22_20-52-00_client-reliability-9.md @@ -0,0 +1,4 @@ +- [x] HEAD-only fix normally landed f41ac656 with435unique files and target ancestry0. +- [x] Build/artifact and controlled native Chromium/Firefox lifecycle smoke pass; resources released. +- [ ] Docs-only final evidence landing and HEAD closure disposition. +- [ ] HLS/torrent/RangeGET separately routed and measured; broader Stage3 unqualified. diff --git a/todo/TODO_2026-10-22_20-54-00_client-reliability-9.md b/todo/TODO_2026-10-22_20-54-00_client-reliability-9.md new file mode 100644 index 00000000..fd5ef854 --- /dev/null +++ b/todo/TODO_2026-10-22_20-54-00_client-reliability-9.md @@ -0,0 +1,5 @@ +- [x] HEAD slice durable,normal435files/buildartifact/controlled nativebrowser complete. +- [x] HLS source/callpath and strict offline12mode diagnostic5fail/7pass documented. +- [ ] Dispatch independent reproduction and explicit HLS helper ownership/spec routing. +- [ ] HLS implementation/retainedtests/verification only after routing. +- [ ] Torrent/RangeGET and broaderStage3 separately open. diff --git a/todo/TODO_2026-10-22_20-58-00_storage-cors-4-closed.md b/todo/TODO_2026-10-22_20-58-00_storage-cors-4-closed.md new file mode 100644 index 00000000..ad946183 --- /dev/null +++ b/todo/TODO_2026-10-22_20-58-00_storage-cors-4-closed.md @@ -0,0 +1,4 @@ +- [x] Coordinator-only productb21ce86c,merged888807ba; focused5/new16,nine restored mutants,lint11. +- [x] Unchanged normalgateexit0; durable7edc7ea8,ancestry0; discovery436 unique (not independent starts audit). +- [x] Sequential buildd9424fb7/artifact29cbe6f0exit0;close4;resourcesreleased. +- [ ] Dispatch integration of shared TODO/preview; native/full-router/live/manual qualification remains outside slice. No new optional item.