diff --git a/src/components/engine/audio/element.js b/src/components/engine/audio/element.js index 13da1c9c..49cc9690 100644 --- a/src/components/engine/audio/element.js +++ b/src/components/engine/audio/element.js @@ -64,6 +64,21 @@ const LOAD_WATCHDOG_MS = 3_000; */ const LOAD_WAIT_LIMIT = 10; +/** + * How many consecutive data-less watchdog reloads an element may attempt + * before recovery is declared futile. When the cause is not the element but + * what it shares with everything else — the browser's connection pool to that + * host (one wedged HTTP/2 connection carries all of them; on HTTP/1.1 six + * hung sockets block the rest) or a server that accepts connections but never + * answers — every fresh request pends forever too. Continuing the cycle + * cannot recover and only occupies more of the connections recovery needs, + * while piling up "pending" entries. Past the limit the element stops + * reloading and surfaces a terminal error; the counter resets on data, + * playback, a seek, refocus or `online`, each of which grants one fresh + * bounded budget. + */ +const DATALESS_RELOAD_LIMIT = 5; + //////////////////////////////////////////// // ELEMENT //////////////////////////////////////////// @@ -654,6 +669,7 @@ class AudioEngine extends BroadcastableDiffuseElement { if (!this.#mediaSourceUrls.has(id)) { // Item was removed while waiting URL.revokeObjectURL(objectUrl); + stream.cancel().catch(() => {}); return; } @@ -661,6 +677,7 @@ class AudioEngine extends BroadcastableDiffuseElement { if (!itemEl) { URL.revokeObjectURL(objectUrl); this.#mediaSourceUrls.delete(id); + stream.cancel().catch(() => {}); return; } @@ -786,6 +803,12 @@ class AudioEngine extends BroadcastableDiffuseElement { } } finally { itemEl.audio.removeEventListener("seeking", onSeeking); + + // Stop pulling from the stream's underlying source (e.g. a fetch). + // Exiting the read loop without cancelling leaves the producer's + // request open — a permanently pending connection. Cancelling an + // already-cancelled/done reader is a no-op. + reader.cancel().catch(() => {}); } } @@ -1184,6 +1207,11 @@ class AudioEngineItem extends BroadcastableDiffuseElement { #watchdogAttempt = 0; /** @type {number} Consecutive data-less windows spent waiting on an in-flight fetch. */ #loadWaitTrips = 0; + /** @type {number} Consecutive data-less reloads — recovery-declared-futile counter. */ + #datalessReloads = 0; + + /** @type {(() => void) | undefined} Visibility/online re-arm listener. */ + #rearmListener = undefined; constructor() { super(); @@ -1279,6 +1307,34 @@ class AudioEngineItem extends BroadcastableDiffuseElement { // is cancelled (see `#watchdogTrip`). this.#armWatchdog(); + // Re-arm a bounded recovery attempt when conditions may have changed: + // refocusing (e.g. after background recovery) or the network coming back. + // An element that gave up (see `#watchdogTrip`) gets one fresh budget + // here; healthy loads just reset and stop from the trip. Without this, + // an element that declared recovery futile would never retry on its own. + // Scoped to elements with play intent or an error so paused, never-loaded + // items don't start a perpetual trip loop. + this.#rearmListener = () => { + if (document.hidden) return; + + let errored = false; + try { + errored = this.audio?.error != null; + } catch { + // No child