aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 13:21:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 13:21:02 +0200
commitbb8aad22c4d41dd1b89c85c8d46878a31a9530e5 (patch)
tree65ca27637b81aa046e71c66204ff96ec138ac06f /packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
parent3f2bb22586d3e1aef765149b555ccc8e174ce7eb (diff)
downloadmeshbay-bb8aad22c4d41dd1b89c85c8d46878a31a9530e5.tar.gz
fix(hub): never collect a large download in the page
`pipelinedDownload` with no writable allocates `new Array(totalChunks)` and keeps every decrypted chunk, so whatever `_openDownloadTarget` returns null for is held whole in RAM. That floor had no upper bound: the `!window.showSaveFilePicker` branch returned null at any size, so on a browser without the File System Access API a 20 GB film went to memory whenever the streamed path did not answer. Nothing logged, nothing refused; the symptom was the tab dying, with no error attributable to this code. MEMORY_CEILING is 100 MB and every `return null` in that chain now goes through a guard that throws above it. The refusal names the size, the limit and why the streamed path declined, and lands in the transfers panel as a failed transfer rather than in a console nobody opens. This is a guard, not a limit on what can be downloaded: with the streamed path primed and retried (previous commit), a file of any size still goes to disk progressively on every browser. Two things had to change for that to be true: - the streamed path is now tried in "ask" mode too, for a file over the ceiling on a browser with no Save As of its own. The mode decides whether to show a dialog; it was silently deciding whether a film could be downloaded at all; - FilePreview had no size check whatsoever — a multi-gigabyte PDF or .csv was fetched whole, and the text branch decoded all of it to keep 500 000 characters. It refuses above the same ceiling and offers the download. ZIP_MAX_BYTES (512 MB) and the ceiling do not contradict: the archive limit bounds the archive, the ceiling bounds what may be built in the page, so a 400 MB zip is allowed when there is somewhere to stream it and refused when the only route left is memory. The build-in-memory confirmation only appears below the ceiling now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
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,
};