aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/file-utils.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js103
1 files changed, 94 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
index 241d761..a942fd5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -47,15 +47,64 @@ const CHUNK_SIZE = 1024 * 1024;
// that hesitates, and looks like a hang while it is quiet.
const PIPELINE_WINDOW = 8;
+// The most this code will ever collect in the page.
+//
+// Below every streaming target there is a floor: `pipelinedDownload` with no
+// `writable` allocates `new Array(totalChunks)` and keeps every decrypted
+// chunk, and `_saveBlob` hands the lot to the browser. That floor is fine for
+// something small and is a dead tab for a film. It had **no upper bound**: the
+// `!window.showSaveFilePicker` branch below returned null at any size, so on a
+// browser without the File System Access API (Firefox, Safari) a 20 GB film
+// went to RAM whenever the service-worker path did not answer — which happens
+// for ordinary reasons (an uncontrolled page, a stream that cannot be
+// transferred, the 8 s timeout). Nothing logged, nothing refused; the symptom
+// was the tab dying, with no error attributable to this code.
+//
+// So: above this, there is no floor. A refusal naming what happened is
+// recoverable and a dead tab is not. `CLAUDE.md`'s standing lesson is that a
+// fallback chain reaches its floor silently — this is that floor being given a
+// bottom.
+const MEMORY_CEILING = 100 * 1024 * 1024;
+
+/**
+ * Thrown instead of falling through to the in-memory floor.
+ *
+ * This should now be unreachable in ordinary use: the streamed path is primed
+ * at application start and retried on demand, so a browser with a service
+ * worker has somewhere to write whatever the size. If it is ever raised, the
+ * reason the streamed path declined is appended — untranslated, because it is a
+ * diagnostic and a vague failure is what made the original bug invisible.
+ */
+class TooLargeForMemoryError extends Error {
+ constructor(filename, size) {
+ const why = downloads.lastStreamFailure();
+ super(t('download.too_large_for_memory', {
+ name: filename, size: formatSize(size), limit: formatSize(MEMORY_CEILING),
+ }) + (why ? ` (${why})` : ''));
+ this.name = 'TooLargeForMemoryError';
+ }
+}
+
/**
* Open somewhere to write, honouring the user's download setting.
*
* Returns a target ({writable, name}), null for "no stream available — collect
* it and hand the browser a blob", or false for "the person dismissed the
* dialog", which is not an error and must not start a transfer.
+ *
+ * **Never returns null above MEMORY_CEILING.** Every `return null` below is
+ * guarded by `_memoryFloor`, which throws instead. A fourth fallback added
+ * later must go through it too — `test_memory_ceiling.py` fails the build if a
+ * bare `return null` appears in this function.
*/
async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
swSize = size) {
+ // "Collect it in the page", or a refusal when that would be too much.
+ const _memoryFloor = () => {
+ if (size > MEMORY_CEILING) throw new TooLargeForMemoryError(filename, size);
+ return null;
+ };
+
// On a desktop build this is the whole answer, and it comes first.
//
// The two browser paths below are both unavailable there — `showDirectoryPicker`
@@ -87,14 +136,27 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
// write, which is how this works at all in Firefox: the alternative there is
// to collect gigabytes in a tab. It goes to the browser's own download
// folder, without a dialog, which is what "save automatically" meant.
- if (downloads.getMode() === 'auto') {
+ //
+ // Tried in "ask" mode too when the file is large and this browser has no Save
+ // As of its own. The mode is about whether to show a dialog; it was never
+ // meant to decide whether a 20 GB film can be downloaded at all, and on
+ // Firefox and Safari — where `showSaveFilePicker` does not exist — skipping
+ // this block left nothing but the in-memory floor. A preference must not cost
+ // a capability.
+ const canPick = typeof window.showSaveFilePicker === 'function';
+ if (downloads.getMode() === 'auto' || (size > MEMORY_CEILING && !canPick)) {
const streamed = await downloads.openStreamedDownload(filename, swSize);
if (streamed) return streamed;
- // Nothing to stream to: small enough for memory, and no dialog.
- if (size < downloads.BLOB_LIMIT) return null;
+ // Nothing to stream to: small enough for memory, and no dialog. The old
+ // comparison here was against downloads.BLOB_LIMIT (512 MB), five times
+ // this ceiling — and it was the *only* size test in the whole chain, with
+ // the branch below it unguarded.
+ if (size <= MEMORY_CEILING) return _memoryFloor();
}
- if (!window.showSaveFilePicker) return null;
+ // No File System Access API — Firefox, Safari. This is the branch that used
+ // to return null at any size.
+ if (!canPick) return _memoryFloor();
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename, ...pickerOpts,
@@ -210,7 +272,20 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
* download button — both just want "get this entry to disk".
*/
async function downloadEntry(transfers, transport, gek, entry) {
- const target = await _openDownloadTarget(entry.name, entry.size);
+ let target;
+ try {
+ target = await _openDownloadTarget(entry.name, entry.size);
+ } catch (err) {
+ // The refusal belongs in the transfers panel, not in a console nobody
+ // opens: that is where someone who just clicked Download is looking, and a
+ // failed row naming the reason is the whole point of refusing rather than
+ // filling the tab. Started only to be failed, deliberately.
+ transfers.start({
+ kind: 'download', name: entry.name, total: entry.size, transport,
+ run: async () => { throw err; },
+ });
+ return;
+ }
if (target === false) return; // the picker was dismissed
const openRef = { url: null };
@@ -292,10 +367,19 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
// totalBytes decides how this is delivered, but it is not the archive's
// size — headers and the central directory come on top — so it is not
// announced as a Content-Length that the download would then miss.
- const target = await _openDownloadTarget(suggested, totalBytes, {
- types: [{ description: 'ZIP archive',
- accept: { 'application/zip': ['.zip'] } }],
- }, 0);
+ let target;
+ try {
+ target = await _openDownloadTarget(suggested, totalBytes, {
+ types: [{ description: 'ZIP archive',
+ accept: { 'application/zip': ['.zip'] } }],
+ }, 0);
+ } catch (err) {
+ // Reported beside the folder that was clicked, like zip_too_large just
+ // above — this function is called in a loop over a selection, and the
+ // sibling folders must still download.
+ setError(err.message);
+ return;
+ }
if (target === false) return;
if (!target && !confirm(t('group.zip_no_stream', {
size: formatSize(totalBytes), name: suggested,
@@ -351,6 +435,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
export {
FILE_ICONS,
formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE, ZIP_MAX_BYTES,
+ MEMORY_CEILING, TooLargeForMemoryError,
_openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry,
downloadDirectory,
};