"""
The transfer store, which is what keeps a download alive after you leave a group.
Run under Node, because the behaviour worth pinning is timing and lifetime:
that a cancel actually stops the work rather than only greying out a row, that a
stalled transfer reads as slow instead of reporting its historical average, and
that a transport handed over by a departing page is closed by the last transfer
using it — not before, and not never.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
TRANSFERS = STATIC / "transfers.js"
pytestmark = pytest.mark.skipif(
shutil.which("node") is None or not TRANSFERS.exists(),
reason="node or the SPA sources are not available")
def _run(body, tmp_path):
module = tmp_path / "transfers.mjs"
module.write_text(TRANSFERS.read_text())
script = tmp_path / "case.mjs"
script.write_text(
f"import {{ TransferStore, formatSpeed }} from '{module.as_posix()}';\n"
"const out = [];\n"
"const say = (...a) => out.push(...a);\n"
f"{body}\n"
"console.log(JSON.stringify(out));\n")
proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_a_cancel_actually_stops_the_work(tmp_path):
"""
The flag has to be read by the thing doing the work. A store that only
marks a row cancelled gives you a button that lies.
"""
result = _run("""
const store = new TransferStore();
let chunksWritten = 0;
const id = store.start({
kind: 'download', name: 'big.mp4', total: 100,
run: async ({ signal, onProgress }) => {
for (let i = 0; i < 100; i++) {
if (signal.aborted) throw Object.assign(new Error('stop'), { name: 'AbortError' });
chunksWritten++;
onProgress(i + 1, 100);
await new Promise(r => setTimeout(r, 1));
}
},
});
await new Promise(r => setTimeout(r, 20));
store.cancel(id);
await new Promise(r => setTimeout(r, 40));
say(chunksWritten < 100, store.list()[0].status, store.active);
""", tmp_path)
assert result[0] is True, "the work ran to completion despite being cancelled"
assert result[1] == "cancelled"
assert result[2] == 0
def test_speed_is_measured_over_a_window_not_since_the_start(tmp_path):
"""A transfer that stalls should read as slow now, not as its own average."""
result = _run("""
let clock = 0;
const store = new TransferStore(() => clock);
let report;
store.start({
kind: 'download', name: 'x', total: 100_000_000,
run: async ({ onProgress }) => { report = onProgress; await new Promise(() => {}); },
});
await new Promise(r => setTimeout(r, 1)); // start() defers run() by a tick
// A fast megabyte a second, for six seconds.
for (let i = 1; i <= 6; i++) { clock = i * 1000; report(i * 1_000_000, 100_000_000); }
say(Math.round(store.list()[0].speed));
// Then it stalls: the clock moves, the bytes do not.
for (let i = 7; i <= 12; i++) { clock = i * 1000; report(6_000_000, 100_000_000); }
say(Math.round(store.list()[0].speed));
""", tmp_path)
assert 900_000 <= result[0] <= 1_100_000, f"expected ~1 MB/s, got {result[0]}"
assert result[1] == 0, f"a stalled transfer still reports {result[1]} B/s"
def test_a_transport_is_closed_by_the_last_transfer_that_needed_it(tmp_path):
"""
Leaving a group page must not kill a running download, and must not leak
the connection either.
"""
result = _run("""
const store = new TransferStore();
let closed = 0;
const transport = { close: () => { closed++; }, onIndexSync: () => {} };
let done1, done2;
store.start({ kind: 'download', name: 'a', transport,
run: () => new Promise(r => { done1 = r; }) });
store.start({ kind: 'download', name: 'b', transport,
run: () => new Promise(r => { done2 = r; }) });
store.releaseWhenIdle(transport); // the page goes away
await new Promise(r => setTimeout(r, 5));
say(closed); // still working: must stay open
done1(); await new Promise(r => setTimeout(r, 5));
say(closed); // one left: still open
done2(); await new Promise(r => setTimeout(r, 5));
say(closed); // now it can go
""", tmp_path)
assert result == [0, 0, 1], f"close() calls after each step: {result}"
def test_an_idle_transport_is_closed_straight_away(tmp_path):
result = _run("""
const store = new TransferStore();
let closed = 0;
store.releaseWhenIdle({ close: () => { closed++; } });
say(closed);
""", tmp_path)
assert result == [1], "leaving a group with nothing running should close it"
def test_signing_out_cancels_everything_and_lets_go(tmp_path):
"""
Navigating is not signing out. The second one has to stop transfers running
on tokens that are about to stop being ours.
"""
result = _run("""
const store = new TransferStore();
let closed = 0;
const transport = { close: () => { closed++; } };
store.start({ kind: 'upload', name: 'a', transport,
run: () => new Promise(() => {}) });
store.reset();
await new Promise(r => setTimeout(r, 5));
say(store.list().length, closed, store.active);
""", tmp_path)
assert result == [0, 1, 0]
def test_a_failure_is_kept_and_named(tmp_path):
"""A transfer that dies silently is one the user retries at random."""
result = _run("""
const store = new TransferStore();
store.start({ kind: 'download', name: 'gone.mp4',
run: async () => { throw new Error('File not found'); } });
await new Promise(r => setTimeout(r, 5));
const row = store.list()[0];
say(row.status, row.error);
store.clearFinished();
say(store.list().length);
""", tmp_path)
assert result[0] == "failed"
assert result[1] == "File not found"
assert result[2] == 0
def test_the_rate_is_readable(tmp_path):
result = _run("""
say(formatSpeed(0), formatSpeed(2048), formatSpeed(5 * 1024 * 1024));
""", tmp_path)
assert result == ["", "2 KB/s", "5.0 MB/s"]
# ── What the widget shows without being opened ──────────────────────────────
APP = STATIC / "app.js"
def _widget() -> str:
source = APP.read_text(encoding="utf-8")
start = source.index("function TransferWidget(")
return source[start:source.index("\nfunction ", start + 1)]
def test_the_widget_marks_itself_while_transfers_run():
"""
The badge counts them, but a count has to be read. Colour is what says
"something is moving" from across the room, which is the point of a widget
that lives in the nav bar rather than on the page.
"""
widget = _widget()
assert "running.length ? 'active' : ''" in widget, (
"the button no longer marks itself while transfers are running")
def test_the_mark_comes_off_when_the_last_one_finishes():
"""`running` is derived from the live list on every render, not stored — so
there is no state to forget to clear."""
widget = _widget()
assert "const running = items.filter(i => i.status === 'running');" in widget
def test_the_colour_is_defined_for_that_mark():
"""The class is set in one file and coloured in another; either alone does
nothing, and neither fails loudly."""
css = (STATIC / "style.css").read_text(encoding="utf-8")
assert ".transfer-btn.active" in css
# ── The file list ───────────────────────────────────────────────────────────
def test_a_folder_name_carries_no_trailing_slash():
"""
The folder icon in the cell beside it already says what it is.
Anchored on `key=${full}`, which is the row that renders a directory. The
first `dir-row` in the file is the ".." row added later, whose only cell is
an ellipsis — slicing from there found no `${d}` and failed on a name it
had never looked at.
"""
source = STATIC.joinpath("files-app.js").read_text(encoding="utf-8")
start = source.rindex("
", start)]
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.
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 "