From bb8aad22c4d41dd1b89c85c8d46878a31a9530e5 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 13:21:02 +0200 Subject: fix(hub): never collect a large download in the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../meshbay-hub/src/meshbay_hub/static/files-app.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/files-app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 4947f8c..36c5af3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -6,7 +6,7 @@ import { Icon } from './icon.js'; import { entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; import { - FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, + FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, MEMORY_CEILING, pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory, } from './file-utils.js'; @@ -598,6 +598,24 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { useEffect(() => { let cancelled = false; const load = async () => { + // Nothing here streams: a preview is decrypted whole, held as an array of + // chunks, and turned into a blob. That is right for a page of text and a + // photograph, and it is a dead tab for the things that also reach here — + // a scanned PDF, a multi-gigabyte .csv or .log. There was no size test at + // all, and the text branch is the sharpest illustration: it decoded the + // entire file and then kept 500 000 characters of it. + // + // Films and music never arrive (group-page.js's onPreview routes video to + // the MSE player and audio to the music queue), so this guard is only ever + // met by a document somebody clicked without knowing how big it was. It + // offers the download instead, which does stream. + if (entry.size > MEMORY_CEILING) { + setError(t('preview.too_large', { + size: formatSize(entry.size), limit: formatSize(MEMORY_CEILING), + })); + setPhase('error'); + return; + } const transport = transportRef.current; if (!transport || !transport.connected) { setError(t('video.err_transport')); -- cgit v1.2.3