aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
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 /packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
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
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/file-utils.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js57
1 files changed, 46 insertions, 11 deletions
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);