diff options
Diffstat (limited to 'packages/meshbay-hub')
15 files changed, 280 insertions, 87 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index ff066cf..6f4bb92 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -157,9 +157,11 @@ function TransferWidget() { }, [open]); const running = items.filter(i => i.status === 'running'); - const waiting = items.filter(i => i.status === 'queued'); + const waiting = items.filter( + i => i.status === 'queued' || i.status === 'preparing'); const finished = items.filter( - i => i.status !== 'running' && i.status !== 'queued'); + i => i.status !== 'running' && i.status !== 'queued' + && i.status !== 'preparing'); const active = running.length + waiting.length; // Grouped, and in this order: what is moving, what is waiting, what is over. @@ -240,7 +242,8 @@ function TransferRow({ it }) { ? html`<a class="transfer-name" href="#" title=${it.name} onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>` : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`} - ${(it.status === 'running' || it.status === 'queued') && html` + ${(it.status === 'running' || it.status === 'queued' + || it.status === 'preparing') && html` <button class="transfer-cancel" aria-label=${t('transfers.cancel_one', { name: it.name })} title=${t('transfers.cancel')} @@ -249,7 +252,19 @@ function TransferRow({ it }) { </button> `} </div> - ${it.status === 'queued' + ${it.status === 'preparing' + ? html` + ${/* Not a progress bar at 0%: nothing is wrong and nothing is + stalled, the download is still finding somewhere to write. The + row exists from the click precisely so this state is visible + instead of being an empty panel. */''} + <div class="dl-progress dl-waiting"></div> + <div class="transfer-meta"> + <span>${t('transfers.preparing')}</span> + <span>${formatSize(it.total)}</span> + </div> + ` + : it.status === 'queued' ? html` <div class="dl-progress dl-waiting"></div> <div class="transfer-meta"> 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 61002d6..3475f81 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -321,47 +321,34 @@ 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) { - // The target FIRST, then the slot — and that order is load-bearing. - // - // Asking for the slot first looks better (the widget could draw a row while - // the target is being chosen) and is wrong: a granted slot has to be taken up - // within the node's acceptance deadline, and opening a target can take thirty - // seconds of streamed-download timeouts, or as long as somebody leaves a Save - // As dialog open. The node then revokes the grant and passes it to the next - // in the queue — `transfer: reclaimed … (not_taken_up)` in its log — and this - // download starts fetching under a `tr` that is no longer granted. - // - // Measured, not reasoned: three downloads started, one arrived, and the - // node's log named the reason. Do not move this again without moving the - // deadline, and the deadline exists so a client that dies between asking and - // starting does not hold a slot nobody can use. - 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 }; const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + const openRef = { url: null }; + let target = null; + transfers.start({ - kind: 'download', name: (target && target.name) || entry.name, - total: entry.size, transport, - // Asked for here, once there is somewhere to write: see the note above. - lease: transport.openTransfer({ + kind: 'download', name: entry.name, total: entry.size, transport, + + // The row exists from the click. Opening a target is what takes the time — + // the streamed path waits for the worker (twice), a Save As dialog waits + // 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); + // Dismissed: nothing was started, so nothing is left on screen. + if (target === false) return false; + return target ? { name: target.name } : true; + }, + + // After the target, never before: a granted slot has to be taken up within + // the node's deadline, and opening a target can outlast it. See §8.1 of + // ~/next/improve-downloads.md — the other order was tried and cost two of + // three downloads. + makeLease: () => transport.openTransfer({ kind: 'download', bytes: entry.size, chunks: totalChunks }), - open: target - ? (target.open || null) - : () => { if (openRef.url) window.open(openRef.url, '_blank'); }, + + open: () => (target && target.open) ? target.open() + : (openRef.url ? window.open(openRef.url, '_blank') : undefined), + run: async ({ signal, onProgress, lease }) => { let done = 0; const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; @@ -435,38 +422,36 @@ 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. - 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, - }))) { - return; - } const zipOpenRef = { url: null }; + let target = null; transfers.start({ - kind: 'download', name: (target && target.name) || suggested, - total: totalBytes, transport, + kind: 'download', name: suggested, total: totalBytes, transport, + + // 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, { + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }, 0); + if (target === false) return false; + if (!target && !confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return false; + } + return target ? { name: target.name } : true; + }, + // **One** lease for the archive, not one per file. Dozens of leases for a // folder would deadlock against the member's own cap: the job cannot finish // until it holds them all, and it can never hold more than two. - lease: transport.openTransfer({ + makeLease: () => transport.openTransfer({ kind: 'download', bytes: totalBytes, chunks: files.length }), - open: target - ? (target.open || null) - : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, + + open: () => (target && target.open) ? target.open() + : (zipOpenRef.url ? window.open(zipOpenRef.url, '_blank') : undefined), run: async ({ signal, onProgress, lease }) => { const writable = target ? target.writable : null; const parts = writable ? null : []; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index f5c0c78..1458cf2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -578,6 +578,7 @@ export default { 'transfers.open': 'Öffnen', 'transfers.done': 'Abgeschlossen', 'transfers.cancelled': 'Abgebrochen', + 'transfers.preparing': 'Wird vorbereitet…', 'transfers.waiting_own_slots': 'Wartet — Ihre Plätze sind belegt', 'transfers.waiting_node': 'Wartet — {n} davor', 'transfers.summary': '{running} laufend · {waiting} wartend', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index e3ed844..b0430c0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -694,6 +694,7 @@ export default { 'transfers.open': 'Open', 'transfers.done': 'Finished', 'transfers.cancelled': 'Cancelled', + 'transfers.preparing': 'Preparing…', 'transfers.waiting_own_slots': 'Waiting — your slots are busy', 'transfers.waiting_node': 'Waiting — {n} ahead', 'transfers.summary': '{running} running · {waiting} waiting', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index c509599..adfb1cc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -574,6 +574,7 @@ export default { 'transfers.open': 'Abrir', 'transfers.done': 'Terminada', 'transfers.cancelled': 'Cancelada', + 'transfers.preparing': 'Preparando…', 'transfers.waiting_own_slots': 'En espera — sus espacios están ocupados', 'transfers.waiting_node': 'En espera — {n} por delante', 'transfers.summary': '{running} en curso · {waiting} en espera', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 49a1071..b409927 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -577,6 +577,7 @@ export default { 'transfers.open': 'Ouvrir', 'transfers.done': 'Terminé', 'transfers.cancelled': 'Annulé', + 'transfers.preparing': 'Préparation…', 'transfers.waiting_own_slots': 'En attente — vos slots sont occupés', 'transfers.waiting_node': 'En attente — {n} devant', 'transfers.summary': '{running} en cours · {waiting} en attente', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 8f1ef1e..7e6246e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -577,6 +577,7 @@ export default { 'transfers.open': 'Apri', 'transfers.done': 'Completato', 'transfers.cancelled': 'Annullato', + 'transfers.preparing': 'Preparazione…', 'transfers.waiting_own_slots': 'In attesa — i suoi posti sono occupati', 'transfers.waiting_node': 'In attesa — {n} prima', 'transfers.summary': '{running} in corso · {waiting} in attesa', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 7ea9789..a74eec9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -569,6 +569,7 @@ export default { 'transfers.open': '開く', 'transfers.done': '完了', 'transfers.cancelled': 'キャンセル済み', + 'transfers.preparing': '準備中…', 'transfers.waiting_own_slots': '待機中 — 自分の枠がすべて使用中です', 'transfers.waiting_node': '待機中 — 前に {n} 件', 'transfers.summary': '実行中 {running} · 待機中 {waiting}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 74cd269..a7d64f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -578,6 +578,7 @@ export default { 'transfers.open': 'Openen', 'transfers.done': 'Voltooid', 'transfers.cancelled': 'Geannuleerd', + 'transfers.preparing': 'Voorbereiden…', 'transfers.waiting_own_slots': 'Wacht — uw plaatsen zijn bezet', 'transfers.waiting_node': 'Wacht — {n} ervoor', 'transfers.summary': '{running} bezig · {waiting} wachtend', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 584e5f2..5bd34fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -590,6 +590,7 @@ export default { 'transfers.open': 'Otwórz', 'transfers.done': 'Zakończony', 'transfers.cancelled': 'Anulowany', + 'transfers.preparing': 'Przygotowywanie…', 'transfers.waiting_own_slots': 'Oczekiwanie — Twoje miejsca są zajęte', 'transfers.waiting_node': 'Oczekiwanie — {n} przed', 'transfers.summary': '{running} w toku · {waiting} oczekuje', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index b034117..102ec92 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -576,6 +576,7 @@ export default { 'transfers.open': 'Abrir', 'transfers.done': 'Concluída', 'transfers.cancelled': 'Cancelada', + 'transfers.preparing': 'Preparando…', 'transfers.waiting_own_slots': 'Aguardando — seus espaços estão ocupados', 'transfers.waiting_node': 'Aguardando — {n} na frente', 'transfers.summary': '{running} em andamento · {waiting} aguardando', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index ffe2cb0..6b64785 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -557,6 +557,7 @@ export default { 'transfers.open': '打开', 'transfers.done': '已完成', 'transfers.cancelled': '已取消', + 'transfers.preparing': '准备中…', 'transfers.waiting_own_slots': '等待中 — 您的通道已占满', 'transfers.waiting_node': '等待中 — 前面还有 {n} 个', 'transfers.summary': '进行中 {running} · 等待中 {waiting}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 46f75a2..d3b164d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -22,6 +22,12 @@ const SPEED_WINDOW_MS = 5000; +/** Not finished: still preparing, waiting for a slot, or transferring. One + * definition, because six places ask and they were drifting apart. */ +function _live(status) { + return status === 'preparing' || status === 'queued' || status === 'running'; +} + function _abortError() { const err = new Error('Cancelled'); err.name = 'AbortError'; @@ -84,8 +90,7 @@ export class TransferStore { /** Running or waiting for a slot — what the nav badge counts. */ get pending() { - return this._items.filter( - it => it.status === 'running' || it.status === 'queued').length; + return this._items.filter(it => _live(it.status)).length; } _speed(it) { @@ -104,8 +109,28 @@ export class TransferStore { * `run` receives `{ signal, onProgress }`. It must poll `signal.aborted` — a * cancel that only sets a flag nobody reads is a button that lies. */ + /** + * Start a transfer. + * + * `run` receives `{ signal, onProgress, lease }`. It must poll + * `signal.aborted` — a cancel that only sets a flag nobody reads is a button + * that lies. + * + * `prepare` is optional and runs before anything else, with the row already + * on screen. It is where a download opens its target, which can take tens of + * seconds — the streamed path waits for the worker, twice, and a Save As + * dialog waits for a person. Doing that *before* creating the row meant three + * clicks produced no panel at all, not even the icon, and then several rows + * at once. Returning `false` drops the row again, which is what a dismissed + * dialog should look like: nothing, rather than a cancelled transfer nobody + * started. + * + * `makeLease` is called after `prepare` succeeds, never before. A granted + * slot must be taken up within the node's deadline, so it is asked for once + * there is somewhere to write — see file-utils.js's downloadEntry. + */ start({ kind, name, total = 0, transport = null, run, open = null, - lease = null }) { + lease = null, prepare = null, makeLease = null }) { const item = { id: _nextId++, kind, name, total, transport, open, lease, @@ -114,7 +139,8 @@ export class TransferStore { // 'running'. Two different things are true of it — nothing is moving, and // nothing is wrong — and a status that conflates them is what makes a // queue look like a hang. - status: lease && lease.state !== 'granted' ? 'queued' : 'running', + status: prepare ? 'preparing' + : (lease && lease.state !== 'granted' ? 'queued' : 'running'), ahead: (lease && lease.ahead) || 0, error: '', samples: [{ t: this._now(), done: 0 }], @@ -155,17 +181,32 @@ export class TransferStore { // appear to move — the widget showing "3 ahead" for ever while the node // quietly worked through the queue. Nothing about that looks wrong from // either side, which is why it needs a test rather than a reading. - if (item.lease) { - item.lease._onState = (lease) => { - if (item.status !== 'queued' && item.status !== 'running') return; - item.ahead = lease.ahead; - item.status = lease.state === 'granted' ? 'running' : 'queued'; - this._emit(); - }; - } + if (item.lease) this._watchLease(item); const promise = Promise.resolve() .then(async () => { + if (prepare) { + const ready = await prepare(); + if (item.signal.aborted) throw _abortError(); + if (ready === false) { + // Dismissed. Not a failure and not a cancellation: nothing was ever + // started, so nothing should be left on screen to explain. + this._drop(item.id); + return undefined; + } + if (ready && ready.name) item.name = ready.name; + item.status = 'running'; + this._emit(); + } + if (makeLease && !item.lease) { + item.lease = makeLease(); + this._watchLease(item); + if (item.lease.state !== 'granted') { + item.status = 'queued'; + item.ahead = item.lease.ahead || 0; + this._emit(); + } + } if (item.lease) { await item.lease.acquire(); if (item.signal.aborted) throw _abortError(); @@ -191,6 +232,21 @@ export class TransferStore { return item.id; } + _watchLease(item) { + item.lease._onState = (lease) => { + if (item.status !== 'queued' && item.status !== 'running') return; + item.ahead = lease.ahead; + item.status = lease.state === 'granted' ? 'running' : 'queued'; + this._emit(); + }; + } + + /** Remove a row entirely. Only for a transfer that never started. */ + _drop(id) { + this._items = this._items.filter(it => it.id !== id); + this._emit(); + } + /** * Hand a finished download to the browser to display. * @@ -209,7 +265,7 @@ export class TransferStore { // 'queued' too: a transfer waiting for a slot is exactly the one somebody // is most likely to give up on, and its queue entry has to go with it or // the node grants a slot to a transfer that will never use it. - if (!item || (item.status !== 'running' && item.status !== 'queued')) return; + if (!item || !_live(item.status)) return; item.signal.aborted = true; if (item.lease) item.lease.release('cancelled'); // Marked at once. The work stops when it next looks, but a cancelled @@ -221,14 +277,13 @@ export class TransferStore { cancelAll() { for (const it of this._items) { - if (it.status === 'running' || it.status === 'queued') this.cancel(it.id); + if (_live(it.status)) this.cancel(it.id); } } /** Drop everything finished, keeping what is still running or waiting. */ clearFinished() { - this._items = this._items.filter( - it => it.status === 'running' || it.status === 'queued'); + this._items = this._items.filter(it => _live(it.status)); this._emit(); } @@ -237,8 +292,7 @@ export class TransferStore { // slot can never be granted one, and the transfer would sit at "waiting" // for ever with nothing left to answer it. return this._items.some( - it => it.transport === transport - && (it.status === 'running' || it.status === 'queued')); + it => it.transport === transport && _live(it.status)); } /** diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index e805afc..7e21409 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -435,3 +435,119 @@ def test_the_slot_is_asked_for_after_there_is_somewhere_to_write(): assert fn.index("_openDownloadTarget") < fn.index("openTransfer"), ( "downloadEntry asks for a transfer slot before it has anywhere to " "write — the grant expires before the download can use it") + + +# ── the row exists from the click ─────────────────────────────────────────── + +def test_the_row_appears_before_the_target_is_open(tmp_path): + """ + Opening a target is the slow part — the streamed path waits for the worker + twice, a Save As dialog waits for a person — and the row used to be created + only after it returned. Three clicks produced no panel at all, not even the + icon, and then several rows at once. + """ + out = _run(""" + const t = new TransferStore(); + let release; + const opened = new Promise(r => { release = r; }); + t.start({ kind: 'download', name: 'film.mkv', total: 10, + prepare: async () => { await opened; return { name: 'saved.mkv' }; }, + run: async () => { say('ran'); } }); + const shot = (when) => say(when + '=' + t.list().length + ':' + + t.list().map(i => i.status + '/' + i.name).join(',')); + shot('click'); + release(); + await new Promise(r => setTimeout(r, 10)); + shot('after'); + """, tmp_path) + # Tagged, not indexed. An earlier version counted pushes by hand and was one + # out, which reads exactly like a failing assertion about the code. + seen = dict(line.split("=", 1) for line in out + if isinstance(line, str) and "=" in line) + assert {"click", "after"} <= set(seen), f"probe produced: {out}" + assert seen["click"] == "1:preparing/film.mkv", ( + f"no row, or the wrong one, at the moment of the click: {seen['click']}") + assert seen["after"] == "1:done/saved.mkv", ( + f"the row must keep the name it was saved under: {seen['after']}") + + +def test_a_dismissed_dialog_leaves_nothing_behind(tmp_path): + """Dismissing a Save As dialog is not a failure and not a cancellation: + nothing was started, so nothing should be left on screen explaining it.""" + out = _run(""" + const t = new TransferStore(); + t.start({ kind: 'download', name: 'film.mkv', total: 10, + prepare: async () => false, + run: async () => { say('ran'); } }); + say('at click:', t.list().length); + await new Promise(r => setTimeout(r, 10)); + say('after:', t.list().length, out.includes('ran')); + """, tmp_path) + assert out[1] == 1 + assert out[3] == 0, "a dismissed dialog left a row behind" + assert out[4] is False + + +def test_the_slot_is_only_asked_for_once_there_is_somewhere_to_write(tmp_path): + """ + A granted slot must be taken up within the node's deadline, and opening a + target can outlast it. Asking first cost two of three downloads. + """ + out = _run(""" + const t = new TransferStore(); + let release; + const opened = new Promise(r => { release = r; }); + let asked = false; + t.start({ kind: 'download', name: 'f', total: 10, + prepare: async () => { await opened; return true; }, + makeLease: () => { asked = true; return { + state: 'granted', ahead: 0, tr: 'x', + acquire: () => Promise.resolve(), release: () => {} }; }, + run: async () => {} }); + say('while preparing, asked?', asked); + release(); + await new Promise(r => setTimeout(r, 10)); + say('after preparing, asked?', asked, t.list()[0].status); + """, tmp_path) + assert out[1] is False, "the slot was taken before there was a target" + assert out[3] is True + assert out[4] == "done" + + +def test_a_target_that_cannot_be_opened_fails_the_row_it_already_has(tmp_path): + """The refusal above the memory ceiling lands in the panel, on the row that + is already there, rather than in a console nobody opens.""" + out = _run(""" + const t = new TransferStore(); + t.start({ kind: 'download', name: 'film.mkv', total: 10, + prepare: async () => { throw new Error('too large for memory'); }, + run: async () => { say('ran'); } }); + await new Promise(r => setTimeout(r, 10)); + const it = t.list()[0]; + say(it.status, it.error, out.includes('ran')); + """, tmp_path) + assert out[0] == "failed" + assert "too large" in out[1] + assert out[2] is False + + +def test_a_transport_is_not_closed_under_a_preparing_transfer(tmp_path): + """It has no lease yet and has moved no bytes, but closing its transport + would strand it exactly like a queued one.""" + out = _run(""" + const t = new TransferStore(); + let release; + const opened = new Promise(r => { release = r; }); + let closed = false; + const transport = { close() { closed = true; } }; + t.start({ kind: 'download', name: 'f', total: 10, transport, + prepare: async () => { await opened; return true; }, + run: async () => {} }); + t.releaseWhenIdle(transport); + say('closed while preparing:', closed); + release(); + await new Promise(r => setTimeout(r, 10)); + say('closed after:', closed); + """, tmp_path) + assert out[1] is False + assert out[3] is True diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py index e970c55..28e3a0c 100644 --- a/packages/meshbay-hub/tests/test_zip_size_limit.py +++ b/packages/meshbay-hub/tests/test_zip_size_limit.py @@ -60,7 +60,7 @@ 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; -const out = {{ errors: [], started: 0, asked: 0 }}; +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 // asks first. Answering yes is what lets the at-the-limit case get as far as @@ -80,7 +80,18 @@ if ({picker_js}) {{ const M = await import('{(sandbox / "file-utils.js").as_posix()}'); -const transfers = {{ start: () => {{ out.started += 1; }} }}; +// Faithful enough to the real store: it runs `prepare` and honours what it +// returns. The target is opened there now — the row exists from the click and +// the slow part happens behind it — so a stub that only counts calls would +// never reach the size check this file is about. +const transfers = {{ start: (opts) => {{ + out.started += 1; + if (!opts.prepare) return; + Promise.resolve() + .then(() => opts.prepare()) + .then((ready) => {{ if (ready === false) {{ out.started -= 1; out.dropped += 1; }} }}) + .catch((e) => {{ out.started -= 1; out.errors.push(e.message); }}); +}} }}; // A transport hands out transfer slots now (transfers.py's leases). The stub // grants at once, which is what a node with no caps does: what this file is // about is the archive limit, not the queue. @@ -100,6 +111,8 @@ await M.downloadDirectory(transfers, transport, null, entries, 'album', {{ setError: (m) => out.errors.push(m), }}); +// `prepare` runs on a microtask, so let it. +await new Promise(r => setTimeout(r, 10)); out.limit = M.ZIP_MAX_BYTES; console.log(JSON.stringify(out)); """, encoding="utf-8") |