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 23:43:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 23:43:46 +0200
commit77615ddb5fead3e74751a94847d3bcc99fc0a96d (patch)
tree72e596fdc56567925100b18460793a978bb29c63 /packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
parent1ba91bb4f38f6338d1c86678ebcd19195db2a120 (diff)
downloadmeshbay-77615ddb5fead3e74751a94847d3bcc99fc0a96d.tar.gz
fix(hub): the transfers row exists from the click
Clicking Download produced nothing — no row, no icon, no panel — for as long as it took to open somewhere to write, and then several rows at once. The streamed path waits for the worker twice; a Save As dialog waits for a person. The row was created after that, so the slowest part of a download happened with nothing on screen to say it had begun. The store gains a `prepare` step, distinct from `run`, and the order is now: row, then target, then slot. That last part is why the obvious fix was wrong. Taking the slot first would let the row appear immediately, and it was tried this morning: a granted slot has to be taken up within the node's deadline, opening a target can outlast it, and three downloads became one. (The diagnosis at the time blamed that ordering for revocations which were in fact a missing `touch()` call — the revert was right for the wrong reason.) `makeLease` is called after `prepare` succeeds, never before. Three behaviours fall out, each with a test: - a dismissed dialog leaves nothing behind. `prepare` returning false drops the row: nothing started, so nothing should remain on screen to explain it; - the row takes the name the file was actually saved under, once known; - a refusal above the memory ceiling fails the row that is already there, rather than creating one to kill it. `preparing` counts as live everywhere — badge, cancel, clearFinished, and `_busy`, since closing a transport under a preparing transfer strands it exactly as under a queued one. Six places asked "is this finished?" and were drifting apart; there is one definition now. Two mistakes in the tests, worth the note: one counted positions in an output array by hand and was one out, which reads exactly like a failing assertion about the code — the values are tagged now, not indexed. And test_zip_size_limit.py's stub did not run `prepare`, so it no longer reached the size check the file is about; it now behaves like the real store. 819 hub, 1169 node, 0 failed. 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.js111
1 files changed, 48 insertions, 63 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 61002d6..3475f81 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -321,47 +321,34 @@ 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);
- } catch (err) {
- // The refusal belongs in the transfers panel, not in a console nobody
- // opens: that is where someone who just clicked Download is looking, and a
- // failed row naming the reason is the whole point of refusing rather than
- // filling the tab. Started only to be failed, deliberately.
- transfers.start({
- kind: 'download', name: entry.name, total: entry.size, transport,
- run: async () => { throw err; },
- });
- return;
- }
- if (target === false) return; // the picker was dismissed
-
- const openRef = { url: null };
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ const openRef = { url: null };
+ let target = null;
+
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', name: entry.name, total: entry.size, transport,
+
+ // The row exists from the click. Opening a target is what takes the time —
+ // the streamed path waits for the worker (twice), a Save As dialog waits
+ // for a person — and doing it before the row meant three clicks produced no
+ // panel at all and then several rows at once.
+ prepare: async () => {
+ target = await _openDownloadTarget(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;
+ },
+
+ // After the target, never before: a granted slot has to be taken up within
+ // the node's deadline, and opening a target can outlast it. See §8.1 of
+ // ~/next/improve-downloads.md — the other order was tried and cost two of
+ // three downloads.
+ makeLease: () => transport.openTransfer({
kind: 'download', bytes: entry.size, chunks: totalChunks }),
- open: target
- ? (target.open || null)
- : () => { if (openRef.url) window.open(openRef.url, '_blank'); },
+
+ open: () => (target && target.open) ? target.open()
+ : (openRef.url ? window.open(openRef.url, '_blank') : undefined),
+
run: async ({ signal, onProgress, lease }) => {
let done = 0;
const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
@@ -435,38 +422,36 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
// totalBytes decides how this is delivered, but it is not the archive's
// size — headers and the central directory come on top — so it is not
// announced as a Content-Length that the download would then miss.
- let target;
- try {
- target = await _openDownloadTarget(suggested, totalBytes, {
- types: [{ description: 'ZIP archive',
- accept: { 'application/zip': ['.zip'] } }],
- }, 0);
- } catch (err) {
- // Reported beside the folder that was clicked, like zip_too_large just
- // above — this function is called in a loop over a selection, and the
- // sibling folders must still download.
- setError(err.message);
- return;
- }
- if (target === false) return;
- if (!target && !confirm(t('group.zip_no_stream', {
- size: formatSize(totalBytes), name: suggested,
- }))) {
- return;
- }
const zipOpenRef = { url: null };
+ let target = null;
transfers.start({
- kind: 'download', name: (target && target.name) || suggested,
- total: totalBytes, transport,
+ kind: 'download', name: suggested, total: totalBytes, transport,
+
+ // Same order as downloadEntry: the row first, then the target, then the
+ // slot. A folder of forty files is exactly where the wait is longest.
+ prepare: async () => {
+ target = await _openDownloadTarget(suggested, totalBytes, {
+ types: [{ description: 'ZIP archive',
+ accept: { 'application/zip': ['.zip'] } }],
+ }, 0);
+ if (target === false) return false;
+ if (!target && !confirm(t('group.zip_no_stream', {
+ size: formatSize(totalBytes), name: suggested,
+ }))) {
+ return false;
+ }
+ return target ? { name: target.name } : true;
+ },
+
// **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({
+ makeLease: () => transport.openTransfer({
kind: 'download', bytes: totalBytes, chunks: files.length }),
- open: target
- ? (target.open || null)
- : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); },
+
+ open: () => (target && target.open) ? target.open()
+ : (zipOpenRef.url ? window.open(zipOpenRef.url, '_blank') : undefined),
run: async ({ signal, onProgress, lease }) => {
const writable = target ? target.writable : null;
const parts = writable ? null : [];