diff options
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.js | 103 |
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, }; |