Repository navigation
fix(studio): a clip that cannot be read gives up after two retries with one typed line - #6477
Merged
Merged
Conversation
…th one typed line
Contributor
There was a problem hiding this comment.
Verdict: REQUEST CHANGES · 2 blockers · 0 important · 0 nits · head 21e313f3
- blocker: the required CI ›
Lintcheck fails, and so does preview-regression's preflight.no-restricted-globalsrejects the rawfetchatmediaFetch.ts:47and:50("Use studioApiFetch…"). The body says oxlint is clean, but it is not. I recommend keeping the plain fetch, because that is what mediabunny's default already used, and adding a scoped disable with the reason (a media read, not an API call).studioApiFetchsetscredentials: "omit"on loopback, which moves these reads to a different socket pool. - blocker: a stream that breaks mid-read still gives up with a bare
TypeError. ↓ inline
Scope
I read mediaFetch.ts, both readers and mediabunny 1.45.3 retriedFetch and UrlSource._runWorker. These are the only two UrlSource sites in the repo. The 37 tests pass locally.
Contributor
Edit accuracy: accurate 271 (base branch 271), smooth 149 of thoseThe gate passes. Quarantined, measured but not gated (0) |
4 tasks done
meefs
pushed a commit
to meefs/hyperframes
that referenced
this pull request
Oct 28, 2026
…ails (heygen-com#6629) * perf(studio): keep released timeline video frames for scroll-back A video thumbnail frame was revoked the moment no mounted strip held it, so scrolling back past the scheduler's cache decoded every clip again: 80 decode passes for 40 clips on a 44-minute source, the whole first pass repeated. Released frames now stay shared in a least-recently-released list capped by the new sharedFrameCacheBytes budget (32 MiB of JPEG, about 11 frames for each of a 473-clip episode's clips). Object URLs are revoked only on eviction, and a frame any strip still shows is never evicted. Which frame a strip shows is unchanged: the acceptance window is the same predicate. Co-Authored-By: Claude Opus 5.5 <[email protected]> * perf(studio): open each video source once for all its timeline clips Every clip's thumbnail job opened its own mediabunny Input on the source URL, so 40 clips cut from one 44-minute source opened it 80 times, re-reading and re-parsing its 1.44 MB index each time (behind a 304, at 13-30 ms an open). Jobs now share one pooled reader per source: the Input, its primary video track and the track's size and duration, reference-counted and disposed thumbnailSourceIdleMs (10 s) after the last job ends, or at once when the source's contentVersion changes. Each job keeps its own CanvasSink: the sink reuses one pooled canvas and a job blanks the canvases it saw on release, so sharing one across jobs would corrupt a neighbour's frames. A failed open leaves the pool at once, and a job that fails for any reason but cancellation retires its reader, so the next clip opens afresh. Retries are still counted per reader, as mediaUrlSourceOptions has done since heygen-com#6477: two jobs reading through one reader cannot tell whose read failed, and retiring a reader whose retries are spent keeps that budget per reader rather than letting a spent count fail later clips at once. The pool also owns the one import("mediabunny"), which the decoder now takes from the opened source. Co-Authored-By: Claude Opus 5.5 <[email protected]> * perf(studio): start no timeline video decodes while the preview plays A playing preview shares the machine's video decoder with timeline thumbnails, and a long many-clip timeline queues hundreds of thumbnail decodes. While the player store reports isPlaying, the scheduler now starts no new visible or overscan video decode; a scrub's interaction request still starts, decodes already running finish, and pausing drains the queue. Image, composition and waveform work is unaffected. The new thumbnailPlaybackHold module subscribes to the store's state rather than wrapping setIsPlaying, so a reset or a project switch, which write isPlaying: false directly, also release the held work. VideoThumbnail binds it to the app's scheduler where video thumbnails load, which is also the path HyperFrames Desktop runs (it embeds StudioApp, not Studio's main.tsx). Co-Authored-By: Claude Opus 5.5 <[email protected]> * perf(studio): bound thumbnail range reads and their read cache mediabunny reads a URL with open-ended `Range: bytes=N-` requests and cancels each once it has the bytes it wanted, by when a local server has streamed megabytes past them: about 4 MB sent for each thumbnail frame decoded. The thumbnail reader's fetch now asks for at most 512 KiB from N; a shorter answer than mediabunny wanted makes it ask again from where it stopped (its worker resumes a capped response when the file size is known, which the first 206's Content-Range supplies). Closed ranges and the probe reader are untouched, and so is Studio's playback, which does not read through mediaUrlSourceOptions. Measured on two 44-minute 1080p sources, 40 clips each (a poster and a 4-frame strip per clip), against the same branch unbounded: bytes sent fall 13-22% one decode at a time and 33% two at a time; first-pass wall time falls 4-20%. The frames decoded are byte-identical. Most of what remains is real: a strip slot whose keyframe is outside its window decodes from that keyframe forward through its GOP. A pooled reader also now caches at most 16 MiB of what it read, not mediabunny's default 64 MiB, since it outlives its clip by up to thumbnailSourceIdleMs. 8 MiB cost requests when a clip's poster and strip decode together; 16 MiB measured the same as 64. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(studio): bound thumbnail ranges only once Content-Range is readable A thumbnail reader bounded even its first range request to 512 KiB. When the answer's Content-Range cannot be read, mediabunny sizes the file as the request start plus the answer's Content-Length, assuming the answer runs to the end of the file, so a bounded first answer made a 10 MB file end at 512 KiB. Browsers hide Content-Range cross-origin unless the server lists it in Access-Control-Expose-Headers, and Studio reads remote https sources such as HeyGen's gen-os-static bucket, which does not. With the header hidden, every format failed: faststart and fragmented MP4 decoded 1 of 12 thumbnails, and moov-at-end MP4, MOV and WebM none ("no decodable video track"). A reader now bounds its ranges only after an answer of its own was a 206 whose Content-Range it could read and that starts where the request asked; its first request stays open-ended, as before the bound. Any answer without such a Content-Range, a whole-file 200 included, keeps that reader unbounded for good. Same-origin readers, where Content-Range is readable, still bound. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(studio): count a thumbnail reader's read failures consecutively A reader's retry budget (two retries, from heygen-com#6477) counted every failure over the reader's life. Pooled, a reader serves every clip of its source, so the third transient failure anywhere on the source failed a clip with no retry. The count now resets whenever a read delivers bytes, so a long-lived reader keeps the per-clip tolerance a reader had when each clip opened its own, and three failures in a row still give up. Bytes, not an ok status: a body that breaks on its first read must still use up the budget. A reader that gives up is retired, as before, so the next clip opens a fresh one. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(studio): a read stalled mid-response no longer hangs its video source The thumbnail job's signal reached only the source's open: keyframe lookups and the decode loop ignore it, so a job whose response stalled mid-body never ended and never released its pooled reader. The reader was never idle, so never closed, and every later clip that needed the same bytes waited on the same stalled read for the rest of the session. Before pooling, each clip had its own reader, so only the stalled clip hung. The scheduler's timeout now aborts with a TimeoutError reason. A job that times out drops its reader at once; a job cancelled any other way that has still not ended a load timeout later drops it then; a cancel the job answers, a scroll away, leaves the reader pooled. Dropping disposes the reader whoever still uses it, which fails their pending reads too. Disposing a mediabunny Input does not end a read already waiting on a stalled body (its orchestrator only flags the worker, which never wakes), so mediaUrlSourceOptions gains cancelReads(), which aborts every fetch the reader has in flight; the worker's read then fails and mediabunny rejects the reads waiting on it. Measured in Chromium with a server that stalls one response: the stalled clip settles at its timeout, and the same clip asked again, now and after 11 s idle, decodes. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(studio): a clip's own failed result keeps its source's reader A thumbnail job that failed for any reason but a cancel retired its pooled reader, so one clip whose frames all failed to decode, or whose canvas would not encode, made the next clip re-read and re-parse the source's index. Those two failures are now a ClipResultError, which leaves the reader pooled; fetch, demux and decoder failures still retire it. Also pins four pool behaviours mutation testing found unguarded: a reader closed after sitting idle has left the pool, so the next clip opens a live one; a version change closes the old reader only once the job still decoding on it ends; a job cancelled while the track is read stops the open there; and a scroll-away cancel keeps the reader (the last landed with the stall fix). Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(studio): keep a thumbnail reader's retry budget per start byte ff16aed98 reset a pooled reader's retry count whenever any read delivered bytes. That broke the heygen-com#6477 give-up in two ways, both measured with real mediabunny: - A server that ignores Range answers 200 from byte 0, so mediabunny resumes at the bytes it got and is sent byte 0 again. With a body that breaks after 64 KiB, those bytes reset the count every time: 61 fetches in 30 s and the read still pending. - mediabunny reads with two workers. A healthy stream in one region kept forgiving a region that always answered 503: 41 fetches in 20 s, pending. Failures are now counted per start byte, with no reset. A resume after progress asks for a new start and gets its own two retries; asking for the same start again uses them up whatever else the reader reads meanwhile. The start is the byte the answer began at: the asked start for a 206 or a failed request, 0 for a 200. Traced for the 200 case: requests at 0, 65536 and 65536 are all keyed 0, so the third failure gives up. Both repros now give up after 3 fetches ("network TypeError after 2 retries", "HTTP 503 after 2 retries"), and failures at different starts, one per clip, never add up. The fetch and the answer's typing move into typedFetch and typedResponse, so fetchFn stays under the complexity gate. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(studio): fail a stalled thumbnail read on silence, not on a timeout dbd820706 dropped a reader when the job using it timed out, but only once the reader had opened: a timeout during the shared open released the job and left the stalled open pooled, so every clip of that source joined it and timed out in turn while clips kept arriving. Dropping on a timeout was also wrong when bytes were still coming (a slow link's 25 s open threw its parsed index away), and dropping a reader other clips were using failed them with "Media reads cancelled", leaving them on "no preview" until they remounted. A thumbnail read now fails only on silence. A request waiting 8 s for its answer, or for its next bytes, is aborted and fails as a MediaFetchError "stalled", which mediabunny retries like any read failure: a resume after progress asks for a new start and continues, and three silent waits at one start (8 + 0.5 + 8 + 1 + 8 = 25.5 s, inside the 30 s load timeout) give up, which retires the reader. This covers the open and the decode alike, keeps a reader whose bytes are still arriving, and never disposes a reader another clip is using, so the job-level abandon and the scheduler's TimeoutError reason are removed. Measured in Chromium against a stalling server: - one-shot stall in the open: the clip decodes at 8.7 s (was: it and the next clip both timed out at 30 s); - one-shot stall mid-decode: decodes at 8.7 s (main: hangs); - a source that never sends a byte: each clip fails typed at 25.5 s and the next opens a fresh reader. Each request gets its own AbortController, which follows mediabunny's signal and the reader's cancel without AbortSignal.any; every way a request ends unlinks it from the reader's long-lived cancel signal. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(studio): pin the thumbnail behaviours mutation testing left unguarded - A bounded answer's Content-Range keeps the reader bounded: a closed-range pattern that matched only open ranges flipped it unbounded after the first bounded request, undoing cf8f4554e. - A frame that will not encode, through toBlob or OffscreenCanvas convertToBlob, is the clip's failure and keeps its reader. The convertToBlob rejection is now a ClipResultError too; it escaped as the browser's own error and retired the reader. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(studio): pin the stall watchdog's guarantees about its own requests Four guarantees of the thumbnail reader's per-request watchdog had no test; each mutant that broke one passed the suite: - a silent body fails as "stalled" and aborts the fetch under it, so the stalled connection is not left open; - every way a request ends unlinks it from mediabunny's signal: an answered body, a rejected fetch, a 503, no body, a broken body, a cancel; - a probe reader's body is never timed, however slow; - a request made after the reader's reads were cancelled starts aborted. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(studio): time a thumbnail read's silence only once its answer has started The 8 s stall rule also timed the wait for a response's headers, and that wait includes Chrome's queue for one of six HTTP/1.1 connections per host. Desktop serves all media from one loopback host, where Studio's event stream and streaming previews hold connections. Measured in Chromium against a healthy server: with five of six connections held and 20 cancel/re-ask bursts over the 40-clip pass, no false stall; with all six held for 40 s, two clips failed as "stalled". The watchdog now times only a started body. A server that never answers is now caught at the job's own timeout. The scheduler's timeout again aborts with a TimeoutError reason. A job that timed out without progress in its whole run (its source never finished opening, or it decoded no frame) failed because of its source: a source that never answers, or one that trickles bytes and never finishes. It logs one typed MediaFetchError line ("network timeout") and retires its reader. A job that waited for an open that finished, or decoded a frame, timed out on a slow link and keeps the reader and its parsed index. Bytes alone are not progress, or a trickling source would keep its reader for good. An aborted job also ends its claim on the reader at the abort, rather than when it settles: a job stuck in a read that ignores its signal no longer keeps the reader open. A retired reader still waits for the clips decoding on it. Measured in Chromium: a source that never answers fails each clip with one typed line at 30 s and the next opens a fresh reader; with all six connections held for 40 s, the two starved clips fail typed, none as a stall. Co-Authored-By: Claude Opus 5.5 <[email protected]> * refactor(studio): move the thumbnail reader's explanations into names The comment ratchet (scripts/comment-ratchet.mjs) fails a source file whose share of comment lines rises over its merge-base, and a new file above its package's share. Five files of this branch did. The reasoning they carried is in the commit messages and the PR description; what stays in code is named: SILENT_BODY_STALL_MS, POOLED_READER_CACHE_BYTES, failuresByStart, inFlightReads, decodedByJob, timeoutWithoutProgress and madeProgress. No behaviour changes. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
When Studio's media probe or thumbnail decoder cannot read a clip, it now retries twice and then gives up with one typed line: which reader, the HTTP status or the network error, and how many retries, never the URL. Example:
[Studio] probe range fetch failed: network TypeError after 2 retries.Why
Both readers build a mediabunny
UrlSourcewith its defaults. That default retries a same-origin network failure forever, backing off up to 16 s, and logs each attempt asRetrying failed fetch. Error: TypeError: Failed to fetch. When a range request fails while no read is waiting on it, mediabunny also rethrows it so it isn't swallowed, which reaches the page'sunhandledrejectionas a bareTypeError. A media file that cannot be read (locked by another program, for example) therefore produced an endless stream of bareTypeErrors with nothing saying which reader failed or why. An HTTP error status, on the other hand, came back asError fetching <full URL>: <status>, putting the local media URL in the log.How
player/lib/mediaFetch.tsowns theUrlSourceoptions for both readers:fetchFnturns a rejected fetch intoMediaFetchErrorwith the network code (cause.codewhen present, else the error's name) and an error status intoMediaFetchErrorwith that status. It also wraps the response body, so a stream that breaks mid-read errors withMediaFetchErrortoo (mediabunny rethrows a body error as-is). Cancellations pass through untouched. It callsfetchdirectly, with a scoped lint disable: this is a media read, on the media's own sockets, the same request mediabunny's default makes;studioApiFetchis for Studio's API calls.getRetryDelayretries a network failure or a 5xx twice (0.5 s, then 1 s), never retries a 4xx, and writes the retry count into the error's message. It counts per source, because mediabunny passes attempt 1 for every failed mid-stream read.reportMediaFetchFailurewrites the one[Studio]line for aMediaFetchErrorand ignores other errors.mediaProbe.tsandthumbnailVideoDecoder.tspass those options and report at their existing catch. The probe still records the URL as failed and returnsnull; the decoder still rethrows.MediaFetchError, so the page's unhandled-rejection report names the reader and cause too.Before
Headless Chrome 152, a page reading a clip from a server that drops every connection, with the default
UrlSource, 20 s: a bareTypeErrorper attempt, backing off, and the read never settles.After
The same page with the options from this PR: two retries, one typed line, and the read settles at 2.3 s.
Test plan
Unit tests added/updated
Manual testing performed
Documentation updated (if applicable)
Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug
mediaFetch.test.ts: a network failure and an error status becomeMediaFetchErrorwithout the URL; acause.codeis reported; a cancellation passes through; a network failure is retried twice then given up with the count in the message; mid-stream read failures (always attempt 1) are counted too; a 4xx is not retried; a real mediabunnyInputfails with the typed error, and so does its unawaited read-ahead; a 206 whose body breaks on the first read gives up through a realInputwithnetwork TypeError after 2 retries; the report writes one line for a typed failure and nothing for others.mediaProbe.test.tsandthumbnailVideoDecoder.test.ts: each reader builds its source with the retry rule and writes the one line on a failed fetch. Both fail with the readers put back on mediabunny's defaultUrlSource.Without the body wrapper, the broken-body test fails (the read gives up with a bare
TypeError).38 tests pass 3 runs in a row; studio
tsc --noEmit,oxlint .over the repo, oxfmt, the comment ratchet and the fallow audit are clean.