From 1a495f5ed3f8a55222d406152c833882264dc377 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 22:54:16 +0200 Subject: feat(hub): client-side transfer leases and the transfers panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- .../src/meshbay_hub/static/transfers.js | 105 +++++++++++++++++++-- 1 file changed, 95 insertions(+), 10 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transfers.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index fa34c67..46f75a2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -22,6 +22,12 @@ const SPEED_WINDOW_MS = 5000; +function _abortError() { + const err = new Error('Cancelled'); + err.name = 'AbortError'; + return err; +} + let _nextId = 1; export class TransferStore { @@ -53,8 +59,18 @@ export class TransferStore { total: it.total, done: it.done, status: it.status, + // How many are in front of this one, and whose limit is holding it up: + // "your own two slots are busy" and "the node is full" are different + // situations and the person can act on only one of them. + ahead: it.ahead || 0, + queuedByOwnLimit: Boolean( + it.lease && it.lease.cap && it.lease.used >= it.lease.cap), error: it.error || '', speed: this._speed(it), + // The ETA is drawn only once the window holds a few seconds of real + // measurement -- see etaSeconds. + settled: it.samples.length > 2 + && (it.samples[it.samples.length - 1].t - it.samples[0].t) >= 3000, percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0, // 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. @@ -66,6 +82,12 @@ export class TransferStore { return this._items.filter(it => it.status === 'running').length; } + /** Running or waiting for a slot — what the nav badge counts. */ + get pending() { + return this._items.filter( + it => it.status === 'running' || it.status === 'queued').length; + } + _speed(it) { // Over a window rather than since the start: a transfer that stalls should // read as slow immediately, not as its own historical average. @@ -82,12 +104,18 @@ export class TransferStore { * `run` receives `{ signal, onProgress }`. It must poll `signal.aborted` — a * cancel that only sets a flag nobody reads is a button that lies. */ - start({ kind, name, total = 0, transport = null, run, open = null }) { + start({ kind, name, total = 0, transport = null, run, open = null, + lease = null }) { const item = { id: _nextId++, - kind, name, total, transport, open, + kind, name, total, transport, open, lease, done: 0, - status: 'running', + // A transfer that has to wait for a slot starts as 'queued', not + // 'running'. Two different things are true of it — nothing is moving, and + // nothing is wrong — and a status that conflates them is what makes a + // queue look like a hang. + status: lease && lease.state !== 'granted' ? 'queued' : 'running', + ahead: (lease && lease.ahead) || 0, error: '', samples: [{ t: this._now(), done: 0 }], signal: { aborted: false }, @@ -113,8 +141,39 @@ export class TransferStore { this._maybeRelease(item.transport); }; + // The slot is given back in a `finally` around everything, so it survives + // a throw, a cancel and a return alike. A slot not returned is a member who + // cannot start another transfer until the node times it out. + const finished = () => { + if (item.lease) item.lease.release( + item.signal.aborted ? 'cancelled' : 'done'); + }; + + // Installed here and not inside the promise chain below. A state push that + // arrived before the first microtask ran was simply dropped, so a transfer + // could sit at the position it was given when it was created and never + // appear to move — the widget showing "3 ahead" for ever while the node + // quietly worked through the queue. Nothing about that looks wrong from + // either side, which is why it needs a test rather than a reading. + if (item.lease) { + item.lease._onState = (lease) => { + if (item.status !== 'queued' && item.status !== 'running') return; + item.ahead = lease.ahead; + item.status = lease.state === 'granted' ? 'running' : 'queued'; + this._emit(); + }; + } + const promise = Promise.resolve() - .then(() => run({ signal: item.signal, onProgress })) + .then(async () => { + if (item.lease) { + await item.lease.acquire(); + if (item.signal.aborted) throw _abortError(); + item.status = 'running'; + this._emit(); + } + return run({ signal: item.signal, onProgress, lease: item.lease }); + }) .then(() => { if (item.signal.aborted) finish('cancelled'); else { @@ -125,7 +184,8 @@ export class TransferStore { .catch(err => { if (item.signal.aborted || err.name === 'AbortError') finish('cancelled'); else finish('failed', err.message || String(err)); - }); + }) + .finally(finished); item.promise = promise; return item.id; @@ -146,8 +206,12 @@ export class TransferStore { cancel(id) { const item = this._items.find(it => it.id === id); - if (!item || item.status !== 'running') return; + // 'queued' too: a transfer waiting for a slot is exactly the one somebody + // is most likely to give up on, and its queue entry has to go with it or + // the node grants a slot to a transfer that will never use it. + if (!item || (item.status !== 'running' && item.status !== 'queued')) return; item.signal.aborted = true; + if (item.lease) item.lease.release('cancelled'); // 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'; @@ -157,19 +221,24 @@ export class TransferStore { cancelAll() { for (const it of this._items) { - if (it.status === 'running') this.cancel(it.id); + if (it.status === 'running' || it.status === 'queued') this.cancel(it.id); } } - /** Drop everything finished, keeping what is still running. */ + /** Drop everything finished, keeping what is still running or waiting. */ clearFinished() { - this._items = this._items.filter(it => it.status === 'running'); + this._items = this._items.filter( + it => it.status === 'running' || it.status === 'queued'); this._emit(); } _busy(transport) { + // Queued counts as busy: a transport closed while a transfer waits for a + // slot can never be granted one, and the transfer would sit at "waiting" + // for ever with nothing left to answer it. return this._items.some( - it => it.transport === transport && it.status === 'running'); + it => it.transport === transport + && (it.status === 'running' || it.status === 'queued')); } /** @@ -211,6 +280,22 @@ export class TransferStore { export const transfers = new TransferStore(); +/** + * Seconds left, or null when saying nothing is the honest answer. + * + * Withheld until the speed window has real samples in it: a figure computed + * from the first two chunks of a transfer swings between "4 seconds" and "an + * hour" and back, and a number that behaves like that is worse than a blank — + * people read the first one they see and plan around it. + */ +export function etaSeconds(item) { + if (item.status !== 'running' || !item.total || !item.speed) return null; + const left = item.total - item.done; + if (left <= 0) return null; + const secs = left / item.speed; + return Number.isFinite(secs) ? secs : null; +} + /** Human-readable rate, for a widget that updates several times a second. */ export function formatSpeed(bytesPerSecond) { if (!bytesPerSecond || bytesPerSecond < 1) return ''; -- cgit v1.2.3 From 77615ddb5fead3e74751a94847d3bcc99fc0a96d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 23:43:46 +0200 Subject: fix(hub): the transfers row exists from the click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 23 +++- .../src/meshbay_hub/static/file-utils.js | 111 +++++++++----------- .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../src/meshbay_hub/static/transfers.js | 90 ++++++++++++---- packages/meshbay-hub/tests/test_transfers.py | 116 +++++++++++++++++++++ packages/meshbay-hub/tests/test_zip_size_limit.py | 17 ++- 15 files changed, 280 insertions(+), 87 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transfers.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index ff066cf..6f4bb92 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -157,9 +157,11 @@ function TransferWidget() { }, [open]); const running = items.filter(i => i.status === 'running'); - const waiting = items.filter(i => i.status === 'queued'); + const waiting = items.filter( + i => i.status === 'queued' || i.status === 'preparing'); const finished = items.filter( - i => i.status !== 'running' && i.status !== 'queued'); + i => i.status !== 'running' && i.status !== 'queued' + && i.status !== 'preparing'); const active = running.length + waiting.length; // Grouped, and in this order: what is moving, what is waiting, what is over. @@ -240,7 +242,8 @@ function TransferRow({ it }) { ? html` { e.preventDefault(); transfers.open(it.id); }}>${it.name}` : html`${it.name}`} - ${(it.status === 'running' || it.status === 'queued') && html` + ${(it.status === 'running' || it.status === 'queued' + || it.status === 'preparing') && html` + `} ${(it.status === 'running' || it.status === 'queued' - || it.status === 'preparing') && html` + || it.status === 'preparing' || it.status === 'paused') && html`