diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
| commit | 7e2d078fe1870d256ae47781bee6ac4f454edf24 (patch) | |
| tree | 05689049fd48f01ebbdb9995d5189cba15cee052 /packages/meshbay-hub/tests/test_transfers.py | |
| parent | 813d18424ec57963bb56e6f40824a2db0ccce50d (diff) | |
| parent | e6f895c473a0b19e7b186889c1836d3945bc880b (diff) | |
| download | meshbay-7e2d078fe1870d256ae47781bee6ac4f454edf24.tar.gz | |
Merge branch 'fix/large-download-paths'
Concurrent-transfer limits, with the queue, the pause and the flag day.
A node now caps how many transfers it runs at once (8 downloads, 8 uploads,
node-wide) and how many one member may run in one group (2 by default,
operator-signed). Beyond that the node answers "queued" and the client waits its
turn, visibly, in the transfers panel — and a slot that frees starts whatever is
next, skipping past a member who is at their own cap rather than letting them
stall everyone behind them.
Browsing is never subject to a slot: not the poster grid, not the covers, not
opening a photo to look at it. That is structural — a transfer is what the
transfers widget shows — and the exemption is bounded rather than open, at two
files in flight per session, because an exemption with no bound is a leaseless
branch under another name.
Transfers can be cancelled, and now paused and resumed. A paused one holds
nothing: its slot goes back at once and resuming rejoins the queue at the tail.
Uploads survive the connection that started them and resume where the node
stopped, asked for inside the seal rather than on a clear message. What they
leave behind when they are abandoned is reaped, which closes a disk leak that
predates this work.
MNP 3.0 makes the lease compulsory and refuses 2.x at the handshake, with the
desktop client checking `client.minimum` before connecting so an un-updated one
says "update" instead of failing every connection in a protocol vocabulary.
Fourteen defects were found on the way, eight of them by a person clicking
Download and pasting a console — none of which 2075 tests could reach. Section
12 of ~/next/improve-downloads.md is that report, including the three this work
introduced itself and the one that turned out to be caused by an instruction to
hard-reload after each deployment.
Node suite 1209 passed, hub suite 866 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/tests/test_transfers.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_transfers.py | 692 |
1 files changed, 692 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index 3316615..d93cf80 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -222,3 +222,695 @@ def test_a_folder_name_carries_no_trailing_slash(): assert "dir-row" in row, "the anchor no longer lands on the directory row" assert "${d}/" not in row, "the folder name is rendered with a trailing slash" assert "${d}" in row + + +# ── Transfer slots, client side ───────────────────────────────────────────── +# +# A queue can lie in two directions, and both are worse than no queue: a +# transfer that shows "waiting" on a node that already granted it, and a slot +# the page holds after it has stopped using it. Everything below is one of +# those two. + +def _lease_stub(): + """A Lease as the store sees it, driveable from the test.""" + return """ +class L { + constructor() { + this.state = 'queued'; this.ahead = 2; this.closed = false; + this.released = []; this.tr = 'tr1'; + this._wait = new Promise(r => { this._go = r; }); + } + acquire() { return this._wait; } + release(reason) { if (!this.closed) { this.closed = true; this.released.push(reason); } } + grant() { this.state = 'granted'; if (this._onState) this._onState(this); this._go(); } + push(state, ahead) { this.state = state; this.ahead = ahead; if (this._onState) this._onState(this); } +} +""" + + +def test_a_transfer_waiting_for_a_slot_is_queued_not_running(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran'); } }); + say(t.list()[0].status, t.list()[0].ahead); + await new Promise(r => setTimeout(r, 0)); + say('still:' + t.list()[0].status); + """, tmp_path) + assert out[:2] == ["queued", 2] + assert "ran" not in out, "the work started before the slot was granted" + assert out[-1] == "still:queued" + + +def test_the_grant_starts_the_work(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran:' + t.list()[0].status); } }); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('after:' + t.list()[0].status); + """, tmp_path) + assert out[0] == "ran:running" + assert out[1] == "after:done" + + +def test_the_slot_comes_back_however_the_transfer_ends(tmp_path): + """A slot not returned is a member who cannot transfer again until the node + times it out — so this must hold for a throw as much as for a success.""" + out = _run(_lease_stub() + """ + for (const mode of ['ok', 'throw']) { + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { if (mode === 'throw') throw new Error('x'); } }); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say(mode + ':' + lease.released.join(',') + ':' + t.list()[0].status); + } + """, tmp_path) + assert out == ["ok:done:done", "throw:done:failed"] + + +def test_cancelling_while_queued_gives_the_slot_back(tmp_path): + """The transfer somebody is most likely to give up on is the one that has + not started. Its queue entry has to go, or the node grants a slot to a + transfer that will never use it.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + const id = t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran'); } }); + t.cancel(id); + say(t.list()[0].status, lease.released.join(',')); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('ran?', out.includes('ran')); + """, tmp_path) + assert out[0] == "cancelled" + assert out[1] == "cancelled" + assert out[-1] is False, "a cancelled transfer ran anyway once granted" + + +def test_a_queue_position_update_reaches_the_view(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + const seen = []; + t.subscribe(items => seen.push(items[0].ahead)); + t.start({ kind: 'download', name: 'f', total: 10, lease, run: async () => {} }); + lease.push('queued', 1); + lease.push('queued', 0); + say(seen.join('>')); + """, tmp_path) + assert out[0].endswith("1>0"), "the widget never learns it is moving up" + + +def test_a_transport_with_a_queued_transfer_is_not_closed(tmp_path): + """Closing it would leave the transfer waiting for a grant that can never + arrive — waiting for ever, with nothing left to answer.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + let closed = false; + const transport = { close() { closed = true; } }; + t.start({ kind: 'download', name: 'f', total: 10, transport, lease, + run: async () => {} }); + t.releaseWhenIdle(transport); + say('closed while queued:', closed); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('closed after:', closed); + """, tmp_path) + assert out[1] is False + assert out[3] is True + + +def test_clearing_finished_keeps_what_is_waiting(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'waiting', total: 1, lease, run: async () => {} }); + t.start({ kind: 'download', name: 'done', total: 1, run: async () => {} }); + await new Promise(r => setTimeout(r, 10)); + t.clearFinished(); + say(t.list().map(i => i.name + ':' + i.status).join(',')); + """, tmp_path) + assert out[0] == "waiting:queued" + + +def test_asking_for_a_slot_on_a_dead_channel_does_not_throw(tmp_path): + """ + The transport reconnects on its own and re-asks for every live lease when it + does, so a closed channel at the moment a transfer starts is a wait, not a + failure. `_fetchChunkResilient` has always treated it that way — and before + leases existed a chunk request was the first thing to touch the channel, so + a download begun on a briefly dead connection simply retried. + + Asking for a slot first made `_send` the first contact. It threw + "DataChannel not open (state: closed)" straight out of `downloadEntry`, + where nothing catches it: a download that used to recover became an error + with no row in the widget to show it. Found live, by downloading a file + just after a connection dropped. + """ + module = tmp_path / "transport_lease.mjs" + # The real Lease, lifted out as text — the class is not exported, and a + # second copy of it here would agree with whatever it was copied from. + src = (STATIC / "transport.js").read_text() + # From the constant the class depends on, not from the class: lifting only + # the class left LEASE_WATCHDOG_MS undefined, which the class reads the + # first time it arms its watchdog. + start = src.index("const LEASE_WATCHDOG_MS") + end = src.index("\nclass MeshBayTransport") + module.write_text(src[start:end] + "\nexport { Lease };\n") + + script = tmp_path / "case.mjs" + script.write_text(f""" +import {{ Lease }} from '{module.as_posix()}'; +const out = []; +const transport = {{ + supportsTransferSlots: true, + _leases: new Map(), + _send() {{ throw new Error('DataChannel not open (state: closed)'); }}, +}}; +let threw = null; +const lease = new Lease(transport, 'tr1', 'download', 10, 1, null); +try {{ lease._request(); }} catch (e) {{ threw = e.message; }} +out.push(threw); +// And releasing one must be just as safe: a lease not released is a member who +// cannot start another transfer until the node times it out. +try {{ lease.release('cancelled'); out.push('release ok'); }} +catch (e) {{ out.push('release threw: ' + e.message); }} +clearTimeout(lease._watchdog); +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out[0] is None, f"asking for a slot threw: {out[0]}" + assert out[1] == "release ok" + + +def test_the_slot_is_asked_for_after_there_is_somewhere_to_write(): + """ + A granted slot has to be taken up within the node's acceptance deadline, so + it must not be asked for until the download can actually start. + + Asking first reads better — the widget could draw a row while the target is + being chosen — and is wrong: opening a target takes thirty seconds of + streamed-download timeouts, or as long as somebody leaves a Save As dialog + open. The node revokes the grant, passes it to the next in the queue + (`transfer: reclaimed … (not_taken_up)` in its log), and the download then + fetches under a `tr` that is no longer granted. Three downloads started, one + arrived. + + Source-reading, because the ordering is the whole property and it has no + behaviour of its own to drive: what matters is which call comes first. + """ + src = (STATIC / "file-utils.js").read_text() + fn = src[src.index("async function downloadEntry"):] + fn = fn[:fn.index("\n}\n")] + # `_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") + + +# ── 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 + + +# ── Pause and resume ──────────────────────────────────────────────────────── +# +# The rule the whole design turns on: **a paused transfer holds nothing.** Its +# slot goes back to the node the moment it stops, and resuming rejoins the queue +# at the tail. Anything else lets one member close a node by pausing four +# downloads and going to lunch (§6.2 of ~/next/improve-downloads.md). + + +def _pausable_run(): + """A `run` that stops where it is told and reports where it resumed.""" + return """ +const mkStore = () => { + const t = new TransferStore(); + const leases = []; + const state = { starts: [], paused: null, aborted: false }; + t.start({ + kind: 'download', name: 'f', total: 1000, + prepare: async () => ({ name: 'f', pausable: true }), + makeLease: () => { const l = new L(); leases.push(l); return l; }, + run: async ({ signal, from }) => { + state.starts.push(from); + state.running = true; + try { + // Runs until told to stop, one "chunk" at a time. + for (let i = from; i < 10; i++) { + await new Promise(r => setTimeout(r, 5)); + if (signal.aborted) { const e = new Error('c'); e.name = 'AbortError'; throw e; } + if (signal.paused) { + signal.resumeFrom = i; + const e = new Error('p'); e.name = 'PausedError'; throw e; + } + } + } finally { state.running = false; } + }, + }); + return { t, leases, state }; +}; +""" + + +def test_pausing_gives_the_slot_back(tmp_path): + """The node has to get it back at once, not when the person resumes: the + whole point of a queue is that a slot nobody is using is a slot somebody + else can have.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + say('before:' + t.list()[0].status); + t.pause(t.list()[0].id); + await new Promise(r => setTimeout(r, 30)); + say('after:' + t.list()[0].status); + say('released:' + leases[0].released.join(',')); + say('leases:' + leases.length); + """, tmp_path) + assert out[0] == "before:running" + assert out[1] == "after:paused" + assert out[2] == "released:paused", "a paused transfer kept its slot" + assert out[3] == "leases:1" + + +def test_resuming_asks_for_a_new_slot_and_continues_where_it_stopped(tmp_path): + """Rejoining at the tail is the design, not an accident: a paused transfer + that could reclaim its old place would be a way to hold one.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases, state } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + t.pause(id); + await new Promise(r => setTimeout(r, 30)); + t.resume(id); + await new Promise(r => setTimeout(r, 10)); + say('queued:' + t.list()[0].status, 'leases:' + leases.length); + leases[1].grant(); + await new Promise(r => setTimeout(r, 120)); + say('end:' + t.list()[0].status); + say('starts:' + state.starts.join(',')); + """, tmp_path) + assert out[0] == "queued:queued", "a resumed transfer skipped the queue" + assert out[1] == "leases:2", "resuming did not ask for a slot again" + assert out[2] == "end:done" + starts = out[3].split(":")[1].split(",") + assert starts[0] == "0" and int(starts[1]) > 0, ( + f"resumed from {starts} — it started again from the beginning") + + +def test_a_transfer_whose_target_cannot_pause_is_not_paused(tmp_path): + """A service-worker stream is a download the browser already owns: not + writing to it stalls it outside our control and an idle worker is killed + within seconds. A button that silently restarts from zero is worse than no + button, so `pause` refuses rather than pretending.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + prepare: async () => ({ name: 'f' }), + run: async ({ signal }) => { + while (!signal.aborted) await new Promise(r => setTimeout(r, 5)); + } }); + lease.grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + say('pausable:' + t.list()[0].pausable); + t.pause(id); + await new Promise(r => setTimeout(r, 20)); + say('status:' + t.list()[0].status); + t.cancel(id); + """, tmp_path) + assert out == ["pausable:false", "status:running"] + + +def test_cancelling_a_paused_transfer_actually_ends_it(tmp_path): + """A paused run is parked on a promise. Without waking it, cancel marks the + row and leaves the work parked for the life of the page, holding its target + open — a button that lies, in the same way the first test in this file + describes.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases, state } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + t.pause(id); + await new Promise(r => setTimeout(r, 30)); + t.cancel(id); + // What matters is whether the store's own loop ends, not whether the row + // says so: the row is marked at once either way. + const settled = await Promise.race([ + t._items[0].promise.then(() => 'settled', () => 'settled'), + new Promise(r => setTimeout(() => r('parked'), 60)), + ]); + say('status:' + t.list()[0].status); + say('loop:' + settled); + say('resumed:' + state.starts.length); + """, tmp_path) + assert out[0] == "status:cancelled" + assert out[1] == "loop:settled", ( + "the run was still parked on the resume promise after a cancel — the " + "row said cancelled over work that had not stopped") + assert out[2] == "resumed:1", "cancelling started the work again" + + +def test_a_paused_transfer_still_counts_as_live(tmp_path): + """It is not finished, and its transport must not be closed under it — the + person is coming back to it.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + t.pause(t.list()[0].id); + await new Promise(r => setTimeout(r, 30)); + say('pending:' + t.pending); + """, tmp_path) + assert out == ["pending:1"] + + +def test_an_upload_can_be_paused_without_a_prepare_step(tmp_path): + """A download learns whether it can pause from its target, because only the + target knows. An upload has no target to ask: a `File` is seekable and the + node keeps the position, so it says so outright. + + This was missed when pause shipped — the button appeared on downloads and + nowhere else, including in the desktop app where everything else works. + """ + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const leases = []; + t.start({ + kind: 'upload', name: 'f', total: 100, pausable: true, + makeLease: () => { const l = new L(); leases.push(l); return l; }, + run: async ({ signal, from }) => { + for (let i = from || 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 5)); + if (signal.paused) { + signal.resumeFrom = i; + const e = new Error('p'); e.name = 'PausedError'; throw e; + } + } + }, + }); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + say('pausable:' + t.list()[0].pausable); + t.pause(t.list()[0].id); + await new Promise(r => setTimeout(r, 30)); + say('status:' + t.list()[0].status); + say('released:' + leases[0].released.join(',')); + """, tmp_path) + assert out == ["pausable:true", "status:paused", "released:paused"] + + +def test_an_upload_handed_a_lease_it_cannot_recreate_is_not_offered_pause(tmp_path): + """Pausing gives the slot back. A transfer that cannot ask for another one + would pause once and wait for ever, so the button is refused instead.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'upload', name: 'f', total: 100, pausable: true, lease, + run: async ({ signal }) => { + while (!signal.aborted) await new Promise(r => setTimeout(r, 5)); + } }); + lease.grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + t.pause(id); + await new Promise(r => setTimeout(r, 20)); + say('status:' + t.list()[0].status); + t.cancel(id); + """, tmp_path) + assert out == ["status:running"] + + +def test_pausing_one_transfer_leaves_the_others_alone(tmp_path): + """Reported: three downloads running, one upload paused, and the three + downloads lost their pause buttons. + + The button is drawn from `pausable` and the status, so this asks the store + what it says about the other three at the moment one of them pauses. + """ + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const leases = []; + const mk = (kind, name) => t.start({ + kind, name, total: 100, pausable: true, + makeLease: () => { const l = new L(); leases.push(l); return l; }, + run: async ({ signal, from }) => { + for (let i = from || 0; i < 40; i++) { + await new Promise(r => setTimeout(r, 5)); + if (signal.aborted) { const e = new Error('c'); e.name = 'AbortError'; throw e; } + if (signal.paused) { + signal.resumeFrom = i; + const e = new Error('p'); e.name = 'PausedError'; throw e; + } + } + }, + }); + mk('download', 'd1'); mk('download', 'd2'); mk('download', 'd3'); + mk('upload', 'u1'); + await new Promise(r => setTimeout(r, 5)); + for (const l of leases) l.grant(); + await new Promise(r => setTimeout(r, 20)); + const up = t.list().find(i => i.kind === 'upload'); + say('before:' + t.list().filter( + i => i.kind === 'download' && i.pausable && i.status === 'running').length); + t.pause(up.id); + await new Promise(r => setTimeout(r, 40)); + const rows = t.list(); + say('after:' + rows.filter( + i => i.kind === 'download' && i.pausable && i.status === 'running').length); + say('statuses:' + rows.map(i => i.kind[0] + ':' + i.status).join(',')); + for (const r of rows) t.cancel(r.id); + """, tmp_path) + assert out[0] == "before:3" + assert out[1] == "after:3", ( + f"pausing the upload changed the downloads — {out[2]}") + + +def test_a_paused_transfer_is_not_filed_under_finished(tmp_path): + """"Finished" was defined by exclusion — everything that is not running, + queued or preparing — so it quietly swallowed `paused` the day pausing + shipped. A transfer somebody stopped on purpose then sat beside the ones + that are actually over, offering a resume button in the section of things + that cannot be resumed. + + The three filters are lifted out of `app.js` and run, rather than described + here: a copy of them in this file would agree with a broken version by + construction. + """ + src = (STATIC / "app.js").read_text() + start = src.index(" const running = items.filter(") + block = src[start:src.index("const active =", start)] + + script = tmp_path / "groups.mjs" + script.write_text(""" +const items = [ + { id: 1, status: 'running' }, + { id: 2, status: 'queued' }, + { id: 3, status: 'preparing' }, + { id: 4, status: 'paused' }, + { id: 5, status: 'done' }, + { id: 6, status: 'failed' }, + { id: 7, status: 'cancelled' }, +]; +""" + block + """ +const seen = { running, waiting, paused, finished }; +console.log(JSON.stringify(Object.fromEntries( + Object.entries(seen).map(([k, v]) => [k, v.map(i => i.id)])))); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + groups = json.loads(proc.stdout) + + assert groups["paused"] == [4] + assert groups["finished"] == [5, 6, 7], ( + f"paused landed in {groups['finished']}") + assert groups["running"] == [1] and groups["waiting"] == [2, 3] + # Every row appears exactly once: a state added later that lands in no group + # is a transfer the panel simply does not show. + placed = sum((groups[k] for k in groups), []) + assert sorted(placed) == [1, 2, 3, 4, 5, 6, 7] + + +def test_a_paused_transfer_still_counts_as_active(tmp_path): + """The badge says how much is going on. A paused transfer is not over — the + person means to come back to it — so counting it as nothing would be a + panel that says "0" over work that is still there.""" + src = (STATIC / "app.js").read_text() + start = src.index(" const running = items.filter(") + block = src[start:src.index("\n\n", src.index("const active =", start))] + + script = tmp_path / "active.mjs" + script.write_text(""" +const items = [{ id: 1, status: 'paused' }, { id: 2, status: 'done' }]; +""" + block + """ +console.log(JSON.stringify({ active })); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["active"] == 1 + + +def test_a_row_that_cannot_pause_says_so_where_the_button_would_be(): + """Reported from Chrome: four downloads with no pause button and an upload + with one, and no way to tell why. + + The reason is real — without a granted folder the browser writes through the + service worker, a download it already owns and cannot pause — but it was + stated only in a Settings line nobody reads on the way to a download. A gap + where the row above has a button is not an explanation. + + Shown only where a folder can actually be chosen: Firefox and Safari have + none to choose, and "choose a folder" would be advice that cannot be taken. + """ + src = (STATIC / "app.js").read_text() + row = src[src.index("function TransferRow"):] + row = row[:row.index("\n}\n")] + + hint = row[row.index("!it.pausable"):] + hint = hint[:hint.index("`}")] + assert "downloads.SUPPORTED" in hint, ( + "the hint would tell a Firefox user to choose a folder it cannot offer") + assert "it.kind === 'download'" in hint, ( + "an upload is always pausable; this is about download targets") + assert "transfers.not_pausable" in hint, "the reason is not stated" + # Not a button. There is nothing to click, and a disabled one invites the + # click anyway. + assert "<button" not in hint + + +def test_the_reason_is_translated_everywhere(): + """`t()` falls back to the key, so a missing catalogue entry shows + `transfers.not_pausable` in a tooltip rather than a sentence.""" + for path in sorted((STATIC / "locales").glob("*.js")): + assert "'transfers.not_pausable'" in path.read_text(), path.name |