From 0adbfc5fb3569b741d3555bde140e3b110137923 Mon Sep 17 00:00:00 2001 From: robinebers Date: Sat, 14 Nov 2026 18:34:50 +0000 Subject: [PATCH] fix(core): a film waiting on a clip that gets no data recovers after 5 s The film holds while a clip it needs has no data, and it held for good when that data never came. On a page that loads every clip at parse, Chrome and Firefox keep each paused clip's request open with its unread bytes. Those requests fill the connection's window, and the request for the clip the film waits on is never answered. The stall rule in isUnplayable does not catch this clip: it reports readyState 1 while still loading. After 5 s with no change in the held clips' readyState or buffered time, the runtime now releases every clip outside the current window that holds a request, at preload none, which frees the connection. Each one loads again when it comes due. To reproduce: open a composition of 37 clips cut from a 363 MB and a 180 MB .mov served over HTTP/3 on its own, without the player. In Chrome it stops at 0:17 and never moves again. --- .../core/src/runtime/init.buffering.test.ts | 46 +++++++++++++++++++ packages/core/src/runtime/init.ts | 28 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/core/src/runtime/init.buffering.test.ts b/packages/core/src/runtime/init.buffering.test.ts index 84ee600405..25d1dc804c 100644 --- a/packages/core/src/runtime/init.buffering.test.ts +++ b/packages/core/src/runtime/init.buffering.test.ts @@ -227,6 +227,52 @@ it("a hold for data does not reload a later clip", () => { expect(load.mock.contexts.filter((el) => el === later).length).toBe(before); }); +function laterClipHoldingARequest() { + const later = document.getElementById("later") as HTMLVideoElement; + Object.defineProperties(later, { + readyState: { get: () => 1, configurable: true }, + networkState: { get: () => 2, configurable: true }, + }); + const srcWrites = new MutationObserver(() => {}); + srcWrites.observe(later, { attributeFilter: ["src"] }); + return { later, srcWrites }; +} + +it("a hold whose clip gets no data for 5 s frees the clips not due, whose open requests can fill the connection", () => { + const { voice, voiceReady, playFor } = playPodcast( + 0, + "video", + '', + ); + const { later, srcWrites } = laterClipHoldingARequest(); + + playFor(4); + expect(srcWrites.takeRecords()).toHaveLength(0); + playFor(1.5); + expect(srcWrites.takeRecords().length).toBeGreaterThan(0); + expect([later.preload, later.getAttribute("src")]).toEqual(["none", "/later.mp4"]); + expect(voice.getAttribute("src")).toBe("/voice.m4a"); + + voiceReady.value = 4; + playFor(1); + expect(window.__player!.getTime()).toBeGreaterThan(0.9); +}); + +it("a hold whose clip is still receiving data frees nothing", () => { + const { voiceReady, playFor } = playPodcast( + 0, + "video", + '', + ); + const { srcWrites } = laterClipHoldingARequest(); + + for (let s = 1; s <= 8; s++) { + voiceReady.bufferedTo = s * 0.05; + playFor(1); + } + expect(srcWrites.takeRecords()).toHaveLength(0); +}); + it("a film held for data reads as choppy, so the app can lower it", () => { const reports: Array<{ choppy: boolean; waitingShare: number; heldBy: unknown }> = []; const { voiceReady, playFor } = playPodcast(0, "video"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 67a3e1ca92..a34137f99e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -6194,6 +6194,32 @@ export function initSandboxRuntimeModular(): void { ) .map(({ el }) => el); + const HELD_WITHOUT_PROGRESS_MS = 5000; + const loadProgress = (els: HTMLMediaElement[]) => + els + .map((el) => { + let seconds = 0; + for (let i = 0; i < el.buffered.length; i++) + seconds += el.buffered.end(i) - el.buffered.start(i); + return `${el.readyState}:${seconds}`; + }) + .join(); + let heldProgress = { held: bufferingFor, mark: "", sinceMs: 0 }; + const releaseMediaHoldingTheConnection = () => { + const nowMs = performance.now(); + const mark = loadProgress(bufferingFor); + if (heldProgress.held !== bufferingFor || heldProgress.mark !== mark) + heldProgress = { held: bufferingFor, mark, sinceMs: nowMs }; + if (nowMs - heldProgress.sinceMs < HELD_WITHOUT_PROGRESS_MS) return; + heldProgress.sinceMs = nowMs; + for (const { el, start, end } of resolveMediaClipIndex().clips) { + if (bufferingFor.includes(el) || isInClipWindow(state.currentTime, start, end)) continue; + if (el.readyState === 0 && el.networkState !== 2) continue; + el.preload = "none"; + withoutOwnTimingRecords(el, () => releaseMedia(el)); + } + }; + const transportTick = () => { if (state.tornDown || inTransportTick) return; inTransportTick = true; @@ -6381,6 +6407,8 @@ export function initSandboxRuntimeModular(): void { !mediaNotReadyAt(state.currentTime, canResumeFrom).some((el) => bufferingFor.includes(el)) ) { transport.play(); + } else if (bufferingFor.length > 0) { + releaseMediaHoldingTheConnection(); } postState(false); } finally {