summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 14:14:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 14:14:11 +0200
commitb472b4d43149ed12a7f64c94f34c34f436bef492 (patch)
treed31c2b24afa81027f3fa17aa861b3cce11fa8c61
parent53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac (diff)
downloadmeshbay-b472b4d43149ed12a7f64c94f34c34f436bef492.tar.gz
fix(spa): a paused transfer is not a finished one
Reported while testing the flag day: pausing an upload put it under "Finished". "Finished" was defined by exclusion — everything that is not running, queued or preparing — so it 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, and dropped out of the badge, which announced less activity than there was. Paused is now its own group, in all ten catalogues, and counts as active: it is not over, the person means to come back to it. The three filters are lifted out of `app.js` and executed rather than described in the test, and one case asserts that every status lands in exactly one group — a state added later that falls into none is a transfer the panel simply does not show, which is how this one got in. The same report also said the three running downloads lost their pause buttons when the upload was paused. That part is **not** explained and **not** fixed: the store returns `pausable` true and status `running` for all three (new test), closing an upload lease pumps only the upload queue, and the button's condition is a pure function of those two. All three say the buttons should have stayed, so an observation is missing rather than a cause. Hub suite 864 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/app.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py109
12 files changed, 132 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 0394af6..de47982 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -159,10 +159,20 @@ function TransferWidget() {
const running = items.filter(i => i.status === 'running');
const waiting = items.filter(
i => i.status === 'queued' || i.status === 'preparing');
+ // Its own group, and not a leftover.
+ //
+ // "Finished" used to be defined as everything that is not running, queued or
+ // preparing — a definition by exclusion, which quietly swallowed `paused` the
+ // day pausing shipped. A transfer somebody stopped on purpose then sat under
+ // "Finished", beside the ones that are actually over, offering a resume
+ // button in the section of things that cannot be resumed.
+ const paused = items.filter(i => i.status === 'paused');
const finished = items.filter(
i => i.status !== 'running' && i.status !== 'queued'
- && i.status !== 'preparing');
- const active = running.length + waiting.length;
+ && i.status !== 'preparing' && i.status !== 'paused');
+ // Paused counts as active: it is not over, the person means to come back to
+ // it, and the badge saying nothing is happening would be a lie.
+ const active = running.length + waiting.length + paused.length;
// Grouped, and in this order: what is moving, what is waiting, what is over.
// Re-sorting the flat list on every emit made rows jump under the pointer
@@ -171,6 +181,7 @@ function TransferWidget() {
const groups = [
['running', running],
['waiting', waiting],
+ ['paused', paused],
['finished', finished],
].filter(([, rows]) => rows.length);
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 fd63ddf..738b131 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -584,6 +584,7 @@ export default {
'transfers.summary': '{running} laufend · {waiting} wartend',
'transfers.group_running': 'Laufend',
'transfers.group_waiting': 'Wartend',
+ 'transfers.group_paused': 'Angehalten',
'transfers.group_finished': 'Abgeschlossen',
'transfers.cancel_one': '{name} abbrechen',
'transfers.pause': 'Anhalten',
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 e9d4072..0b9de8a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -700,6 +700,7 @@ export default {
'transfers.summary': '{running} running · {waiting} waiting',
'transfers.group_running': 'Running',
'transfers.group_waiting': 'Waiting',
+ 'transfers.group_paused': 'Paused',
'transfers.group_finished': 'Finished',
'transfers.cancel_one': 'Cancel {name}',
'transfers.pause': 'Pause',
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 4fa19e7..8da8f9f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -580,6 +580,7 @@ export default {
'transfers.summary': '{running} en curso · {waiting} en espera',
'transfers.group_running': 'En curso',
'transfers.group_waiting': 'En espera',
+ 'transfers.group_paused': 'En pausa',
'transfers.group_finished': 'Finalizados',
'transfers.cancel_one': 'Cancelar {name}',
'transfers.pause': 'Pausar',
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 6fba91d..3135c03 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -583,6 +583,7 @@ export default {
'transfers.summary': '{running} en cours · {waiting} en attente',
'transfers.group_running': 'En cours',
'transfers.group_waiting': 'En attente',
+ 'transfers.group_paused': 'En pause',
'transfers.group_finished': 'Terminés',
'transfers.cancel_one': 'Annuler {name}',
'transfers.pause': 'Suspendre',
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 76b3101..91f0e9b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -583,6 +583,7 @@ export default {
'transfers.summary': '{running} in corso · {waiting} in attesa',
'transfers.group_running': 'In corso',
'transfers.group_waiting': 'In attesa',
+ 'transfers.group_paused': 'In pausa',
'transfers.group_finished': 'Completati',
'transfers.cancel_one': 'Annulla {name}',
'transfers.pause': 'Sospendi',
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 cfc1125..b352188 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -575,6 +575,7 @@ export default {
'transfers.summary': '実行中 {running} · 待機中 {waiting}',
'transfers.group_running': '実行中',
'transfers.group_waiting': '待機中',
+ 'transfers.group_paused': '一時停止中',
'transfers.group_finished': '完了',
'transfers.cancel_one': '{name} をキャンセル',
'transfers.pause': '一時停止',
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 29cc566..e0a799d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -584,6 +584,7 @@ export default {
'transfers.summary': '{running} bezig · {waiting} wachtend',
'transfers.group_running': 'Bezig',
'transfers.group_waiting': 'Wachtend',
+ 'transfers.group_paused': 'Gepauzeerd',
'transfers.group_finished': 'Voltooid',
'transfers.cancel_one': '{name} annuleren',
'transfers.pause': 'Pauzeren',
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 38fe714..e6319b0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -596,6 +596,7 @@ export default {
'transfers.summary': '{running} w toku · {waiting} oczekuje',
'transfers.group_running': 'W toku',
'transfers.group_waiting': 'Oczekuje',
+ 'transfers.group_paused': 'Wstrzymane',
'transfers.group_finished': 'Zakończone',
'transfers.cancel_one': 'Anuluj {name}',
'transfers.pause': 'Wstrzymaj',
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 5942b77..02c8356 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
@@ -582,6 +582,7 @@ export default {
'transfers.summary': '{running} em andamento · {waiting} aguardando',
'transfers.group_running': 'Em andamento',
'transfers.group_waiting': 'Aguardando',
+ 'transfers.group_paused': 'Pausados',
'transfers.group_finished': 'Concluídos',
'transfers.cancel_one': 'Cancelar {name}',
'transfers.pause': 'Pausar',
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 56af6c8..6c4c73f 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
@@ -563,6 +563,7 @@ export default {
'transfers.summary': '进行中 {running} · 等待中 {waiting}',
'transfers.group_running': '进行中',
'transfers.group_waiting': '等待中',
+ 'transfers.group_paused': '已暂停',
'transfers.group_finished': '已完成',
'transfers.cancel_one': '取消 {name}',
'transfers.pause': '暂停',
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index f27d4fd..d19a458 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -770,3 +770,112 @@ def test_an_upload_handed_a_lease_it_cannot_recreate_is_not_offered_pause(tmp_pa
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