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 | |
| 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
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/file-utils.js | 58 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_downloads.py | 66 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_memory_ceiling.py | 53 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transfers.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_zip_size_limit.py | 4 |
5 files changed, 177 insertions, 8 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); diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index e68396f..81ab9d3 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -154,7 +154,10 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): # the ability to refuse an oversized download (test_memory_ceiling.py). # What this test is about -- the `0` -- did not move. app = (STATIC / "file-utils.js").read_text() - zip_call = app[app.index("_openDownloadTarget(suggested"):] + # Anchored on the argument list, not on the function name: the call became + # `_openTargetInTurn(suggested, …)` when target openings were serialised. + # The `0` this test is about did not move. + zip_call = app[app.index("(suggested, totalBytes"):] zip_call = zip_call[:zip_call.index(");") + 2] assert zip_call.rstrip().endswith(", 0);"), ( "the zip download announces a Content-Length it will not match") @@ -363,3 +366,64 @@ def test_the_worker_is_kept_alive_while_it_streams(): # event, but the reply is what tells the page it is talking to the worker # that holds its stream. assert "mbdl-ping" in sw and "mbdl-pong" in sw + + +def test_targets_are_opened_one_at_a_time(tmp_path): + """ + 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 (…) await downloadFile(e)` serialised them. + Opening the target inside `prepare` — so the row appears at the click rather + than tens of seconds later — removed the accident, and four pickers raced. + Reported from Chrome: one file downloaded, a prompt for the second, the + other two timed out. Firefox and Electron never noticed, because neither + opens a picker at all, which is why this reached one browser only. + + The queue is on the *targets*, never on the rows: every download still + appears the moment it is asked for. + + Queueing alone was not enough: a second dialog with no gesture behind it + still waits for a human, and the two behind it wait for the dialog. So + everything that has to wait its turn is also marked `batched`, which the + opener reads as "do not ask" — see the streamed-path branch below. + """ + src = (STATIC / "file-utils.js").read_text() + fn = src[src.index("function _openTargetInTurn"):] + fn = fn[:fn.index("\n}\n") + 2] + + script = tmp_path / "case.mjs" + script.write_text(""" +const out = []; +let live = 0, peak = 0; +// Stands in for _openDownloadTarget: records how many are open at once. +const asked = []; +const _openDownloadTarget = async (name, size, opts, swSize, flags) => { + live += 1; peak = Math.max(peak, live); + asked.push(flags && flags.batched); + await new Promise(r => setTimeout(r, 20)); + live -= 1; + if (name === 'boom') throw new Error('refused'); + return { name }; +}; +let _targetQueue = Promise.resolve(); +let _targetsInFlight = 0; +""" + fn + """ +const results = await Promise.allSettled( + ['a', 'boom', 'c', 'd'].map(n => _openTargetInTurn(n))); +out.push(peak); +out.push(results.map(r => r.status).join(',')); +out.push(asked); +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + peak, statuses, batched = json.loads(proc.stdout) + assert peak == 1, f"{peak} targets were being opened at once" + assert batched == [False, True, True, True], ( + "only the first of a batch holds the user's gesture; the rest must be " + "opened without asking") + # And one refusal must not stop the rest: a chain that breaks on a rejection + # leaves every later download unable to open anything at all. + assert statuses == "fulfilled,rejected,fulfilled,fulfilled" diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py index 8430627..d46cae3 100644 --- a/packages/meshbay-hub/tests/test_memory_ceiling.py +++ b/packages/meshbay-hub/tests/test_memory_ceiling.py @@ -66,12 +66,16 @@ def target_fn(): def _run(target_fn, tmp_path, *, size, native=False, granted=False, - streamed=False, picker=False, mode="auto"): + streamed=False, picker=False, mode="auto", batched=False): """Drive the real function against one browser shape.""" script = tmp_path / "case.mjs" script.write_text(f""" // Stubs for everything the lifted function reaches. `formatSize` and `t` only // build the message; the assertions are about which branch was taken. +// +// stdout carries the outcome and nothing else, so the function's own logging +// goes to stderr -- where it is still shown when a case fails. +console.info = (...a) => console.error(...a); const formatSize = (n) => `${{n}} B`; const t = (key, vars) => key + ' ' + JSON.stringify(vars); const platform = {{ @@ -107,7 +111,8 @@ if ({json.dumps(picker)}) {{ let outcome; try {{ - const r = await _openDownloadTarget('film.mkv', {size}); + const r = await _openDownloadTarget('film.mkv', {size}, {{}}, {size}, + {{ batched: {json.dumps(batched)} }}); outcome = r === null ? {{ kind: 'memory' }} : r === false ? {{ kind: 'cancelled' }} : {{ kind: 'stream', name: r.name }}; @@ -180,6 +185,50 @@ def test_a_browser_with_a_picker_is_offered_one_instead_of_being_refused( assert out == {"kind": "stream", "name": "p"} +# ── One dialog per gesture, not one per file ──────────────────────────────── + +def test_the_first_of_a_batch_still_asks_where_to_save(target_fn, tmp_path): + """The preference is not being taken away. Someone who asked to choose the + folder chooses it, for the download they actually clicked.""" + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, + streamed=True) + assert out == {"kind": "stream", "name": "p"} + + +def test_the_rest_of_a_batch_stream_instead_of_asking(target_fn, tmp_path): + """A browser grants one picker per user gesture and selecting four files is + one gesture. Chrome showed the dialog for the second file anyway and then + waited for a human, so the third and fourth sat behind it until they timed + out — reported as three downloads frozen. + + There is no gesture left to spend, so 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. + """ + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, + streamed=True, batched=True) + assert out == {"kind": "stream", "name": "s"} + + +def test_a_batched_download_falls_back_to_the_dialog_rather_than_failing( + target_fn, tmp_path): + """When the worker does not answer, asking is better than refusing: a + dialog that has to be answered is still a download, and the alternative + here is losing the file. A preference must not cost a capability, and + neither must the fix for one.""" + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, + streamed=False, batched=True) + assert out == {"kind": "stream", "name": "p"} + + +def test_batching_never_pushes_a_large_file_into_memory(target_fn, tmp_path): + """Firefox shape — no picker at all. Nothing about the batch flag may reach + the memory floor above the ceiling.""" + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=False, + streamed=False, batched=True) + assert out["kind"] == "refused", out + + # ── The one that outlives today's branches ────────────────────────────────── def test_no_unguarded_memory_floor(target_fn): diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index 7e21409..c48e38c 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -432,7 +432,9 @@ def test_the_slot_is_asked_for_after_there_is_somewhere_to_write(): src = (STATIC / "file-utils.js").read_text() fn = src[src.index("async function downloadEntry"):] fn = fn[:fn.index("\n}\n")] - assert fn.index("_openDownloadTarget") < fn.index("openTransfer"), ( + # `_openTargetInTurn` since target openings were serialised — same call, + # queued. What is pinned is that it comes before the slot is asked for. + assert fn.index("_openTargetInTurn") < fn.index("openTransfer"), ( "downloadEntry asks for a transfer slot before it has anywhere to " "write — the grant expires before the download can use it") diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py index 28e3a0c..9471b8a 100644 --- a/packages/meshbay-hub/tests/test_zip_size_limit.py +++ b/packages/meshbay-hub/tests/test_zip_size_limit.py @@ -60,6 +60,10 @@ globalThis.localStorage = {{ // Node 22 defines `navigator` itself, so it is left alone; `window` is what // platform.js reaches for to decide it is not running in the desktop app. globalThis.window = globalThis; +// stdout carries the outcome and nothing else, so file-utils' own logging goes +// to stderr -- where it is still shown when a case fails. It logs before every +// save dialog, which is exactly what this harness provokes. +console.info = (...a) => console.error(...a); const out = {{ errors: [], started: 0, asked: 0, dropped: 0 }}; // Reached only once the size check has passed: with no File System Access API // under Node, downloadDirectory falls through to its build-in-memory path and |