Repository navigation
fix(storage): video pieces really go to disk; saves keep their file; fewer download stalls - #304
Merged
Merged
Conversation
…, catch short writes
- Every downloaded fragment's identifier is its URL and range
("https://host/seg.ts::0-100::arraybuffer"), and Firefox refuses an OPFS name
with a "/" (IsValidName, dom/fs/shared/FileSystemHelpers.cpp). So no fragment
was ever stored: each write failed, was logged, and the fragment stayed in RAM.
OPFSManager now gives each identifier a short file name of its own (fileName)
and the worker only sees those (#132).
- The 30 s worker timeout ran from when a call was posted. The worker answers
one call at a time, so a backlog of writes longer than that (a whole video
downloading to a slow disk) counted as a crash and OPFS was dropped for the
session. One watchdog now measures how long the worker has gone without
answering any call, and no timer is left per call (#135).
- FileSystemSyncAccessHandle.write() reports a write that failed part-way (disk
or quota full) only through the byte count it returns; Gecko never throws
for it. Nothing checked it, so a fragment could be stored cut short and a save
go on with a hole. Every write now checks the count; a fragment that did not
fit is removed and kept in RAM, and a save fails (#138).
Proven in tests/unit/opfsStorage.test.mjs, which now runs the real worker on a
stand-in OPFS that applies Firefox's name rule and can run out of space: with
the old OPFSManager the URL-named fragment and the backlog cases fail, and with
the count check removed the three short-write cases fail. storage.e2e.mjs
round-trips a URL-named fragment in real Firefox.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A streamed save (accelerated MP4, a direct video, an archive) writes its file into its own OPFS session and hands Firefox a URL to it. The session was closed two minutes after the save ended, whatever the download was doing, and a closed session is deleted by the next player or save that starts (10 s after its last heartbeat). So a download longer than that - a big video to a slow disk - could lose its file midway, with no message. Utils.revokeWhenDownloaded now says (a promise) when the download is over, and StreamSaver closes the store then, never sooner than the old two minutes, which is still all a download without an id to follow gets. Both sinks (OPFS and memory) do the same. tests/unit/StreamSaver.test.mjs: with the old timer the store closes at two minutes of a ten-minute download (fails); without the floor it closes right after a quick download (fails). revokeWhenDownloaded.test.mjs checks the promise. Part of #134: merged saves (DASH, HLS) still close their store on the timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ad sizes unsigned
An .fsa archive's header and entry sizes come from the file. LargeBuffer.read
allocated the buffer before checking the length against what was left, so a
damaged or crafted archive could ask for up to 4 GB before "out of range". The
32-bit size was assembled with a signed `<< 24`, so a size from 2 GB up came out
negative and failed with an unexplained RangeError, and getParts took a negative
length as "read nothing", which put every later read in the wrong place.
The length is now checked first (and must be a whole, non-negative number), and
uint32() is unsigned. tests/unit/LargeBuffer.test.mjs and
FastStreamArchiveUtils.test.mjs ("says a header size past the end of the file is
out of range"): all four new cases fail on the old LargeBuffer.
Fixes #141.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
writeFSAToStream did not await writer.close(), so "Saved" showed before the save's stream had handed its file to the download, and a failure there (quota, download refused) was an unhandled rejection with no message. An entry that was not complete, or a failed read, threw with the stream left open: the save's OPFS file handle, worker and session stayed until the tab closed. It now awaits close() and, on any failure, aborts the stream (which ends the save's session) before passing the error on. tests/unit/FastStreamArchiveUtils.test.mjs: the three writer cases fail on the old code (and the failing close is an unhandled rejection there). Fixes #136. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ays answers FSBlob's last backend (used when OPFS and the Cache API both refuse): - prune() opened every other player's temporary database to read its heartbeat and closed only the stale ones. A live player's connection stayed open, so when that player closed, its deleteDatabase was blocked (5 s timeout, then the database stayed on disk) until the pruning tab closed too. The connection is now closed either way (#137). - transact() had no abort handler, so a commit that failed (quota exceeded aborts without an error event) never answered. And a callback that threw before making its request (a value that cannot be stored) left a transaction with nothing in it, which completed: the call answered null as if it had worked, and FSBlob then kept `undefined` as the fragment. Both now reject; the executor is no longer async (its throw was an unhandled rejection). keepAlive catches a failed beat, which is retried a second later (#149). tests/unit/IndexedDBManager.test.mjs: on the old code the first transact case answers "resolved: null", the second stays pending, and the prune case leaves the live connection open. Fixes #137, fixes #149. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls nothing else StandardDownloader.run makes the request (a DASH segment's preprocessor) before loading it. When the entry was aborted meanwhile (a seek) and the request then failed, the catch used `this.entry`: null after the abort (a TypeError, an unhandled rejection), or the next entry the downloader had started by then, which it failed and cleaned up. It now returns when its own run was aborted. tests/unit/StandardDownloader.test.mjs: both new cases fail on the old code (the next download is failed; an unhandled rejection with nothing else running). Fixes #148. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e one gets work - A failed download (after XHRLoader's own retries) takes one downloader away, to ease off a struggling server, and nothing gave it back: three failures in a long video left three downloaders of six until the next video. Now, once no download has failed for 10 s, each successful download brings one back, up to as many as were taken. A downloader the user adds or removes resets that. - queueNext started at most one download per call. After a failure it is called once, when the 1 s wait is over, so with five free downloaders and forty queued fragments one ran at a time until other calls came in. It now starts a download on every free downloader. tests/unit/DownloadManager.test.mjs: each case fails with its fix taken out (one download started of three possible; the pool stays at one). Fixes #133. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…can fail - SpeedTracker.getSpeed divided by the time since its oldest entry started. A response that arrived within one performance.now() tick of being asked for (a cache hit; Firefox's clock moves in 1 ms steps) gave Infinity. The speed shown smooths each value into the last, so it then said Infinity for the rest of the session, and the download speed limit stopped predownloading while it lasted. A zero window now counts as 0, like no data (#140). - The prune test added its stale entries through update(), which already prunes, so it passed with prune() doing nothing (`while (false)`: 6 of 6). The prune cases now fill the buffer directly and check exactly what is left: four stale entries leave the last two, two stale entries are both kept, and only stale entries go (#251). SpeedTracker.mjs is left out of Stryker's list here; #253 adds it with the other tested modules. Mutation-checked: `while (false)` fails three cases, a floor of 1 instead of 2 fails three, a wider cutoff fails one, and without the zero check the new getSpeed case fails. Fixes #140, fixes #251. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…headers fail at once - A server that ignores the Range header answers 200 with the whole file. That was taken for the range asked for: the file's first bytes, handed to the player as a range further in (a byte-range HLS segment, a DASH index or segment, an MP4 range). XHRLoader now cuts the asked-for range out of such an answer and stops reading once it is in; a range that starts past the end of the file fails as 416 (#139). - A header fetch() cannot send (a custom header whose name has a space) made fetch() reject with a TypeError, which was retried like a network error, six times over about a minute. The headers are now checked with `new Headers()` first, and such a load fails at once (#150). tests/unit/XHRLoader.test.mjs: the header case and three of the four ignored- Range cases fail on the old loader; without the reader.cancel() the "stops reading" case fails. Fixes #139, fixes #150. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateHasDownloadSpace estimates the video's bitrate from the downloaded fragments once five are in: their bytes over their duration. MP4Player starts its 1 MB ranges on whole seconds, so on a video of about 40 Mbit/s or more the first five lie in the first second and last 0 s. The bitrate came out as Infinity: the player said storage was short, set the buffer back, and marked every downloaded fragment to be kept for the rest of the session. The estimate is now Utils.measuredBitrate, which answers null (use the level's own bitrate, as before five fragments) while the fragments span no time. tests/unit/measuredBitrate.test.mjs; without the zero-time check its third case gets Infinity. Fixes #210. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a video looks up its remembered position under two PBKDF2 hashes of its address (600,000 rounds each), and the video's start - autoplay, the seek to the time in its URL - waits for that lookup. The two were derived one after the other; they are now derived at once. The hashes, and so the stored records, are unchanged. In Node the pair takes about 100 ms instead of 180 ms; Firefox runs WebCrypto work off the main thread as well, so the wait should drop the same way (not measured there). tests/unit/SecureMemory.test.mjs: the new case fails on the old code (the second hash starts only after the first ends). Part of #146 (the proposal to replace PBKDF2 is left for a decision). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ain it eslint.modules.config.js's promise/catch-or-return flagged the bare .then() chain added in the previous StreamSaver commit. Same behaviour; the StreamSaver tests still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What a later reader of the OPFS code needs: fragments were never stored there until #132's fix, files are found by OPFSManager.fileName(), write() reports a full disk only by its count, and the 30 s limit is a watchdog over the worker's answers, not a per-call timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ange Narrows the previous commit's ignored-Range handling to the clear case. A 200 with a Content-Range header, or exactly as long as the range asked for (from a start past 0, which no whole file can be), is a server sending the range with the wrong status; that worked before and is left alone. Only a 200 without either is the whole file and has the range cut out of it. tests/unit/XHRLoader.test.mjs: "takes a 200 that says it is the range" fails with either check taken out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The save PR's memory sink waits on FSBlob.whenStored. With both PRs merged, this test's stand-in lacked it and two memory-sink cases failed; one no-op method fixes that, so the two PRs merge in either order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Storage and downloads: video pieces really go to disk, saves keep their file, fewer stalls
What was wrong, as you'd notice it:
How it's proven:
For the final try-list: this switches on, for the first time, the on-disk store for playback pieces. Things to try:
Fixes #132
Fixes #133
Fixes #135
Fixes #136
Fixes #137
Fixes #138
Fixes #139
Fixes #140
Fixes #141
Fixes #148
Fixes #149
Fixes #150
Fixes #210
Fixes #251
Not fixed here:
🤖 Generated with Claude Code