diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 01:54:10 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 01:54:10 +0200 |
| commit | d6c4808d9a3ef6bc740cda7892508f15ee9ea030 (patch) | |
| tree | b9420bd6cc86ac0a72aef09d5ca0ee7e25a3533f /packages/meshbay-hub/src | |
| parent | a0b070e5fd382bb4a4262637836cb8d4b268fbf7 (diff) | |
| download | meshbay-d6c4808d9a3ef6bc740cda7892508f15ee9ea030.tar.gz | |
fix(spa): one save dialog per batch, not one per file
Selecting four files on Chrome produced a Save As dialog for the first, then
one for the second only after that file had finished, while the last two timed
out; on a later attempt the three remaining transfers appeared frozen.
Two things were going on. Opening the target inside `prepare` had removed the
accidental serialisation that `for (…) await downloadFile(e)` used to provide,
so `_openTargetInTurn` now queues the openings — but a queue whose head is an
unanswered dialog is a head-of-line block, which is what the "freeze" was.
The code already recovered from a picker with no gesture behind it by streaming
instead, on the `SecurityError` Chrome throws. That branch was never reached:
Chrome does not throw, it shows the dialog anyway and waits for a human. So
anything that has to wait its turn is now marked `batched`, and a batched
opening prefers the streamed path whatever the download mode says. The first
file of a batch — the one actually holding the gesture — still gets its dialog,
so the preference is honoured where it can be. For the rest there is no gesture
left to spend and nothing is lost by streaming: the file still lands on disk,
in the browser's own download folder. Only the choice of folder goes, and it
was not on offer. If the worker does not answer, a batched download falls back
to the dialog rather than failing.
Also logs which path led to a dialog. A dialog is the one outcome nobody can
diagnose after the fact — it looks the same whether it was asked for or fallen
back to — and the report this fixes needed three test cycles to narrow. The two
harnesses that lift `_openDownloadTarget` as text now route console.info to
stderr, since they parse stdout as JSON.
Measured against the deployed hub in Chrome 152: the streamed path serves the
hidden iframe in 2-3 ms on a normal load, after a hard reload (via the
`mbdl-claim` recovery already in `_claimController`), and twice in the same
document. Hub suite 824 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/file-utils.js | 58 |
1 files changed, 54 insertions, 4 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 3475f81..065079b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -98,7 +98,7 @@ class TooLargeForMemoryError extends Error { * bare `return null` appears in this function. */ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, - swSize = size) { + swSize = size, { batched = false } = {}) { // "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); @@ -144,7 +144,18 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, // 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)) { + // `batched` means this is not the first download of a batch, and it makes the + // streamed path preferred whatever the mode. + // + // "Ask where to save" asks per file, which is right for one file and wrong + // for four: a browser grants one picker per user gesture, so the second + // dialog has no gesture behind it and the third and fourth wait behind a + // dialog that waits for a human — reported from Chrome as three downloads + // frozen. There is no gesture left to spend, so there is nothing to lose by + // streaming instead: the file still lands on disk, in the browser's own + // download folder. Only the choice of folder goes, and it was not on offer. + if (downloads.getMode() === 'auto' || batched + || (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. The old @@ -157,6 +168,13 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, // No File System Access API — Firefox, Safari. This is the branch that used // to return null at any size. if (!canPick) return _memoryFloor(); + // A dialog is the one outcome nobody can diagnose after the fact: it looks + // the same whether it was asked for, or fallen back to because the worker + // did not answer. Say which, once per download, so the next report from a + // browser we do not have does not need a second round trip. + console.info('[MeshBay] asking where to save %s — mode=%s batched=%s stream=%s', + filename, downloads.getMode(), batched, + downloads.lastStreamFailure() || 'not attempted'); try { const handle = await window.showSaveFilePicker({ suggestedName: filename, ...pickerOpts, @@ -187,6 +205,38 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, } } +// Target openings run one at a time, across every download on the page. +// +// A browser shows one file picker at a time and grants one per user gesture, so +// four downloads asking at once get one dialog and three failures. That used to +// be prevented by accident: `downloadEntry` awaited the target inline, and +// files-app.js's `for (const e of selected) await downloadFile(e)` serialised +// them. Opening the target inside `prepare` — so the row appears at the click +// instead of tens of seconds later — removed that accident, and four pickers +// raced. Chrome showed one, prompted for a second, and the rest timed out; +// Firefox and Electron never noticed, because neither opens a picker at all. +// +// So the queue is explicit now, and it is the *targets* that queue, not the +// rows: every download still appears the moment it is asked for. +let _targetQueue = Promise.resolve(); + +let _targetsInFlight = 0; + +function _openTargetInTurn(filename, size, pickerOpts, swSize) { + // Anything that has to wait its turn is, by definition, not the first of the + // batch — so it will not be the one holding the user's gesture. + const batched = _targetsInFlight > 0; + _targetsInFlight += 1; + const mine = _targetQueue + .then(() => _openDownloadTarget(filename, size, pickerOpts, swSize, + { batched })) + .finally(() => { _targetsInFlight -= 1; }); + // The chain must not break on a rejection, or one refused download stops + // every later one from ever opening a target. + _targetQueue = mine.catch(() => {}); + return mine; +} + /** The download of last resort, for browsers with no way to stream to disk. */ function _saveBlob(blob, filename) { const url = URL.createObjectURL(blob); @@ -333,7 +383,7 @@ async function downloadEntry(transfers, transport, gek, entry) { // for a person — and doing it before the row meant three clicks produced no // panel at all and then several rows at once. prepare: async () => { - target = await _openDownloadTarget(entry.name, entry.size); + target = await _openTargetInTurn(entry.name, entry.size); // Dismissed: nothing was started, so nothing is left on screen. if (target === false) return false; return target ? { name: target.name } : true; @@ -431,7 +481,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE // Same order as downloadEntry: the row first, then the target, then the // slot. A folder of forty files is exactly where the wait is longest. prepare: async () => { - target = await _openDownloadTarget(suggested, totalBytes, { + target = await _openTargetInTurn(suggested, totalBytes, { types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], }, 0); |