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-08 22:54:16 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 22:54:16 +0200
commit1a495f5ed3f8a55222d406152c833882264dc377 (patch)
tree0c048544cba200fe5ba939d45edd311b2fc69e59 /packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
parent6803447a8a5cc7a612d08bb858394fd7ae1b049c (diff)
downloadmeshbay-1a495f5ed3f8a55222d406152c833882264dc377.tar.gz
feat(hub): client-side transfer leases and the transfers panel
Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots since step 2 and nothing asked for one; now the client does, and the panel shows what is happening. `transport.openTransfer()` returns a Lease: `acquire()` resolves when the node grants, `release()` gives it back exactly once, and nothing else in the client speaks to the node about slots. Whether a node hands out slots is read from the handshake ack rather than guessed from a timeout — "no answer yet" and "this node will never answer" are indistinguishable in time, and guessing wrong either stalls every download or defeats the cap. Two things exist only because a queue can lie: a watchdog re-asks when a pushed grant does not arrive (the node is idempotent on `tr`, so asking again is free), and a grant for a transfer the page has forgotten is handed straight back rather than held until the node's deadline. The slot is asked for **after** there is somewhere to write, and that ordering is load-bearing: opening a target takes thirty seconds of streamed-download timeouts, or as long as somebody leaves a Save As dialog open, and a grant not taken up in time is revoked. Moving it earlier looked better and broke three downloads into one. Pinned by a test. The panel groups by state — running, waiting, finished — rather than re-sorting a flat list, so a row moves only when its own state does. The ETA is withheld until the speed window holds real measurement: a figure from the first two chunks swings between four seconds and an hour, and people plan around the first number they see. One live region announces state changes and not progress. Three silent paths closed on the way: a download refused for want of a user gesture (a browser grants one file picker per gesture, and downloading three files is one gesture) now falls back to the streamed path, which needs none; a click with no connection says so instead of doing nothing at all; and a queued transfer counts as busy, so a transport is never closed under one that is waiting for a grant that could then never arrive. 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.js96
1 files changed, 85 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 a942fd5..61002d6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -164,6 +164,25 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
return { writable: await handle.createWritable(), name: handle.name || filename };
} catch (err) {
if (err.name === 'AbortError') return false;
+ // "Must be handling a user gesture to show a file picker."
+ //
+ // A browser grants one picker per gesture, and downloading three files is
+ // one gesture. So the second and third throw this, and the person sees a
+ // failed transfer with a message from Chrome about gestures, for having
+ // done something entirely reasonable.
+ //
+ // The streamed path needs no gesture at all, which makes it the right
+ // answer here rather than a consolation: the file still lands on disk, in
+ // the browser's own download folder, written as it arrives. Only the choice
+ // of folder is lost, and it was already lost — there was no picker to make
+ // it in.
+ if (err.name === 'SecurityError' || /user gesture/i.test(err.message || '')) {
+ console.warn('[MeshBay] no gesture left for a save dialog; streaming '
+ + 'this one to the download folder instead');
+ const streamed = await downloads.openStreamedDownload(filename, swSize);
+ if (streamed) return streamed;
+ if (size <= MEMORY_CEILING) return _memoryFloor();
+ }
throw err;
}
}
@@ -193,17 +212,47 @@ function _saveBlob(blob, filename) {
const CHUNK_RETRY_ATTEMPTS = 6;
const CHUNK_RETRY_DELAY_MS = 1500;
+// How long one megabyte may take to reach the disk before we call it stuck.
+//
+// Every other await on this path is bounded and says so when it expires:
+// `_sendAndWait` logs a Response timeout, `_fetchChunkResilient` retries and
+// then throws. `writable.write()` was the exception — a sink that stops
+// consuming (a service-worker stream the browser has stopped reading, a file
+// handle that has gone away) leaves it pending for ever. It never rejects, so
+// there is no error, no log and no failed transfer: the progress bar simply
+// stops, the console stays empty, and the node is perfectly healthy the whole
+// time, which is what made this invisible.
+//
+// Generous on purpose. A megabyte takes milliseconds on any working sink; a
+// minute means the sink is gone, not slow.
+const WRITE_STALL_MS = 60000;
+
+/** `writable.write`, but it fails instead of hanging for ever. */
+async function _writeOrStall(writable, bytes, at) {
+ let timer = 0;
+ const stalled = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(
+ t('group.download_write_stalled', { seconds: WRITE_STALL_MS / 1000 })
+ + ` (chunk ${at})`)), WRITE_STALL_MS);
+ });
+ try {
+ await Promise.race([writable.write(bytes), stalled]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
function _isRetryableTransportError(err) {
return err.name === 'TransportLostError'
|| err.message === 'Response timeout'
|| (err.message || '').startsWith('DataChannel not open');
}
-async function _fetchChunkResilient(transport, fileId, index) {
+async function _fetchChunkResilient(transport, fileId, index, tr = '') {
let lastErr;
for (let attempt = 0; attempt < CHUNK_RETRY_ATTEMPTS; attempt++) {
try {
- return await transport.fetchChunk(fileId, index);
+ return await transport.fetchChunk(fileId, index, tr);
} catch (err) {
if (!_isRetryableTransportError(err)) throw err;
lastErr = err;
@@ -216,14 +265,14 @@ async function _fetchChunkResilient(transport, fileId, index) {
}
async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk,
- writable, signal) {
+ writable, signal, tr = '') {
const results = writable ? null : new Array(totalChunks);
let nextSend = 0, nextRecv = 0;
const inflight = new Array(totalChunks);
const fire = () => {
while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) {
- inflight[nextSend] = _fetchChunkResilient(transport, fileId, nextSend);
+ inflight[nextSend] = _fetchChunkResilient(transport, fileId, nextSend, tr);
nextSend++;
}
};
@@ -254,7 +303,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct);
if (writable) {
- await writable.write(plaintext);
+ await _writeOrStall(writable, plaintext, nextRecv);
} else {
results[nextRecv] = plaintext;
}
@@ -272,6 +321,20 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
* download button — both just want "get this entry to disk".
*/
async function downloadEntry(transfers, transport, gek, entry) {
+ // The target FIRST, then the slot — and that order is load-bearing.
+ //
+ // Asking for the slot first looks better (the widget could draw a row while
+ // the target is being chosen) and is wrong: a granted slot has to be taken up
+ // within the node's acceptance deadline, and opening a target can take thirty
+ // seconds of streamed-download timeouts, or as long as somebody leaves a Save
+ // As dialog open. The node then revokes the grant and passes it to the next
+ // in the queue — `transfer: reclaimed … (not_taken_up)` in its log — and this
+ // download starts fetching under a `tr` that is no longer granted.
+ //
+ // Measured, not reasoned: three downloads started, one arrived, and the
+ // node's log named the reason. Do not move this again without moving the
+ // deadline, and the deadline exists so a client that dies between asking and
+ // starting does not hold a slot nobody can use.
let target;
try {
target = await _openDownloadTarget(entry.name, entry.size);
@@ -289,21 +352,25 @@ async function downloadEntry(transfers, transport, gek, entry) {
if (target === false) return; // the picker was dismissed
const openRef = { url: null };
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
transfers.start({
kind: 'download', name: (target && target.name) || entry.name,
total: entry.size, transport,
+ // Asked for here, once there is somewhere to write: see the note above.
+ lease: transport.openTransfer({
+ kind: 'download', bytes: entry.size, chunks: totalChunks }),
open: target
? (target.open || null)
: () => { if (openRef.url) window.open(openRef.url, '_blank'); },
- run: async ({ signal, onProgress }) => {
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ run: async ({ signal, onProgress, lease }) => {
let done = 0;
const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
if (target) {
try {
await pipelinedDownload(transport, gek, entry.id, totalChunks,
- onChunk, target.writable, signal);
+ onChunk, target.writable, signal,
+ lease && lease.tr);
await target.writable.close();
} catch (err) {
await target.writable.abort().catch(() => {});
@@ -311,7 +378,8 @@ async function downloadEntry(transfers, transport, gek, entry) {
}
} else {
const chunks = await pipelinedDownload(
- transport, gek, entry.id, totalChunks, onChunk, null, signal);
+ transport, gek, entry.id, totalChunks, onChunk, null, signal,
+ lease && lease.tr);
const blob = new Blob(chunks);
_saveBlob(blob, entry.name);
openRef.url = URL.createObjectURL(blob);
@@ -391,10 +459,15 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
transfers.start({
kind: 'download', name: (target && target.name) || suggested,
total: totalBytes, transport,
+ // **One** lease for the archive, not one per file. Dozens of leases for a
+ // folder would deadlock against the member's own cap: the job cannot finish
+ // until it holds them all, and it can never hold more than two.
+ lease: transport.openTransfer({
+ kind: 'download', bytes: totalBytes, chunks: files.length }),
open: target
? (target.open || null)
: () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); },
- run: async ({ signal, onProgress }) => {
+ run: async ({ signal, onProgress, lease }) => {
const writable = target ? target.writable : null;
const parts = writable ? null : [];
let written = 0;
@@ -414,7 +487,8 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
transport, gek, entry.id, totalChunks,
(bytes) => { written += bytes; onProgress(written, totalBytes); },
// pipelinedDownload writes in order, which the archive needs.
- { write: (plaintext) => zip.write(plaintext) }, signal);
+ { write: (plaintext) => zip.write(plaintext) }, signal,
+ lease && lease.tr);
await zip.end();
}
await zip.finish();