aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transfers.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/transfers.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/transfers.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js90
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));
}
/**