diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transfers.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transfers.js | 254 |
1 files changed, 243 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index fa34c67..524b7f3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -22,6 +22,26 @@ 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' + || status === 'paused'; +} + +/** Raised by `run` when it stopped because the transfer was paused. */ +function _pausedError() { + const err = new Error('Paused'); + err.name = 'PausedError'; + return err; +} + +function _abortError() { + const err = new Error('Cancelled'); + err.name = 'AbortError'; + return err; +} + let _nextId = 1; export class TransferStore { @@ -53,12 +73,25 @@ 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. canOpen: it.status === 'done' && typeof it.open === 'function', + // Whether the target can be stopped and continued. False is the honest + // answer for a service-worker stream, and the button is not drawn. + pausable: Boolean(it.pausable), })); } @@ -66,6 +99,11 @@ 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 => _live(it.status)).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,15 +120,56 @@ 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 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, prepare = null, makeLease = null, pausable = false }) { 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: prepare ? 'preparing' + : (lease && lease.state !== 'granted' ? 'queued' : 'running'), + ahead: (lease && lease.ahead) || 0, error: '', samples: [{ t: this._now(), done: 0 }], - signal: { aborted: false }, + signal: { aborted: false, paused: false }, + // Whether this transfer can be stopped and continued. A download learns + // it from `prepare`, because only its target knows; an upload says so + // outright, because a `File` is always seekable and the node keeps the + // position (see uploads.py). + pausable: Boolean(pausable), + // Where a resumed run picks up, in chunks. Zero until something pauses. + resumeFrom: 0, + // Resolved by resume(); awaited by the run loop while paused. + resumed: null, + _wake: null, + // Pausing gives the slot back, so resuming has to be able to ask for + // another one. A transfer handed a lease directly cannot, and must not + // be offered a button that would drop its slot for good. + _canRelease: Boolean(makeLease), }; this._items.push(item); this._emit(); @@ -113,8 +192,87 @@ 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) this._watchLease(item); + const promise = Promise.resolve() - .then(() => run({ signal: item.signal, onProgress })) + .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; + // Only the target knows. A service-worker stream is already an HTTP + // response the browser is writing to its own download folder: not + // writing to it stalls that download outside our control, and an idle + // worker is terminated within seconds, taking the stream with it. So + // the button is offered where it works and nowhere else — a pause + // that silently restarts from zero is worse than no pause. + if (ready && ready.pausable) item.pausable = true; + item.status = 'running'; + this._emit(); + } + // Run, and be prepared to be stopped and started again. + // + // A paused transfer holds **nothing**: its slot goes back to the node + // and resuming rejoins the queue at the tail. Anything else lets one + // member close a node by pausing four downloads and going to lunch. + // So the lease is taken inside this loop, not before it. + for (;;) { + 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(); + item.status = 'running'; + this._emit(); + } + try { + return await run({ signal: item.signal, onProgress, + lease: item.lease, from: item.resumeFrom || 0 }); + } catch (err) { + if (err.name !== 'PausedError') throw err; + } + // Where to pick up. `run` records it on the signal rather than + // returning it, because it has to survive being thrown past. + item.resumeFrom = item.signal.resumeFrom || 0; + if (item.lease) { + item.lease.release('paused'); + item.lease = null; + } + item.status = 'paused'; + item.ahead = 0; + this._emit(); + this._maybeRelease(item.transport); + await item.resumed; + if (item.signal.aborted) throw _abortError(); + } + }) .then(() => { if (item.signal.aborted) finish('cancelled'); else { @@ -125,12 +283,28 @@ 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; } + _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. * @@ -144,10 +318,49 @@ export class TransferStore { if (item && typeof item.open === 'function') return item.open(); } + /** + * Stop a running transfer, keeping what it has already written. + * + * Only while running: a queued transfer is already stopped and holds no slot, + * and pausing it would only cost it its place. Only where the target can do + * it — see the note in `start`. + * + * The slot goes back to the node at once (§6.2 of the plan): a paused + * transfer holds nothing, and resuming rejoins the queue at the tail. + */ + pause(id) { + const item = this._items.find(it => it.id === id); + if (!item || !item.pausable || !item._canRelease + || item.status !== 'running') return; + item.signal.paused = true; + // Created here rather than in resume(): the run loop awaits it the moment + // `run` throws, which can be sooner than the next call into this store. + item.resumed = new Promise((resolve) => { item._wake = resolve; }); + this._emit(); + } + + /** Start it again, from where it stopped, behind whatever is waiting now. */ + resume(id) { + const item = this._items.find(it => it.id === id); + if (!item || item.status !== 'paused') return; + item.signal.paused = false; + item.status = 'queued'; + this._emit(); + if (item._wake) { item._wake(); item._wake = null; } + } + 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 || !_live(item.status)) return; item.signal.aborted = true; + if (item.lease) item.lease.release('cancelled'); + // A paused run is parked on `item.resumed`. Without this it stays parked + // for the life of the page, holding its target open, and the row says + // "cancelled" over a download that never stopped. + if (item._wake) { item._wake(); item._wake = null; } // 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 +370,22 @@ export class TransferStore { cancelAll() { for (const it of this._items) { - if (it.status === 'running') this.cancel(it.id); + if (_live(it.status)) 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 => _live(it.status)); 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 && _live(it.status)); } /** @@ -211,6 +427,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 ''; |