aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 10:48:49 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 10:48:49 +0200
commit29e93e5e553c94818cd2b4e587b0e54cfe7d8424 (patch)
tree0c4e9a7c834566c3a7abd4e4f9f085b4f29d78bc
parentcb43495f998015850f34829329aa4509bd55d2cb (diff)
downloadmeshbay-29e93e5e553c94818cd2b4e587b0e54cfe7d8424.tar.gz
feat(spa): pause and resume a download, in session
Stage 7a of ~/next/improve-downloads.md: pausing within a session, on the targets that can actually do it. Resuming across a reload is 7b. A paused transfer holds **nothing**. Its slot goes back to the node the moment it stops and resuming rejoins the queue at the tail, because anything else lets one member close a node by pausing four downloads and going to lunch. So the lease is taken inside the run loop rather than before it, and pause is refused outright for a transfer that could not ask for another one. Resuming is exact rather than approximate: the pipeline stops between two chunks and never inside one, so what is on disk is always a whole number of chunks and `fromChunk` is a verified position. The failure mode being avoided is a file that looks complete and is quietly corrupt. The target has to survive it, so a pause no longer reaches the `abort()` that a failure does -- that would delete Electron's `.part` or the file just created in the granted folder, leaving nothing to continue. And the in-memory fallback keeps its accumulated chunks rather than starting a second array. The button is drawn only where the target says it can. A service-worker stream says no, in its own code and for its own reasons: the browser is already writing an HTTP response into its own download folder, not feeding it stalls that download where we cannot see or resume it, and an idle worker is terminated within seconds. Firefox and Safari therefore keep cancel and get no pause, which is the decision recorded in §6.5. Cancelling a paused transfer ends it. A paused run is parked on a promise; without waking it the row said "cancelled" over work that had not stopped and a target that was still open. Six cases, each checked against the unfixed source. Hub suite 842 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.js32
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js118
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py68
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py160
17 files changed, 484 insertions, 26 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 6f4bb92..0394af6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -242,8 +242,24 @@ 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.pausable && (it.status === 'running' || it.status === 'paused')
+ && html`
+ ${/* Offered only where the target can actually do it: a
+ service-worker stream is a download the browser already owns,
+ and a pause there would restart from zero. */''}
+ <button class="transfer-pause"
+ aria-label=${t(it.status === 'paused'
+ ? 'transfers.resume_one' : 'transfers.pause_one',
+ { name: it.name })}
+ title=${t(it.status === 'paused'
+ ? 'transfers.resume' : 'transfers.pause')}
+ onClick=${() => (it.status === 'paused'
+ ? transfers.resume(it.id) : transfers.pause(it.id))}>
+ <${Icon} name=${it.status === 'paused' ? 'play' : 'pause'} />
+ </button>
+ `}
${(it.status === 'running' || it.status === 'queued'
- || it.status === 'preparing') && html`
+ || it.status === 'preparing' || it.status === 'paused') && html`
<button class="transfer-cancel"
aria-label=${t('transfers.cancel_one', { name: it.name })}
title=${t('transfers.cancel')}
@@ -274,6 +290,20 @@ function TransferRow({ it }) {
<span>${formatSize(it.total)}</span>
</div>
`
+ : it.status === 'paused'
+ ? html`
+ ${/* The bar keeps its fill: what has been written is still there,
+ and resuming continues from it rather than starting again. */''}
+ <div class="dl-progress" role="progressbar"
+ aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100">
+ <div class="dl-fill dl-paused" style="width:${it.percent}%"></div>
+ </div>
+ <div class="transfer-meta">
+ <span>${t('transfers.paused')}</span>
+ <span>${formatSize(it.done)}${it.total
+ ? ' / ' + formatSize(it.total) : ''}</span>
+ </div>
+ `
: it.status === 'running'
? html`
<div class="dl-progress" role="progressbar"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
index 87d18b7..6ee7cf0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -178,6 +178,9 @@ export async function openTarget(filename) {
},
},
name,
+ // A held-open `FileSystemWritableFileStream`: pausing is simply not
+ // writing to it, and nothing is lost while nothing is written.
+ pausable: true,
// Reading it back is the only way a page can "open" a file it wrote: hand
// the bytes to a tab and let the browser decide what to do with them. No
// web page can start a desktop application, or show a file manager.
@@ -612,6 +615,14 @@ async function _attemptStreamedDownload(filename, size, attempt,
const writer = writable.getWriter();
return {
name: filename,
+ // **Not pausable, and this is not a limitation of our code.** The browser
+ // is already writing an HTTP response into its own download folder: not
+ // writing to the stream stalls that download where we cannot see it or
+ // resume it, and an idle worker is terminated within seconds, taking the
+ // stream with it. A pause button here would restart from zero, which is
+ // worse than not offering one. §6.5 of ~/next/improve-downloads.md records
+ // what giving Firefox and Safari a resumable target would cost.
+ pausable: false,
writable: {
write: (bytes) => { lastWrite = Date.now(); return writer.write(bytes); },
close: async () => {
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 ef074dd..99d9f9a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -118,7 +118,10 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
filename, { auto: downloads.getMode() === 'auto' });
// Null means the person dismissed the dialog, which is not an error and
// must not start a transfer.
- return native || false;
+ //
+ // The desktop sink is an open file stream in the main process: not
+ // writing to it for a while costs nothing and loses nothing.
+ return native ? { ...native, pausable: true } : false;
} catch (err) {
console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err));
return false;
@@ -179,7 +182,8 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
const handle = await window.showSaveFilePicker({
suggestedName: filename, ...pickerOpts,
});
- return { writable: await handle.createWritable(), name: handle.name || filename };
+ return { writable: await handle.createWritable(),
+ name: handle.name || filename, pausable: true };
} catch (err) {
if (err.name === 'AbortError') return false;
// "Must be handling a user gesture to show a file picker."
@@ -350,9 +354,10 @@ async function _fetchChunkResilient(transport, fileId, index, tr = '') {
}
async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk,
- writable, signal, tr = '') {
- const results = writable ? null : new Array(totalChunks);
- let nextSend = 0, nextRecv = 0;
+ writable, signal, tr = '', fromChunk = 0,
+ results = null) {
+ if (!writable && !results) results = new Array(totalChunks);
+ let nextSend = fromChunk, nextRecv = fromChunk;
const inflight = new Array(totalChunks);
const fire = () => {
@@ -369,6 +374,21 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
err.name = 'AbortError';
throw err;
}
+ // Between two chunks, never inside one. Everything written so far is a
+ // whole number of chunks, which is what makes resuming exact rather than
+ // approximate — `fromChunk` is a position, not an estimate, and a resumed
+ // file is never appended to at an offset nobody checked.
+ //
+ // The chunks already in flight past this point are abandoned and asked for
+ // again on resume: at most one pipeline window of duplicated traffic, in
+ // exchange for not having to hold a half-received window across a pause of
+ // unknown length.
+ if (signal && signal.paused) {
+ signal.resumeFrom = nextRecv;
+ const err = new Error('Paused');
+ err.name = 'PausedError';
+ throw err;
+ }
const chunkMsg = await inflight[nextRecv];
// One shape, and a refusal for anything else. There used to be two fallbacks
// below this: a base64 `ct_b64` chunk, which was the real wire format until
@@ -409,6 +429,9 @@ async function downloadEntry(transfers, transport, gek, entry) {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
const openRef = { url: null };
let target = null;
+ // The in-memory fallback's accumulator, held out here so a pause does not
+ // discard what has already been decrypted.
+ const memoryChunks = new Array(totalChunks);
transfers.start({
kind: 'download', name: entry.name, total: entry.size, transport,
@@ -421,7 +444,10 @@ async function downloadEntry(transfers, transport, gek, entry) {
target = await _openTargetInTurn(entry.name, entry.size);
// Dismissed: nothing was started, so nothing is left on screen.
if (target === false) return false;
- return target ? { name: target.name } : true;
+ // `pausable` travels with the target, because only the target knows. The
+ // in-memory fallback (a null target) is just an array and pauses fine.
+ return target ? { name: target.name, pausable: !!target.pausable }
+ : { pausable: true };
},
// After the target, never before: a granted slot has to be taken up within
@@ -434,24 +460,33 @@ async function downloadEntry(transfers, transport, gek, entry) {
open: () => (target && target.open) ? target.open()
: (openRef.url ? window.open(openRef.url, '_blank') : undefined),
- run: async ({ signal, onProgress, lease }) => {
- let done = 0;
+ // Kept across a pause: the chunks collected so far on the in-memory path.
+ // A resumed run fills in from where it stopped rather than starting a
+ // second array and throwing the first away.
+ run: async ({ signal, onProgress, lease, from = 0 }) => {
+ let done = from * CHUNK_SIZE;
const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
if (target) {
try {
await pipelinedDownload(transport, gek, entry.id, totalChunks,
onChunk, target.writable, signal,
- lease && lease.tr);
+ lease && lease.tr, from);
await target.writable.close();
} catch (err) {
- await target.writable.abort().catch(() => {});
+ // A pause is not a failure, and the target must survive it: aborting
+ // here would delete the `.part` (Electron) or the file just created
+ // in the granted folder, and resuming would then have nothing to
+ // continue. Only a real end tears the target down.
+ if (err.name !== 'PausedError') {
+ await target.writable.abort().catch(() => {});
+ }
throw err;
}
} else {
const chunks = await pipelinedDownload(
transport, gek, entry.id, totalChunks, onChunk, null, signal,
- lease && lease.tr);
+ lease && lease.tr, from, memoryChunks);
const blob = new Blob(chunks);
_saveBlob(blob, entry.name);
openRef.url = URL.createObjectURL(blob);
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 f0ec776..ae20687 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -586,6 +586,11 @@ export default {
'transfers.group_waiting': 'Wartend',
'transfers.group_finished': 'Abgeschlossen',
'transfers.cancel_one': '{name} abbrechen',
+ 'transfers.pause': 'Anhalten',
+ 'transfers.resume': 'Fortsetzen',
+ 'transfers.paused': 'Angehalten',
+ 'transfers.pause_one': '{name} anhalten',
+ 'transfers.resume_one': '{name} fortsetzen',
'transfers.eta_seconds': 'noch {n} s',
'transfers.eta_minutes': 'noch {n} Min.',
'transfers.eta_hours': 'noch {n} Std.',
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 c065b8a..7033778 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -702,6 +702,11 @@ export default {
'transfers.group_waiting': 'Waiting',
'transfers.group_finished': 'Finished',
'transfers.cancel_one': 'Cancel {name}',
+ 'transfers.pause': 'Pause',
+ 'transfers.resume': 'Resume',
+ 'transfers.paused': 'Paused',
+ 'transfers.pause_one': 'Pause {name}',
+ 'transfers.resume_one': 'Resume {name}',
'transfers.eta_seconds': '{n}s left',
'transfers.eta_minutes': '{n} min left',
'transfers.eta_hours': '{n} h left',
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 f655bc0..311ba1f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -582,6 +582,11 @@ export default {
'transfers.group_waiting': 'En espera',
'transfers.group_finished': 'Finalizados',
'transfers.cancel_one': 'Cancelar {name}',
+ 'transfers.pause': 'Pausar',
+ 'transfers.resume': 'Reanudar',
+ 'transfers.paused': 'En pausa',
+ 'transfers.pause_one': 'Pausar {name}',
+ 'transfers.resume_one': 'Reanudar {name}',
'transfers.eta_seconds': 'quedan {n} s',
'transfers.eta_minutes': 'quedan {n} min',
'transfers.eta_hours': 'quedan {n} h',
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 e011873..106e3cd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -585,6 +585,11 @@ export default {
'transfers.group_waiting': 'En attente',
'transfers.group_finished': 'Terminés',
'transfers.cancel_one': 'Annuler {name}',
+ 'transfers.pause': 'Suspendre',
+ 'transfers.resume': 'Reprendre',
+ 'transfers.paused': 'En pause',
+ 'transfers.pause_one': 'Suspendre {name}',
+ 'transfers.resume_one': 'Reprendre {name}',
'transfers.eta_seconds': '{n} s restantes',
'transfers.eta_minutes': '{n} min restantes',
'transfers.eta_hours': '{n} h restantes',
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 80e3c17..a610683 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -585,6 +585,11 @@ export default {
'transfers.group_waiting': 'In attesa',
'transfers.group_finished': 'Completati',
'transfers.cancel_one': 'Annulla {name}',
+ 'transfers.pause': 'Sospendi',
+ 'transfers.resume': 'Riprendi',
+ 'transfers.paused': 'In pausa',
+ 'transfers.pause_one': 'Sospendi {name}',
+ 'transfers.resume_one': 'Riprendi {name}',
'transfers.eta_seconds': '{n} s rimanenti',
'transfers.eta_minutes': '{n} min rimanenti',
'transfers.eta_hours': '{n} h rimanenti',
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 5dbb4fa..aed4ba0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -577,6 +577,11 @@ export default {
'transfers.group_waiting': '待機中',
'transfers.group_finished': '完了',
'transfers.cancel_one': '{name} をキャンセル',
+ 'transfers.pause': '一時停止',
+ 'transfers.resume': '再開',
+ 'transfers.paused': '一時停止中',
+ 'transfers.pause_one': '{name} を一時停止',
+ 'transfers.resume_one': '{name} を再開',
'transfers.eta_seconds': '残り {n} 秒',
'transfers.eta_minutes': '残り {n} 分',
'transfers.eta_hours': '残り {n} 時間',
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 f9c7cf3..fe10e12 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -586,6 +586,11 @@ export default {
'transfers.group_waiting': 'Wachtend',
'transfers.group_finished': 'Voltooid',
'transfers.cancel_one': '{name} annuleren',
+ 'transfers.pause': 'Pauzeren',
+ 'transfers.resume': 'Hervatten',
+ 'transfers.paused': 'Gepauzeerd',
+ 'transfers.pause_one': '{name} pauzeren',
+ 'transfers.resume_one': '{name} hervatten',
'transfers.eta_seconds': 'nog {n} s',
'transfers.eta_minutes': 'nog {n} min',
'transfers.eta_hours': 'nog {n} u',
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 b26f3cb..c2ba35c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -598,6 +598,11 @@ export default {
'transfers.group_waiting': 'Oczekuje',
'transfers.group_finished': 'Zakończone',
'transfers.cancel_one': 'Anuluj {name}',
+ 'transfers.pause': 'Wstrzymaj',
+ 'transfers.resume': 'Wznów',
+ 'transfers.paused': 'Wstrzymano',
+ 'transfers.pause_one': 'Wstrzymaj {name}',
+ 'transfers.resume_one': 'Wznów {name}',
'transfers.eta_seconds': 'pozostało {n} s',
'transfers.eta_minutes': 'pozostało {n} min',
'transfers.eta_hours': 'pozostało {n} godz.',
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 db5a408..83d179b 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
@@ -584,6 +584,11 @@ export default {
'transfers.group_waiting': 'Aguardando',
'transfers.group_finished': 'Concluídos',
'transfers.cancel_one': 'Cancelar {name}',
+ 'transfers.pause': 'Pausar',
+ 'transfers.resume': 'Retomar',
+ 'transfers.paused': 'Pausado',
+ 'transfers.pause_one': 'Pausar {name}',
+ 'transfers.resume_one': 'Retomar {name}',
'transfers.eta_seconds': 'faltam {n} s',
'transfers.eta_minutes': 'faltam {n} min',
'transfers.eta_hours': 'faltam {n} h',
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 ba88f4e..9acfb17 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
@@ -565,6 +565,11 @@ export default {
'transfers.group_waiting': '等待中',
'transfers.group_finished': '已完成',
'transfers.cancel_one': '取消 {name}',
+ 'transfers.pause': '暂停',
+ 'transfers.resume': '继续',
+ 'transfers.paused': '已暂停',
+ 'transfers.pause_one': '暂停 {name}',
+ 'transfers.resume_one': '继续 {name}',
'transfers.eta_seconds': '剩余 {n} 秒',
'transfers.eta_minutes': '剩余 {n} 分钟',
'transfers.eta_hours': '剩余 {n} 小时',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 22fcd3f..a37e541 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1975,6 +1975,20 @@ a.transfer-name {
display: flex;
}
.transfer-cancel:hover { color: var(--error); }
+/* Same shape as cancel, and beside it: pausing and cancelling are the two
+ things a person does to a transfer, and one of them is not destructive. */
+.transfer-pause {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ padding: 0 2px;
+ display: flex;
+}
+.transfer-pause:hover { color: var(--accent); }
+/* A paused bar keeps its fill -- what was written is still on disk -- but stops
+ looking like something in progress. */
+.dl-fill.dl-paused { background: var(--text-dim); }
.transfer-meta {
display: flex;
justify-content: space-between;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
index d3b164d..797321c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
@@ -25,7 +25,15 @@ 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';
+ return status === 'preparing' || status === 'queued' || status === 'running'
+ || status === 'paused';
+}
+
+/** Raised by `run` when it stopped because the transfer was paused. */
+function _pausedError() {
+ const err = new Error('Paused');
+ err.name = 'PausedError';
+ return err;
}
function _abortError() {
@@ -81,6 +89,9 @@ export class TransferStore {
// Only for a file written into a folder the browser granted us: that is
// the one case where the page can read its own download back.
canOpen: it.status === 'done' && typeof it.open === 'function',
+ // Whether the target can be stopped and continued. False is the honest
+ // answer for a service-worker stream, and the button is not drawn.
+ pausable: Boolean(it.pausable),
}));
}
@@ -144,7 +155,18 @@ export class TransferStore {
ahead: (lease && lease.ahead) || 0,
error: '',
samples: [{ t: this._now(), done: 0 }],
- signal: { aborted: false },
+ signal: { aborted: false, paused: false },
+ // Set from `prepare`: whether this target can be stopped and continued.
+ pausable: false,
+ // Where a resumed run picks up, in chunks. Zero until something pauses.
+ resumeFrom: 0,
+ // Resolved by resume(); awaited by the run loop while paused.
+ resumed: null,
+ _wake: null,
+ // Pausing gives the slot back, so resuming has to be able to ask for
+ // another one. A transfer handed a lease directly cannot, and must not
+ // be offered a button that would drop its slot for good.
+ _canRelease: Boolean(makeLease),
};
this._items.push(item);
this._emit();
@@ -195,25 +217,58 @@ export class TransferStore {
return undefined;
}
if (ready && ready.name) item.name = ready.name;
+ // Only the target knows. A service-worker stream is already an HTTP
+ // response the browser is writing to its own download folder: not
+ // writing to it stalls that download outside our control, and an idle
+ // worker is terminated within seconds, taking the stream with it. So
+ // the button is offered where it works and nowhere else — a pause
+ // that silently restarts from zero is worse than no pause.
+ if (ready && ready.pausable) item.pausable = true;
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;
+ // Run, and be prepared to be stopped and started again.
+ //
+ // A paused transfer holds **nothing**: its slot goes back to the node
+ // and resuming rejoins the queue at the tail. Anything else lets one
+ // member close a node by pausing four downloads and going to lunch.
+ // So the lease is taken inside this loop, not before it.
+ for (;;) {
+ 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();
+ item.status = 'running';
this._emit();
}
- }
- if (item.lease) {
- await item.lease.acquire();
- if (item.signal.aborted) throw _abortError();
- item.status = 'running';
+ try {
+ return await run({ signal: item.signal, onProgress,
+ lease: item.lease, from: item.resumeFrom || 0 });
+ } catch (err) {
+ if (err.name !== 'PausedError') throw err;
+ }
+ // Where to pick up. `run` records it on the signal rather than
+ // returning it, because it has to survive being thrown past.
+ item.resumeFrom = item.signal.resumeFrom || 0;
+ if (item.lease) {
+ item.lease.release('paused');
+ item.lease = null;
+ }
+ item.status = 'paused';
+ item.ahead = 0;
this._emit();
+ this._maybeRelease(item.transport);
+ await item.resumed;
+ if (item.signal.aborted) throw _abortError();
}
- return run({ signal: item.signal, onProgress, lease: item.lease });
})
.then(() => {
if (item.signal.aborted) finish('cancelled');
@@ -260,6 +315,37 @@ export class TransferStore {
if (item && typeof item.open === 'function') return item.open();
}
+ /**
+ * Stop a running transfer, keeping what it has already written.
+ *
+ * Only while running: a queued transfer is already stopped and holds no slot,
+ * and pausing it would only cost it its place. Only where the target can do
+ * it — see the note in `start`.
+ *
+ * The slot goes back to the node at once (§6.2 of the plan): a paused
+ * transfer holds nothing, and resuming rejoins the queue at the tail.
+ */
+ pause(id) {
+ const item = this._items.find(it => it.id === id);
+ if (!item || !item.pausable || !item._canRelease
+ || item.status !== 'running') return;
+ item.signal.paused = true;
+ // Created here rather than in resume(): the run loop awaits it the moment
+ // `run` throws, which can be sooner than the next call into this store.
+ item.resumed = new Promise((resolve) => { item._wake = resolve; });
+ this._emit();
+ }
+
+ /** Start it again, from where it stopped, behind whatever is waiting now. */
+ resume(id) {
+ const item = this._items.find(it => it.id === id);
+ if (!item || item.status !== 'paused') return;
+ item.signal.paused = false;
+ item.status = 'queued';
+ this._emit();
+ if (item._wake) { item._wake(); item._wake = null; }
+ }
+
cancel(id) {
const item = this._items.find(it => it.id === id);
// 'queued' too: a transfer waiting for a slot is exactly the one somebody
@@ -268,6 +354,10 @@ export class TransferStore {
if (!item || !_live(item.status)) return;
item.signal.aborted = true;
if (item.lease) item.lease.release('cancelled');
+ // A paused run is parked on `item.resumed`. Without this it stays parked
+ // for the life of the page, holding its target open, and the row says
+ // "cancelled" over a download that never stopped.
+ if (item._wake) { item._wake(); item._wake = null; }
// Marked at once. The work stops when it next looks, but a cancelled
// transfer should not keep reporting progress in the meantime.
item.status = 'cancelled';
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index dfb1433..41ae5d3 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -489,3 +489,71 @@ out.push(asked);
assert batched == [False, True, True], (
"the stuck one is still the only holder of the gesture, so the released "
"openings must not try for a dialog of their own")
+
+
+def test_a_pause_falls_between_chunks_and_resumes_at_one(tmp_path):
+ """What makes resuming exact rather than approximate.
+
+ Everything written is a whole number of chunks, because the loop checks for
+ a pause between two of them and never inside one. So `fromChunk` is a
+ position, not an estimate, and a resumed download is never appended to at an
+ offset nobody verified — the failure mode being avoided is a file that looks
+ complete and is quietly corrupt.
+
+ The real `pipelinedDownload` is lifted out and run against stubs, on the
+ rule this repo follows for the video player: model the environment, never
+ the code under test.
+ """
+ src = (STATIC / "file-utils.js").read_text()
+ fn = src[src.index("async function pipelinedDownload"):]
+ fn = fn[:fn.index("\n}\n") + 2]
+
+ script = tmp_path / "pipeline.mjs"
+ script.write_text("""
+const CHUNK_SIZE = 8;
+const PIPELINE_WINDOW = 4;
+const written = [];
+// `ct` has to be truthy: chunk 0 with a falsy body is refused as undecryptable,
+// which is the guard working, not the harness.
+const _fetchChunkResilient = async (transport, fileId, i) =>
+ ({ ct: new Uint8Array([i & 0xff]), nonce: new Uint8Array(12) });
+const _writeOrStall = async (w, bytes, index) => { written.push(index); };
+globalThis.window = { MeshBayCrypto: {
+ // The plaintext carries its own index, so what lands where can be checked.
+ decryptChunkBin: async (k, id, index) => ({ byteLength: CHUNK_SIZE, index }),
+} };
+""" + fn + """
+const out = {};
+const signal = { aborted: false, paused: false };
+// Stop it part way, the way the store does.
+let seen = 0;
+const onChunk = () => { if (++seen === 3) signal.paused = true; };
+try {
+ await pipelinedDownload({}, 'k', 'file', 10, onChunk, {}, signal, '', 0);
+ out.threw = 'no';
+} catch (err) {
+ out.threw = err.name;
+}
+out.resumeFrom = signal.resumeFrom;
+out.writtenBeforePause = written.slice();
+
+// And again, from where it said.
+signal.paused = false;
+written.length = 0;
+await pipelinedDownload({}, 'k', 'file', 10, () => {}, {}, signal, '',
+ out.resumeFrom);
+out.writtenAfterResume = written.slice();
+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["threw"] == "PausedError", out
+ # Whole chunks only, in order, with nothing skipped.
+ assert out["writtenBeforePause"] == list(range(len(out["writtenBeforePause"])))
+ assert out["resumeFrom"] == len(out["writtenBeforePause"]), (
+ f"stopped after {len(out['writtenBeforePause'])} chunks but asked to "
+ f"resume at {out['resumeFrom']} — that gap is a hole in the file")
+ # The resumed run covers exactly the rest, and repeats nothing.
+ assert out["writtenAfterResume"] == list(range(out["resumeFrom"], 10)), out
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index c48e38c..035f569 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -553,3 +553,163 @@ def test_a_transport_is_not_closed_under_a_preparing_transfer(tmp_path):
""", 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"]