Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
0848099
fix(storage): store fragments in OPFS at all, survive a write backlog…
Nawid3333 Oct 3, 2026
0e16175
fix(save): keep a streamed save's file until its download is over
Nawid3333 Oct 3, 2026
c03554e
fix(archive): check a length from an archive before allocating it, re…
Nawid3333 Oct 3, 2026
1eb6ca3
fix(archive): finish or abort the save stream when writing an archive
Nawid3333 Oct 3, 2026
f6505cb
fix(storage): IndexedDB prune lets go of live databases; transact alw…
Nawid3333 Oct 3, 2026
576cb50
fix(network): a request that fails after its download was aborted fai…
Nawid3333 Oct 3, 2026
edc2a6c
fix(network): downloaders dropped after failures come back; every fre…
Nawid3333 Oct 3, 2026
a2c3bf4
fix(network): speed over a zero-length window is 0, and prune's test …
Nawid3333 Oct 3, 2026
3c26d2c
fix(network): ignored Range answers are cut to the range; unsendable …
Nawid3333 Oct 3, 2026
3afbbd8
fix(player): no false "not enough storage" on high-bitrate MP4s
Nawid3333 Oct 3, 2026
fa17116
perf(progress): derive the two remembered-position hashes side by side
Nawid3333 Oct 3, 2026
8af51b0
style(save): await the download in closeWhenDownloaded rather than ch…
Nawid3333 Oct 3, 2026
85551bf
docs: OPFS file names and the worker watchdog in CLAUDE.md
Nawid3333 Oct 3, 2026
c6e759b
fix(network): a 200 that says it is the range is still taken as the r…
Nawid3333 Oct 3, 2026
9b6bddc
test(save): the StreamSaver test's stand-in store has whenStored
Nawid3333 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,16 @@ than trusting `FSBlob`'s own self-report.
`getFile()`). It read each fragment back through the worker into an `ArrayBuffer`
wrapped in a Blob, so every "offloaded" fragment was in RAM as well. A stored fragment
is now deleted from disk on eviction, as the Cache backend's always were.
- A worker call unanswered for 30 s counts as a crash (`CallTimeoutMs`), and a dead
worker moves FSBlob on to its next backend instead of keeping every later fragment in
RAM.
- A worker that answers nothing for 30 s while calls wait counts as a crash
(`CallTimeoutMs`, one watchdog re-armed by every answer: it runs calls one at a time, so
timing each call from its post counted a write backlog as a crash), and a dead worker
moves FSBlob on to its next backend instead of keeping every later fragment in RAM.
- **Until 2026-10-03 no playback fragment was ever stored in OPFS** (#132): their
identifiers are URLs, and Firefox refuses a name with a `/` (and `\` on Windows). The
worker now only sees names `OPFSManager.fileName()` hands out (`f0`, `f1`, ...), so a
file on disk is found by `opfsManager.fileName(identifier)`, not by its identifier. A
sync access handle's `write()` reports a disk-full write only by a short count (Gecko
never throws), which the worker checks (`writeAll`).
- `prune()` in the worker leaves a session directory younger than `STALE_MS` alone
(it exists before its first heartbeat: two players starting together deleted each
other's), and a heartbeat that exists but cannot be read (its owner is writing it) means
Expand Down
15 changes: 1 addition & 14 deletions chrome/player/FastStreamClient.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -644,20 +644,7 @@ export class FastStreamClient extends EventEmitter {
let bitrate = level.bitrate;
const fragments = this.fragments;
if (fragments) {
let count = 0;
let size = 0;
let totalDuration = 0;
fragments.forEach((fragment) => {
if (fragment && fragment.dataSize !== null) {
count++;
size += fragment.dataSize;
totalDuration += fragment.duration;
}
});

if (count > 4) {
bitrate = size / totalDuration * 8;
}
bitrate = Utils.measuredBitrate(fragments) ?? bitrate;
}
if (bitrate && this.duration) {
let storageAvailable = (this.storageAvailable * 8) * 0.6;
Expand Down
12 changes: 10 additions & 2 deletions chrome/player/modules/LargeBuffer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export class LargeBuffer {
}

async getParts(length) {
// Lengths come from the file itself (an archive's headers): a negative one, or one
// that is no number, read nothing, and the next read started in the wrong place.
if (!Number.isSafeInteger(length) || length < 0) {
throw new Error('Invalid length ' + length);
}
const parts = [];
this.offset += length;
if (this.offset > this.byteLength) {
Expand All @@ -51,8 +56,10 @@ export class LargeBuffer {
}

async read(length) {
const uint8 = new Uint8Array(length);
// Checked first: a length from a damaged or crafted archive asked for up to 4 GB of
// memory before it was found out of range.
const parts = await this.getParts(length);
const uint8 = new Uint8Array(length);
let offset = 0;

for (let i = 0; i < parts.length; i++) {
Expand All @@ -74,6 +81,7 @@ export class LargeBuffer {

async uint32() {
const arr = await this.read(4);
return (arr[0] << 24) | (arr[1] << 16) | (arr[2] << 8) | arr[3];
// >>> 0: `<< 24` is signed, and a size from 2 GB up came out negative.
return ((arr[0] << 24) | (arr[1] << 16) | (arr[2] << 8) | arr[3]) >>> 0;
}
}
8 changes: 6 additions & 2 deletions chrome/player/modules/SecureMemory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,12 @@ export class SecureMemory {
}

async getHashes(identifier) {
const identifierHash = await SecureMemory.hash(identifier, this.identifierSalt);
const keyHash= await SecureMemory.hash(identifier, this.keySalt);
// Side by side: each is 600,000 rounds of PBKDF2, and the video's start (autoplay, the
// seek to the time in its URL) waits for both.
const [identifierHash, keyHash] = await Promise.all([
SecureMemory.hash(identifier, this.identifierSalt),
SecureMemory.hash(identifier, this.keySalt),
]);
return {identifierHash, keyHash};
}

Expand Down
35 changes: 23 additions & 12 deletions chrome/player/modules/StreamSaver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ function createWriteStreamBlob(filename, opts, size) {
}, opts.writableStrategy);
}

/**
* Revokes a save's URL and closes its blob store once its download is over. Firefox reads
* the file from the store's OPFS session as it downloads it, and a closed session is
* deleted by the next prune (any player or save starting, 10 s after its last heartbeat):
* closed on a fixed two-minute timer, a longer download (a big video to a slow disk) lost
* its file midway, with no message. Never sooner than those two minutes, which is all a
* download without an id to follow (a link click) has.
* @param {FSBlob} blobManager
* @param {string} url - The blob: URL being downloaded.
* @param {*} download - What Utils.downloadURL resolved with.
* @return {Promise<void>} Not awaited: the save is done before its download is.
*/
async function closeWhenDownloaded(blobManager, url, download) {
await Promise.all([
Utils.revokeWhenDownloaded(url, download),
Utils.asyncTimeout(120000),
]);
blobManager.close();
}

/**
* Progressive, disk-backed sink: every chunk is appended to one OPFS file and
* the finished file goes to the download as a File, so RAM stays flat no
Expand Down Expand Up @@ -90,13 +110,8 @@ function createOPFSSink(filename, blobManager) {
throw e;
}
// chrome.downloads resolves before Firefox has read the blob URL: kept until the
// download is over (revokeWhenDownloaded). Defer closing the worker the same way
// mp4merger.mjs's destroy() does, so a slow transfer still has time to finish
// reading from the OPFS-backed file before its session is torn down.
Utils.revokeWhenDownloaded(url, download);
setTimeout(() => {
blobManager.close();
}, 120000);
// download is over (revokeWhenDownloaded), and the OPFS file behind it too.
closeWhenDownloaded(blobManager, url, download);
},
async abort() {
await opfsWriterReady.catch(() => {});
Expand Down Expand Up @@ -124,11 +139,7 @@ function createMemorySink(filename, blobManager) {
const chunks = await Promise.all(blobs.map((blob) => blobManager.getBlob(blob)));
const blob = new Blob(chunks, {type: 'application/octet-stream'});
const url = URL.createObjectURL(blob);
Utils.revokeWhenDownloaded(url, await Utils.downloadURL(url, filename));

setTimeout(() => {
blobManager.close();
}, 120000);
closeWhenDownloaded(blobManager, url, await Utils.downloadURL(url, filename));
},
async abort() {
blobs.length = 0;
Expand Down
59 changes: 39 additions & 20 deletions chrome/player/network/DownloadManager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {DownloadEntry} from './DownloadEntry.mjs';
import {StandardDownloader} from './StandardDownloader.mjs';

export class DownloadManager {
/** How long no download may fail before a downloader dropped for a failure comes back. */
static FailureRecoveryMs = 10000;

constructor(client) {
this.client = client;
this.queue = [];
Expand All @@ -18,6 +21,8 @@ export class DownloadManager {
this.testing = true;
this.lastSpeed = 0;
this.lastFailed = 0;
// Downloaders taken away after failed downloads, and not back yet.
this.droppedDownloaders = 0;

this.failed = 0;

Expand Down Expand Up @@ -208,13 +213,15 @@ export class DownloadManager {

addDownloader() {
this.testing = false;
this.droppedDownloaders = 0;
this.downloaders.push(new StandardDownloader(this));
this.client.predownloadFragments();
this.queueNext();
}

removeDownloader() {
this.testing = false;
this.droppedDownloaders = 0;
const downloader = this.downloaders.pop();

downloader.abort();
Expand All @@ -239,6 +246,7 @@ export class DownloadManager {

removeAllDownloaders() {
this.testing = false;
this.droppedDownloaders = 0;
this.downloaders.forEach((downloader) => {
downloader.abort();
});
Expand All @@ -255,10 +263,18 @@ export class DownloadManager {
const ind = this.downloaders.indexOf(downloader);
if (ind !== -1) {
this.downloaders.splice(ind, 1);
this.droppedDownloaders++;
this.client.resetFailed();
console.log('Downloader failed, removing downloader and trying again');
}
}
} else if (entry.status === DownloadStatus.DOWNLOAD_COMPLETE && this.droppedDownloaders > 0 &&
Date.now() - this.lastFailed > DownloadManager.FailureRecoveryMs) {
// A failure takes a downloader away, easing off a server that is struggling, and it
// never came back: three failures in a long video left three downloaders of six for
// the rest of it. Once downloads succeed again, each success brings one back.
this.droppedDownloaders--;
this.downloaders.push(new StandardDownloader(this));
}

if (this.testing) {
Expand Down Expand Up @@ -312,30 +328,32 @@ export class DownloadManager {
this.queueNext();
}

/**
* Starts as many queued downloads as there are free downloaders. It started one per call,
* so after a failure's cooldown, which calls it once, one download ran at a time until
* something else called it.
*/
queueNext() {
if (this.paused) return;
if (this.queue.length === 0) return;

if (this.queue[0].status !== DownloadStatus.ENQUEUED) {
this.queue.shift();
this.queueNext();
return;
}
while (!this.paused && this.queue.length > 0) {
if (this.queue[0].status !== DownloadStatus.ENQUEUED) {
this.queue.shift();
continue;
}

const failCooldown = 1000;
if (this.lastFailed + failCooldown > Date.now()) {
if (this.failCooldown) clearTimeout(this.failCooldown);
this.failCooldown = setTimeout(() => {
this.queueNext();
}, failCooldown + 100);
return;
}
const failCooldown = 1000;
if (this.lastFailed + failCooldown > Date.now()) {
if (this.failCooldown) clearTimeout(this.failCooldown);
this.failCooldown = setTimeout(() => {
this.queueNext();
}, failCooldown + 100);
return;
}

const downloader = this.downloaders.find((downloader) => {
return downloader.canHandle(this.queue[0].details);
});
const downloader = this.downloaders.find((downloader) => {
return downloader.canHandle(this.queue[0].details);
});
if (!downloader) return;

if (downloader) {
const entry = this.queue.shift();
downloader.run(entry);
}
Expand All @@ -355,6 +373,7 @@ export class DownloadManager {

this.testing = true;
this.downloaders = [];
this.droppedDownloaders = 0;
this.speedTestBuffer = [];
this.speedTestSeen = [];
this.speedTestCount = 0;
Expand Down
36 changes: 29 additions & 7 deletions chrome/player/network/IndexedDBManager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,18 @@ export class IndexedDBManager {
try {
const db = await IndexedDBManager.requestDB(database.name, false);
// check if stale
let stale = false;
try {
const updatedTime = await IndexedDBManager.getValue(db, 'metadata', 'updated_time');
if (!updatedTime || Date.now() - updatedTime > 10000) {
throw new Error('Stale');
}
stale = !updatedTime || Date.now() - updatedTime > 10000;
} catch (e) {
stale = true;
} finally {
// A live tab's database too: kept open, it blocked that tab's own
// deleteDatabase when it closed, until this tab closed as well.
db.close();
}
if (stale) {
await IndexedDBManager.deleteDB(database.name);
console.log('Pruned', database.name);
}
Expand Down Expand Up @@ -229,12 +234,13 @@ export class IndexedDBManager {

keepAlive() {
if (this.db && !this.isPersistent()) {
IndexedDBManager.setValue(this.db, 'metadata', 'updated_time', Date.now());
// A beat that fails is tried again in a second.
IndexedDBManager.setValue(this.db, 'metadata', 'updated_time', Date.now()).catch(() => {});
}
}

static transact(db, storeName, mode, callback) {
return new Promise(async (resolve, reject)=>{
return new Promise((resolve, reject)=>{
let transaction;
try {
transaction = db.transaction(storeName, mode);
Expand All @@ -248,13 +254,29 @@ export class IndexedDBManager {
console.error(event);
reject(event);
};
// A commit that fails (quota) aborts with no error event: the call never settled.
transaction.onabort = () => {
reject(transaction.error || new Error('the transaction was aborted'));
};

transaction.oncomplete = async (event) => {
resolve(await result);
};

result = callback(transaction);
transaction.commit();
try {
result = callback(transaction);
transaction.commit();
} catch (e) {
// A request the callback could not make (put() of a value that cannot be stored):
// the transaction, with nothing in it, completed and the call answered null, as if
// it had worked.
try {
transaction.abort();
} catch (abortError) {
// Already finished.
}
reject(e);
}
});
}

Expand Down
Loading
Loading