diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-08 23:43:46 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-08 23:43:46 +0200 |
| commit | 77615ddb5fead3e74751a94847d3bcc99fc0a96d (patch) | |
| tree | 72e596fdc56567925100b18460793a978bb29c63 /packages/meshbay-hub/src/meshbay_hub/static/transfers.js | |
| parent | 1ba91bb4f38f6338d1c86678ebcd19195db2a120 (diff) | |
| download | meshbay-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/transfers.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transfers.js | 90 |
1 files changed, 72 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 46f75a2..d3b164d 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; +/** 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'; +} + function _abortError() { const err = new Error('Cancelled'); err.name = 'AbortError'; @@ -84,8 +90,7 @@ export class TransferStore { /** 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; + return this._items.filter(it => _live(it.status)).length; } _speed(it) { @@ -104,8 +109,28 @@ 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 a transfer. + * + * `run` receives `{ signal, onProgress, lease }`. It must poll + * `signal.aborted` — a cancel that only sets a flag nobody reads is a button + * that lies. + * + * `prepare` is optional and runs before anything else, with the row already + * on screen. It is where a download opens its target, which can take tens of + * seconds — the streamed path waits for the worker, twice, and a Save As + * dialog waits for a person. Doing that *before* creating the row meant three + * clicks produced no panel at all, not even the icon, and then several rows + * at once. Returning `false` drops the row again, which is what a dismissed + * dialog should look like: nothing, rather than a cancelled transfer nobody + * started. + * + * `makeLease` is called after `prepare` succeeds, never before. A granted + * slot must be taken up within the node's deadline, so it is asked for once + * there is somewhere to write — see file-utils.js's downloadEntry. + */ start({ kind, name, total = 0, transport = null, run, open = null, - lease = null }) { + lease = null, prepare = null, makeLease = null }) { const item = { id: _nextId++, kind, name, total, transport, open, lease, @@ -114,7 +139,8 @@ export class TransferStore { // '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', + status: prepare ? 'preparing' + : (lease && lease.state !== 'granted' ? 'queued' : 'running'), ahead: (lease && lease.ahead) || 0, error: '', samples: [{ t: this._now(), done: 0 }], @@ -155,17 +181,32 @@ export class TransferStore { // 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(); - }; - } + if (item.lease) this._watchLease(item); const promise = Promise.resolve() .then(async () => { + if (prepare) { + const ready = await prepare(); + if (item.signal.aborted) throw _abortError(); + if (ready === false) { + // Dismissed. Not a failure and not a cancellation: nothing was ever + // started, so nothing should be left on screen to explain. + this._drop(item.id); + return undefined; + } + if (ready && ready.name) item.name = ready.name; + 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; + this._emit(); + } + } if (item.lease) { await item.lease.acquire(); if (item.signal.aborted) throw _abortError(); @@ -191,6 +232,21 @@ export class TransferStore { return item.id; } + _watchLease(item) { + 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(); + }; + } + + /** Remove a row entirely. Only for a transfer that never started. */ + _drop(id) { + this._items = this._items.filter(it => it.id !== id); + this._emit(); + } + /** * Hand a finished download to the browser to display. * @@ -209,7 +265,7 @@ export class TransferStore { // '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; + if (!item || !_live(item.status)) 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 @@ -221,14 +277,13 @@ export class TransferStore { cancelAll() { for (const it of this._items) { - if (it.status === 'running' || it.status === 'queued') this.cancel(it.id); + if (_live(it.status)) this.cancel(it.id); } } /** Drop everything finished, keeping what is still running or waiting. */ clearFinished() { - this._items = this._items.filter( - it => it.status === 'running' || it.status === 'queued'); + this._items = this._items.filter(it => _live(it.status)); this._emit(); } @@ -237,8 +292,7 @@ export class TransferStore { // 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.status === 'queued')); + it => it.transport === transport && _live(it.status)); } /** |