diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 14:28:40 +0200 |
| commit | 7e2d078fe1870d256ae47781bee6ac4f454edf24 (patch) | |
| tree | 05689049fd48f01ebbdb9995d5189cba15cee052 /packages/meshbay-hub | |
| parent | 813d18424ec57963bb56e6f40824a2db0ccce50d (diff) | |
| parent | e6f895c473a0b19e7b186889c1836d3945bc880b (diff) | |
| download | meshbay-7e2d078fe1870d256ae47781bee6ac4f454edf24.tar.gz | |
Merge branch 'fix/large-download-paths'
Concurrent-transfer limits, with the queue, the pause and the flag day.
A node now caps how many transfers it runs at once (8 downloads, 8 uploads,
node-wide) and how many one member may run in one group (2 by default,
operator-signed). Beyond that the node answers "queued" and the client waits its
turn, visibly, in the transfers panel — and a slot that frees starts whatever is
next, skipping past a member who is at their own cap rather than letting them
stall everyone behind them.
Browsing is never subject to a slot: not the poster grid, not the covers, not
opening a photo to look at it. That is structural — a transfer is what the
transfers widget shows — and the exemption is bounded rather than open, at two
files in flight per session, because an exemption with no bound is a leaseless
branch under another name.
Transfers can be cancelled, and now paused and resumed. A paused one holds
nothing: its slot goes back at once and resuming rejoins the queue at the tail.
Uploads survive the connection that started them and resume where the node
stopped, asked for inside the seal rather than on a clear message. What they
leave behind when they are abandoned is reaped, which closes a disk leak that
predates this work.
MNP 3.0 makes the lease compulsory and refuses 2.x at the handshake, with the
desktop client checking `client.minimum` before connecting so an un-updated one
says "update" instead of failing every connection in a protocol vocabulary.
Fourteen defects were found on the way, eight of them by a person clicking
Download and pasting a console — none of which 2075 tests could reach. Section
12 of ~/next/improve-downloads.md is that report, including the three this work
introduced itself and the one that turned out to be caused by an instruction to
hard-reload after each deployment.
Node suite 1209 passed, hub suite 866 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub')
42 files changed, 4594 insertions, 214 deletions
diff --git a/packages/meshbay-hub/pyproject.toml b/packages/meshbay-hub/pyproject.toml index 9e5aace..012824c 100644 --- a/packages/meshbay-hub/pyproject.toml +++ b/packages/meshbay-hub/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "meshbay-hub" -version = "0.12.0" +version = "0.13.0" description = "MeshBay Hub — identity authority and group registry server" requires-python = ">=3.12" dependencies = [ diff --git a/packages/meshbay-hub/src/meshbay_hub/__init__.py b/packages/meshbay-hub/src/meshbay_hub/__init__.py index b713cf7..117aced 100644 --- a/packages/meshbay-hub/src/meshbay_hub/__init__.py +++ b/packages/meshbay-hub/src/meshbay_hub/__init__.py @@ -1,3 +1,3 @@ """MeshBay Hub — identity authority and group registry.""" -__version__ = "0.12.0" +__version__ = "0.13.0" diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 8223400..a48313d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -54,8 +54,21 @@ async def hub_pubkey(): # user "update to keep using this" before it becomes "this stopped working". # Raise `minimum` only for a change a client genuinely cannot survive, and # remember store review latency makes that expensive on Android. -MIN_CLIENT_VERSION = "0.1.0" -RECOMMENDED_CLIENT_VERSION = "0.1.0" +# Raised on the MNP 3.0 flag day (2026-09-09). A client older than this speaks +# MNP 2.x, cannot ask for a transfer lease, and is refused at the node's +# handshake with `version_too_old` — a refusal in a protocol vocabulary that +# surfaces as "the node will not talk to me". The client checks this field +# before connecting and says something a person can act on instead. +# +# **This first raise does not reach the clients already installed**, and that is +# understood rather than overlooked. `package.json` had drifted to "1.0.0" while +# every other package was on 0.12.0, so an installed client announces a version +# that sorts *above* this minimum and sails through the gate — then meets the +# handshake refusal anyway. The operator is updating every client, node and hub +# by hand for this flag day, which is what makes that acceptable exactly once. +# The gate is in place for the next one, where it will work as intended. +MIN_CLIENT_VERSION = "0.13.0" +RECOMMENDED_CLIENT_VERSION = "0.13.0" @router.get("/version") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 3cfb208..96b93bb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -111,8 +111,30 @@ CSP = "; ".join([ "font-src 'self'", "connect-src 'self' https: wss:", "worker-src 'self'", - f"frame-src {_RECAPTCHA_SRC}", - "frame-ancestors 'none'", + # `'self'` is not decoration: the streamed-download path works by navigating + # a hidden iframe to `/_mbdl/<id>` so the service worker is asked for the + # response it is holding. Without it Chrome refuses the frame, the worker is + # never asked, and the page waits out its timeout for a download that cannot + # happen — on Firefox and Safari that is the *only* way to write a large + # file to disk, so the whole path was dead. Added when reCAPTCHA needed a + # frame, which is why nobody connected the two. + f"frame-src 'self' {_RECAPTCHA_SRC}", + # `'self'`, not `'none'`, and the difference is one same-origin iframe. + # + # The threat frame-ancestors answers is clickjacking: a *foreign* page + # framing this one and stealing clicks. `'self'` refuses every foreign + # origin exactly as `'none'` does — what it additionally allows is this + # origin framing itself, which is precisely how a streamed download works + # (a hidden iframe navigates to `/_mbdl/<id>` so the service worker is + # asked for the response it holds). + # + # Under `'none'` Firefox blocked that frame, the worker was never asked, + # and every large download waited out two 15-second timeouts and then fell + # through — on Firefox and Safari that is the only way to write a large + # file to disk. Chrome did not show it: its worker intercepts the + # navigation before the network response and its CSP are ever considered, + # which is why this looked like a Firefox-only problem for an afternoon. + "frame-ancestors 'self'", "base-uri 'none'", "form-action 'none'", ]) diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 2daa55b..7b187df 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -154,7 +154,22 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: response.headers.setdefault("Content-Security-Policy", CSP) response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") - response.headers.setdefault("X-Frame-Options", "DENY") + # SAMEORIGIN, matching `frame-ancestors 'self'` in the CSP above. + # + # The two say the same thing to different generations of browser, and + # they were saying different things: CSP allowed this origin to frame + # itself, this header forbade all framing. The spec says a browser must + # ignore X-Frame-Options when the CSP carries frame-ancestors — but + # relying on that while shipping a header that contradicts our own + # policy is asking to be surprised, and we were: the streamed download + # (a hidden iframe onto `/_mbdl/<id>`, the only way to write a large + # file to disk on Firefox and Safari) stayed blocked after the CSP was + # fixed, and this header was why it looked like the fix had not worked. + # + # No foreign origin may frame this page under either spelling. That is + # the property; DENY was one notch stricter than the property needed and + # broke a feature to get there. + response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") return response # Routers (webapp last — catches / before API routes) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c2858f4..dfd0aeb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -4,8 +4,9 @@ import { } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; -import { transfers, formatSpeed } from './transfers.js'; +import { transfers, formatSpeed, etaSeconds } from './transfers.js'; import * as platform from './platform.js'; +import * as downloads from './downloads.js'; import { Icon } from './icon.js'; import { formatSize } from './file-utils.js'; import { @@ -156,69 +157,80 @@ function TransferWidget() { }, [open]); const running = items.filter(i => i.status === 'running'); + const waiting = items.filter( + i => i.status === 'queued' || i.status === 'preparing'); + // Its own group, and not a leftover. + // + // "Finished" used to be defined as everything that is not running, queued or + // preparing — a definition by exclusion, which quietly swallowed `paused` the + // day pausing shipped. A transfer somebody stopped on purpose then sat under + // "Finished", beside the ones that are actually over, offering a resume + // button in the section of things that cannot be resumed. + const paused = items.filter(i => i.status === 'paused'); + const finished = items.filter( + i => i.status !== 'running' && i.status !== 'queued' + && i.status !== 'preparing' && i.status !== 'paused'); + // Paused counts as active: it is not over, the person means to come back to + // it, and the badge saying nothing is happening would be a lie. + const active = running.length + waiting.length + paused.length; + + // Grouped, and in this order: what is moving, what is waiting, what is over. + // Re-sorting the flat list on every emit made rows jump under the pointer + // each time a neighbour finished — the group is what changes, not the + // position within it, so a row only moves when its own state does. + const groups = [ + ['running', running], + ['waiting', waiting], + ['paused', paused], + ['finished', finished], + ].filter(([, rows]) => rows.length); + if (!items.length) return null; return html` <div class="transfer-wrap" ref=${ref}> <button class="nav-notif transfer-btn ${running.length ? 'active' : ''}" + aria-label=${t('transfers.title')} + aria-expanded=${open ? 'true' : 'false'} title=${t('transfers.title')} onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}> <${Icon} name="transfer" /> - ${running.length > 0 && html` - <span class="notif-badge">${running.length}</span> + ${active > 0 && html` + <span class="notif-badge">${active}</span> `} </button> + ${/* One live region for the panel, announcing what changed state rather + than every progress tick — a reader that says "62%… 63%… 64%" for a + four-gigabyte film is a reader nobody leaves on. */''} + <span class="sr-only" aria-live="polite"> + ${t('transfers.summary', { running: running.length, waiting: waiting.length })} + </span> ${open && html` - <div class="transfer-panel"> + <div class="transfer-panel" role="group" + aria-label=${t('transfers.title')}> <div class="transfer-head"> - ${t('transfers.title')} - <button class="btn-secondary" - onClick=${() => transfers.clearFinished()}> - ${t('transfers.clear')} - </button> + <span class="transfer-head-title">${t('transfers.title')}</span> + ${active > 0 && html` + <span class="transfer-head-summary"> + ${t('transfers.summary', { + running: running.length, waiting: waiting.length })} + </span> + `} + ${finished.length > 0 && html` + <button class="btn-secondary" + onClick=${() => transfers.clearFinished()}> + ${t('transfers.clear')} + </button> + `} </div> - ${items.map(it => html` - <div class="transfer-item" key=${it.id}> - <div class="transfer-line"> - <span class="transfer-kind"> - <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> - </span> - ${it.canOpen - ? html`<a class="transfer-name" href="#" title=${it.name} - onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>` - : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`} - ${it.status === 'running' && html` - <button class="transfer-cancel" title=${t('transfers.cancel')} - onClick=${() => transfers.cancel(it.id)}> - <${Icon} name="close" /> - </button> - `} - </div> - ${it.status === 'running' - ? html` - <div class="dl-progress"> - <div class="dl-fill" style="width:${it.percent}%"></div> - </div> - <div class="transfer-meta"> - <span>${formatSize(it.done)}${it.total - ? ' / ' + formatSize(it.total) : ''}</span> - <span>${formatSpeed(it.speed)}</span> - </div> - ` - : html` - <div class="transfer-meta"> - <span class=${it.status === 'failed' ? 'transfer-failed' : ''}> - ${it.status === 'done' ? t('transfers.done') - : it.status === 'cancelled' ? t('transfers.cancelled') - : it.error || t('transfers.failed')} - </span> - ${it.canOpen && html` - <button class="link-btn" onClick=${() => transfers.open(it.id)}> - ${t('transfers.open')} - </button> - `} - </div> - `} + ${groups.map(([label, rows]) => html` + <div class="transfer-group" key=${label}> + ${groups.length > 1 && html` + <div class="transfer-group-head"> + ${t('transfers.group_' + label, { n: rows.length })} + </div> + `} + ${rows.map(it => html`<${TransferRow} it=${it} key=${it.id} />`)} </div> `)} </div> @@ -227,6 +239,136 @@ function TransferWidget() { `; } +/** One row. Split out so the panel above reads as a layout and this as a state + * machine — they change for different reasons. */ +function TransferRow({ it }) { + const eta = etaSeconds(it); + return html` + <div class="transfer-item transfer-${it.status}"> + <div class="transfer-line"> + <span class="transfer-kind" aria-hidden="true"> + <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> + </span> + ${it.canOpen + ? html`<a class="transfer-name" href="#" title=${it.name} + onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>` + : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`} + ${it.pausable && (it.status === 'running' || it.status === 'paused') + && html` + ${/* Offered only where the target can actually do it: a + service-worker stream is a download the browser already owns, + and a pause there would restart from zero. */''} + <button class="transfer-pause" + aria-label=${t(it.status === 'paused' + ? 'transfers.resume_one' : 'transfers.pause_one', + { name: it.name })} + title=${t(it.status === 'paused' + ? 'transfers.resume' : 'transfers.pause')} + onClick=${() => (it.status === 'paused' + ? transfers.resume(it.id) : transfers.pause(it.id))}> + <${Icon} name=${it.status === 'paused' ? 'play' : 'pause'} /> + </button> + `} + ${!it.pausable && downloads.SUPPORTED && it.kind === 'download' + && (it.status === 'running' || it.status === 'queued') && html` + ${/* Say why, rather than leaving a gap where a button is on the row + above. Without a granted folder this browser writes through the + service worker — a download it already owns, which cannot be + paused — so the button is absent for a reason nobody can see, + and an upload beside it has one. Shown only where choosing a + folder is actually possible: on Firefox and Safari there is no + folder to choose and this hint would be a lie. */''} + <span class="transfer-nopause" title=${t('transfers.not_pausable')} + aria-label=${t('transfers.not_pausable')}> + <${Icon} name="pause" /> + </span> + `} + ${(it.status === 'running' || it.status === 'queued' + || it.status === 'preparing' || it.status === 'paused') && html` + <button class="transfer-cancel" + aria-label=${t('transfers.cancel_one', { name: it.name })} + title=${t('transfers.cancel')} + onClick=${() => transfers.cancel(it.id)}> + <${Icon} name="close" /> + </button> + `} + </div> + ${it.status === 'preparing' + ? html` + ${/* Not a progress bar at 0%: nothing is wrong and nothing is + stalled, the download is still finding somewhere to write. The + row exists from the click precisely so this state is visible + instead of being an empty panel. */''} + <div class="dl-progress dl-waiting"></div> + <div class="transfer-meta"> + <span>${t('transfers.preparing')}</span> + <span>${formatSize(it.total)}</span> + </div> + ` + : it.status === 'queued' + ? html` + <div class="dl-progress dl-waiting"></div> + <div class="transfer-meta"> + <span>${it.queuedByOwnLimit + ? t('transfers.waiting_own_slots') + : t('transfers.waiting_node', { n: it.ahead })}</span> + <span>${formatSize(it.total)}</span> + </div> + ` + : it.status === 'paused' + ? html` + ${/* The bar keeps its fill: what has been written is still there, + and resuming continues from it rather than starting again. */''} + <div class="dl-progress" role="progressbar" + aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100"> + <div class="dl-fill dl-paused" style="width:${it.percent}%"></div> + </div> + <div class="transfer-meta"> + <span>${t('transfers.paused')}</span> + <span>${formatSize(it.done)}${it.total + ? ' / ' + formatSize(it.total) : ''}</span> + </div> + ` + : it.status === 'running' + ? html` + <div class="dl-progress" role="progressbar" + aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100"> + <div class="dl-fill" style="width:${it.percent}%"></div> + </div> + <div class="transfer-meta"> + <span>${formatSize(it.done)}${it.total + ? ' / ' + formatSize(it.total) : ''}</span> + <span>${[formatSpeed(it.speed), + it.settled && eta !== null ? formatEta(eta) : ''] + .filter(Boolean).join(' · ')}</span> + </div> + ` + : html` + <div class="transfer-meta"> + <span class=${it.status === 'failed' ? 'transfer-failed' : ''}> + ${it.status === 'done' ? t('transfers.done') + : it.status === 'cancelled' ? t('transfers.cancelled') + : it.error || t('transfers.failed')} + </span> + ${it.canOpen && html` + <button class="link-btn" onClick=${() => transfers.open(it.id)}> + ${t('transfers.open')} + </button> + `} + </div> + `} + </div> + `; +} + +/** "4 min left". Coarse on purpose: a per-second countdown on a transfer whose + * speed varies is a number that is wrong most of the time and looks precise. */ +function formatEta(seconds) { + if (seconds < 60) return t('transfers.eta_seconds', { n: Math.ceil(seconds) }); + if (seconds < 3600) return t('transfers.eta_minutes', { n: Math.round(seconds / 60) }); + return t('transfers.eta_hours', { n: Math.round(seconds / 360) / 10 }); +} + // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, @@ -941,6 +1083,14 @@ const trayLabels = () => ({ // falls back to English rather than rejecting, so this cannot strand the page. const mount = () => { render(html`<${App} />`, document.getElementById('app')); + // Get the download worker registered and this page under its control now, + // rather than inside the first click on Download. On Firefox and Safari it is + // the only unbounded way to write a file to disk, and it used to be + // registered lazily — so the first download of a session paid install, + // activate and claim while somebody watched, and a claim that missed its + // budget sent the file to a path that cannot hold a film. Fire-and-forget: + // nothing renders differently for it, and a failure is retried on demand. + downloads.primeServiceWorker(); // After the catalogue, so the labels are in the right language. A no-op in a // browser and on macOS. A language change reloads the page, which comes back // through here, so nothing else has to watch for it. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 90f88b0..c790394 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -155,9 +155,32 @@ export async function openTarget(filename) { }; const name = await freeName(filename, exists); const handle = await dir.getFileHandle(name, { create: true }); + const writable = await handle.createWritable(); return { - writable: await handle.createWritable(), + // `abort()` on a FileSystemWritableFileStream discards the swap file and + // leaves the target as it was — which here is the empty file + // `getFileHandle({create: true})` just made, before a single byte arrived. + // So every cancelled or failed download left a 0-byte file behind, and + // because `freeName` avoids collisions, three cancels left `film.mkv`, + // `film (2).mkv` and `film (3).mkv`, all empty, in the person's folder. + // + // Removing it is safe *here and only here*: `freeName` guarantees this name + // was not taken, so the file being deleted is one we created moments ago + // and nothing else. The `showSaveFilePicker` path in file-utils.js must not + // do the same — there the person may have picked an existing file, whose + // contents `abort()` correctly preserves. + writable: { + write: (bytes) => writable.write(bytes), + close: () => writable.close(), + abort: async (reason) => { + try { await writable.abort(reason); } catch { /* already gone */ } + try { await dir.removeEntry(name); } catch { /* already gone */ } + }, + }, name, + // A held-open `FileSystemWritableFileStream`: pausing is simply not + // writing to it, and nothing is lost while nothing is written. + pausable: true, // Reading it back is the only way a page can "open" a file it wrote: hand // the bytes to a tab and let the browser decide what to do with them. No // web page can start a desktop application, or show a file manager. @@ -180,40 +203,281 @@ export const BLOB_LIMIT = 512 * 1024 * 1024; // ── Streaming to disk without the File System Access API ──────────────────── const SW_PATH = '/sw.js'; -let _swReady = null; +// Kept in step with sw.js's own PREFIX. +const PREFIX_PATH = '/_mbdl/'; + +// On Firefox and Safari this worker is not a nicety, it is the only unbounded +// way to write a download to disk: the File System Access API does not exist +// there, and OPFS is capped at 10% of the volume's size (measured on Firefox +// 154: 389,233,459 bytes on a 3,892,334,592-byte volume, refused to the byte), +// which a film can exceed. Everything below exists to make sure this path is +// available when it is needed, because there is nothing underneath it. +// +// How long to wait for this page to become *controlled*. Generous on purpose: +// the cost of waiting is a spinner, and the cost of giving up is a download +// this browser then cannot do at all. +const SW_CONTROL_BUDGET_MS = 15000; +// How long to wait for the worker to confirm it answered the iframe. +const SW_SERVED_BUDGET_MS = 15000; +// A transient miss gets a second go with a fresh id and a fresh iframe. +const SW_ATTEMPTS = 2; +// How often the page pokes the worker while a download is being written. +// Firefox terminates a service worker that has had no event for roughly thirty +// seconds, and a streaming response does not count as activity — so a download +// that takes longer than that lost its reader half way through. Ten seconds +// leaves a wide margin and costs one empty message. +const SW_KEEPALIVE_MS = 10000; +// And the ping stops on its own once nothing has been written for this long. +// Well past any real gap between chunks, and short enough that an abandoned +// target does not ping for ever. Bounded because the alternative is a timer +// whose lifetime depends on every caller remembering to close its sink. +const SW_KEEPALIVE_IDLE_MS = 120000; +// How long to spend waking the worker, and then confirming it holds the stream, +// before starting the navigation that has to find it. +// +// Both are answered in milliseconds when the worker is alive. They exist for +// when it is not: `pending` lives in the worker's memory, and one with nothing +// to do is terminated within tens of seconds — which a long upload spends +// without giving it a single event. A stream handed to a worker in that state +// is lost, and the iframe then wakes it with nothing to find, which is a 404 +// from the hub and fifteen seconds of silence per attempt. +const SW_WAKE_BUDGET_MS = 3000; + +// Holds a *successful* controller, or an in-flight attempt. Never a failure — +// see serviceWorker(). The previous version cached the rejected/null result +// for the life of the page, so one slow first click (a cold worker, a busy +// phone) left the tab unable to stream anything ever again, with no way back +// but a reload nobody knew to do. +let _swPromise = null; +let _lastFailure = ''; + +/** Why the streamed path last declined, for a message worth reading. */ +export function lastStreamFailure() { return _lastFailure; } export const STREAMS_VIA_SW = typeof window !== 'undefined' && 'serviceWorker' in navigator && typeof TransformStream === 'function' && window.isSecureContext; -async function serviceWorker() { - if (!STREAMS_VIA_SW) return null; - if (!_swReady) { - _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' }) - .then(() => navigator.serviceWorker.ready) - .then(async (reg) => { - // `reg.active` is not enough. A worker can be active while this page is - // still uncontrolled, and an uncontrolled page's requests are never - // handed to its fetch handler — so the worker would take our stream and - // then never be asked for it. The iframe would 404, nothing would read - // the stream, and the first write() would block for good: a download - // stuck at one chunk. - if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller; - // sw.js claims clients on activate, so control usually arrives within a - // tick of registration. Wait briefly rather than give up at once. - return await new Promise((resolve) => { - const done = () => resolve(navigator.serviceWorker.controller || null); - navigator.serviceWorker.addEventListener('controllerchange', done, { once: true }); - setTimeout(done, 3000); - }); - }) - .catch(err => { - console.warn('[MeshBay] service worker unavailable:', err.message); - return null; - }); +/** Resolves with the controller, or null once `budgetMs` is spent. */ +function _awaitControl(budgetMs) { + if (navigator.serviceWorker.controller) { + return Promise.resolve(navigator.serviceWorker.controller); + } + return new Promise((resolve) => { + let timer = 0; + const done = () => { + clearTimeout(timer); + navigator.serviceWorker.removeEventListener('controllerchange', done); + resolve(navigator.serviceWorker.controller || null); + }; + navigator.serviceWorker.addEventListener('controllerchange', done); + timer = setTimeout(done, budgetMs); + }); +} + +/** + * The promise's value, or `TIMED_OUT` once `ms` is spent. + * + * A rejection is still a rejection — the caller reports those — and the timer + * is cleared either way, so nothing is left running behind a fast answer. + */ +const TIMED_OUT = Symbol('timed out'); + +function _within(promise, ms) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(TIMED_OUT), ms); + promise.then((v) => { clearTimeout(timer); resolve(v); }, + (err) => { clearTimeout(timer); reject(err); }); + }); +} + +async function _claimController(budgetMs) { + // Every wait in here is inside one budget. Neither of the first two used to + // have any deadline at all, and `_swPromise` is shared, so a single one of + // them left every download on the page waiting on the same promise for ever + // — four rows stuck at "preparing", with nothing in the node's journal + // because no transfer had been asked for yet. + const deadline = Date.now() + budgetMs; + const left = () => Math.max(0, deadline - Date.now()); + + let reg = await _within( + navigator.serviceWorker.register(SW_PATH, { scope: '/' }), left()); + if (reg === TIMED_OUT) { + _lastFailure = `the worker did not register within ${budgetMs / 1000}s`; + return null; + } + // `register()` resolves as soon as the registration object exists, with + // nothing but an *installing* worker; `ready` is what waits for an active + // one. A worker that never finishes installing leaves `ready` pending + // indefinitely — measured on Firefox 154: an install handler that rejects + // leaves `ready` unsettled past ten seconds while `register()` returns in + // seven milliseconds. + let ready = await _within(navigator.serviceWorker.ready, left()); + if (ready === TIMED_OUT && !reg.active) { + // A registration stuck with nothing but an installing worker does not heal + // on its own: every later visit finds the same registration and waits on + // the same `ready`. Left alone it is permanent, and it costs Firefox the + // only unbounded way it has to write a download to disk — so the stuck + // registration is thrown away and asked for once more, with its own budget, + // rather than reported and lived with. + console.warn('[MeshBay] the download worker never became active; ' + + 'discarding the registration and asking again'); + try { + await _within(reg.unregister(), budgetMs); + } catch (err) { + console.warn('[MeshBay] could not discard it:', err.message); + } + const again = await _within( + navigator.serviceWorker.register(SW_PATH, { scope: '/' }), budgetMs); + if (again === TIMED_OUT) { + _lastFailure = `the worker did not register within ${budgetMs / 1000}s`; + return null; + } + reg = again; + ready = await _within(navigator.serviceWorker.ready, budgetMs); + if (ready === TIMED_OUT && !reg.active) { + _lastFailure = `the worker did not become active within ${budgetMs / 1000}s` + + ', even after its registration was discarded'; + return null; + } + } + // Past here `ready` may still be waiting on a *newer* worker that cannot + // install while an older one is perfectly able to serve. An active worker is + // all this path needs, so a stuck `ready` is not on its own a reason to give + // up a capability Firefox has nothing else to offer for. + // + // Being active is not being in control, either. An uncontrolled page's + // requests never reach the fetch handler, so the worker would take our stream + // and never be asked for it — the download then freezes after exactly one + // chunk, which is how that was found. + // + // Ask for the claim *before* waiting, not after. A page that is uncontrolled + // while an active worker exists will not be claimed on its own — a document + // fetched by a hard reload is exactly that shape — so the whole control + // budget is spent waiting for something that is not coming, and it was: about + // thirty seconds during which somebody clicks download and watches four rows + // hang. Asking first costs one message and makes the common case immediate. + if (!navigator.serviceWorker.controller && reg.active) { + try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ } + } + const controller = await _awaitControl(left()); + if (controller) return controller; + // Active but not controlling after the whole budget. `sw.js` calls + // `clients.claim()` on activate, so this is rare; when it happens the page + // was loaded before any worker existed and the claim was missed. Ask the + // active worker to claim again rather than declare the path unavailable. + // This wait is deliberately outside the budget above: giving up here would + // cost Firefox the only unbounded way it has to write a download to disk. + if (reg.active) { + try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ } + return await _awaitControl(2000); + } + return null; +} + +/** + * Register the worker and get this page controlled, now. + * + * Called at application start, not at the first download. Registration used to + * happen inside the first click, so that click paid install, activate and claim + * while somebody watched a button do nothing — and if the claim did not land + * inside the budget, the download fell through to a path that cannot hold a + * film. By the time anyone clicks anything, this has long since finished. + * + * Fire-and-forget by design: nothing waits on it, and a failure here is not + * fatal because `serviceWorker()` will simply try again. + */ +export function primeServiceWorker() { + if (!STREAMS_VIA_SW) return; + _priming = _repairIfBypassed() + .then(() => serviceWorker()) + .catch(() => {}); +} + +// Resolved once the check below has run. A download that starts while it is +// still in flight waits for it rather than racing it: on a page that turns out +// to be unservable the click would otherwise spend thirty seconds failing on a +// path that is about to be repaired. +let _priming = null; + +// Set for the life of this tab, so the repair below happens at most once and +// can never become a reload loop. +const REPAIRED_KEY = 'meshbay.sw-repaired'; + +/** + * Was this document loaded with the service worker bypassed? + * + * A document fetched by a **hard** reload — Ctrl+F5, Ctrl+Shift+R — is loaded + * with the worker bypassed. It can still be claimed afterwards, so + * `navigator.serviceWorker.controller` comes back and every control check + * passes; but the navigations it starts keep missing the worker, and the hidden + * iframe a streamed download needs *is* a navigation. On Firefox and Safari + * that is the only way to write a file too large to hold in memory, so every + * download fails for the life of that page. + * + * Measured on Chrome, at document start, before anything registers: + * + * first visit controller false, registration false + * ordinary reload controller true, registration true + * hard reload controller false, registration true + * + * So being uncontrolled while an active registration already exists names the + * case exactly, and costs nothing to ask. + * + * The first version of this asked by *performing a download* — a four-byte + * stream through a hidden iframe — which was both unreliable and expensive: + * Chrome rations downloads a page starts without a user gesture to about three, + * so the test competed with the person's real downloads for that budget and its + * answer depended on how many had been spent. It reloaded a healthy page on + * every first visit, taking the group's WebRTC session down with it. + */ +const _controlledAtLoad = STREAMS_VIA_SW + && Boolean(navigator.serviceWorker.controller); +// Started here, at module load, because `register()` would make the answer +// true whatever it was. +const _registeredAtLoad = STREAMS_VIA_SW + ? navigator.serviceWorker.getRegistration('/') + .then((reg) => Boolean(reg && reg.active)).catch(() => false) + : Promise.resolve(false); + +/** An ordinary reload puts the document back under the worker, so do that once. */ +async function _repairIfBypassed() { + if (_controlledAtLoad) return; + // Uncontrolled with nothing registered is a first visit, not a bypass: the + // worker is being installed right now and the page is fine after it claims. + if (!await _registeredAtLoad) return; + let repaired = false; + try { repaired = sessionStorage.getItem(REPAIRED_KEY) === '1'; } catch { /* blocked */ } + if (repaired) return; + console.warn('[MeshBay] this page was loaded with the download worker ' + + 'bypassed (a hard reload does that) — reloading once to put it ' + + 'back under the worker\u2019s control'); + try { sessionStorage.setItem(REPAIRED_KEY, '1'); } catch { /* blocked */ } + location.reload(); +} + +async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) { + if (!STREAMS_VIA_SW) { + _lastFailure = 'no service worker support in this browser'; + return null; + } + if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller; + if (!_swPromise) { + _swPromise = _claimController(controlMs).catch((err) => { + _lastFailure = 'service worker registration failed: ' + err.message; + console.warn('[MeshBay]', _lastFailure); + return null; + }); } - return _swReady; + const controller = await _swPromise; + if (!controller) { + // Not remembered. The next attempt starts from scratch, which is the whole + // point: these failures are transient far more often than they are final. + _swPromise = null; + if (!_lastFailure) _lastFailure = 'the page did not come under the worker’s control'; + } + return controller; } /** @@ -229,8 +493,59 @@ async function serviceWorker() { * Returns {writable, name} shaped like the File System Access one, or null if * this browser cannot do it either. */ -export async function openStreamedDownload(filename, size = 0) { - const worker = await serviceWorker(); +export async function openStreamedDownload(filename, size = 0, { + controlMs = SW_CONTROL_BUDGET_MS, + servedMs = SW_SERVED_BUDGET_MS, + attempts = SW_ATTEMPTS, +} = {}) { + if (_priming) { try { await _priming; } catch { /* reported already */ } } + for (let attempt = 1; attempt <= attempts; attempt++) { + const target = await _attemptStreamedDownload( + filename, size, attempt, controlMs, servedMs); + if (target) return target; + // A miss is usually the worker having been asleep or the navigation losing + // a race, not this browser being unable. Falling through on the first miss + // is what sent large downloads to the in-memory floor. + if (attempt < attempts) { + console.warn(`[MeshBay] streamed download attempt ${attempt} missed ` + + `(${_lastFailure}); retrying`); + } + } + return null; +} + +/** + * Get the worker running, and know that it is. + * + * `mbdl-ping` exists already — the page sends it every ten seconds *while* + * writing, because a streaming response does not count as activity and Firefox + * kills an idle worker mid-download. Nothing sent one before *starting* a + * download, which is the case that fails after a long upload has left the + * worker with nothing to do for minutes. + * + * Never fatal: a worker that does not answer may still be perfectly able to + * serve, and the caller finds that out the honest way. + */ +async function _wake(worker) { + const chan = new MessageChannel(); + const pong = new Promise((resolve) => { + chan.port1.onmessage = () => resolve(true); + }); + try { + worker.postMessage({ type: 'mbdl-ping' }, [chan.port2]); + } catch { + return false; + } + const awake = await Promise.race([ + pong, new Promise((r) => setTimeout(() => r(false), SW_WAKE_BUDGET_MS)), + ]); + try { chan.port1.close(); } catch { /* already gone */ } + return awake; +} + +async function _attemptStreamedDownload(filename, size, attempt, + controlMs, servedMs) { + const worker = await serviceWorker(controlMs); if (!worker) return null; const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; @@ -241,52 +556,111 @@ export async function openStreamedDownload(filename, size = 0) { // backpressure that will never be relieved, which reads as a download frozen // after one chunk rather than as an error. const chan = new MessageChannel(); + let markReady = null; + const held = new Promise((resolve) => { markReady = resolve; }); const serving = new Promise((resolve) => { chan.port1.onmessage = (e) => { - if (e.data && e.data.type === 'mbdl-serving') resolve(true); + if (!e.data) return; + // The worker says it has the stream. Waiting for this is what stops the + // navigation racing a worker that was asleep when we posted. + if (e.data.type === 'mbdl-ready') markReady(true); + if (e.data.type === 'mbdl-serving') resolve(true); }; }); + // Wake it first, and wait for the answer. A worker that has been idle through + // a long upload is terminated, and a message posted to it in that state is + // lost — silently, which is the whole difficulty. + await _wake(worker); + try { worker.postMessage({ type: 'mbdl', id, filename, size, readable, port: chan.port2 }, [readable, chan.port2]); } catch (err) { // Transferable streams are what makes the backpressure work; without them - // this would be a memory buffer wearing a stream's clothes. - console.warn('[MeshBay] streams cannot be transferred here:', err.message); + // this would be a memory buffer wearing a stream's clothes. This one is + // final rather than transient — a browser does not grow the capability + // between two attempts — so it is reported as such. + _lastFailure = 'this browser cannot transfer a stream to the worker: ' + err.message; + console.warn('[MeshBay]', _lastFailure); return null; } + // Confirmed, not assumed. A worker that predates this sends no answer, and + // then navigating anyway is exactly what this code did before. + await Promise.race([ + held, new Promise((r) => setTimeout(r, SW_WAKE_BUDGET_MS)), + ]); + const frame = document.createElement('iframe'); frame.hidden = true; - frame.src = `/_mbdl/${id}`; + frame.src = `${PREFIX_PATH}${id}`; document.body.appendChild(frame); const answered = await Promise.race([ serving, - new Promise((r) => setTimeout(() => r(false), 8000)), + new Promise((r) => setTimeout(() => r(false), servedMs)), ]); if (!answered) { // Some browsers refuse a download started from a hidden iframe, and an - // uncontrolled page never reaches the worker at all. Say so and let the - // caller fall back rather than hand back a sink nothing drains. - console.warn('[MeshBay] the service worker never served the download; ' - + 'falling back'); + // uncontrolled page never reaches the worker at all. Tear this attempt + // down completely — the stream, the port and the frame — so a retry starts + // clean rather than leaving a half-open sink behind. + _lastFailure = `the worker did not answer the download within ` + + `${servedMs / 1000}s (attempt ${attempt})`; + console.warn('[MeshBay]', _lastFailure); frame.remove(); + try { chan.port1.close(); } catch { /* already gone */ } try { await writable.abort('not served'); } catch { /* already gone */ } return null; } + _lastFailure = ''; + // Every few seconds for as long as this download is being written. Well + // inside the ~30 s Firefox allows an idle worker, and cheap: one postMessage + // with no payload. Cleared by close() and abort() below, so a finished + // download leaves no timer behind. + // Self-limiting, and that is not belt-and-braces: a target can be opened and + // then never written to — a transfer cancelled while it waits for a slot + // never runs, so nothing calls close() or abort() — and an interval nobody + // clears pings for the life of the page. It also kept the Node test process + // alive for ever, which is the same defect wearing a louder symptom (the + // MessagePort above did exactly this a few hours earlier). + let lastWrite = Date.now(); + const keepAlive = setInterval(() => { + if (Date.now() - lastWrite > SW_KEEPALIVE_IDLE_MS) { + clearInterval(keepAlive); + return; + } + try { worker.postMessage({ type: 'mbdl-ping' }); } catch { /* gone */ } + }, SW_KEEPALIVE_MS); + // The port has delivered the one message it exists for. Closing it matters: + // an open MessagePort is a live handle, and one was leaked per download for + // the life of the page. (It is also what hung the Node harness in + // test_streamed_download_reliability.py — there the leak is a process that + // never exits, which is the same defect wearing a louder symptom.) + try { chan.port1.close(); } catch { /* already gone */ } + const writer = writable.getWriter(); return { name: filename, + // **Not pausable, and this is not a limitation of our code.** The browser + // is already writing an HTTP response into its own download folder: not + // writing to the stream stalls that download where we cannot see it or + // resume it, and an idle worker is terminated within seconds, taking the + // stream with it. A pause button here would restart from zero, which is + // worse than not offering one. §6.5 of ~/next/improve-downloads.md records + // what giving Firefox and Safari a resumable target would cost. + pausable: false, writable: { - write: (bytes) => writer.write(bytes), + write: (bytes) => { lastWrite = Date.now(); return writer.write(bytes); }, close: async () => { + clearInterval(keepAlive); await writer.close(); setTimeout(() => frame.remove(), 2000); }, abort: async (reason) => { + clearInterval(keepAlive); try { await writer.abort(reason); } catch { /* already gone */ } frame.remove(); }, 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 241d761..99d9f9a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -47,15 +47,64 @@ const CHUNK_SIZE = 1024 * 1024; // that hesitates, and looks like a hang while it is quiet. const PIPELINE_WINDOW = 8; +// The most this code will ever collect in the page. +// +// Below every streaming target there is a floor: `pipelinedDownload` with no +// `writable` allocates `new Array(totalChunks)` and keeps every decrypted +// chunk, and `_saveBlob` hands the lot to the browser. That floor is fine for +// something small and is a dead tab for a film. It had **no upper bound**: the +// `!window.showSaveFilePicker` branch below returned null at any size, so on a +// browser without the File System Access API (Firefox, Safari) a 20 GB film +// went to RAM whenever the service-worker path did not answer — which happens +// for ordinary reasons (an uncontrolled page, a stream that cannot be +// transferred, the 8 s timeout). Nothing logged, nothing refused; the symptom +// was the tab dying, with no error attributable to this code. +// +// So: above this, there is no floor. A refusal naming what happened is +// recoverable and a dead tab is not. `CLAUDE.md`'s standing lesson is that a +// fallback chain reaches its floor silently — this is that floor being given a +// bottom. +const MEMORY_CEILING = 100 * 1024 * 1024; + +/** + * Thrown instead of falling through to the in-memory floor. + * + * This should now be unreachable in ordinary use: the streamed path is primed + * at application start and retried on demand, so a browser with a service + * worker has somewhere to write whatever the size. If it is ever raised, the + * reason the streamed path declined is appended — untranslated, because it is a + * diagnostic and a vague failure is what made the original bug invisible. + */ +class TooLargeForMemoryError extends Error { + constructor(filename, size) { + const why = downloads.lastStreamFailure(); + super(t('download.too_large_for_memory', { + name: filename, size: formatSize(size), limit: formatSize(MEMORY_CEILING), + }) + (why ? ` (${why})` : '')); + this.name = 'TooLargeForMemoryError'; + } +} + /** * Open somewhere to write, honouring the user's download setting. * * Returns a target ({writable, name}), null for "no stream available — collect * it and hand the browser a blob", or false for "the person dismissed the * dialog", which is not an error and must not start a transfer. + * + * **Never returns null above MEMORY_CEILING.** Every `return null` below is + * guarded by `_memoryFloor`, which throws instead. A fourth fallback added + * later must go through it too — `test_memory_ceiling.py` fails the build if a + * bare `return null` appears in this function. */ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, - swSize = size) { + swSize = size, { batched = false } = {}) { + // "Collect it in the page", or a refusal when that would be too much. + const _memoryFloor = () => { + if (size > MEMORY_CEILING) throw new TooLargeForMemoryError(filename, size); + return null; + }; + // On a desktop build this is the whole answer, and it comes first. // // The two browser paths below are both unavailable there — `showDirectoryPicker` @@ -69,7 +118,10 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, filename, { auto: downloads.getMode() === 'auto' }); // Null means the person dismissed the dialog, which is not an error and // must not start a transfer. - return native || false; + // + // The desktop sink is an open file stream in the main process: not + // writing to it for a while costs nothing and loses nothing. + return native ? { ...native, pausable: true } : false; } catch (err) { console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err)); return false; @@ -87,25 +139,143 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, // write, which is how this works at all in Firefox: the alternative there is // to collect gigabytes in a tab. It goes to the browser's own download // folder, without a dialog, which is what "save automatically" meant. - if (downloads.getMode() === 'auto') { + // + // Tried in "ask" mode too when the file is large and this browser has no Save + // As of its own. The mode is about whether to show a dialog; it was never + // meant to decide whether a 20 GB film can be downloaded at all, and on + // Firefox and Safari — where `showSaveFilePicker` does not exist — skipping + // this block left nothing but the in-memory floor. A preference must not cost + // a capability. + const canPick = typeof window.showSaveFilePicker === 'function'; + // `batched` means this is not the first download of a batch, and it makes the + // streamed path preferred whatever the mode. + // + // "Ask where to save" asks per file, which is right for one file and wrong + // for four: a browser grants one picker per user gesture, so the second + // dialog has no gesture behind it and the third and fourth wait behind a + // dialog that waits for a human — reported from Chrome as three downloads + // frozen. There is no gesture left to spend, so there is nothing to lose by + // streaming instead: the file still lands on disk, in the browser's own + // download folder. Only the choice of folder goes, and it was not on offer. + if (downloads.getMode() === 'auto' || batched + || (size > MEMORY_CEILING && !canPick)) { const streamed = await downloads.openStreamedDownload(filename, swSize); if (streamed) return streamed; - // Nothing to stream to: small enough for memory, and no dialog. - if (size < downloads.BLOB_LIMIT) return null; + // Nothing to stream to: small enough for memory, and no dialog. The old + // comparison here was against downloads.BLOB_LIMIT (512 MB), five times + // this ceiling — and it was the *only* size test in the whole chain, with + // the branch below it unguarded. + if (size <= MEMORY_CEILING) return _memoryFloor(); } - if (!window.showSaveFilePicker) return null; + // No File System Access API — Firefox, Safari. This is the branch that used + // to return null at any size. + if (!canPick) return _memoryFloor(); + // A dialog is the one outcome nobody can diagnose after the fact: it looks + // the same whether it was asked for, or fallen back to because the worker + // did not answer. Say which, once per download, so the next report from a + // browser we do not have does not need a second round trip. + console.info('[MeshBay] asking where to save %s — mode=%s batched=%s stream=%s', + filename, downloads.getMode(), batched, + downloads.lastStreamFailure() || 'not attempted'); try { const handle = await window.showSaveFilePicker({ suggestedName: filename, ...pickerOpts, }); - return { writable: await handle.createWritable(), name: handle.name || filename }; + return { writable: await handle.createWritable(), + name: handle.name || filename, pausable: true }; } catch (err) { if (err.name === 'AbortError') return false; + // "Must be handling a user gesture to show a file picker." + // + // A browser grants one picker per gesture, and downloading three files is + // one gesture. So the second and third throw this, and the person sees a + // failed transfer with a message from Chrome about gestures, for having + // done something entirely reasonable. + // + // The streamed path needs no gesture at all, which makes it the right + // answer here rather than a consolation: the file still lands on disk, in + // the browser's own download folder, written as it arrives. Only the choice + // of folder is lost, and it was already lost — there was no picker to make + // it in. + if (err.name === 'SecurityError' || /user gesture/i.test(err.message || '')) { + console.warn('[MeshBay] no gesture left for a save dialog; streaming ' + + 'this one to the download folder instead'); + const streamed = await downloads.openStreamedDownload(filename, swSize); + if (streamed) return streamed; + if (size <= MEMORY_CEILING) return _memoryFloor(); + } throw err; } } +// Target openings run one at a time, across every download on the page. +// +// A browser shows one file picker at a time and grants one per user gesture, so +// four downloads asking at once get one dialog and three failures. That used to +// be prevented by accident: `downloadEntry` awaited the target inline, and +// files-app.js's `for (const e of selected) await downloadFile(e)` serialised +// them. Opening the target inside `prepare` — so the row appears at the click +// instead of tens of seconds later — removed that accident, and four pickers +// raced. Chrome showed one, prompted for a second, and the rest timed out; +// Firefox and Electron never noticed, because neither opens a picker at all. +// +// So the queue is explicit now, and it is the *targets* that queue, not the +// rows: every download still appears the moment it is asked for. +// +// Two things keep the queue from becoming the problem it was meant to solve. +// It only ever holds openings that could actually put a dialog on screen, and +// no opening waits behind another for longer than a budget. +let _targetQueue = Promise.resolve(); + +let _targetsInFlight = 0; + +// How long an opening waits for the one ahead of it before going anyway. +// +// A queue with no bound is a way for one stuck opening to freeze every later +// download for the life of the page, since `_targetQueue` is never reset. That +// is what turned a slow first download into four rows stuck at "preparing" on +// Firefox. Generous, because a dialog legitimately waits for a person and +// cutting in front of one would be worse than waiting; finite, because the +// alternative is a download panel that never recovers. +// +// Going anyway is safe: whatever was ahead is still the only unbatched opening, +// so the one released here takes the streamed path and opens no second dialog. +const TARGET_QUEUE_BUDGET_MS = 90000; + +function _openTargetInTurn(filename, size, pickerOpts, swSize) { + // Only an opening that could show a dialog has any reason to wait. Firefox + // and Safari have no `showSaveFilePicker` at all, so nothing there can race + // anything, and queueing them bought nothing while costing everything: four + // downloads that used to open their targets at the same time became four + // that waited on the slowest. + const canPick = typeof window !== 'undefined' + && typeof window.showSaveFilePicker === 'function'; + if (!canPick) return _openDownloadTarget(filename, size, pickerOpts, swSize); + + // Anything that has to wait its turn is, by definition, not the first of the + // batch — so it will not be the one holding the user's gesture. + const batched = _targetsInFlight > 0; + _targetsInFlight += 1; + const mine = _waitBriefly(_targetQueue, TARGET_QUEUE_BUDGET_MS) + .then(() => _openDownloadTarget(filename, size, pickerOpts, swSize, + { batched })) + .finally(() => { _targetsInFlight -= 1; }); + // The chain must not break on a rejection, or one refused download stops + // every later one from ever opening a target. + _targetQueue = mine.catch(() => {}); + return mine; +} + +/** Settles with `promise`, or after `ms`, whichever comes first. */ +function _waitBriefly(promise, ms) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + promise.then(() => { clearTimeout(timer); resolve(); }, + () => { clearTimeout(timer); resolve(); }); + }); +} + /** The download of last resort, for browsers with no way to stream to disk. */ function _saveBlob(blob, filename) { const url = URL.createObjectURL(blob); @@ -131,17 +301,47 @@ function _saveBlob(blob, filename) { const CHUNK_RETRY_ATTEMPTS = 6; const CHUNK_RETRY_DELAY_MS = 1500; +// How long one megabyte may take to reach the disk before we call it stuck. +// +// Every other await on this path is bounded and says so when it expires: +// `_sendAndWait` logs a Response timeout, `_fetchChunkResilient` retries and +// then throws. `writable.write()` was the exception — a sink that stops +// consuming (a service-worker stream the browser has stopped reading, a file +// handle that has gone away) leaves it pending for ever. It never rejects, so +// there is no error, no log and no failed transfer: the progress bar simply +// stops, the console stays empty, and the node is perfectly healthy the whole +// time, which is what made this invisible. +// +// Generous on purpose. A megabyte takes milliseconds on any working sink; a +// minute means the sink is gone, not slow. +const WRITE_STALL_MS = 60000; + +/** `writable.write`, but it fails instead of hanging for ever. */ +async function _writeOrStall(writable, bytes, at) { + let timer = 0; + const stalled = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + t('group.download_write_stalled', { seconds: WRITE_STALL_MS / 1000 }) + + ` (chunk ${at})`)), WRITE_STALL_MS); + }); + try { + await Promise.race([writable.write(bytes), stalled]); + } finally { + clearTimeout(timer); + } +} + function _isRetryableTransportError(err) { return err.name === 'TransportLostError' || err.message === 'Response timeout' || (err.message || '').startsWith('DataChannel not open'); } -async function _fetchChunkResilient(transport, fileId, index) { +async function _fetchChunkResilient(transport, fileId, index, tr = '') { let lastErr; for (let attempt = 0; attempt < CHUNK_RETRY_ATTEMPTS; attempt++) { try { - return await transport.fetchChunk(fileId, index); + return await transport.fetchChunk(fileId, index, tr); } catch (err) { if (!_isRetryableTransportError(err)) throw err; lastErr = err; @@ -154,14 +354,15 @@ async function _fetchChunkResilient(transport, fileId, index) { } async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, - writable, signal) { - const results = writable ? null : new Array(totalChunks); - let nextSend = 0, nextRecv = 0; + writable, signal, tr = '', fromChunk = 0, + results = null) { + if (!writable && !results) results = new Array(totalChunks); + let nextSend = fromChunk, nextRecv = fromChunk; const inflight = new Array(totalChunks); const fire = () => { while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { - inflight[nextSend] = _fetchChunkResilient(transport, fileId, nextSend); + inflight[nextSend] = _fetchChunkResilient(transport, fileId, nextSend, tr); nextSend++; } }; @@ -173,6 +374,21 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk err.name = 'AbortError'; throw err; } + // Between two chunks, never inside one. Everything written so far is a + // whole number of chunks, which is what makes resuming exact rather than + // approximate — `fromChunk` is a position, not an estimate, and a resumed + // file is never appended to at an offset nobody checked. + // + // The chunks already in flight past this point are abandoned and asked for + // again on resume: at most one pipeline window of duplicated traffic, in + // exchange for not having to hold a half-received window across a pause of + // unknown length. + if (signal && signal.paused) { + signal.resumeFrom = nextRecv; + const err = new Error('Paused'); + err.name = 'PausedError'; + throw err; + } const chunkMsg = await inflight[nextRecv]; // One shape, and a refusal for anything else. There used to be two fallbacks // below this: a base64 `ct_b64` chunk, which was the real wire format until @@ -192,7 +408,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk const plaintext = await window.MeshBayCrypto.decryptChunkBin( gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); if (writable) { - await writable.write(plaintext); + await _writeOrStall(writable, plaintext, nextRecv); } else { results[nextRecv] = plaintext; } @@ -210,33 +426,67 @@ 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) { - const target = await _openDownloadTarget(entry.name, entry.size); - if (target === false) return; // the picker was dismissed - + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); const openRef = { url: null }; + let target = null; + // The in-memory fallback's accumulator, held out here so a pause does not + // discard what has already been decrypted. + const memoryChunks = new Array(totalChunks); + transfers.start({ - kind: 'download', name: (target && target.name) || entry.name, - total: entry.size, transport, - open: target - ? (target.open || null) - : () => { if (openRef.url) window.open(openRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - let done = 0; + 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 _openTargetInTurn(entry.name, entry.size); + // Dismissed: nothing was started, so nothing is left on screen. + if (target === false) return false; + // `pausable` travels with the target, because only the target knows. The + // in-memory fallback (a null target) is just an array and pauses fine. + return target ? { name: target.name, pausable: !!target.pausable } + : { pausable: 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) ? target.open() + : (openRef.url ? window.open(openRef.url, '_blank') : undefined), + + // Kept across a pause: the chunks collected so far on the in-memory path. + // A resumed run fills in from where it stopped rather than starting a + // second array and throwing the first away. + run: async ({ signal, onProgress, lease, from = 0 }) => { + let done = from * CHUNK_SIZE; const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; if (target) { try { await pipelinedDownload(transport, gek, entry.id, totalChunks, - onChunk, target.writable, signal); + onChunk, target.writable, signal, + lease && lease.tr, from); await target.writable.close(); } catch (err) { - await target.writable.abort().catch(() => {}); + // A pause is not a failure, and the target must survive it: aborting + // here would delete the `.part` (Electron) or the file just created + // in the granted folder, and resuming would then have nothing to + // continue. Only a real end tears the target down. + if (err.name !== 'PausedError') { + await target.writable.abort().catch(() => {}); + } throw err; } } else { const chunks = await pipelinedDownload( - transport, gek, entry.id, totalChunks, onChunk, null, signal); + transport, gek, entry.id, totalChunks, onChunk, null, signal, + lease && lease.tr, from, memoryChunks); const blob = new Blob(chunks); _saveBlob(blob, entry.name); openRef.url = URL.createObjectURL(blob); @@ -292,25 +542,37 @@ 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. - const target = await _openDownloadTarget(suggested, totalBytes, { - types: [{ description: 'ZIP archive', - accept: { 'application/zip': ['.zip'] } }], - }, 0); - 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, - open: target - ? (target.open || null) - : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { + 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 _openTargetInTurn(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. + makeLease: () => transport.openTransfer({ + kind: 'download', bytes: totalBytes, chunks: files.length }), + + 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 : []; let written = 0; @@ -330,7 +592,8 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE transport, gek, entry.id, totalChunks, (bytes) => { written += bytes; onProgress(written, totalBytes); }, // pipelinedDownload writes in order, which the archive needs. - { write: (plaintext) => zip.write(plaintext) }, signal); + { write: (plaintext) => zip.write(plaintext) }, signal, + lease && lease.tr); await zip.end(); } await zip.finish(); @@ -351,6 +614,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE export { FILE_ICONS, formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE, ZIP_MAX_BYTES, + MEMORY_CEILING, TooLargeForMemoryError, _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry, downloadDirectory, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 4947f8c..65860ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -6,7 +6,7 @@ import { Icon } from './icon.js'; import { entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; import { - FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, + FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, MEMORY_CEILING, pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory, } from './file-utils.js'; @@ -66,7 +66,15 @@ function FilesPanel({ transport = transportRef.current; gek = gekRef.current; } - if (!transport || !transport.connected) return; + // Never a silent return. A click that produces nothing at all — no + // transfer, no icon, no message — is indistinguishable from a broken + // button, and it is what a download looks like whenever the WebRTC + // connection is not up: on a screen lock, mid-reconnect, or after the node + // restarted. Say so instead. + if (!transport || !transport.connected) { + setError(t('group.download_offline')); + return; + } await downloadEntry(transfers, transport, gek, entry); }, [getTransport]); @@ -86,13 +94,23 @@ function FilesPanel({ for (const file of files) { transfers.start({ kind: 'upload', name: file.name, total: file.size, transport, - run: async ({ signal, onProgress }) => { + // `makeLease`, not `lease`: pausing gives the slot back, so resuming + // has to be able to ask for another one, and a transfer handed a lease + // it cannot re-create is refused the button rather than offered one + // that would drop its slot for good. + makeLease: () => transport.openTransfer({ kind: 'upload', + bytes: file.size }), + // A `File` is seekable and the node remembers how much it holds, so + // there is no target tier to consult here — unlike a download. + pausable: true, + run: async ({ signal, onProgress, lease }) => { await transport.uploadFile(file, { // Bytes the node acknowledged, not bytes read locally. onProgress: (sent) => onProgress(sent, file.size), signal, root: uploadRoot, dir: uploadDir, + tr: lease && lease.tr, }); // The node re-indexes on a filesystem event, so there is nothing to // wait on but the clock. Refreshing here means the file appears in @@ -598,6 +616,24 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { useEffect(() => { let cancelled = false; const load = async () => { + // Nothing here streams: a preview is decrypted whole, held as an array of + // chunks, and turned into a blob. That is right for a page of text and a + // photograph, and it is a dead tab for the things that also reach here — + // a scanned PDF, a multi-gigabyte .csv or .log. There was no size test at + // all, and the text branch is the sharpest illustration: it decoded the + // entire file and then kept 500 000 characters of it. + // + // Films and music never arrive (group-page.js's onPreview routes video to + // the MSE player and audio to the music queue), so this guard is only ever + // met by a document somebody clicked without knowing how big it was. It + // offers the download instead, which does stream. + if (entry.size > MEMORY_CEILING) { + setError(t('preview.too_large', { + size: formatSize(entry.size), limit: formatSize(MEMORY_CEILING), + })); + setPhase('error'); + return; + } const transport = transportRef.current; if (!transport || !transport.connected) { setError(t('video.err_transport')); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 6f41426..c6748f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -553,7 +553,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // toolbar actions call the same shared helper from files-app.js, since // only a single open file/video is ever in play here. const transport = transportRef.current; - if (!transport || !transport.connected) return; + if (!transport || !transport.connected) { + // Same reasoning as files-app.js's downloadFile: a click that does + // nothing at all is worse than a refusal. + setError(t('group.download_offline')); + return; + } await downloadEntry(transfers, transport, gekRef.current, entry); }, []); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 97a835d..0f632e3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'Neuer Ordner', 'group.mkdir_prompt': 'Name des neuen Ordners', 'group.mkdir_offline': 'Nicht mit dem Node verbunden.', + 'group.download_offline': 'Keine Verbindung zum Node — der Download kann nicht starten. Die Verbindung wird automatisch wiederhergestellt; versuchen Sie es gleich erneut.', + 'group.download_write_stalled': 'Die Datei wird nicht mehr auf die Festplatte geschrieben ({seconds} s ohne Fortschritt). Der Download wurde abgebrochen statt hängen gelassen; versuchen Sie es erneut.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -216,6 +218,8 @@ export default { 'video.close': 'Schließen (Esc)', 'preview.pdf_fallback': 'Dieser Browser zeigt das PDF nicht direkt an. Laden Sie es ' + 'stattdessen herunter — entschlüsselt wurde es ohnehin hier.', + 'preview.too_large': 'Diese Datei ist {size} groß, mehr als diese Seite im Arbeitsspeicher halten kann ({limit}). Laden Sie sie stattdessen herunter — ein Download wird direkt auf die Festplatte geschrieben.', + 'download.too_large_for_memory': '„{name}“ ist {size} groß. Dieser Browser kann eine Datei dieser Größe nur speichern, indem er sie direkt auf die Festplatte schreibt, und das ist hier nicht möglich — er müsste die ganze Datei im Arbeitsspeicher halten. Laden Sie die Seite neu und versuchen Sie es erneut; falls das nicht hilft, verwenden Sie die Desktop-App.', 'group.upload_indexing': 'wird indiziert …', 'video.err_transport': 'Transport nicht verbunden', 'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}', @@ -313,7 +317,7 @@ export default { + 'auch wenn Sie eine Auswahl herunterladen.', 'settings.dl_folder': 'Ordner: {name}', 'settings.dl_no_folder': 'Kein Ordner ausgewählt — Downloads landen dort, wo Ihr ' - + 'Browser sie ablegt', + + 'Browser sie ablegt, und sie lassen sich nicht anhalten', 'settings.dl_choose': 'Ordner auswählen', 'settings.dl_change': 'Ändern', 'settings.dl_forget': 'Verwerfen', @@ -574,6 +578,24 @@ export default { 'transfers.open': 'Öffnen', 'transfers.done': 'Abgeschlossen', 'transfers.cancelled': 'Abgebrochen', + 'transfers.preparing': 'Wird vorbereitet…', + 'transfers.waiting_own_slots': 'Wartet — Ihre Plätze sind belegt', + 'transfers.waiting_node': 'Wartet — {n} davor', + 'transfers.summary': '{running} laufend · {waiting} wartend', + 'transfers.group_running': 'Laufend', + 'transfers.group_waiting': 'Wartend', + 'transfers.group_paused': 'Angehalten', + 'transfers.group_finished': 'Abgeschlossen', + 'transfers.cancel_one': '{name} abbrechen', + 'transfers.pause': 'Anhalten', + 'transfers.resume': 'Fortsetzen', + 'transfers.paused': 'Angehalten', + 'transfers.not_pausable': 'Kann nicht angehalten werden — wählen Sie in den Einstellungen einen Download-Ordner', + 'transfers.pause_one': '{name} anhalten', + 'transfers.resume_one': '{name} fortsetzen', + 'transfers.eta_seconds': 'noch {n} s', + 'transfers.eta_minutes': 'noch {n} Min.', + 'transfers.eta_hours': 'noch {n} Std.', 'transfers.failed': 'Fehlgeschlagen', 'group.select': 'Auswählen', 'group.select_done': 'Fertig', @@ -737,6 +759,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': 'Max. gleichzeitige Downloads', + 'node.setting_max_uploads': 'Max. gleichzeitige Uploads', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index c55a702..cb7f4a0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'New folder', 'group.mkdir_prompt': 'New folder name', 'group.mkdir_offline': 'Not connected to the node.', + 'group.download_offline': 'Not connected to the node — the download cannot start. It reconnects on its own; try again in a moment.', + 'group.download_write_stalled': 'The file stopped being written to disk ({seconds}s with no progress). The download was stopped rather than left hanging; try it again.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -214,6 +216,8 @@ export default { 'video.from_start': "Start from the beginning", 'video.close': 'Close (Esc)', 'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.', + 'preview.too_large': 'This file is {size}, more than this page can hold in memory ({limit}). Download it instead — a download is written straight to disk.', + 'download.too_large_for_memory': '"{name}" is {size}. This browser can only save a file that large by streaming it to disk, and it has no way to do that here — it would have to hold the whole file in memory. Reload the page and try again; if that does not help, use the desktop app.', 'group.upload_indexing': 'indexing…', 'video.err_transport': 'Transport not connected', 'video.err_mse': 'Codec not supported for streaming: {codec}', @@ -311,7 +315,7 @@ export default { + 'including when you download a selection.', 'settings.dl_folder': 'Folder: {name}', 'settings.dl_no_folder': 'No folder chosen — downloads go wherever your browser ' - + 'puts them', + + 'puts them, and they cannot be paused', 'settings.dl_choose': 'Choose folder', 'settings.dl_change': 'Change', 'settings.dl_forget': 'Forget', @@ -690,6 +694,24 @@ export default { 'transfers.open': 'Open', 'transfers.done': 'Finished', 'transfers.cancelled': 'Cancelled', + 'transfers.preparing': 'Preparing…', + 'transfers.waiting_own_slots': 'Waiting — your slots are busy', + 'transfers.waiting_node': 'Waiting — {n} ahead', + 'transfers.summary': '{running} running · {waiting} waiting', + 'transfers.group_running': 'Running', + 'transfers.group_waiting': 'Waiting', + 'transfers.group_paused': 'Paused', + 'transfers.group_finished': 'Finished', + 'transfers.cancel_one': 'Cancel {name}', + 'transfers.pause': 'Pause', + 'transfers.resume': 'Resume', + 'transfers.paused': 'Paused', + 'transfers.not_pausable': 'Cannot be paused — choose a download folder in Settings to enable it', + 'transfers.pause_one': 'Pause {name}', + 'transfers.resume_one': 'Resume {name}', + 'transfers.eta_seconds': '{n}s left', + 'transfers.eta_minutes': '{n} min left', + 'transfers.eta_hours': '{n} h left', 'transfers.failed': 'Failed', 'group.select': 'Select', 'group.select_done': 'Done', @@ -888,6 +910,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': 'Max concurrent downloads', + 'node.setting_max_uploads': 'Max concurrent uploads', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 9ce3900..fe1c13b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -160,6 +160,8 @@ export default { 'group.mkdir': 'Nueva carpeta', 'group.mkdir_prompt': 'Nombre de la nueva carpeta', 'group.mkdir_offline': 'Sin conexión con el nodo.', + 'group.download_offline': 'Sin conexión con el nodo — la descarga no puede empezar. Se reconecta sola; inténtelo de nuevo en un momento.', + 'group.download_write_stalled': 'El archivo dejó de escribirse en el disco ({seconds} s sin avance). La descarga se detuvo en lugar de quedarse colgada; inténtelo de nuevo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -214,6 +216,8 @@ export default { 'video.close': 'Cerrar (Esc)', 'preview.pdf_fallback': 'Este navegador no mostrará el PDF integrado. Descárguelo ' + 'en su lugar — en cualquier caso se descifró aquí.', + 'preview.too_large': 'Este archivo ocupa {size}, más de lo que esta página puede mantener en memoria ({limit}). Descárguelo en su lugar — una descarga se escribe directamente en disco.', + 'download.too_large_for_memory': '«{name}» ocupa {size}. Este navegador solo puede guardar un archivo así transmitiéndolo al disco, y aquí no puede hacerlo — tendría que mantener el archivo entero en memoria. Recargue la página e inténtelo de nuevo; si eso no ayuda, use la aplicación de escritorio.', 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte no conectado', 'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}', @@ -311,7 +315,7 @@ export default { + 'también cuando descarga una selección.', 'settings.dl_folder': 'Carpeta: {name}', 'settings.dl_no_folder': 'Ninguna carpeta elegida — las descargas van adonde las ' - + 'ponga su navegador', + + 'ponga su navegador, y no se pueden pausar', 'settings.dl_choose': 'Elegir carpeta', 'settings.dl_change': 'Cambiar', 'settings.dl_forget': 'Olvidar', @@ -570,6 +574,24 @@ export default { 'transfers.open': 'Abrir', 'transfers.done': 'Terminada', 'transfers.cancelled': 'Cancelada', + 'transfers.preparing': 'Preparando…', + 'transfers.waiting_own_slots': 'En espera — sus espacios están ocupados', + 'transfers.waiting_node': 'En espera — {n} por delante', + 'transfers.summary': '{running} en curso · {waiting} en espera', + 'transfers.group_running': 'En curso', + 'transfers.group_waiting': 'En espera', + 'transfers.group_paused': 'En pausa', + 'transfers.group_finished': 'Finalizados', + 'transfers.cancel_one': 'Cancelar {name}', + 'transfers.pause': 'Pausar', + 'transfers.resume': 'Reanudar', + 'transfers.paused': 'En pausa', + 'transfers.not_pausable': 'No se puede pausar — elija una carpeta de descargas en Ajustes para activarlo', + 'transfers.pause_one': 'Pausar {name}', + 'transfers.resume_one': 'Reanudar {name}', + 'transfers.eta_seconds': 'quedan {n} s', + 'transfers.eta_minutes': 'quedan {n} min', + 'transfers.eta_hours': 'quedan {n} h', 'transfers.failed': 'Fallida', 'group.select': 'Seleccionar', 'group.select_done': 'Listo', @@ -732,6 +754,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': 'Descargas simultáneas máximas', + 'node.setting_max_uploads': 'Subidas simultáneas máximas', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index d612f71..ea3f60e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -161,6 +161,8 @@ export default { 'group.mkdir': 'Nouveau dossier', 'group.mkdir_prompt': 'Nom du nouveau dossier', 'group.mkdir_offline': 'Non connecté au nœud.', + 'group.download_offline': 'Pas de connexion au node — le téléchargement ne peut pas démarrer. La reconnexion est automatique, réessayez dans un instant.', + 'group.download_write_stalled': 'L\'écriture du fichier sur le disque s\'est arrêtée ({seconds} s sans progression). Le téléchargement a été interrompu plutôt que laissé en suspens ; réessayez.', 'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud', 'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.', 'device.add_btn': 'Obtenir un code de liaison', @@ -215,6 +217,8 @@ export default { 'video.close': 'Fermer (Échap)', 'preview.pdf_fallback': 'Ce navigateur n’affichera pas le PDF directement. ' + 'Téléchargez-le plutôt — il a été déchiffré ici dans les deux cas.', + 'preview.too_large': 'Ce fichier fait {size}, plus que cette page ne peut garder en mémoire ({limit}). Téléchargez-le plutôt — un téléchargement est écrit directement sur le disque.', + 'download.too_large_for_memory': '« {name} » fait {size}. Ce navigateur ne peut enregistrer un fichier de cette taille qu\'en l\'écrivant au fil de l\'eau sur le disque, ce qu\'il ne peut pas faire ici — il devrait garder le fichier entier en mémoire. Rechargez la page et réessayez ; si cela ne suffit pas, utilisez l\'application de bureau.', 'group.upload_indexing': 'indexation…', 'video.err_transport': 'Transport non connecté', 'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}', @@ -312,7 +316,7 @@ export default { + 'un par fichier, y compris lorsque vous téléchargez une sélection.', 'settings.dl_folder': 'Dossier : {name}', 'settings.dl_no_folder': 'Aucun dossier choisi — les téléchargements vont là où ' - + 'votre navigateur les place', + + 'votre navigateur les place, et ne peuvent pas être suspendus', 'settings.dl_choose': 'Choisir un dossier', 'settings.dl_change': 'Changer', 'settings.dl_forget': 'Oublier', @@ -573,6 +577,24 @@ export default { 'transfers.open': 'Ouvrir', 'transfers.done': 'Terminé', 'transfers.cancelled': 'Annulé', + 'transfers.preparing': 'Préparation…', + 'transfers.waiting_own_slots': 'En attente — vos slots sont occupés', + 'transfers.waiting_node': 'En attente — {n} devant', + 'transfers.summary': '{running} en cours · {waiting} en attente', + 'transfers.group_running': 'En cours', + 'transfers.group_waiting': 'En attente', + 'transfers.group_paused': 'En pause', + 'transfers.group_finished': 'Terminés', + 'transfers.cancel_one': 'Annuler {name}', + 'transfers.pause': 'Suspendre', + 'transfers.resume': 'Reprendre', + 'transfers.paused': 'En pause', + 'transfers.not_pausable': 'Non suspendable — choisissez un dossier de téléchargement dans les Réglages pour l\'activer', + 'transfers.pause_one': 'Suspendre {name}', + 'transfers.resume_one': 'Reprendre {name}', + 'transfers.eta_seconds': '{n} s restantes', + 'transfers.eta_minutes': '{n} min restantes', + 'transfers.eta_hours': '{n} h restantes', 'transfers.failed': 'Échec', 'group.select': 'Sélectionner', 'group.select_done': 'Terminé', @@ -735,6 +757,8 @@ export default { 'node.setting_pair_ttl': 'Durée du code d\'appairage', 'node.setting_device_ttl': 'Durée des demandes d\'appareil', 'node.setting_max_streams': 'Flux vidéo simultanés max', + 'node.setting_max_downloads': 'Téléchargements simultanés max', + 'node.setting_max_uploads': 'Téléversements simultanés max', 'node.setting_transcode': 'Transcoder les vidéos incompatibles', 'node.setting_unit_hours': 'heures', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index c61b8e5..0687b25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -161,6 +161,8 @@ export default { 'group.mkdir': 'Nuova cartella', 'group.mkdir_prompt': 'Nome della nuova cartella', 'group.mkdir_offline': 'Non connesso al nodo.', + 'group.download_offline': 'Nessuna connessione al nodo — il download non può iniziare. La riconnessione è automatica, riprovi tra poco.', + 'group.download_write_stalled': 'Il file ha smesso di essere scritto su disco ({seconds} s senza progressi). Il download è stato interrotto invece di restare bloccato; riprovi.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -215,6 +217,8 @@ export default { 'video.close': 'Chiudi (Esc)', 'preview.pdf_fallback': 'Questo browser non mostrerà il PDF nella pagina. Lo scarichi ' + 'invece — in ogni caso è stato decifrato qui.', + 'preview.too_large': 'Questo file è di {size}, più di quanto questa pagina possa tenere in memoria ({limit}). Lo scarichi invece — un download viene scritto direttamente su disco.', + 'download.too_large_for_memory': '«{name}» è di {size}. Questo browser può salvare un file di queste dimensioni solo scrivendolo su disco man mano, e qui non può farlo — dovrebbe tenere l’intero file in memoria. Ricarichi la pagina e riprovi; se non basta, usi l’applicazione desktop.', 'group.upload_indexing': 'indicizzazione…', 'video.err_transport': 'Trasporto non connesso', 'video.err_mse': 'Codec non supportato per lo streaming: {codec}', @@ -312,7 +316,7 @@ export default { + 'file, anche quando scarica una selezione.', 'settings.dl_folder': 'Cartella: {name}', 'settings.dl_no_folder': 'Nessuna cartella scelta — i download finiscono dove li ' - + 'colloca il browser', + + 'colloca il browser, e non si possono sospendere', 'settings.dl_choose': 'Scegli una cartella', 'settings.dl_change': 'Cambia', 'settings.dl_forget': 'Dimentica', @@ -573,6 +577,24 @@ export default { 'transfers.open': 'Apri', 'transfers.done': 'Completato', 'transfers.cancelled': 'Annullato', + 'transfers.preparing': 'Preparazione…', + 'transfers.waiting_own_slots': 'In attesa — i suoi posti sono occupati', + 'transfers.waiting_node': 'In attesa — {n} prima', + 'transfers.summary': '{running} in corso · {waiting} in attesa', + 'transfers.group_running': 'In corso', + 'transfers.group_waiting': 'In attesa', + 'transfers.group_paused': 'In pausa', + 'transfers.group_finished': 'Completati', + 'transfers.cancel_one': 'Annulla {name}', + 'transfers.pause': 'Sospendi', + 'transfers.resume': 'Riprendi', + 'transfers.paused': 'In pausa', + 'transfers.not_pausable': 'Non si può sospendere — scelga una cartella di download nelle Impostazioni', + 'transfers.pause_one': 'Sospendi {name}', + 'transfers.resume_one': 'Riprendi {name}', + 'transfers.eta_seconds': '{n} s rimanenti', + 'transfers.eta_minutes': '{n} min rimanenti', + 'transfers.eta_hours': '{n} h rimanenti', 'transfers.failed': 'Non riuscito', 'group.select': 'Seleziona', 'group.select_done': 'Fine', @@ -734,6 +756,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': 'Download simultanei massimi', + 'node.setting_max_uploads': 'Caricamenti simultanei massimi', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 20c5fd1..a02e058 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -159,6 +159,8 @@ export default { 'group.mkdir': '新しいフォルダー', 'group.mkdir_prompt': '新しいフォルダー名', 'group.mkdir_offline': 'ノードに接続していません。', + 'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。', + 'group.download_write_stalled': 'ファイルのディスクへの書き込みが止まりました({seconds} 秒間進みません)。ぶら下がったままにせず中止しました。もう一度お試しください。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -212,6 +214,8 @@ export default { 'video.close': '閉じる(Esc)', 'preview.pdf_fallback': 'このブラウザーはページ内に PDF を表示しません。' + 'ダウンロードしてご覧ください。いずれにせよ復号はここで行われています。', + 'preview.too_large': 'このファイルは {size} で、このページがメモリに保持できる上限({limit})を超えています。代わりにダウンロードしてください。ダウンロードはディスクに直接書き込まれます。', + 'download.too_large_for_memory': '「{name}」は {size} です。このブラウザーでこの大きさのファイルを保存するにはディスクへ逐次書き出すしかありませんが、ここではそれができません — ファイル全体をメモリに保持することになります。ページを再読み込みしてもう一度お試しください。解決しない場合はデスクトップアプリをお使いください。', 'group.upload_indexing': 'インデックスを作成中…', 'video.err_transport': 'トランスポートが接続されていません', 'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}', @@ -309,7 +313,7 @@ export default { + 'まとめてダウンロードする場合も 1 ファイルにつき 1 回です。', 'settings.dl_folder': 'フォルダー:{name}', 'settings.dl_no_folder': 'フォルダーが選ばれていません。ダウンロードは' - + 'ブラウザーが決めた場所に保存されます', + + 'ブラウザーが決めた場所に保存され、一時停止できません', 'settings.dl_choose': 'フォルダーを選択', 'settings.dl_change': '変更', 'settings.dl_forget': '解除', @@ -565,6 +569,24 @@ export default { 'transfers.open': '開く', 'transfers.done': '完了', 'transfers.cancelled': 'キャンセル済み', + 'transfers.preparing': '準備中…', + 'transfers.waiting_own_slots': '待機中 — 自分の枠がすべて使用中です', + 'transfers.waiting_node': '待機中 — 前に {n} 件', + 'transfers.summary': '実行中 {running} · 待機中 {waiting}', + 'transfers.group_running': '実行中', + 'transfers.group_waiting': '待機中', + 'transfers.group_paused': '一時停止中', + 'transfers.group_finished': '完了', + 'transfers.cancel_one': '{name} をキャンセル', + 'transfers.pause': '一時停止', + 'transfers.resume': '再開', + 'transfers.paused': '一時停止中', + 'transfers.not_pausable': '一時停止できません。設定でダウンロードフォルダーを選ぶと使えます', + 'transfers.pause_one': '{name} を一時停止', + 'transfers.resume_one': '{name} を再開', + 'transfers.eta_seconds': '残り {n} 秒', + 'transfers.eta_minutes': '残り {n} 分', + 'transfers.eta_hours': '残り {n} 時間', 'transfers.failed': '失敗', 'group.select': '選択', 'group.select_done': '完了', @@ -722,6 +744,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': '同時ダウンロードの上限', + 'node.setting_max_uploads': '同時アップロードの上限', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 4b992ae..0235d8a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'Nieuwe map', 'group.mkdir_prompt': 'Naam van de nieuwe map', 'group.mkdir_offline': 'Niet verbonden met de node.', + 'group.download_offline': 'Geen verbinding met de node — de download kan niet starten. Er wordt automatisch opnieuw verbonden; probeer het zo weer.', + 'group.download_write_stalled': 'Het bestand wordt niet meer naar schijf geschreven ({seconds} s zonder voortgang). De download is gestopt in plaats van te blijven hangen; probeer het opnieuw.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -216,6 +218,8 @@ export default { 'video.close': 'Sluiten (Esc)', 'preview.pdf_fallback': 'Deze browser toont de PDF niet in de pagina zelf. Download ' + 'hem in plaats daarvan — ontsleuteld werd hij hoe dan ook hier.', + 'preview.too_large': 'Dit bestand is {size}, meer dan deze pagina in het geheugen kan houden ({limit}). Download het in plaats daarvan — een download wordt rechtstreeks naar schijf geschreven.', + 'download.too_large_for_memory': '“{name}” is {size}. Deze browser kan een bestand van die omvang alleen opslaan door het meteen naar schijf te schrijven, en dat kan hier niet — het hele bestand zou in het geheugen moeten. Herlaad de pagina en probeer het opnieuw; als dat niet helpt, gebruik dan de desktop-app.', 'group.upload_indexing': 'indexeren…', 'video.err_transport': 'Transport niet verbonden', 'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}', @@ -313,7 +317,7 @@ export default { + 'ook wanneer u een selectie downloadt.', 'settings.dl_folder': 'Map: {name}', 'settings.dl_no_folder': 'Geen map gekozen — downloads komen terecht waar uw browser ' - + 'ze neerzet', + + 'ze neerzet, en ze kunnen niet worden gepauzeerd', 'settings.dl_choose': 'Map kiezen', 'settings.dl_change': 'Wijzigen', 'settings.dl_forget': 'Vergeten', @@ -574,6 +578,24 @@ export default { 'transfers.open': 'Openen', 'transfers.done': 'Voltooid', 'transfers.cancelled': 'Geannuleerd', + 'transfers.preparing': 'Voorbereiden…', + 'transfers.waiting_own_slots': 'Wacht — uw plaatsen zijn bezet', + 'transfers.waiting_node': 'Wacht — {n} ervoor', + 'transfers.summary': '{running} bezig · {waiting} wachtend', + 'transfers.group_running': 'Bezig', + 'transfers.group_waiting': 'Wachtend', + 'transfers.group_paused': 'Gepauzeerd', + 'transfers.group_finished': 'Voltooid', + 'transfers.cancel_one': '{name} annuleren', + 'transfers.pause': 'Pauzeren', + 'transfers.resume': 'Hervatten', + 'transfers.paused': 'Gepauzeerd', + 'transfers.not_pausable': 'Kan niet worden gepauzeerd — kies een downloadmap in Instellingen', + 'transfers.pause_one': '{name} pauzeren', + 'transfers.resume_one': '{name} hervatten', + 'transfers.eta_seconds': 'nog {n} s', + 'transfers.eta_minutes': 'nog {n} min', + 'transfers.eta_hours': 'nog {n} u', 'transfers.failed': 'Mislukt', 'group.select': 'Selecteren', 'group.select_done': 'Klaar', @@ -736,6 +758,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': 'Max. gelijktijdige downloads', + 'node.setting_max_uploads': 'Max. gelijktijdige uploads', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 5389220..af4a5bb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -165,6 +165,8 @@ export default { 'group.mkdir': 'Nowy folder', 'group.mkdir_prompt': 'Nazwa nowego folderu', 'group.mkdir_offline': 'Brak połączenia z węzłem.', + 'group.download_offline': 'Brak połączenia z węzłem — pobieranie nie może się rozpocząć. Połączenie wróci samo; proszę spróbować za chwilę.', + 'group.download_write_stalled': 'Plik przestał być zapisywany na dysk ({seconds} s bez postępu). Pobieranie zostało przerwane, zamiast wisieć w nieskończoność; proszę spróbować ponownie.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -221,6 +223,8 @@ export default { 'video.close': 'Zamknij (Esc)', 'preview.pdf_fallback': 'Ta przeglądarka nie wyświetli pliku PDF na stronie. Proszę ' + 'go pobrać — i tak został odszyfrowany tutaj.', + 'preview.too_large': 'Ten plik ma {size}, więcej niż ta strona może utrzymać w pamięci ({limit}). Proszę go zamiast tego pobrać — pobieranie jest zapisywane wprost na dysk.', + 'download.too_large_for_memory': '„{name}” ma {size}. Ta przeglądarka może zapisać plik tej wielkości tylko strumieniowo na dysk, a tutaj nie ma takiej możliwości — musiałaby utrzymać cały plik w pamięci. Proszę odświeżyć stronę i spróbować ponownie; jeśli to nie pomoże, proszę użyć aplikacji desktopowej.', 'group.upload_indexing': 'indeksowanie…', 'video.err_transport': 'Transport nie jest połączony', 'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}', @@ -324,7 +328,7 @@ export default { + 'także przy pobieraniu zaznaczonych pozycji.', 'settings.dl_folder': 'Folder: {name}', 'settings.dl_no_folder': 'Nie wybrano folderu — pobrane pliki trafiają tam, gdzie ' - + 'umieszcza je przeglądarka', + + 'umieszcza je przeglądarka, i nie można ich wstrzymać', 'settings.dl_choose': 'Wybierz folder', 'settings.dl_change': 'Zmień', 'settings.dl_forget': 'Zapomnij', @@ -586,6 +590,24 @@ export default { 'transfers.open': 'Otwórz', 'transfers.done': 'Zakończony', 'transfers.cancelled': 'Anulowany', + 'transfers.preparing': 'Przygotowywanie…', + 'transfers.waiting_own_slots': 'Oczekiwanie — Twoje miejsca są zajęte', + 'transfers.waiting_node': 'Oczekiwanie — {n} przed', + 'transfers.summary': '{running} w toku · {waiting} oczekuje', + 'transfers.group_running': 'W toku', + 'transfers.group_waiting': 'Oczekuje', + 'transfers.group_paused': 'Wstrzymane', + 'transfers.group_finished': 'Zakończone', + 'transfers.cancel_one': 'Anuluj {name}', + 'transfers.pause': 'Wstrzymaj', + 'transfers.resume': 'Wznów', + 'transfers.paused': 'Wstrzymano', + 'transfers.not_pausable': 'Nie można wstrzymać — proszę wybrać folder pobierania w Ustawieniach', + 'transfers.pause_one': 'Wstrzymaj {name}', + 'transfers.resume_one': 'Wznów {name}', + 'transfers.eta_seconds': 'pozostało {n} s', + 'transfers.eta_minutes': 'pozostało {n} min', + 'transfers.eta_hours': 'pozostało {n} godz.', 'transfers.failed': 'Nie powiódł się', 'group.select': 'Zaznacz', 'group.select_done': 'Gotowe', @@ -754,6 +776,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': 'Maks. równoczesnych pobierań', + 'node.setting_max_uploads': 'Maks. równoczesnych wysyłek', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index d72a6e7..f179c6f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'Nova pasta', 'group.mkdir_prompt': 'Nome da nova pasta', 'group.mkdir_offline': 'Sem conexão com o nó.', + 'group.download_offline': 'Sem conexão com o nó — o download não pode começar. Ele reconecta sozinho; tente de novo em instantes.', + 'group.download_write_stalled': 'O arquivo parou de ser gravado no disco ({seconds}s sem progresso). O download foi interrompido em vez de ficar travado; tente de novo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -216,6 +218,8 @@ export default { 'video.close': 'Fechar (Esc)', 'preview.pdf_fallback': 'Este navegador não exibirá o PDF na própria página. Baixe ' + 'o arquivo — de todo modo ele foi descriptografado aqui.', + 'preview.too_large': 'Este arquivo tem {size}, mais do que esta página consegue manter na memória ({limit}). Baixe-o em vez disso — um download é gravado direto no disco.', + 'download.too_large_for_memory': '"{name}" tem {size}. Este navegador só consegue salvar um arquivo desse tamanho gravando-o direto no disco, e aqui ele não tem como — precisaria manter o arquivo inteiro na memória. Recarregue a página e tente novamente; se não resolver, use o aplicativo para computador.', 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte não conectado', 'video.err_mse': 'Codec sem suporte para transmissão: {codec}', @@ -313,7 +317,7 @@ export default { + 'arquivo, inclusive quando você baixa uma seleção.', 'settings.dl_folder': 'Pasta: {name}', 'settings.dl_no_folder': 'Nenhuma pasta escolhida — os downloads vão para onde o ' - + 'seu navegador os colocar', + + 'seu navegador os colocar, e não podem ser pausados', 'settings.dl_choose': 'Escolher pasta', 'settings.dl_change': 'Alterar', 'settings.dl_forget': 'Esquecer', @@ -572,6 +576,24 @@ export default { 'transfers.open': 'Abrir', 'transfers.done': 'Concluída', 'transfers.cancelled': 'Cancelada', + 'transfers.preparing': 'Preparando…', + 'transfers.waiting_own_slots': 'Aguardando — seus espaços estão ocupados', + 'transfers.waiting_node': 'Aguardando — {n} na frente', + 'transfers.summary': '{running} em andamento · {waiting} aguardando', + 'transfers.group_running': 'Em andamento', + 'transfers.group_waiting': 'Aguardando', + 'transfers.group_paused': 'Pausados', + 'transfers.group_finished': 'Concluídos', + 'transfers.cancel_one': 'Cancelar {name}', + 'transfers.pause': 'Pausar', + 'transfers.resume': 'Retomar', + 'transfers.paused': 'Pausado', + 'transfers.not_pausable': 'Não pode ser pausado — escolha uma pasta de downloads em Configurações', + 'transfers.pause_one': 'Pausar {name}', + 'transfers.resume_one': 'Retomar {name}', + 'transfers.eta_seconds': 'faltam {n} s', + 'transfers.eta_minutes': 'faltam {n} min', + 'transfers.eta_hours': 'faltam {n} h', 'transfers.failed': 'Falhou', 'group.select': 'Selecionar', 'group.select_done': 'Concluir', @@ -733,6 +755,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': 'Máximo de downloads simultâneos', + 'node.setting_max_uploads': 'Máximo de envios simultâneos', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index c672a13..c89bbdc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -158,6 +158,8 @@ export default { 'group.mkdir': '新建文件夹', 'group.mkdir_prompt': '新文件夹名称', 'group.mkdir_offline': '未连接到节点。', + 'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。', + 'group.download_write_stalled': '文件停止写入磁盘({seconds} 秒无进展)。已中止下载而不是让它一直卡住,请重试。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -209,6 +211,8 @@ export default { 'video.from_start': "从头开始播放", 'video.close': '关闭(Esc)', 'preview.pdf_fallback': '此浏览器不会在页面内显示该 PDF。请改为下载——无论如何它都已在本地解密。', + 'preview.too_large': '该文件为 {size},超出本页面可在内存中保存的上限({limit})。请改为下载——下载会直接写入磁盘。', + 'download.too_large_for_memory': '“{name}”为 {size}。此浏览器只能通过边下边写入磁盘来保存这么大的文件,而这里无法做到——它将不得不把整个文件放在内存中。请重新加载页面后重试;如果仍然无效,请使用桌面应用。', 'group.upload_indexing': '建立索引中…', 'video.err_transport': '传输未连接', 'video.err_mse': '该编解码器不支持流式播放:{codec}', @@ -305,7 +309,7 @@ export default { 'settings.dl_ask_hint': '每个文件弹出一次“另存为”对话框——每个文件一次,' + '批量下载时也是如此。', 'settings.dl_folder': '文件夹:{name}', - 'settings.dl_no_folder': '未选择文件夹——下载内容会保存到浏览器指定的位置', + 'settings.dl_no_folder': '未选择文件夹——下载内容会保存到浏览器指定的位置,且无法暂停', 'settings.dl_choose': '选择文件夹', 'settings.dl_change': '更改', 'settings.dl_forget': '忘记', @@ -553,6 +557,24 @@ export default { 'transfers.open': '打开', 'transfers.done': '已完成', 'transfers.cancelled': '已取消', + 'transfers.preparing': '准备中…', + 'transfers.waiting_own_slots': '等待中 — 您的通道已占满', + 'transfers.waiting_node': '等待中 — 前面还有 {n} 个', + 'transfers.summary': '进行中 {running} · 等待中 {waiting}', + 'transfers.group_running': '进行中', + 'transfers.group_waiting': '等待中', + 'transfers.group_paused': '已暂停', + 'transfers.group_finished': '已完成', + 'transfers.cancel_one': '取消 {name}', + 'transfers.pause': '暂停', + 'transfers.resume': '继续', + 'transfers.paused': '已暂停', + 'transfers.not_pausable': '无法暂停——请在设置中选择下载文件夹以启用', + 'transfers.pause_one': '暂停 {name}', + 'transfers.resume_one': '继续 {name}', + 'transfers.eta_seconds': '剩余 {n} 秒', + 'transfers.eta_minutes': '剩余 {n} 分钟', + 'transfers.eta_hours': '剩余 {n} 小时', 'transfers.failed': '失败', 'group.select': '选择', 'group.select_done': '完成', @@ -709,6 +731,8 @@ export default { 'node.setting_pair_ttl': 'Pairing code TTL', 'node.setting_device_ttl': 'Device request TTL', 'node.setting_max_streams': 'Max concurrent streams', + 'node.setting_max_downloads': '最大同时下载数', + 'node.setting_max_uploads': '最大同时上传数', 'node.setting_transcode': 'Transcode incompatible video', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 2e216a9..c1d57f9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -1044,6 +1044,20 @@ export function NodePage({ groups }) { </div> </label> <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_max_downloads')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.max_concurrent_downloads} + onInput=${e => setEditSettings(s => ({...s, max_concurrent_downloads: parseInt(e.target.value) || 1}))} /> + </div> + </label> + <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_max_uploads')}</span> + <div class="node-setting-input"> + <input type="number" min="1" value=${editSettings.max_concurrent_uploads} + onInput=${e => setEditSettings(s => ({...s, max_concurrent_uploads: parseInt(e.target.value) || 1}))} /> + </div> + </label> + <label class="node-setting"> <span class="node-setting-label">${t('node.setting_transcode')}</span> <div class="node-setting-input"> <input type="checkbox" checked=${editSettings.transcode_incompatible_video} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index dfed44b..5e08a6a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1975,6 +1975,30 @@ a.transfer-name { display: flex; } .transfer-cancel:hover { color: var(--error); } +/* Same shape as cancel, and beside it: pausing and cancelling are the two + things a person does to a transfer, and one of them is not destructive. */ +.transfer-pause { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 0 2px; + display: flex; +} +.transfer-pause:hover { color: var(--accent); } +/* Not a button: there is nothing to click. Dimmer than the cancel beside it, + and it carries its explanation in a tooltip rather than in the row, which + would be four lines of prose in a panel that has none. */ +.transfer-nopause { + color: var(--text-dim); + opacity: .45; + padding: 0 2px; + display: flex; + cursor: help; +} +/* A paused bar keeps its fill -- what was written is still on disk -- but stops + looking like something in progress. */ +.dl-fill.dl-paused { background: var(--text-dim); } .transfer-meta { display: flex; justify-content: space-between; @@ -4243,3 +4267,67 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } color: var(--warn); margin-bottom: 2px; } + +/* A transfer waiting for a slot. Deliberately not a progress bar at 0%: it is + not stalled and nothing is wrong, and a bar that never moves is exactly how a + queue comes to look like a hang. The stripes say "not yet", not "broken". */ +.dl-progress.dl-waiting { + background-color: var(--bg-raised); + background-image: repeating-linear-gradient( + 45deg, + var(--accent-bg) 0 8px, + transparent 8px 16px); + animation: dl-waiting-slide 1.1s linear infinite; +} +@keyframes dl-waiting-slide { + from { background-position: 0 0; } + to { background-position: 22.6px 0; } +} +/* The state has to survive without the motion: someone who asked for less of it + still needs to see that this row is waiting rather than stopped. */ +@media (prefers-reduced-motion: reduce) { + .dl-progress.dl-waiting { animation: none; } +} + +/* ── Transfers panel (grouped) ───────────────────────────────────────────── */ + +.transfer-head-title { font-weight: 600; } +.transfer-head-summary { + margin-left: auto; + margin-right: .5rem; + font-size: .85em; + color: var(--text-dim); + /* Never wraps to a second line: it is the one part of the header that grows + with what is happening, and a header that changes height as transfers come + and go pushes every row under it. */ + white-space: nowrap; +} +.transfer-group + .transfer-group { border-top: 1px solid var(--border); } +.transfer-group-head { + padding: .35rem .6rem .2rem; + font-size: .78em; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-dim); +} +/* Finished rows recede rather than disappear: somebody who just downloaded + three files wants to see that all three are there. */ +.transfer-item.transfer-done .transfer-name, +.transfer-item.transfer-cancelled .transfer-name { color: var(--text-dim); } + +/* Announced, not shown. The transfers panel needs a live region that says what + changed state without drawing anything — used with aria-live, so it must + stay in the accessibility tree: `display: none` would remove it from there + too and announce nothing at all, which is the usual way this is got wrong. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js index 119687d..5f663f9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/sw.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js @@ -17,6 +17,35 @@ */ const PREFIX = '/_mbdl/'; + +/** + * A filename, safe to put in Content-Disposition. + * + * `encodeURIComponent` alone is not enough, and the way it fails is invisible + * until somebody downloads the wrong film: it leaves `'` untouched, and `'` is + * the *delimiter* in RFC 5987's `filename*=<charset>'<lang>'<value>`. A single + * apostrophe in a name therefore makes the header unparseable, and a browser + * that cannot parse it falls back to the last segment of the URL — which here + * is the made-up id this worker answers on. The file arrives complete, 449 MB + * of it, called "mtsshk9w-ohqty535". + * + * Found by downloading three files where exactly one had an apostrophe in its + * name. `(`, `)` and `*` are excluded from RFC 5987's attr-char for the same + * reason and get the same treatment. + * + * The plain `filename=` beside it is the ASCII fallback every parser + * understands: it loses the accents, and it is what stops a name being lost + * entirely the next time one of these encodings surprises us. + */ +function contentDisposition(name) { + const encoded = encodeURIComponent(name) + .replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()); + // Quotes and backslashes would end the quoted-string early; anything not + // plain ASCII is dropped rather than mangled, since the starred form above + // carries the real name. + const ascii = name.replace(/["\\]/g, '_').replace(/[^\x20-\x7e]/g, '_'); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; +} const pending = new Map(); self.addEventListener('install', () => self.skipWaiting()); @@ -24,6 +53,31 @@ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim( self.addEventListener('message', (event) => { const data = event.data || {}; + // A worker with nothing to do is terminated — Firefox after about thirty + // seconds, and `respondWith(new Response(stream))` does not extend its life + // for the duration of the response. So a download longer than that lost its + // reader mid-file: the page's next `write()` never resolved and never + // rejected, the progress bar stopped, the console stayed empty and the node + // went on looking perfectly healthy. Handling a message is an event, and an + // event resets that timer, so the page pings while it is writing. + // + // It also has to be answered: a ping that only arrives keeps *this* worker + // alive, and the reply is how the page learns the worker it is talking to is + // still the one holding its stream. + if (data.type === 'mbdl-ping') { + if (event.ports && event.ports[0]) { + try { event.ports[0].postMessage({ type: 'mbdl-pong' }); } catch { /* gone */ } + } + return; + } + // A page that loaded before any worker existed can miss the claim on + // activate. Rather than declare the streamed path unavailable — which on + // Firefox and Safari means the download cannot happen at all — the page asks + // for another claim and waits a moment longer. + if (data.type === 'mbdl-claim') { + event.waitUntil(self.clients.claim()); + return; + } if (data.type !== 'mbdl' || !data.id || !data.readable) return; pending.set(data.id, { readable: data.readable, @@ -35,6 +89,21 @@ self.addEventListener('message', (event) => { // stream and the page's first write blocks for good. port: data.port || null, }); + // Say so, on the port the page is already listening to. + // + // `pending` is in memory, and a worker with nothing to do is terminated: + // Chrome after tens of seconds, which a long upload spends without giving + // this worker a single event. A stream posted to a worker in that state is + // lost, the iframe then wakes it with no entry to find, and the request falls + // through to the network — measured as a 404 from the hub and thirty seconds + // of nothing, twice, before the download started at all. + // + // The page waits for this before navigating, so the entry is known to be here + // rather than hoped to be. A page talking to an older worker gets no answer + // and navigates anyway, which is what it did before. + if (data.port) { + try { data.port.postMessage({ type: 'mbdl-ready', id: data.id }); } catch { /* gone */ } + } // A tab that is closed before it navigates would leave a stream here for the // life of the worker. setTimeout(() => pending.delete(data.id), 60000); @@ -56,8 +125,7 @@ self.addEventListener('fetch', (event) => { const headers = { 'Content-Type': 'application/octet-stream', // filename* so a name with accents or spaces survives the trip. - 'Content-Disposition': - `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`, + 'Content-Disposition': contentDisposition(entry.filename), 'Cache-Control': 'no-store', }; // Only when it is known. A zip is assembled as it goes and announcing a 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 ''; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index c4a24c5..0b2fed5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -40,6 +40,16 @@ async function _pkEdFromSk(skPkcs8B64) { // flight, which saturates any path up to roughly 100 Mb/s at 100 ms. const UPLOAD_CHUNK_SIZE = 48 * 1024; const UPLOAD_WINDOW = 32; +// "Where am I?", asked as an ordinary sealed upload chunk with no bytes rather +// than on a clear message. Mirrors UPLOAD_PROBE_INDEX in +// meshbay_common/protocol.py; the node writes nothing and answers with +// `resume_from`, and one that predates it refuses the index, which reads as +// "start from the beginning". +const UPLOAD_PROBE_INDEX = -1; +// How long to wait for that answer before assuming there is none. A node that +// answers neither the probe nor its refusal must not leave an upload waiting +// for ever, and starting over is always safe. +const UPLOAD_PROBE_TIMEOUT_MS = 5000; const UPLOAD_BUFFER_HIGH = 1024 * 1024; // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a @@ -251,8 +261,12 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '2.0'; -const MNP_V_MIN = '1.0'; +const MNP_V = '3.0'; +// Raised with it on the 3.0 flag day. A node older than 3.0 cannot grant the +// lease this client opens for every download and upload, so talking to one +// would mean every transfer failing for a reason the person cannot act on. +// Refusing it at the handshake says so once, in a sentence. +const MNP_V_MIN = '3.0'; // Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's // check_version): `version_too_old` means *we* are too old for it, @@ -283,6 +297,129 @@ const JOIN_REFUSALS = { group_mismatch: 'The node refused a request naming a different group.', }; +/** + * One transfer's slot on the node, from this side. + * + * The contract the transfer store depends on: `acquire()` resolves when the + * node has granted the slot (immediately on a node that hands out none), and + * `release(reason)` gives it back exactly once. Nothing else in the client + * speaks to the node about slots. + * + * Two things here exist only because a queue can lie, and both are the + * difference between "waiting" and "waiting for ever": + * + * - **the watchdog.** A grant is pushed, not polled, so a lost push leaves + * this side waiting on a node that believes it has started. Re-asking is + * free — the node is idempotent on `tr` — and it is the only thing that + * recovers a message that did not arrive. + * - **release is idempotent and unconditional.** A slot given back twice + * costs nothing; one never given back is a member who cannot transfer + * again until a timeout the node runs on its own. + */ +const LEASE_WATCHDOG_MS = 60000; + +class Lease { + constructor(transport, tr, kind, bytes, chunks, onState) { + this.transport = transport; + this.tr = tr; + this.kind = kind; + this.bytes = bytes; + this.chunks = chunks; + this.state = 'opening'; + this.ahead = 0; + this.closed = false; + this._onState = onState; + this._granted = null; + this._watchdog = 0; + this._wait = new Promise((resolve) => { this._granted = resolve; }); + } + + /** No slots on this node: behave as though one was granted at once. */ + _skip() { + this.state = 'granted'; + this._granted(); + } + + _request() { + // A closed channel is not a failure here, and must not throw: the transport + // reconnects on its own, `_reopenTransfers` re-asks for every live lease + // when it does, and the watchdog below asks again meanwhile. + // + // This is the same tolerance `_fetchChunkResilient` already gives a chunk + // request — and before leases existed, a chunk request was the first thing + // to touch the channel, so a download started on a briefly dead connection + // simply retried. Asking for a slot first made `_send` the first contact + // and threw "DataChannel not open (state: closed)" out of `downloadEntry`, + // where nothing catches it: a download that used to recover became an + // error with no row in the widget to show it. Found by downloading a file + // right after a connection dropped. + try { + this.transport._send({ + type: 'transfer_open', v: '0.1', tr: this.tr, kind: this.kind, + bytes: this.bytes, chunks: this.chunks, + }); + } catch (err) { + console.warn('[MeshBay] could not ask for a slot yet:', err.message); + } + this._arm(); + } + + _arm() { + clearTimeout(this._watchdog); + if (this.closed || this.state === 'granted') return; + this._watchdog = setTimeout(() => { + if (this.closed || this.state === 'granted') return; + console.warn('[MeshBay] no answer for transfer', this.tr.slice(0, 8), + '- asking again'); + this._request(); + }, LEASE_WATCHDOG_MS); + } + + _apply(msg) { + if (this.closed) return; + this.state = msg.state; + this.ahead = msg.ahead || 0; + this.used = msg.used; + this.cap = msg.cap; + if (msg.state === 'granted') { + clearTimeout(this._watchdog); + this._granted(); + } else if (msg.state === 'closed') { + // The node ended it: reclaimed as idle, or revoked. Not an error here — + // whoever is running the transfer finds out through its own failure — but + // the slot is gone and asking again is the only way back. + clearTimeout(this._watchdog); + } else { + this._arm(); + } + if (this._onState) { + try { this._onState(this); } catch (e) { + console.error('[MeshBay] lease state handler threw:', e); + } + } + } + + /** Resolves once the node has granted the slot. */ + acquire() { return this._wait; } + + /** + * Give the slot back. Safe to call twice, and safe on a dead transport: a + * lease that is not released is a member who cannot start another transfer + * until the node times it out, so this must never be conditional on anything. + */ + release(reason = 'done') { + if (this.closed) return; + this.closed = true; + clearTimeout(this._watchdog); + this.transport._leases.delete(this.tr); + if (!this.transport.supportsTransferSlots) return; + try { + this.transport._send({ type: 'transfer_close', v: '0.1', tr: this.tr, + reason }); + } catch { /* the connection is gone, and so is the lease with it */ } + } +} + class MeshBayTransport { constructor(hubUrl, accessToken) { this._hubUrl = hubUrl; @@ -315,6 +452,13 @@ class MeshBayTransport { // Names, not ids: the "already being uploaded" guard is about the file the // caller passed, and two `uploadFile` calls for one file draw two ids. this._inFlightUploads = new Set(); + // tr → Lease. A transfer's slot on the node, from the client's side. + this._leases = new Map(); + // Set from the handshake ack: a node that answers with `transfer_limits` + // speaks transfer slots. Used instead of a timeout, because "no answer + // yet" and "this node will never answer" are indistinguishable in time and + // guessing wrong either stalls every download or defeats the cap. + this._transferLimits = null; // Set once close() runs — stops the automatic reconnect from firing on a // connection the caller tore down on purpose (leaving the group, page // unload), which would otherwise race back in right as everything else @@ -377,6 +521,20 @@ class MeshBayTransport { get nodeVersion() { return this._nodeVersion || ''; } /** + * Whether this node hands out transfer slots. + * + * Read from the handshake ack rather than from the MNP version: the caps + * shipped before the version bump that will make leases compulsory, so for + * now a node either answers with `transfer_limits` or it predates all of + * this. A node that does not is asked for nothing and enforces nothing — + * every download behaves exactly as it did. + */ + get supportsTransferSlots() { return this._transferLimits !== null; } + + /** This member's own caps in this group, or null when the node said nothing. */ + get transferLimits() { return this._transferLimits; } + + /** * Whether the node speaks the per-root and per-app operations MNP 1.1 added: * `root_update`/`root_eject`/`root_plug`, `app_directories`, * `chat_directory`, `chat_link_preview`. @@ -880,6 +1038,7 @@ class MeshBayTransport { delete ack.nonce; delete ack.ct; Object.assign(ack, config); + this._transferLimits = ack.transfer_limits || null; // Tell the node which of this account's devices is on this connection. // Deliberately after the ack, and gated on the node's own version rather @@ -1016,6 +1175,10 @@ class MeshBayTransport { } trace('reconnect_ok', { attempt: this._reconnectAttempts }); console.log('[MeshBay] Reconnected after', this._reconnectAttempts, 'attempt(s)'); + // Before the caller's own hook: a transfer that resumes mid-chunk must + // have asked for its slot back first, or its next `file_req` carries a + // `tr` the node has never heard of. + this._reopenTransfers(); if (this._onReconnected) { try { this._onReconnected(); } catch (e) { console.error('[MeshBay] onReconnected handler threw:', e); @@ -1100,12 +1263,52 @@ class MeshBayTransport { return msg; } - async fetchChunk(fileId, chunkIndex) { + // ── Transfer slots ───────────────────────────────────────────────────────── + + /** + * Ask the node for a slot, and wait until it says yes. + * + * `tr` is drawn here, not by the node — 16 random bytes, exactly like + * `upload_id` — which is what makes re-opening after a reconnect idempotent + * rather than a second charge against the member's cap. + * + * On a node that predates transfer slots this resolves at once and costs + * nothing: there is no cap to respect and no message that would be + * understood. + */ + openTransfer({ kind = 'download', bytes = 0, chunks = 0, onState = null } = {}) { + const tr = _hex(crypto.getRandomValues(new Uint8Array(16))); + const lease = new Lease(this, tr, kind, bytes, chunks, onState); + if (!this.supportsTransferSlots) { + lease._skip(); + return lease; + } + this._leases.set(tr, lease); + lease._request(); + return lease; + } + + /** Re-ask for every live lease. Called after a reconnect. */ + _reopenTransfers() { + if (!this.supportsTransferSlots) return; + for (const lease of this._leases.values()) { + // The node lost the lease with the session, so this is a fresh request + // for the same `tr` — which the node treats as the same transfer rather + // than a second one. + if (!lease.closed) lease._request(); + } + } + + async fetchChunk(fileId, chunkIndex, tr = '') { const msg = await this._sendAndWait({ type: 'file_req', v: '0.1', file_id: fileId, chunk_index: chunkIndex, + // Present only when this download holds a slot. The node does not require + // it yet; carrying it is what lets the node see the transfer is alive and + // not reclaim its slot as idle. + ...(tr ? { tr } : {}), }); if (msg.type === 'error') throw new Error(msg.detail); return msg; @@ -2189,7 +2392,8 @@ class MeshBayTransport { * folder on screen to name. Omitting both leaves the node to pick, which it * only does for a client old enough to have had one destination. */ - async uploadFile(file, { chunkSize, onProgress, signal, root, dir } = {}) { + async uploadFile(file, { chunkSize, onProgress, signal, root, dir, + tr = '' } = {}) { // The same file twice at once would confuse the node, which keys its own // upload state by name — and would race for the same destination. The guard // is by name for that reason, even though the map below is keyed by id. @@ -2219,8 +2423,23 @@ class MeshBayTransport { const waiter = acks.shift(); if (waiter) waiter(); }; + // "Where am I?" — resolved by the node's answer to the probe chunk below, + // or by anything that says this node cannot answer it. + let settleProbe = null; + const probed = new Promise((r) => { settleProbe = r; }); + const answerProbe = (from) => { + if (!settleProbe) return false; + const done = settleProbe; + settleProbe = null; + done(from); + return true; + }; this._uploaders.set(uploadId, (msg) => { if (msg.type === 'error') { + // A node that predates the probe refuses its index. That is not a + // failure — it is the answer "start from the beginning", which is what + // this client did before there was anything to ask. + if (answerProbe(0)) return; failure = new Error(msg.detail || 'Upload refused'); wake(); return; @@ -2233,19 +2452,75 @@ class MeshBayTransport { .then((plain) => { const payload = msgpack_decode(plain); if (payload.stored_as) stored = payload; + // Only the probe's answer carries this, so the two are told apart + // without trusting the index the node echoed back in clear. + if (typeof payload.resume_from === 'number') return answerProbe(payload.resume_from); + return false; }) .catch((e) => { failure = new Error( `The node's upload reply did not open under the group key (${e.message})`); + return false; }) - .finally(wake); + // A probe's answer is not a chunk: waking here would credit the + // progress bar with a chunk that was never sent. + .then((wasProbe) => { if (!wasProbe) wake(); }); }); const nextAck = () => new Promise(r => acks.push(r)); try { - for (let i = 0; i < total; i++) { + // Ask before sending anything. An upload interrupted at 99% used to start + // again from zero, because the node kept its position on the connection + // that was lost — see `uploads.py`. The question goes inside the seal, as + // a chunk with no bytes, because naming the file on a clear message is + // exactly what sealing this path was for. + // Sealed first, spread second — the same shape as the chunk loop below, + // and not only for symmetry: `test_the_upload_itself_is_sealed` reads + // this call and fails if a filename appears in it, which is how it can + // tell a field outside the seal from one inside it. + const probeSealed = await C.sealGroup( + this._gekRaw, 'upload', 'file_upload', groupId, + msgpack_encode({ filename: file.name, data: new Uint8Array(0), + dir: dir || '', root: root || '' })); + this._send({ + type: 'file_upload', + v: '0.1', + upload_id: uploadId, + chunk_index: UPLOAD_PROBE_INDEX, + total_chunks: total, + ...(tr ? { tr } : {}), + ...probeSealed, + }); + // Bounded: a node that answers neither the probe nor its refusal must not + // leave an upload waiting for ever. Starting over is always safe. + let from = await Promise.race([ + probed, + new Promise((r) => setTimeout(() => { answerProbe(0); r(0); }, + UPLOAD_PROBE_TIMEOUT_MS)), + ]); + // Defensive: a node reporting a position at or past the end would have + // renamed the file and dropped its state, so this cannot happen — and if + // it does, sending everything again is the answer that cannot corrupt. + if (!(from > 0) || from >= total) from = 0; + if (from > 0) { + acked = from; + if (onProgress) onProgress(Math.min(file.size, from * size), file.size); + } + + for (let i = from; i < total; i++) { if (signal && signal.aborted) throw _aborted(); + // Between two chunks, never inside one — the node refuses a chunk that + // is not the one it expects, so a position is the only thing worth + // remembering. Nothing is recorded here beyond that: the node holds the + // real position, and the probe above is what asks for it on the way + // back in, which makes resuming correct even across a reconnect. + if (signal && signal.paused) { + signal.resumeFrom = i; + const paused = new Error('Paused'); + paused.name = 'PausedError'; + throw paused; + } // Backpressure: without it the whole file lands in the browser's send // buffer in seconds and the progress bar becomes a work of fiction. while (this._channel && this._channel.bufferedAmount > UPLOAD_BUFFER_HIGH) { @@ -2273,6 +2548,7 @@ class MeshBayTransport { upload_id: uploadId, chunk_index: i, total_chunks: total, + ...(tr ? { tr } : {}), ...sealed, }); } @@ -2992,6 +3268,24 @@ class MeshBayTransport { // "arrived" — and every message after that is one slot off too. Found // live: a group mid-scan corrupted its own handshake and chat history // this way, arriving roughly every 2s for as long as scanning ran. + // Routed by `tr`, and only by `tr`. A grant arrives unsolicited, minutes + // after the request that produced it, so falling through to "the oldest + // pending request" would hand a chat send or a handshake somebody else's + // slot — the class of defect `req_id` was introduced for. + if (msg.type === 'transfer_state') { + const lease = this._leases.get(msg.tr); + if (lease) lease._apply(msg); + else if (msg.state === 'granted') { + // A grant for a transfer this page has forgotten (a reload, a cancel + // that raced the grant). Handing it back at once matters: otherwise the + // node holds it until the 30 s acceptance deadline, and everyone behind + // it waits for nothing. + this._send({ type: 'transfer_close', v: '0.1', tr: msg.tr, + reason: 'cancelled' }); + } + return; + } + if (msg.type === 'index_progress') { if (this._onIndexProgress) { this._onIndexProgress({ diff --git a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py index 7373976..17ec554 100644 --- a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py @@ -193,7 +193,15 @@ class H(http.server.BaseHTTPRequestHandler): def main() -> int: with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: threading.Thread(target=srv.serve_forever, daemon=True).start() - with tempfile.TemporaryDirectory() as profile: + # ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu) + # outlive terminate() on the parent by a moment and go on writing into + # the profile. rmtree then walks a directory that gains a file between + # its readdir and its rmdir and raises "Directory not empty" -- which + # failed the probe, which failed every test in the file, intermittently + # and for a reason nowhere near the chat code they were testing. A few + # bytes left in a throwaway profile are harmless; failing the run is not. + with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True) as profile: # Real time, not `--virtual-time-budget`: the defect is a feedback # loop between layout and an event, and a virtual clock does not # run it. @@ -211,6 +219,7 @@ def main() -> int: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() + proc.wait() if not RECORDS: print(json.dumps({"error": "no measurement"}), file=sys.stderr) return 1 diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index 5f99beb..28635b0 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -297,7 +297,15 @@ class H(http.server.BaseHTTPRequestHandler): def main() -> int: with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: threading.Thread(target=srv.serve_forever, daemon=True).start() - with tempfile.TemporaryDirectory() as profile: + # ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu) + # outlive terminate() on the parent by a moment and go on writing into + # the profile. rmtree then walks a directory that gains a file between + # its readdir and its rmdir and raises "Directory not empty" -- which + # failed the probe, which failed every test in the file, intermittently + # and for a reason nowhere near the chat code they were testing. A few + # bytes left in a throwaway profile are harmless; failing the run is not. + with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True) as profile: proc = subprocess.Popen( ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", f"--user-data-dir={profile}", "--window-size=1100,800", @@ -312,6 +320,7 @@ def main() -> int: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() + proc.wait() if not RECORDS: print(json.dumps({"error": "no measurement"}), file=sys.stderr) return 1 diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py index 5e4e452..b6d2bcc 100644 --- a/packages/meshbay-hub/tests/harness/group_tab_probe.py +++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py @@ -156,7 +156,15 @@ class H(http.server.BaseHTTPRequestHandler): def main() -> int: with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: threading.Thread(target=srv.serve_forever, daemon=True).start() - with tempfile.TemporaryDirectory() as profile: + # ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu) + # outlive terminate() on the parent by a moment and go on writing into + # the profile. rmtree then walks a directory that gains a file between + # its readdir and its rmdir and raises "Directory not empty" -- which + # failed the probe, which failed every test in the file, intermittently + # and for a reason nowhere near the chat code they were testing. A few + # bytes left in a throwaway profile are harmless; failing the run is not. + with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True) as profile: proc = subprocess.Popen( ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", f"--user-data-dir={profile}", "--window-size=1100,900", @@ -171,6 +179,7 @@ def main() -> int: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() + proc.wait() if not RECORDS: print(json.dumps({"error": "no measurement"}), file=sys.stderr) return 1 diff --git a/packages/meshbay-hub/tests/harness/layout_probe.py b/packages/meshbay-hub/tests/harness/layout_probe.py index 530b3f0..65b3083 100644 --- a/packages/meshbay-hub/tests/harness/layout_probe.py +++ b/packages/meshbay-hub/tests/harness/layout_probe.py @@ -19,6 +19,7 @@ single pass. Launching Chrome per width put three minutes on the test suite. """ import http.server import json +import shutil import socketserver import subprocess import sys @@ -118,16 +119,25 @@ def main() -> int: srv = S(("127.0.0.1", PORT), H) threading.Thread(target=srv.serve_forever, daemon=True).start() + # mkdtemp left a Chrome profile in /tmp on every run, for ever, and nothing + # waited for Chrome to exit. Same cleanup rule as the other probes. + profile = tempfile.mkdtemp(prefix="chrome-layout-") chrome = subprocess.Popen([ "google-chrome", "--headless=new", "--no-sandbox", "--window-size=1000,900", - "--user-data-dir=" + tempfile.mkdtemp(prefix="chrome-layout-"), + "--user-data-dir=" + profile, f"http://127.0.0.1:{PORT}/", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) deadline = time.time() + 45 while time.time() < deadline and not RECORDS: time.sleep(0.2) chrome.terminate() + try: + chrome.wait(timeout=10) + except subprocess.TimeoutExpired: + chrome.kill() + chrome.wait() + shutil.rmtree(profile, ignore_errors=True) srv.shutdown() if not RECORDS: print(json.dumps({"error": "no measurement"})) diff --git a/packages/meshbay-hub/tests/harness/scroll_probe.py b/packages/meshbay-hub/tests/harness/scroll_probe.py index 46d8357..ae407b8 100644 --- a/packages/meshbay-hub/tests/harness/scroll_probe.py +++ b/packages/meshbay-hub/tests/harness/scroll_probe.py @@ -151,7 +151,15 @@ class H(http.server.BaseHTTPRequestHandler): def main() -> int: with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: threading.Thread(target=srv.serve_forever, daemon=True).start() - with tempfile.TemporaryDirectory() as profile: + # ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu) + # outlive terminate() on the parent by a moment and go on writing into + # the profile. rmtree then walks a directory that gains a file between + # its readdir and its rmdir and raises "Directory not empty" -- which + # failed the probe, which failed every test in the file, intermittently + # and for a reason nowhere near the chat code they were testing. A few + # bytes left in a throwaway profile are harmless; failing the run is not. + with tempfile.TemporaryDirectory( + ignore_cleanup_errors=True) as profile: subprocess.run( ["google-chrome", "--headless", "--disable-gpu", "--no-sandbox", f"--user-data-dir={profile}", "--window-size=1100,1300", diff --git a/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs b/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs index 0b77e42..a6008c2 100644 --- a/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs +++ b/packages/meshbay-hub/tests/harness/upload_seal_probe.mjs @@ -72,13 +72,34 @@ tp._nodeVersion = input.node_version; const frames = []; let uploadId = null; +let answered = 0; tp._send = (msg) => { frames.push(toHex(msgpack_encode(msg))); if (msg.upload_id) uploadId = msg.upload_id; - if (input.mode !== 'receive') return; + if (input.mode !== 'receive') { + // Nothing answers in this mode -- except the probe, which the client waits + // five seconds for. A node that predates it refuses the index, and that + // refusal is a plain error rather than a sealed ack, so the harness can + // produce it honestly. It is also the degradation path worth exercising. + if (msg.chunk_index === -1) { + // With `probe_ack`, answer it the way a node holding part of this file + // does; without, the way one that predates the probe does. + const reply = input.probe_ack + ? Object.assign(msgpack_decode(hex(input.probe_ack)), + { upload_id: uploadId }) + : { type: 'error', upload_id: uploadId, + code: 'bad_chunk_index', detail: 'Unexpected chunk index' }; + setImmediate(() => tp._dispatch(reply)); + } + return; + } // Answer as the node did, on the next turn of the loop so the send path // finishes first — which is also how a real ack arrives. - const ack = msgpack_decode(hex(input.acks[msg.chunk_index])); + // + // By position, not by `chunk_index`: the node answers every frame including + // the probe, whose index is -1, and the two lists are built from the same + // sequence of frames. + const ack = msgpack_decode(hex(input.acks[answered++])); ack.upload_id = uploadId; // Through the real `_dispatch`, so the routing under test — matching an // ack to its uploader by `upload_id` — is the shipped one. diff --git a/packages/meshbay-hub/tests/test_client_version_gate.py b/packages/meshbay-hub/tests/test_client_version_gate.py new file mode 100644 index 0000000..69ca062 --- /dev/null +++ b/packages/meshbay-hub/tests/test_client_version_gate.py @@ -0,0 +1,141 @@ +""" +The desktop client refuses to start when the hub will no longer talk to it. + +The SPA is served by the hub, so a browser picks up a new client on reload. The +desktop application **ships its own interface**, so on a flag day an un-updated +one can still sign in, still list groups, and then fail every connection with +`version_too_old` — a refusal in a protocol vocabulary, surfacing as a node that +will not talk, with nothing anyone can act on. §12.3 of +~/next/improve-downloads.md named this as the thing that had to exist before +MNP 3.0 could ship. + +`compareVersions` and `refuseIfTooOld` are lifted out of `main.js` **as text** +and executed against a modelled environment, on the rule this repo follows +elsewhere: model the environment, never the code under test. The rest of +`test_desktop_shell.py` can only read the source, because there is no npm here +to launch Electron with; these two are ordinary functions and can be run. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" +MAIN = CLIENT / "src" / "main.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not MAIN.exists(), + reason="node or the desktop client sources are not available") + + +def _lift(name: str) -> str: + src = MAIN.read_text() + cut = src[src.index(name):] + return cut[:cut.index("\n}\n") + 2] + + +def _run(tmp_path, *, mine="1.1.0", hub_base="https://hub.example", + answer=None, status=200, throws=False): + """Drive the gate against one hub. + + `answer` is what `/v1/hub/version` returns; None means the field is absent + entirely, which is what an older hub sends. + """ + script = tmp_path / "gate.mjs" + script.write_text(f""" +const out = {{ dialogs: 0, opened: null }}; +const config = {{ hubBase: {json.dumps(hub_base)} }}; +const app = {{ getVersion: () => {json.dumps(mine)} }}; +const dialog = {{ + showMessageBox: async () => {{ out.dialogs += 1; return {{ response: 0 }}; }}, +}}; +const shell = {{ openExternal: async (u) => {{ out.opened = u; }} }}; +globalThis.fetch = async () => {{ + if ({json.dumps(throws)}) throw new Error('unreachable'); + return {{ ok: {json.dumps(status)} === 200, + json: async () => ({json.dumps(answer)}) }}; +}}; +""" + _lift("function compareVersions") + _lift("async function refuseIfTooOld") + """ +out.refused = await refuseIfTooOld(); +out.compare = [ + compareVersions('1.0.0', '1.1.0'), + compareVersions('1.1.0', '1.1.0'), + compareVersions('1.2.0', '1.1.0'), + compareVersions('1.10.0', '1.9.0'), + compareVersions('1.1', '1.1.0'), + compareVersions('nonsense', '1.1.0'), +]; +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +OK = {"client": {"minimum": "1.1.0", "recommended": "1.1.0"}} + + +# ── the comparison ────────────────────────────────────────────────────────── + +def test_versions_compare_by_number_and_not_by_string(tmp_path): + """`1.10.0` is newer than `1.9.0`, which string comparison gets backwards — + and that mistake locks out exactly the people who did update.""" + assert _run(tmp_path, answer=OK)["compare"] == [-1, 0, 1, 1, 0, 0] + + +# ── the gate ──────────────────────────────────────────────────────────────── + +def test_a_client_older_than_the_minimum_is_stopped(tmp_path): + out = _run(tmp_path, mine="1.0.0", answer=OK) + assert out["refused"] is True + assert out["dialogs"] == 1, "it stopped without saying why" + assert out["opened"] == "https://hub.example", ( + "the offer to download the update led nowhere") + + +def test_a_current_client_starts_normally(tmp_path): + out = _run(tmp_path, mine="1.1.0", answer=OK) + assert out["refused"] is False + assert out["dialogs"] == 0 + + +def test_a_newer_client_is_not_stopped(tmp_path): + """A development build ahead of the hub is not a reason to refuse to open + the application.""" + assert _run(tmp_path, mine="2.0.0", answer=OK)["refused"] is False + + +def test_an_unreachable_hub_is_not_too_old(tmp_path): + """A hub that is down, a laptop with no network, a captive portal. Treating + any of those as "you are out of date" would make an offline start + impossible for ever, and would do it at the worst moment.""" + assert _run(tmp_path, mine="1.0.0", throws=True)["refused"] is False + assert _run(tmp_path, mine="1.0.0", status=503, answer=OK)["refused"] is False + + +def test_a_hub_that_states_no_minimum_stops_nothing(tmp_path): + """An older hub answers without the field. Absent must read as "no opinion", + never as a refusal.""" + assert _run(tmp_path, mine="0.0.1", answer={"hub": "1.2.3"})["refused"] is False + + +def test_a_first_run_with_no_hub_yet_is_not_stopped(tmp_path): + """There is nothing to ask, and the first-run screen is where the address + gets typed.""" + assert _run(tmp_path, mine="0.0.1", hub_base="", answer=OK)["refused"] is False + + +# ── where it is called ────────────────────────────────────────────────────── + +def test_the_gate_runs_before_the_window_is_built(): + """A window that opens and then cannot connect is the failure this + replaces, so the order is the whole point.""" + src = MAIN.read_text() + ready = src[src.index("app.whenReady().then("):] + ready = ready[:ready.index("createWindow();")] + assert "await refuseIfTooOld()" in ready, ( + "the version check does not run before the window is created") + assert "app.quit()" in ready diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index 32d3e11..afb85d6 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -149,8 +149,15 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): # and was lifted into file-utils.js's downloadDirectory (docs/photos.md # §3) so photos-app.js's own "zip this album" button calls the same # implementation rather than a second one. + # Anchored on the call, not on how its result is bound: the assignment + # became a bare `target = ...` inside a try when _openDownloadTarget gained + # the ability to refuse an oversized download (test_memory_ceiling.py). + # What this test is about -- the `0` -- did not move. app = (STATIC / "file-utils.js").read_text() - zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):] + # Anchored on the argument list, not on the function name: the call became + # `_openTargetInTurn(suggested, …)` when target openings were serialised. + # The `0` this test is about did not move. + zip_call = app[app.index("(suggested, totalBytes"):] zip_call = zip_call[:zip_call.index(");") + 2] assert zip_call.rstrip().endswith(", 0);"), ( "the zip download announces a Content-Length it will not match") @@ -168,9 +175,23 @@ def test_backpressure_is_real(tmp_path): # The transfer list may carry more than the stream — a reply port rides # along now — so this asserts that `readable` is transferred, not the exact # shape of the list. - transfer = fn[fn.index("worker.postMessage("):] - transfer = transfer[transfer.index("["):transfer.index("]") + 1] - assert "readable" in transfer, "the readable half must be transferred, not copied" + # + # And every `postMessage` in here, not the first: a ping is sent to wake the + # worker before it is handed anything, and it carries only a port. Reading + # the first one would have moved this check onto the ping the day it was + # added, leaving the stream unguarded while still passing. + posts = [] + rest = fn + while "worker.postMessage(" in rest: + rest = rest[rest.index("worker.postMessage("):] + # Bounded by the call's own end: the keep-alive ping transfers nothing + # at all, and reaching past it for a `[` would read the next call's. + posts.append(rest[:rest.index(");") + 2]) + rest = rest[len("worker.postMessage("):] + lists = [c[c.index("["):c.index("]") + 1] for c in posts if "[" in c] + assert len(posts) >= 2, "the wake-up and the stream are both posted from here" + assert any("readable" in t for t in lists), ( + "the readable half must be transferred, not copied") assert "writer.write(bytes)" in fn assert "return null" in fn, "a browser that cannot transfer streams must say so" @@ -201,9 +222,352 @@ def test_the_streamed_path_gives_up_rather_than_blocking_for_ever(): def test_an_uncontrolled_page_is_not_treated_as_ready(): """`registration.active` says a worker exists, not that it will see our fetch.""" + # Anchored on the streaming section rather than on one function: waiting + # for control moved into `_awaitControl`/`_claimController` when the budget + # became a parameter, and `serviceWorker()` no longer contains the words. + # The behaviour itself is executed in test_streamed_download_reliability.py; + # this stays as the cheap guard on the module's shape. src = DOWNLOADS.read_text() - fn = src[src.index("async function serviceWorker()"):] - fn = fn[:fn.index("\n}")] - assert "navigator.serviceWorker.controller" in fn - assert "controllerchange" in fn, ( + section = src[src.index("// ── Streaming to disk"):] + assert "navigator.serviceWorker.controller" in section + assert "controllerchange" in section, ( "control can arrive a tick after registration; waiting beats refusing") + assert "mbdl-claim" in section, ( + "an active-but-uncontrolled page must ask for a claim, not give up") + + +def test_an_apostrophe_in_a_name_does_not_lose_the_name(tmp_path): + """ + `encodeURIComponent` leaves `'` alone, and `'` is the delimiter in RFC + 5987's `filename*=<charset>'<lang>'<value>`. One apostrophe made the header + unparseable, and a browser that cannot parse it names the file after the + last segment of the URL — which for this worker is a made-up id. The file + arrived complete and 449 MB of it was called "mtsshk9w-ohqty535". + + Found by downloading three files where exactly one had an apostrophe. + Nothing in the suite could have: the header was built correctly for every + name anybody had tested with. + + The real function is lifted out of sw.js and run — a second copy here would + have the same blind spot as the first. + """ + src = SW.read_text() + fn = src[src.index("function contentDisposition"):] + fn = fn[:fn.index("\n}") + 2] + + script = tmp_path / "case.mjs" + script.write_text(fn + """ +const out = {}; +for (const name of ["S03E02. Queen's Landing.mp4", 'Caf\\u00e9 (2019).mkv', + 'plain.mp4', 'quote".mp4', 'star*.mp4']) { + out[name] = contentDisposition(name); +} +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + + for name, header in out.items(): + starred = header.split("filename*=UTF-8''", 1)[1] + assert "'" not in starred, ( + f"{name!r}: an apostrophe survived into the starred value, which " + f"is where RFC 5987 puts its delimiter — the name is lost") + for forbidden in "()*": + assert forbidden not in starred, ( + f"{name!r}: {forbidden!r} is not an attr-char and must be " + f"percent-encoded") + # The starred value has to decode back to the real name, or the escaping + # fixed the parse and broke the result. + from urllib.parse import unquote + assert unquote(starred) == name + + # The ASCII fallback must not end its own quoted string. + for name, header in out.items(): + ascii_part = header.split('filename="', 1)[1].split('";', 1)[0] + assert '"' not in ascii_part and "\\" not in ascii_part + + +def test_a_sink_that_stops_consuming_fails_instead_of_hanging(tmp_path): + """ + `writable.write()` was the one await on the download path with no bound. + + Every other one reports itself: `_sendAndWait` logs a Response timeout, + `_fetchChunkResilient` retries and throws. A sink that stops consuming — a + service-worker stream the browser has stopped reading — leaves `write()` + pending for ever. It never rejects, so there is no error, no log and no + failed transfer: the progress bar stops, the console stays empty, and the + node is healthy throughout. + + That combination is what made it unfindable: three separate measurements + cleared the node, the transport and the worker, because none of them was + wrong. Bounding it does not fix whatever stopped the sink — it turns an + unexplainable freeze into a failed transfer that names itself. + """ + src = (STATIC / "file-utils.js").read_text() + fn = src[src.index("async function _writeOrStall"):] + fn = fn[:fn.index("\n}\n") + 2] + + script = tmp_path / "case.mjs" + script.write_text(""" +const t = (key, vars) => key + ' ' + JSON.stringify(vars); +const WRITE_STALL_MS = 300; // the real value is 60s; the shape is the test +""" + fn + """ +const out = {}; +// A sink that never resolves — the frozen download, exactly. +const dead = { write: () => new Promise(() => {}) }; +const t0 = Date.now(); +try { + await _writeOrStall(dead, new Uint8Array(4), 41); + out.threw = null; +} catch (e) { out.threw = e.message; } +out.ms = Date.now() - t0; + +// And a working sink is not slowed down or wrapped in anything. +const live = { write: async () => {} }; +await _writeOrStall(live, new Uint8Array(4), 0); +out.liveOk = true; +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["threw"], "a dead sink hung for ever instead of failing" + assert "group.download_write_stalled" in out["threw"], ( + "the failure must name itself in the transfers panel") + assert "41" in out["threw"], "and say which chunk it stopped at" + assert out["ms"] < 3000 + assert out["liveOk"] is True + + +def test_the_worker_is_kept_alive_while_it_streams(): + """ + A service worker with no event for ~30 s is terminated — Firefox does it, + and `respondWith(new Response(stream))` does not extend its life while the + response is still being written. The reader vanishes mid-file, the page's + next `write()` never resolves and never rejects: the progress bar stops, + the console stays empty, and the node looks healthy throughout. + + Measured in real Firefox 154 on 2026-09-08, writing 1 MB every 2 s: + without the ping it stalled at 17 MB after 59 s; with it, 40 MB in 80 s, + complete. The first version of that probe wrote 450 MB in two seconds and + passed — fast enough to hide the bug entirely, which is why the pacing + matters and is written down here. + + Source-reading, because the behaviour needs a browser and a minute of wall + clock. What it protects is that the ping exists at all, is cleared on both + exits, and is answered by the worker. + """ + dl = DOWNLOADS.read_text() + sw = SW.read_text() + + assert "SW_KEEPALIVE_MS" in dl and "mbdl-ping" in dl, ( + "nothing keeps the worker alive; downloads longer than ~30 s will " + "stall on Firefox with no error anywhere") + fn = dl[dl.index("async function _attemptStreamedDownload"):] + interval = fn[fn.index("setInterval"):] + assert "mbdl-ping" in interval[:200] + + # Cleared on both ways out, or a finished download leaves a timer pinging a + # worker for the life of the page. + for exit_path in ("close:", "abort:"): + block = fn[fn.index(exit_path):] + assert "clearInterval(keepAlive)" in block[:220], ( + f"the keep-alive is not cleared in {exit_path} — it outlives the " + f"download") + + # And the worker has to answer it: a message it ignores still counts as an + # event, but the reply is what tells the page it is talking to the worker + # that holds its stream. + assert "mbdl-ping" in sw and "mbdl-pong" in sw + + +def _turn_harness(tmp_path, name, body, *, picker=True, budget_ms=90000): + """Run the real `_openTargetInTurn` against a stubbed opener. + + Both it and `_waitBriefly` are lifted out of `file-utils.js` as text; only + the budget is supplied here, so a case about the budget need not wait a + minute and a half for it. + """ + src = (STATIC / "file-utils.js").read_text() + + def lift(decl): + cut = src[src.index(decl):] + return cut[:cut.index("\n}\n") + 2] + + picker_js = ("window.showSaveFilePicker = async () => ({});" + if picker else "") + script = tmp_path / f"{name}.mjs" + script.write_text(f""" +const out = []; +let live = 0, peak = 0; +const asked = []; +// Stands in for _openDownloadTarget: records how many are open at once, and +// whether each was told it is not the first of its batch. +const _openDownloadTarget = async (name, size, opts, swSize, flags) => {{ + live += 1; peak = Math.max(peak, live); + asked.push(!!(flags && flags.batched)); + if (name === 'stuck') return await new Promise(() => {{}}); + await new Promise(r => setTimeout(r, 20)); + live -= 1; + if (name === 'boom') throw new Error('refused'); + return {{ name }}; +}}; +// Only a browser with a Save As dialog has anything to serialise. +globalThis.window = {{}}; +{picker_js} +let _targetQueue = Promise.resolve(); +let _targetsInFlight = 0; +const TARGET_QUEUE_BUDGET_MS = {budget_ms}; +""" + lift("function _openTargetInTurn") + lift("function _waitBriefly") + f""" +{body} +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_targets_are_opened_one_at_a_time(tmp_path): + """ + A browser shows one file picker at a time and grants one per user gesture, + so four downloads asking at once get one dialog and three failures. + + That used to be prevented by accident: `downloadEntry` awaited the target + inline and files-app.js's `for (…) await downloadFile(e)` serialised them. + Opening the target inside `prepare` — so the row appears at the click rather + than tens of seconds later — removed the accident, and four pickers raced. + Reported from Chrome: one file downloaded, a prompt for the second, the + other two timed out. + + The queue is on the *targets*, never on the rows: every download still + appears the moment it is asked for. + + Queueing alone was not enough: a second dialog with no gesture behind it + still waits for a human, and the two behind it wait for the dialog. So + everything that has to wait its turn is also marked `batched`, which the + opener reads as "do not ask" — see the streamed-path branch in + test_memory_ceiling.py. + """ + peak, statuses, batched = _turn_harness(tmp_path, "one_at_a_time", """ +const results = await Promise.allSettled( + ['a', 'boom', 'c', 'd'].map(n => _openTargetInTurn(n))); +out.push(peak); +out.push(results.map(r => r.status).join(',')); +out.push(asked); +""") + assert peak == 1, f"{peak} targets were being opened at once" + # And one refusal must not stop the rest: a chain that breaks on a rejection + # leaves every later download unable to open anything at all. + assert statuses == "fulfilled,rejected,fulfilled,fulfilled" + assert batched == [False, True, True, True], ( + "only the first of a batch holds the user's gesture; the rest must be " + "opened without asking") + + +def test_a_browser_with_no_dialog_does_not_queue_at_all(tmp_path): + """Firefox and Safari have no `showSaveFilePicker`, so no two openings there + can race a dialog and there is nothing for a queue to protect. + + Queueing them anyway was a regression: four downloads that had always opened + their targets at the same time began waiting on the slowest, and all four + sat at "preparing". A queue that buys nothing must not be paid for. + """ + peak, = _turn_harness(tmp_path, "no_picker", """ +await Promise.all(['a', 'b', 'c', 'd'].map(n => _openTargetInTurn(n))); +out.push(peak); +""", picker=False) + assert peak == 4, ( + f"only {peak} target opening(s) ran at once; without a dialog to " + "serialise, all four must proceed together as they did before") + + +def test_one_stuck_opening_does_not_hold_the_others_for_ever(tmp_path): + """`_targetQueue` is never reset, so an opening that never settles would + otherwise leave the page unable to start any download again — a panel that + only a reload can fix. + + The budget is 60 ms here; in the page it is ninety seconds, long enough that + a real dialog is never cut in front of. + """ + statuses, batched = _turn_harness(tmp_path, "stuck", """ +const first = _openTargetInTurn('stuck'); +first.catch(() => {}); +const rest = await Promise.allSettled( + ['b', 'c'].map(n => _openTargetInTurn(n))); +out.push(rest.map(r => r.status).join(',')); +out.push(asked); +""", budget_ms=60) + assert statuses == "fulfilled,fulfilled", ( + "an opening that never settles must not strand the ones behind it") + assert batched == [False, True, True], ( + "the stuck one is still the only holder of the gesture, so the released " + "openings must not try for a dialog of their own") + + +def test_a_pause_falls_between_chunks_and_resumes_at_one(tmp_path): + """What makes resuming exact rather than approximate. + + Everything written is a whole number of chunks, because the loop checks for + a pause between two of them and never inside one. So `fromChunk` is a + position, not an estimate, and a resumed download is never appended to at an + offset nobody verified — the failure mode being avoided is a file that looks + complete and is quietly corrupt. + + The real `pipelinedDownload` is lifted out and run against stubs, on the + rule this repo follows for the video player: model the environment, never + the code under test. + """ + src = (STATIC / "file-utils.js").read_text() + fn = src[src.index("async function pipelinedDownload"):] + fn = fn[:fn.index("\n}\n") + 2] + + script = tmp_path / "pipeline.mjs" + script.write_text(""" +const CHUNK_SIZE = 8; +const PIPELINE_WINDOW = 4; +const written = []; +// `ct` has to be truthy: chunk 0 with a falsy body is refused as undecryptable, +// which is the guard working, not the harness. +const _fetchChunkResilient = async (transport, fileId, i) => + ({ ct: new Uint8Array([i & 0xff]), nonce: new Uint8Array(12) }); +const _writeOrStall = async (w, bytes, index) => { written.push(index); }; +globalThis.window = { MeshBayCrypto: { + // The plaintext carries its own index, so what lands where can be checked. + decryptChunkBin: async (k, id, index) => ({ byteLength: CHUNK_SIZE, index }), +} }; +""" + fn + """ +const out = {}; +const signal = { aborted: false, paused: false }; +// Stop it part way, the way the store does. +let seen = 0; +const onChunk = () => { if (++seen === 3) signal.paused = true; }; +try { + await pipelinedDownload({}, 'k', 'file', 10, onChunk, {}, signal, '', 0); + out.threw = 'no'; +} catch (err) { + out.threw = err.name; +} +out.resumeFrom = signal.resumeFrom; +out.writtenBeforePause = written.slice(); + +// And again, from where it said. +signal.paused = false; +written.length = 0; +await pipelinedDownload({}, 'k', 'file', 10, () => {}, {}, signal, '', + out.resumeFrom); +out.writtenAfterResume = written.slice(); +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + + assert out["threw"] == "PausedError", out + # Whole chunks only, in order, with nothing skipped. + assert out["writtenBeforePause"] == list(range(len(out["writtenBeforePause"]))) + assert out["resumeFrom"] == len(out["writtenBeforePause"]), ( + f"stopped after {len(out['writtenBeforePause'])} chunks but asked to " + f"resume at {out['resumeFrom']} — that gap is a hole in the file") + # The resumed run covers exactly the rest, and repeats nothing. + assert out["writtenAfterResume"] == list(range(out["resumeFrom"], 10)), out diff --git a/packages/meshbay-hub/tests/test_layout_measured.py b/packages/meshbay-hub/tests/test_layout_measured.py index 91f1ed0..a71b6b9 100644 --- a/packages/meshbay-hub/tests/test_layout_measured.py +++ b/packages/meshbay-hub/tests/test_layout_measured.py @@ -54,7 +54,7 @@ NAV = textwrap.dedent(""" <div class="transfer-item"> <div class="transfer-line"> <span class="transfer-kind">↓</span> - <span class="transfer-name">S03E01. Salt and Sea, Fire and Blood.mp4</span> + <span class="transfer-name">Some Saga S03E01 - A Long Enough Title.mp4</span> <button class="transfer-cancel">✕</button> </div> <div class="dl-progress"><div class="dl-fill" style="width:42%"></div></div> @@ -144,3 +144,120 @@ def test_the_page_does_not_scroll_sideways(measured, width): r = measured[str(width)] assert r["docScrollW"] <= r["viewport"]["w"], ( f"the document scrolls to {r['docScrollW']} px on a {width} px screen") + + +# ── The grouped panel (§8.2) ──────────────────────────────────────────────── +# +# The panel gained groups, a header summary and a waiting row. Every one of +# those can push something off a 320 px screen, and none of it can be seen by +# reading the stylesheet: what decides where the panel lands is the button it +# hangs from, which is not at the right edge. That is the defect this file was +# written for, and it comes back with any change to the header's width. + +GROUPED = textwrap.dedent(""" + <nav class="nav"> + <div class="nav-left"> + <button class="nav-hamburger">☰</button> + <a class="nav-brand" href="#/">MeshBay</a> + </div> + <div class="nav-right"> + <div class="transfer-wrap"> + <button class="nav-notif transfer-btn">↓</button> + <div class="transfer-panel"> + <div class="transfer-head"> + <span class="transfer-head-title">Transfers</span> + <span class="transfer-head-summary">2 running · 3 waiting</span> + <button class="btn-secondary">Clear finished</button> + </div> + <div class="transfer-group"> + <div class="transfer-group-head">Running</div> + <div class="transfer-item transfer-running"> + <div class="transfer-line"> + <span class="transfer-kind">↓</span> + <span class="transfer-name">Some Saga S03E01 - A Long Enough Title.mp4</span> + <button class="transfer-cancel">✕</button> + </div> + <div class="dl-progress"><div class="dl-fill" style="width:42%"></div></div> + <div class="transfer-meta"><span>210 MB / 493 MB</span><span>3.1 MB/s · 4 min left</span></div> + </div> + </div> + <div class="transfer-group"> + <div class="transfer-group-head">Waiting</div> + <div class="transfer-item transfer-queued"> + <div class="transfer-line"> + <span class="transfer-kind">↓</span> + <span class="transfer-name">Another File With A Long Name.mkv</span> + <button class="transfer-cancel">✕</button> + </div> + <div class="dl-progress dl-waiting"></div> + <div class="transfer-meta"><span>Waiting — your slots are busy</span><span>1.2 GB</span></div> + </div> + </div> + </div> + </div> + <a class="nav-notif" href="#/">🔔</a> + <div class="user-menu"><button class="nav-btn">someone</button></div> + </div> + </nav> +""") + +GROUPED_SELECTORS = [".transfer-panel", ".transfer-head", ".transfer-head-summary", + ".transfer-group-head", ".transfer-name", + ".transfer-item.transfer-queued .dl-progress"] + + +@pytest.fixture(scope="module") +def grouped(tmp_path_factory): + fragment = tmp_path_factory.mktemp("grouped") / "fragment.html" + fragment.write_text(GROUPED) + proc = subprocess.run( + ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), + str(fragment), *GROUPED_SELECTORS], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, f"no measurement: {out}" + return out + + +def test_the_grouped_panel_stays_on_a_phone_screen(grouped): + for width in WIDTHS: + box = grouped[str(width)]["boxes"][".transfer-panel"] + assert box["offLeft"] == 0, ( + f"at {width} px the panel hangs {box['offLeft']} px off the left — " + "which is where the file names are") + assert box["offRight"] == 0, ( + f"at {width} px the panel hangs {box['offRight']} px off the right") + + +def test_the_file_name_is_on_screen_in_every_group(grouped): + for width in WIDTHS: + box = grouped[str(width)]["boxes"][".transfer-name"] + assert box["offLeft"] == 0 and box["offRight"] == 0, ( + f"at {width} px a file name is cut off: {box}") + assert box["width"] > 40, "the name column collapsed to nothing" + + +def test_the_header_summary_does_not_push_the_header_taller(grouped): + """It is the one part of the header that grows with what is happening. If + it wraps, the header changes height as transfers come and go and every row + below it moves — on the narrowest screen, repeatedly.""" + for width in WIDTHS: + head = grouped[str(width)]["boxes"][".transfer-head"] + summary = grouped[str(width)]["boxes"][".transfer-head-summary"] + assert head["height"] <= 48, ( + f"at {width} px the header is {head['height']} px tall — it wrapped") + assert summary["height"] <= 24, ( + f"at {width} px the summary wrapped to {summary['height']} px") + + +def test_the_waiting_bar_is_as_wide_as_a_progress_bar(grouped): + """A waiting row has no inner fill element — the stripes are on the track + itself. Getting that wrong renders a zero-width bar, which reads as a + transfer stuck at 0% rather than one that has not started.""" + for width in WIDTHS: + bar = grouped[str(width)]["boxes"][ + ".transfer-item.transfer-queued .dl-progress"] + assert bar["width"] > 100, ( + f"at {width} px the waiting bar is {bar['width']} px wide") + assert bar["height"] >= 3, "the waiting bar has no height" diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py new file mode 100644 index 0000000..1654825 --- /dev/null +++ b/packages/meshbay-hub/tests/test_memory_ceiling.py @@ -0,0 +1,329 @@ +""" +No download above the ceiling is ever collected in the page. + +`pipelinedDownload` with no `writable` allocates `new Array(totalChunks)` and +keeps every decrypted chunk, so whatever `_openDownloadTarget` returns `null` +for is a file held whole in RAM. That floor had no upper bound: the +`!window.showSaveFilePicker` branch returned `null` at any size, so on a browser +without the File System Access API a 20 GB film went to memory whenever the +service-worker path did not answer — which happens for ordinary reasons. The +symptom was the tab dying, with nothing in the source to lead back here. + +The real `_openDownloadTarget` is lifted out of `file-utils.js` **as text** and +executed against stubbed browsers, on the rule this repo already follows for the +video player: model the environment, never the code under test. A test that +transcribed the decision tree would agree with a broken version of it by +construction. + +`test_no_unguarded_memory_floor` is the one that outlives today's branches: it +reads the function and fails if a `return null` appears in it that does not go +through the guard — which is what a fourth fallback added in a hurry would look +like. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +FILE_UTILS = STATIC / "file-utils.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not FILE_UTILS.exists(), + reason="node or the SPA sources are not available") + +CEILING = 100 * 1024 * 1024 +GB = 1024 * 1024 * 1024 + + +def _lift(name, source): + """The text of one top-level declaration, from its opening line to the + column-0 brace that closes it. Nothing is re-typed into this test.""" + start = source.index(name) + end = source.index("\n}\n", start) + len("\n}\n") + return source[start:end] + + +@pytest.fixture(scope="module") +def target_fn(): + """The ceiling, its error and the real function — read, never re-typed.""" + src = FILE_UTILS.read_text() + ceiling = re.search(r"^const MEMORY_CEILING = .*?;$", src, re.M) + assert ceiling, "MEMORY_CEILING is gone from file-utils.js" + # The test's own CEILING constant must agree with the source's, or every + # boundary case below is asserting against a number nothing uses. + assert str(CEILING) in ceiling.group(0).replace(" ", "") or \ + eval(ceiling.group(0).split("=")[1].strip(" ;")) == CEILING + return "\n".join([ + ceiling.group(0), + _lift("class TooLargeForMemoryError", src), + _lift("async function _openDownloadTarget", src), + ]) + + +def _run(target_fn, tmp_path, *, size, native=False, granted=False, + streamed=False, picker=False, mode="auto", batched=False): + """Drive the real function against one browser shape.""" + script = tmp_path / "case.mjs" + script.write_text(f""" +// Stubs for everything the lifted function reaches. `formatSize` and `t` only +// build the message; the assertions are about which branch was taken. +// +// stdout carries the outcome and nothing else, so the function's own logging +// goes to stderr -- where it is still shown when a case fails. +console.info = (...a) => console.error(...a); +const formatSize = (n) => `${{n}} B`; +const t = (key, vars) => key + ' ' + JSON.stringify(vars); +const platform = {{ + capabilities: {{ nativeSave: {json.dumps(native)} }}, + nativeSave: async () => ({{ name: 'n', writable: {{}} }}), + bridgeMessage: (e) => String(e), +}}; +const downloads = {{ + BLOB_LIMIT: 512 * 1024 * 1024, + // Called by the refusal to name why the streamed path declined -- absent + // from this stub, the error constructor threw TypeError and the test saw the + // wrong failure entirely. + lastStreamFailure: () => 'stubbed: no streamed target in this harness', + getMode: () => {json.dumps(mode)}, + // `pausable` mirrors the real modules: a granted folder is a held-open file + // handle, a service-worker stream is a download the browser already owns. + openTarget: async () => + ({json.dumps(granted)} ? {{ name: 'g', writable: {{}}, pausable: true }} : null), + openStreamedDownload: async () => + ({json.dumps(streamed)} ? {{ name: 's', writable: {{}}, pausable: false }} : null), +}}; +globalThis.window = {{}}; +if ({json.dumps(picker)}) {{ + window.showSaveFilePicker = async () => {{ + if ({json.dumps(picker)} === 'no-gesture') {{ + const e = new Error("Failed to execute 'showSaveFilePicker' on 'Window': " + + "Must be handling a user gesture to show a file picker."); + e.name = 'SecurityError'; + throw e; + }} + return {{ name: 'p', createWritable: async () => ({{}}) }}; + }}; +}} + +{target_fn} + +let outcome; +try {{ + const r = await _openDownloadTarget('film.mkv', {size}, {{}}, {size}, + {{ batched: {json.dumps(batched)} }}); + outcome = r === null ? {{ kind: 'memory' }} + : r === false ? {{ kind: 'cancelled' }} + : {{ kind: 'stream', name: r.name, pausable: !!r.pausable }}; +}} catch (err) {{ + outcome = {{ kind: 'refused', name: err.name, message: err.message }}; +}} +console.log(JSON.stringify(outcome)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── The hole this was written for ─────────────────────────────────────────── + +def test_a_film_is_refused_rather_than_collected_in_memory(target_fn, tmp_path): + """Firefox/Safari shape: no picker, no granted folder, the worker did not + answer. This returned null — 20 GB into a tab.""" + out = _run(target_fn, tmp_path, size=20 * GB) + assert out["kind"] == "refused", out + assert out["name"] == "TooLargeForMemoryError" + + +def test_the_refusal_says_how_big_and_what_the_limit_is(target_fn, tmp_path): + out = _run(target_fn, tmp_path, size=20 * GB) + assert "download.too_large_for_memory" in out["message"] + assert str(20 * GB) in out["message"] + assert str(CEILING) in out["message"] + + +def test_the_same_browser_in_ask_mode_is_refused_too(target_fn, tmp_path): + """'ask' skips the service-worker block entirely, so it reached the + unguarded branch without even trying to stream.""" + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask") + assert out["kind"] == "refused", out + + +# ── What must keep working ────────────────────────────────────────────────── + +def test_something_small_still_uses_the_memory_floor(target_fn, tmp_path): + out = _run(target_fn, tmp_path, size=4 * 1024 * 1024) + assert out["kind"] == "memory", out + + +def test_the_boundary_is_the_ceiling_itself(target_fn, tmp_path): + assert _run(target_fn, tmp_path, size=CEILING)["kind"] == "memory" + assert _run(target_fn, tmp_path, size=CEILING + 1)["kind"] == "refused" + + +def test_a_granted_folder_streams_whatever_the_size(target_fn, tmp_path): + out = _run(target_fn, tmp_path, size=20 * GB, granted=True) + assert (out["kind"], out["name"]) == ("stream", "g") + + +def test_the_service_worker_streams_whatever_the_size(target_fn, tmp_path): + out = _run(target_fn, tmp_path, size=20 * GB, streamed=True) + assert (out["kind"], out["name"]) == ("stream", "s") + + +def test_the_desktop_app_streams_whatever_the_size(target_fn, tmp_path): + out = _run(target_fn, tmp_path, size=20 * GB, native=True) + assert (out["kind"], out["name"]) == ("stream", "n") + + +def test_a_browser_with_a_picker_is_offered_one_instead_of_being_refused( + target_fn, tmp_path): + """Chrome/Edge: the file is large, nothing streamed yet, but Save As does. + A refusal here would be this fix breaking a path that was never broken.""" + out = _run(target_fn, tmp_path, size=20 * GB, picker=True) + assert (out["kind"], out["name"]) == ("stream", "p") + + +# ── One dialog per gesture, not one per file ──────────────────────────────── + +def test_the_first_of_a_batch_still_asks_where_to_save(target_fn, tmp_path): + """The preference is not being taken away. Someone who asked to choose the + folder chooses it, for the download they actually clicked.""" + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, + streamed=True) + assert (out["kind"], out["name"]) == ("stream", "p") + + +def test_the_rest_of_a_batch_stream_instead_of_asking(target_fn, tmp_path): + """A browser grants one picker per user gesture and selecting four files is + one gesture. Chrome showed the dialog for the second file anyway and then + waited for a human, so the third and fourth sat behind it until they timed + out — reported as three downloads frozen. + + There is no gesture left to spend, so nothing is lost by streaming: the file + still lands on disk, in the browser's own download folder. Only the choice + of folder goes, and it was not on offer. + """ + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, + streamed=True, batched=True) + assert (out["kind"], out["name"]) == ("stream", "s") + + +def test_a_batched_download_falls_back_to_the_dialog_rather_than_failing( + target_fn, tmp_path): + """When the worker does not answer, asking is better than refusing: a + dialog that has to be answered is still a download, and the alternative + here is losing the file. A preference must not cost a capability, and + neither must the fix for one.""" + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=True, + streamed=False, batched=True) + assert (out["kind"], out["name"]) == ("stream", "p") + + +def test_batching_never_pushes_a_large_file_into_memory(target_fn, tmp_path): + """Firefox shape — no picker at all. Nothing about the batch flag may reach + the memory floor above the ceiling.""" + out = _run(target_fn, tmp_path, size=20 * GB, mode="ask", picker=False, + streamed=False, batched=True) + assert out["kind"] == "refused", out + + +# ── The one that outlives today's branches ────────────────────────────────── + +def test_no_unguarded_memory_floor(target_fn): + """Every `return null` in the function goes through the guard. + + A fourth fallback appended to the chain — which is exactly how the third one + got here — is caught by this even though no case above covers it. + """ + body = target_fn[target_fn.index("async function _openDownloadTarget"):] + lines = body.splitlines() + # The guard's own `return null` is the one legitimate instance, so cut its + # definition out before looking. Comments go too — the branch that used to + # be the bug is now described in one, and a test that reads prose is the + # mistake already recorded in CLAUDE.md for the packaged systemd unit. + start = next(n for n, l in enumerate(lines) if "const _memoryFloor" in l) + end = next(n for n in range(start, len(lines)) if lines[n].strip() == "};") + rest = lines[:start] + lines[end + 1:] + code = [re.sub(r"//.*$", "", l) for l in rest] + bare = [l.strip() for l in code if re.search(r"\breturn null\b", l)] + assert bare == [], ( + "an unguarded in-memory fallback was added to _openDownloadTarget; " + "return _memoryFloor() instead: " + "; ".join(bare)) + + +def test_the_guard_is_what_the_preview_uses_too(target_fn): + """`FilePreview` decrypts a whole entry with no writable at all, so it needs + the same ceiling — and must import it rather than keep a second number.""" + files_app = (STATIC / "files-app.js").read_text() + assert "MEMORY_CEILING" in files_app + assert re.search(r"entry\.size\s*>\s*MEMORY_CEILING", files_app), ( + "the preview modal must refuse an oversized entry before fetching it") + assert not re.search(r"100\s*\*\s*1024\s*\*\s*1024", files_app), ( + "the ceiling is defined once, in file-utils.js") + + +def test_a_lost_gesture_streams_instead_of_failing(target_fn, tmp_path): + """ + A browser grants one file picker per user gesture, and downloading three + files is one gesture — so the second and third throw "Must be handling a + user gesture". The person sees a failed transfer, with a message from Chrome + about gestures, for having done something entirely reasonable. + + The streamed path needs no gesture, so it is the right answer rather than a + consolation: the file lands on disk either way, and the only thing lost is + the choice of folder, which there was no picker to make anyway. + """ + out = _run(target_fn, tmp_path, size=20 * GB, + picker="no-gesture", streamed=True, mode="ask") + assert (out["kind"], out["name"]) == ("stream", "s"), out + + +def test_a_lost_gesture_with_nothing_to_stream_to_still_refuses(target_fn, tmp_path): + """And the ceiling still holds underneath: no gesture and no stream is not + a reason to put twenty gigabytes in the page.""" + out = _run(target_fn, tmp_path, size=20 * GB, + picker="no-gesture", streamed=False, mode="ask") + assert out["kind"] == "refused", out + + +# ── Which targets can be paused ───────────────────────────────────────────── +# +# `pausable` travels with the target rather than with the platform, because the +# same browser yields both answers on the same page: a granted folder is a +# held-open file, and a service-worker stream is a download the browser already +# owns. The widget draws its button from this and nothing else. + + +def test_a_granted_folder_can_be_paused(tmp_path, target_fn): + out = _run(target_fn, tmp_path, size=20 * GB, granted=True) + assert out["pausable"] is True + + +def test_a_save_dialog_can_be_paused(tmp_path, target_fn): + out = _run(target_fn, tmp_path, size=20 * GB, picker=True) + assert out["pausable"] is True + + +def test_the_desktop_sink_can_be_paused(tmp_path, target_fn): + out = _run(target_fn, tmp_path, size=20 * GB, native=True) + assert out["pausable"] is True + + +def test_a_service_worker_stream_cannot_be_paused(tmp_path, target_fn): + """Not a shortcoming of this code. The browser is already writing an HTTP + response into its own download folder: not feeding the stream stalls that + download where we can neither see nor resume it, and an idle worker is + terminated within seconds. Firefox and Safari have no other target, so they + get cancel and no pause — the browser's own download manager is where a + pause lives there, for as long as it works. + + This is also why Chrome shows no pause button until a download folder has + been granted: without one, "save automatically" means the service worker. + """ + out = _run(target_fn, tmp_path, size=20 * GB, streamed=True) + assert out["pausable"] is False diff --git a/packages/meshbay-hub/tests/test_security_headers.py b/packages/meshbay-hub/tests/test_security_headers.py index b4d7e6d..44dd7e8 100644 --- a/packages/meshbay-hub/tests/test_security_headers.py +++ b/packages/meshbay-hub/tests/test_security_headers.py @@ -24,7 +24,7 @@ async def test_the_spa_shell_carries_the_policy(client): r = await client.get("/") assert r.headers["content-security-policy"] == CSP assert r.headers["x-content-type-options"] == "nosniff" - assert r.headers["x-frame-options"] == "DENY" + assert r.headers["x-frame-options"] == "SAMEORIGIN" assert "referrer-policy" in r.headers @@ -42,12 +42,17 @@ async def test_even_a_404_carries_the_headers(client): # cannot be framed or content-sniffed either. r = await client.get("/no/such/path") assert r.status_code == 404 - assert r.headers["x-frame-options"] == "DENY" + assert r.headers["x-frame-options"] == "SAMEORIGIN" def test_the_policy_is_locked_down_where_it_matters(): assert "default-src 'none'" in CSP # covers object-src, etc. - assert _directive(CSP, "frame-ancestors") == "frame-ancestors 'none'" + # `'self'`, not `'none'`: every foreign origin is still refused, which is + # the whole of the clickjacking protection. What `'self'` adds is this + # origin framing itself, which the streamed download needs — see + # test_the_streamed_download_frame_is_allowed. Under `'none'` Firefox + # blocked it and large downloads there had no path to disk at all. + assert _directive(CSP, "frame-ancestors") == "frame-ancestors 'self'" assert _directive(CSP, "base-uri") == "base-uri 'none'" script = _directive(CSP, "script-src") @@ -63,3 +68,66 @@ def test_recaptcha_is_the_only_external_origin(): for tok in part.strip().split()[1:]: if tok.startswith(("http://", "https://")): assert tok in hosts, f"unexpected external origin in CSP: {tok}" + + +def test_the_streamed_download_frame_is_allowed(): + """ + `frame-src` must carry `'self'`, and this is not a preference. + + The streamed-download path works by navigating a hidden iframe to + `/_mbdl/<id>` so the service worker is asked for the response it is already + holding. `frame-src` was tightened to reCAPTCHA's two origins when the + captcha needed a frame, and nobody connected the two: Chrome refused the + frame, the worker was never asked, and the page waited out its timeout for + a download that could not happen. On Firefox and Safari that is the *only* + way to write a large file to disk — there is no File System Access API and + OPFS is capped at 10% of the volume — so the whole path was dead, silently, + on the deployed hub. + + Found by clicking Download three times and watching nothing happen, with + the reason in the browser console and nowhere else. + """ + frame_src = _directive(CSP, "frame-src") + assert "'self'" in frame_src, ( + "the same-origin download frame is blocked; large downloads fall back " + "to memory, or are refused outright above the ceiling") + # And still no wildcard: `'self'` is what the download needs, nothing more. + assert "*" not in frame_src + + +def test_no_foreign_origin_may_frame_this_page(): + """The clickjacking property, stated separately from how it is spelled. + + `frame-ancestors` moved from `'none'` to `'self'` so the streamed download + could frame its own URL. That must not become a list of origins, and it must + never become `*`: the threat is a foreign page framing this one and stealing + clicks, and `'self'` is the most permissive value that still refuses every + one of them. + """ + value = _directive(CSP, "frame-ancestors").split(" ", 1)[1].strip() + assert value in ("'none'", "'self'"), ( + f"frame-ancestors is {value!r}: anything naming an origin lets that " + f"origin frame this page") + + +def test_the_two_framing_headers_agree(): + """X-Frame-Options and CSP must say the same thing. + + They did not: the CSP let this origin frame itself (which the streamed + download needs) while `X-Frame-Options: DENY` forbade all framing. The spec + says a browser must ignore the header when frame-ancestors is present, and + counting on that while shipping a contradiction is how an afternoon goes: + the CSP was fixed, the download stayed broken, and the header was why. + + Checked as a pair rather than one value apiece, because the defect was the + disagreement and either one alone reads as correct. + """ + import asyncio + + from meshbay_hub.app import create_app # noqa: F401 (import check) + + ancestors = _directive(CSP, "frame-ancestors").split(" ", 1)[1].strip() + expected = {"'none'": "DENY", "'self'": "SAMEORIGIN"}[ancestors] + assert expected == "SAMEORIGIN", ( + "if frame-ancestors goes back to 'none', X-Frame-Options must go back " + "to DENY in app.py — and the streamed download will stop working again") diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py new file mode 100644 index 0000000..e1b3800 --- /dev/null +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -0,0 +1,589 @@ +""" +The service-worker download path, which on Firefox and Safari is the only +unbounded way to write a file to disk. + +Neither of those browsers has the File System Access API, and OPFS is not a +substitute: measured on Firefox 154, its quota is exactly 10% of the volume's +size (389,233,459 bytes on a 3,892,334,592-byte volume, refused to the byte), +which a film exceeds. So when this path declines, a large download has nowhere +left to go — there is no floor under it that can hold a film. That is what makes +its reliability a correctness property rather than a nicety. + +The real module is imported under Node with the browser pieces it reaches +stubbed — `navigator.serviceWorker`, a document that "navigates" an iframe, and +Node's own TransformStream and MessageChannel, which are the real ones. What is +modelled is the environment; `serviceWorker()` and `openStreamedDownload()` are +executed, never reimplemented. + +Three failures are pinned, all of which shipped: + + - registration happened inside the first click, so that click paid install, + activate and claim while somebody watched a button do nothing; + - a null result was cached for the life of the page, so one slow first click + left the tab unable to stream anything again, curable only by a reload + nobody knew to do; + - one missed navigation fell straight through instead of retrying. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +DOWNLOADS = STATIC / "downloads.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not DOWNLOADS.exists(), + reason="node or the SPA sources are not available") + +# The stub browser. `plan` decides how the fake worker behaves, so one harness +# covers every case below. +PRELUDE = """ +const store = new Map(); +globalThis.localStorage = { + getItem: k => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: k => store.delete(k), +}; +const PLAN = %(plan)s; +const log = { registers: 0, claims: 0, navigations: 0, served: 0, + unregisters: 0, wakes: 0 }; + +// The worker as the page sees it: something with postMessage. It answers a +// navigation by posting mbdl-serving back on the port it was handed, which is +// exactly the confirmation the real sw.js sends from its fetch handler. +let controller = null; +const pendingByFrame = new Map(); +// Set before the controller exists, because the declaration below is what +// the temporal dead zone protects. +let asleep = PLAN.workerAsleep; +const makeController = () => ({ + postMessage: (msg, transfer) => { + // A worker with nothing to do is terminated, and `pending` goes with it. + // A ping wakes it; anything else posted while it sleeps is simply lost, + // which is what makes this failure silent. + if (asleep) { + if (msg.type === 'mbdl-ping') { + asleep = false; + log.wakes += 1; + if (msg.ports || (transfer && transfer[0])) { + const port = (transfer && transfer[0]) || null; + if (port) setTimeout(() => port.postMessage({type: 'mbdl-pong'}), 0); + } + } + return; + } + if (msg.type === 'mbdl-ping') { + const port = (transfer && transfer[0]) || null; + if (port) setTimeout(() => port.postMessage({type: 'mbdl-pong'}), 0); + return; + } + if (msg.type === 'mbdl-claim') { + log.claims += 1; + // A worker that actually claims when asked, which is what sw.js does. + if (PLAN.controlOnClaim) { + controller = makeController(); + for (const fn of listeners) fn(); + } + return; + } + if (msg.type !== 'mbdl') return; + pendingByFrame.set('/_mbdl/' + msg.id, msg.port); + // The worker says it has it, which is what the page waits for. + if (msg.port) setTimeout(() => msg.port.postMessage({type: 'mbdl-ready', + id: msg.id}), 0); + }, +}); + +const listeners = new Set(); +// `globalThis.navigator` is read-only from Node 22 -- assigning to it is the +// mistake CLAUDE.md already records against test_locales.py. Define it. +Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + serviceWorker: { + get controller() { return controller; }, + // What the document started with, which is the whole of the repair's + // evidence now. `getRegistration` is asked before anything registers. + getRegistration: async () => (PLAN.registeredAtLoad + ? {active: makeController()} : undefined), + register: async () => { + log.registers += 1; + if (PLAN.registerThrows) throw new Error('registration blocked'); + // A registration that never answers at all. Distinct from one that + // rejects: nothing is reported, nothing fails, the caller just waits. + if (PLAN.registerHangs) await new Promise(() => {}); + // A worker that only becomes installable once the stuck registration + // has been thrown away -- the browser this was reported from. + const healed = PLAN.activeAfterUnregister && log.unregisters > 0; + if (PLAN.controlAfterMs !== null || healed) { + setTimeout(() => { + controller = makeController(); + for (const fn of listeners) fn(); + }, healed ? 0 : PLAN.controlAfterMs); + } + return { + active: (PLAN.active || healed) ? makeController() : null, + unregister: async () => { log.unregisters += 1; return true; }, + }; + }, + // `register()` resolves as soon as the registration object exists, with + // nothing but an installing worker; `ready` is what waits for an active + // one. Measured on Firefox 154: an install handler that rejects leaves + // `ready` unsettled past ten seconds while `register()` returns in 7 ms. + get ready() { + const healed = PLAN.activeAfterUnregister && log.unregisters > 0; + return (PLAN.readySettles || healed) + ? Promise.resolve({}) : new Promise(() => {}); + }, + addEventListener: (type, fn) => { if (type === 'controllerchange') listeners.add(fn); }, + removeEventListener: (type, fn) => { listeners.delete(fn); }, + }, + }, +}); + +globalThis.window = globalThis; +globalThis.isSecureContext = true; +// Set before the module is imported, because it reads it at evaluation. +controller = %(controlled)s ? makeController() : null; +// The self-test's repair reloads once and remembers it for the tab; both have +// to exist here or priming the worker throws instead of repairing. +const session = new Map(); +globalThis.sessionStorage = { + getItem: k => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: k => session.delete(k), +}; +log.reloads = 0; +globalThis.location = { reload: () => { log.reloads += 1; } }; +globalThis.document = { + createElement: () => ({ hidden: false, src: '', remove() {} }), + body: { + appendChild: (frame) => { + log.navigations += 1; + const port = pendingByFrame.get(frame.src); + const answer = PLAN.serveOnNavigation === 'always' + || (PLAN.serveOnNavigation === 'second' && log.navigations >= 2); + if (port && answer) { + log.served += 1; + setTimeout(() => { + port.postMessage({type: 'mbdl-serving', id: frame.src}); + // The worker's own copy of the port, dropped once answered. sw.js + // drops it with the pending entry; here it has to be explicit or the + // harness process never exits. + port.close(); + }, 0); + } + }, + }, +}; + +const M = await import('%(module)s'); +// Production waits 15 s for each; these cases are about which branch runs. +const FAST = {controlMs: %(control)d, servedMs: 400}; +const out = {}; +""" + + +def _run(tmp_path, body, *, control_after_ms=0, active=True, + serve="always", register_throws=False, control_budget_ms=800, + ready_settles=True, register_hangs=False, + active_after_unregister=False, control_on_claim=False, + controlled_at_load=False, registered_at_load=False, + worker_asleep=False): + module = tmp_path / "downloads.mjs" + module.write_text(DOWNLOADS.read_text()) + (tmp_path / "package.json").write_text('{"type":"module"}') + plan = { + "controlAfterMs": control_after_ms, + "active": active, + "serveOnNavigation": serve, + "registerThrows": register_throws, + "readySettles": ready_settles, + "registerHangs": register_hangs, + "activeAfterUnregister": active_after_unregister, + "controlOnClaim": control_on_claim, + "registeredAtLoad": registered_at_load, + "workerAsleep": worker_asleep, + } + script = tmp_path / "case.mjs" + script.write_text( + (PRELUDE % {"plan": json.dumps(plan), "module": module.as_posix(), + "control": control_budget_ms, + "controlled": json.dumps(controlled_at_load)}) + + body + + "\nout.log = log;\nconsole.log(JSON.stringify(out));\n") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True, + timeout=120) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── A failure must never be cached ────────────────────────────────────────── + +def test_a_missed_claim_does_not_poison_the_page(tmp_path): + """ + The bug: `_swReady` held the null, so every later download in that tab got + it back without trying. One slow first click and the tab could not stream + again — on Firefox, that is every large download for the rest of the visit. + + Here the worker never takes control, so the first call fails; the second + must register again rather than return a remembered null. + """ + r = _run(tmp_path, """ + out.first = await M.openStreamedDownload('a.bin', 10, FAST) !== null; + const after = log.registers; + out.second = await M.openStreamedDownload('b.bin', 10, FAST) !== null; + out.registeredAgain = log.registers > after; + """, control_after_ms=None) + assert r["first"] is False and r["second"] is False + assert r["registeredAgain"] is True, "a failed attempt was cached" + + +def test_a_success_is_reused_rather_than_re_registered(tmp_path): + """The other half: once controlled, it must not re-register per download.""" + r = _run(tmp_path, """ + // Closed, like a real caller: an open target holds a keep-alive interval + // for the worker, and a test that leaks one never lets Node exit. + for (const name of ['a.bin', 'b.bin']) { + const t = await M.openStreamedDownload(name, 10, FAST); + out[name[0]] = t !== null; + if (t) await t.writable.close(); + } + """) + assert r["a"] and r["b"] + assert r["log"]["registers"] <= 1, "re-registered on a page already controlled" + + +# ── Waiting for control, rather than giving up ────────────────────────────── + +def test_control_arriving_late_is_still_used(tmp_path): + """ + Control used to be waited for with a 3 s cap, inside the click. A cold + worker on a busy machine can take longer, and the old code called that a + browser that cannot stream. Scaled down here — the budget is a parameter, so + what is pinned is that a claim arriving after the first check is still used, + not the particular number of seconds. + """ + r = _run(tmp_path, """ + const t0 = Date.now(); + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ok = target !== null; + out.waitedMs = Date.now() - t0; + if (target) await target.writable.close(); + """, control_after_ms=1200, control_budget_ms=6000) + assert r["ok"] is True, "gave up on a claim that arrived late" + assert r["waitedMs"] >= 1100, "did not actually wait for the claim" + + +def test_an_uncontrolled_page_asks_the_worker_to_claim_again(tmp_path): + """ + Active but not controlling — a page loaded before any worker existed, whose + claim was missed. Rather than declare the path unavailable, ask again. + """ + r = _run(tmp_path, """ + out.ok = await M.openStreamedDownload('a.bin', 10, FAST) !== null; + """, control_after_ms=None, active=True) + assert r["log"]["claims"] >= 1, "never asked the active worker to claim" + + +# ── Retrying a missed navigation ──────────────────────────────────────────── + +def test_a_missed_navigation_is_retried(tmp_path): + """ + The worker takes the stream and is then never asked for the URL. The page + used to give up at once; on Firefox that sends a film to the in-memory + floor. It gets a second go, with a fresh id and a fresh iframe. + """ + r = _run(tmp_path, """ + const t = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ok = t !== null; + if (t) await t.writable.close(); + """, serve="second") + assert r["ok"] is True, "one missed navigation ended the download" + assert r["log"]["navigations"] == 2 + + +def test_giving_up_says_why(tmp_path): + """ + A silent null is what made the original defect invisible. Whatever happens, + the reason has to be readable afterwards — it is what the refusal quotes. + """ + r = _run(tmp_path, """ + out.target = await M.openStreamedDownload('a.bin', 10, FAST); + out.why = M.lastStreamFailure(); + """, control_after_ms=None) + assert r["target"] is None + assert r["why"], "declined with no stated reason" + + +def test_a_registration_that_throws_is_reported_not_swallowed(tmp_path): + r = _run(tmp_path, """ + out.target = await M.openStreamedDownload('a.bin', 10, FAST); + out.why = M.lastStreamFailure(); + """, register_throws=True) + assert r["target"] is None + assert "registration" in r["why"] + + +# ── Wiring that the behavioural cases cannot see ──────────────────────────── + +def test_the_worker_is_primed_at_boot_not_at_the_first_click(tmp_path): + """ + Registration inside the first download is the whole reason the claim was + ever raced. `primeServiceWorker` has to be called where the app starts, and + from a module that actually imports it — `node --check` would not notice a + missing import, which is a mistake this repo has already shipped once. + """ + app = (STATIC / "app.js").read_text() + assert "downloads.primeServiceWorker()" in app, "nothing primes the worker" + assert "import * as downloads from './downloads.js'" in app, ( + "app.js calls downloads.primeServiceWorker() without importing downloads") + # In mount(), which runs at start-up — not inside a component or a handler. + mount = app[app.index("const mount = () => {"):] + assert "downloads.primeServiceWorker()" in mount[:mount.index("\n};")] + + +def test_the_worker_answers_a_re_claim(tmp_path): + """The page's last resort before declaring the path unavailable only works + if sw.js implements the other half.""" + sw = (STATIC / "sw.js").read_text() + assert "mbdl-claim" in sw and "clients.claim()" in sw + + +# ── Nothing on this path may wait for ever ────────────────────────────────── + +def test_a_worker_that_never_installs_does_not_hang_every_download(tmp_path): + """The one that reached a person: four downloads stuck at "preparing", for + ever, with nothing in the node's journal because no transfer had been asked + for yet. + + `register()` resolves as soon as the registration object exists — with + nothing but an *installing* worker — and `ready` waits for an active one. + Measured on Firefox 154: an install handler that rejects leaves `ready` + unsettled past ten seconds while `register()` returns in seven + milliseconds. Neither had a deadline, and `_swPromise` is shared, so every + download on the page waited on the same promise that would never settle. + """ + out = _run(tmp_path, """ +const t0 = Date.now(); +out.worker = await M.openStreamedDownload('film.mkv', 1, FAST); +out.ms = Date.now() - t0; +out.why = M.lastStreamFailure(); +""", ready_settles=False, active=False, control_after_ms=None, + control_budget_ms=300) + assert out["worker"] is None + assert out["ms"] < 8000, ( + f"gave up after {out['ms']}ms — a budget that is not enforced is not a " + "budget, and the row above it says 'preparing' the whole time") + assert "active" in out["why"], out["why"] + + +def test_a_stuck_ready_does_not_throw_away_a_working_worker(tmp_path): + """`ready` can be waiting on a *newer* worker that cannot install while an + older one is perfectly able to serve. Giving up then would cost Firefox the + only unbounded way it has to write a download to disk — a deadline must + bound the waiting, never remove the capability.""" + out = _run(tmp_path, """ +const target = await M.openStreamedDownload('film.mkv', 1, FAST); +out.target = target !== null; +// Closing stops the keep-alive; left open, its interval keeps this process +// alive well past the test's own timeout. +if (target) await target.writable.close(); +""", ready_settles=False, active=True, control_budget_ms=300) + assert out["target"] is True + + +def test_a_registration_that_never_answers_gives_up_too(tmp_path): + """The other unbounded await. It rejects loudly in the case above; this is + the case where it says nothing at all.""" + out = _run(tmp_path, """ +const t0 = Date.now(); +out.worker = await M.openStreamedDownload('film.mkv', 1, FAST); +out.ms = Date.now() - t0; +out.why = M.lastStreamFailure(); +""", register_hangs=True, active=False, control_after_ms=None, + control_budget_ms=300) + assert out["worker"] is None + assert out["ms"] < 8000, f"gave up after {out['ms']}ms" + assert "register" in out["why"], out["why"] + + +def test_a_registration_stuck_installing_is_discarded_and_asked_for_again(tmp_path): + """A deadline turns an invisible hang into a named failure, which is better + but is not a fix: a registration stuck with nothing but an installing worker + does not heal on its own. Every later visit finds the same registration and + waits on the same `ready`, so the browser stays unable to stream a download + until somebody opens developer tools — and on Firefox there is nothing else + that can write a film to disk. + + So the stuck registration is thrown away and asked for once more. + """ + out = _run(tmp_path, """ +const target = await M.openStreamedDownload('film.mkv', 1, FAST); +out.target = target !== null; +if (target) await target.writable.close(); +""", ready_settles=False, active=False, control_after_ms=None, + active_after_unregister=True, control_budget_ms=300) + assert out["log"]["unregisters"] == 1, ( + "the stuck registration was left in place") + assert out["target"] is True, ( + "discarding it did not get the page a worker it could stream to") + + +# ── A page loaded with the worker bypassed ────────────────────────────────── + + +def test_a_hard_reloaded_page_reloads_itself_once(tmp_path): + """Uncontrolled at load while an active registration already exists is a + document fetched by a hard reload — Ctrl+F5, Ctrl+Shift+R — and nothing + else. Measured on Chrome at document start: a first visit has neither, an + ordinary reload has both, a hard reload has the registration and no + controller. + + Such a page can still be claimed, so every control check passes; but the + navigations it starts keep missing the worker, and the hidden iframe a + streamed download needs is one. On Firefox and Safari that is the only way + to write a file too large to hold in memory. An ordinary reload undoes it. + """ + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 200)); + out.reloads = log.reloads; + """, controlled_at_load=False, registered_at_load=True) + assert out["reloads"] == 1 + + +def test_a_first_visit_is_not_a_bypass(tmp_path): + """Also uncontrolled at load, and perfectly healthy: the worker is being + installed right now and will claim the page in a moment. Reloading here + would be a flicker on everybody's first visit — and it was, taking the + group's WebRTC session down with it when it landed mid-connection.""" + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 200)); + out.reloads = log.reloads; + """, controlled_at_load=False, registered_at_load=False) + assert out["reloads"] == 0 + + +def test_a_controlled_page_does_not_reload(tmp_path): + """The ordinary case, which must cost nothing at all: no reload, and no + download spent asking. Chrome rations the downloads a page may start + without a user gesture to about three, and the first version of this check + asked its question by performing one — competing with the person's own + downloads for that budget.""" + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 200)); + out.reloads = log.reloads; + out.navigations = log.navigations; + """, controlled_at_load=True, registered_at_load=True) + assert out["reloads"] == 0 + assert out["navigations"] == 0, ( + "priming performed a download; that budget belongs to the person") + + +def test_the_repair_happens_at_most_once(tmp_path): + """The flag is in sessionStorage rather than a variable because the point is + to survive the reload it triggers, and because a page that is still bypassed + afterwards must stop rather than reload again, and again.""" + out = _run(tmp_path, """ + sessionStorage.setItem('meshbay.sw-repaired', '1'); + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 200)); + out.reloads = log.reloads; + """, controlled_at_load=False, registered_at_load=True) + assert out["reloads"] == 0 + + +# ── The claim is asked for, not waited for ────────────────────────────────── + +def test_an_uncontrolled_page_asks_at_once_rather_than_after_the_budget(tmp_path): + """A page that is uncontrolled while an active worker exists will not be + claimed on its own — a document fetched by a hard reload is exactly that + shape. Waiting the whole control budget first spends it on something that + is not coming: about thirty seconds, measured, during which the person + clicks download and watches four rows hang before the page repairs itself. + """ + out = _run(tmp_path, """ + const t0 = Date.now(); + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ms = Date.now() - t0; + out.target = target !== null; + out.claims = log.claims; + // Closing stops the keep-alive; left open, its interval outlives the test. + if (target) await target.writable.close(); + """, control_after_ms=None, control_on_claim=True, control_budget_ms=6000) + assert out["target"] is True + assert out["claims"] >= 1 + assert out["ms"] < 3000, ( + f"took {out['ms']}ms of a 6000ms budget — the claim was asked for only " + "after the wait, not before it") + + +def test_a_download_waits_for_priming(tmp_path): + """A click that lands while priming is still running must not race it: on a + page about to reload, the attempt would fail for nothing.""" + out = _run(tmp_path, """ + M.primeServiceWorker(); + const t0 = Date.now(); + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.ms = Date.now() - t0; + out.target = target !== null; + if (target) await target.writable.close(); + """) + assert out["target"] is True + assert out["ms"] >= 1, "the download did not wait for priming at all" + + +def test_the_streamed_target_says_it_cannot_be_paused(tmp_path): + """The value the widget's pause button is drawn from, read off the real + module rather than a stub of it. + + It is false for a reason that is not about this code: the browser is already + writing an HTTP response into its own download folder, so not feeding the + stream stalls a download we can neither see nor resume, and an idle worker + is terminated within seconds. Firefox and Safari therefore get cancel and no + pause; Chrome gets one as soon as a download folder has been granted, which + yields a held-open file instead of this. + """ + out = _run(tmp_path, """ + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.pausable = target && target.pausable; + if (target) await target.writable.close(); + """) + assert out["pausable"] is False + + +# ── a worker that was asleep when we posted ───────────────────────────────── + +def test_a_sleeping_worker_is_woken_before_it_is_handed_a_stream(tmp_path): + """Reported from Chrome: a download started while an upload was running took + thirty seconds to begin, every time. + + `pending` lives in the worker's memory and a worker with nothing to do is + terminated — which is what a long upload leaves it, for minutes, since a + WebRTC transfer gives it no events at all. The stream posted to it was lost; + the iframe then woke it with nothing to find and the request fell through to + the network, measured in the console as a 404 from the hub and fifteen + seconds of silence, twice. + + `mbdl-ping` already existed — sent every ten seconds *while* writing, for + the same reason. Nothing sent one before *starting*. + """ + out = _run(tmp_path, """ + const target = await M.openStreamedDownload('film.mkv', 20e9, FAST); + out.target = target !== null; + out.wakes = log.wakes; + out.navigations = log.navigations; + if (target) await target.writable.close(); + """, worker_asleep=True) + assert out["target"] is True, "the download never started" + assert out["wakes"] == 1, "the worker was handed a stream while asleep" + assert out["navigations"] == 1, ( + f"took {out['navigations']} attempts — the first one was wasted on a " + "worker that had not been woken") diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index 3316615..d93cf80 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -222,3 +222,695 @@ def test_a_folder_name_carries_no_trailing_slash(): assert "dir-row" in row, "the anchor no longer lands on the directory row" assert "${d}/" not in row, "the folder name is rendered with a trailing slash" assert "${d}" in row + + +# ── Transfer slots, client side ───────────────────────────────────────────── +# +# A queue can lie in two directions, and both are worse than no queue: a +# transfer that shows "waiting" on a node that already granted it, and a slot +# the page holds after it has stopped using it. Everything below is one of +# those two. + +def _lease_stub(): + """A Lease as the store sees it, driveable from the test.""" + return """ +class L { + constructor() { + this.state = 'queued'; this.ahead = 2; this.closed = false; + this.released = []; this.tr = 'tr1'; + this._wait = new Promise(r => { this._go = r; }); + } + acquire() { return this._wait; } + release(reason) { if (!this.closed) { this.closed = true; this.released.push(reason); } } + grant() { this.state = 'granted'; if (this._onState) this._onState(this); this._go(); } + push(state, ahead) { this.state = state; this.ahead = ahead; if (this._onState) this._onState(this); } +} +""" + + +def test_a_transfer_waiting_for_a_slot_is_queued_not_running(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran'); } }); + say(t.list()[0].status, t.list()[0].ahead); + await new Promise(r => setTimeout(r, 0)); + say('still:' + t.list()[0].status); + """, tmp_path) + assert out[:2] == ["queued", 2] + assert "ran" not in out, "the work started before the slot was granted" + assert out[-1] == "still:queued" + + +def test_the_grant_starts_the_work(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran:' + t.list()[0].status); } }); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('after:' + t.list()[0].status); + """, tmp_path) + assert out[0] == "ran:running" + assert out[1] == "after:done" + + +def test_the_slot_comes_back_however_the_transfer_ends(tmp_path): + """A slot not returned is a member who cannot transfer again until the node + times it out — so this must hold for a throw as much as for a success.""" + out = _run(_lease_stub() + """ + for (const mode of ['ok', 'throw']) { + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { if (mode === 'throw') throw new Error('x'); } }); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say(mode + ':' + lease.released.join(',') + ':' + t.list()[0].status); + } + """, tmp_path) + assert out == ["ok:done:done", "throw:done:failed"] + + +def test_cancelling_while_queued_gives_the_slot_back(tmp_path): + """The transfer somebody is most likely to give up on is the one that has + not started. Its queue entry has to go, or the node grants a slot to a + transfer that will never use it.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + const id = t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran'); } }); + t.cancel(id); + say(t.list()[0].status, lease.released.join(',')); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('ran?', out.includes('ran')); + """, tmp_path) + assert out[0] == "cancelled" + assert out[1] == "cancelled" + assert out[-1] is False, "a cancelled transfer ran anyway once granted" + + +def test_a_queue_position_update_reaches_the_view(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + const seen = []; + t.subscribe(items => seen.push(items[0].ahead)); + t.start({ kind: 'download', name: 'f', total: 10, lease, run: async () => {} }); + lease.push('queued', 1); + lease.push('queued', 0); + say(seen.join('>')); + """, tmp_path) + assert out[0].endswith("1>0"), "the widget never learns it is moving up" + + +def test_a_transport_with_a_queued_transfer_is_not_closed(tmp_path): + """Closing it would leave the transfer waiting for a grant that can never + arrive — waiting for ever, with nothing left to answer.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + let closed = false; + const transport = { close() { closed = true; } }; + t.start({ kind: 'download', name: 'f', total: 10, transport, lease, + run: async () => {} }); + t.releaseWhenIdle(transport); + say('closed while queued:', closed); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('closed after:', closed); + """, tmp_path) + assert out[1] is False + assert out[3] is True + + +def test_clearing_finished_keeps_what_is_waiting(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'waiting', total: 1, lease, run: async () => {} }); + t.start({ kind: 'download', name: 'done', total: 1, run: async () => {} }); + await new Promise(r => setTimeout(r, 10)); + t.clearFinished(); + say(t.list().map(i => i.name + ':' + i.status).join(',')); + """, tmp_path) + assert out[0] == "waiting:queued" + + +def test_asking_for_a_slot_on_a_dead_channel_does_not_throw(tmp_path): + """ + The transport reconnects on its own and re-asks for every live lease when it + does, so a closed channel at the moment a transfer starts is a wait, not a + failure. `_fetchChunkResilient` has always treated it that way — and before + leases existed a chunk request was the first thing to touch the channel, so + a download begun on a briefly dead connection simply retried. + + Asking for a slot first made `_send` the first contact. It threw + "DataChannel not open (state: closed)" straight out of `downloadEntry`, + where nothing catches it: a download that used to recover became an error + with no row in the widget to show it. Found live, by downloading a file + just after a connection dropped. + """ + module = tmp_path / "transport_lease.mjs" + # The real Lease, lifted out as text — the class is not exported, and a + # second copy of it here would agree with whatever it was copied from. + src = (STATIC / "transport.js").read_text() + # From the constant the class depends on, not from the class: lifting only + # the class left LEASE_WATCHDOG_MS undefined, which the class reads the + # first time it arms its watchdog. + start = src.index("const LEASE_WATCHDOG_MS") + end = src.index("\nclass MeshBayTransport") + module.write_text(src[start:end] + "\nexport { Lease };\n") + + script = tmp_path / "case.mjs" + script.write_text(f""" +import {{ Lease }} from '{module.as_posix()}'; +const out = []; +const transport = {{ + supportsTransferSlots: true, + _leases: new Map(), + _send() {{ throw new Error('DataChannel not open (state: closed)'); }}, +}}; +let threw = null; +const lease = new Lease(transport, 'tr1', 'download', 10, 1, null); +try {{ lease._request(); }} catch (e) {{ threw = e.message; }} +out.push(threw); +// And releasing one must be just as safe: a lease not released is a member who +// cannot start another transfer until the node times it out. +try {{ lease.release('cancelled'); out.push('release ok'); }} +catch (e) {{ out.push('release threw: ' + e.message); }} +clearTimeout(lease._watchdog); +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out[0] is None, f"asking for a slot threw: {out[0]}" + assert out[1] == "release ok" + + +def test_the_slot_is_asked_for_after_there_is_somewhere_to_write(): + """ + A granted slot has to be taken up within the node's acceptance deadline, so + it must not be asked for until the download can actually start. + + Asking first reads better — the widget could draw a row while the target is + being chosen — and is wrong: opening a target takes thirty seconds of + streamed-download timeouts, or as long as somebody leaves a Save As dialog + open. The node revokes the grant, passes it to the next in the queue + (`transfer: reclaimed … (not_taken_up)` in its log), and the download then + fetches under a `tr` that is no longer granted. Three downloads started, one + arrived. + + Source-reading, because the ordering is the whole property and it has no + behaviour of its own to drive: what matters is which call comes first. + """ + src = (STATIC / "file-utils.js").read_text() + fn = src[src.index("async function downloadEntry"):] + fn = fn[:fn.index("\n}\n")] + # `_openTargetInTurn` since target openings were serialised — same call, + # queued. What is pinned is that it comes before the slot is asked for. + assert fn.index("_openTargetInTurn") < fn.index("openTransfer"), ( + "downloadEntry asks for a transfer slot before it has anywhere to " + "write — the grant expires before the download can use it") + + +# ── the row exists from the click ─────────────────────────────────────────── + +def test_the_row_appears_before_the_target_is_open(tmp_path): + """ + Opening a target is the slow part — the streamed path waits for the worker + twice, a Save As dialog waits for a person — and the row used to be created + only after it returned. Three clicks produced no panel at all, not even the + icon, and then several rows at once. + """ + out = _run(""" + const t = new TransferStore(); + let release; + const opened = new Promise(r => { release = r; }); + t.start({ kind: 'download', name: 'film.mkv', total: 10, + prepare: async () => { await opened; return { name: 'saved.mkv' }; }, + run: async () => { say('ran'); } }); + const shot = (when) => say(when + '=' + t.list().length + ':' + + t.list().map(i => i.status + '/' + i.name).join(',')); + shot('click'); + release(); + await new Promise(r => setTimeout(r, 10)); + shot('after'); + """, tmp_path) + # Tagged, not indexed. An earlier version counted pushes by hand and was one + # out, which reads exactly like a failing assertion about the code. + seen = dict(line.split("=", 1) for line in out + if isinstance(line, str) and "=" in line) + assert {"click", "after"} <= set(seen), f"probe produced: {out}" + assert seen["click"] == "1:preparing/film.mkv", ( + f"no row, or the wrong one, at the moment of the click: {seen['click']}") + assert seen["after"] == "1:done/saved.mkv", ( + f"the row must keep the name it was saved under: {seen['after']}") + + +def test_a_dismissed_dialog_leaves_nothing_behind(tmp_path): + """Dismissing a Save As dialog is not a failure and not a cancellation: + nothing was started, so nothing should be left on screen explaining it.""" + out = _run(""" + const t = new TransferStore(); + t.start({ kind: 'download', name: 'film.mkv', total: 10, + prepare: async () => false, + run: async () => { say('ran'); } }); + say('at click:', t.list().length); + await new Promise(r => setTimeout(r, 10)); + say('after:', t.list().length, out.includes('ran')); + """, tmp_path) + assert out[1] == 1 + assert out[3] == 0, "a dismissed dialog left a row behind" + assert out[4] is False + + +def test_the_slot_is_only_asked_for_once_there_is_somewhere_to_write(tmp_path): + """ + A granted slot must be taken up within the node's deadline, and opening a + target can outlast it. Asking first cost two of three downloads. + """ + out = _run(""" + const t = new TransferStore(); + let release; + const opened = new Promise(r => { release = r; }); + let asked = false; + t.start({ kind: 'download', name: 'f', total: 10, + prepare: async () => { await opened; return true; }, + makeLease: () => { asked = true; return { + state: 'granted', ahead: 0, tr: 'x', + acquire: () => Promise.resolve(), release: () => {} }; }, + run: async () => {} }); + say('while preparing, asked?', asked); + release(); + await new Promise(r => setTimeout(r, 10)); + say('after preparing, asked?', asked, t.list()[0].status); + """, tmp_path) + assert out[1] is False, "the slot was taken before there was a target" + assert out[3] is True + assert out[4] == "done" + + +def test_a_target_that_cannot_be_opened_fails_the_row_it_already_has(tmp_path): + """The refusal above the memory ceiling lands in the panel, on the row that + is already there, rather than in a console nobody opens.""" + out = _run(""" + const t = new TransferStore(); + t.start({ kind: 'download', name: 'film.mkv', total: 10, + prepare: async () => { throw new Error('too large for memory'); }, + run: async () => { say('ran'); } }); + await new Promise(r => setTimeout(r, 10)); + const it = t.list()[0]; + say(it.status, it.error, out.includes('ran')); + """, tmp_path) + assert out[0] == "failed" + assert "too large" in out[1] + assert out[2] is False + + +def test_a_transport_is_not_closed_under_a_preparing_transfer(tmp_path): + """It has no lease yet and has moved no bytes, but closing its transport + would strand it exactly like a queued one.""" + out = _run(""" + const t = new TransferStore(); + let release; + const opened = new Promise(r => { release = r; }); + let closed = false; + const transport = { close() { closed = true; } }; + t.start({ kind: 'download', name: 'f', total: 10, transport, + prepare: async () => { await opened; return true; }, + run: async () => {} }); + t.releaseWhenIdle(transport); + say('closed while preparing:', closed); + release(); + await new Promise(r => setTimeout(r, 10)); + say('closed after:', closed); + """, tmp_path) + assert out[1] is False + assert out[3] is True + + +# ── Pause and resume ──────────────────────────────────────────────────────── +# +# The rule the whole design turns on: **a paused transfer holds nothing.** Its +# slot goes back to the node the moment it stops, and resuming rejoins the queue +# at the tail. Anything else lets one member close a node by pausing four +# downloads and going to lunch (§6.2 of ~/next/improve-downloads.md). + + +def _pausable_run(): + """A `run` that stops where it is told and reports where it resumed.""" + return """ +const mkStore = () => { + const t = new TransferStore(); + const leases = []; + const state = { starts: [], paused: null, aborted: false }; + t.start({ + kind: 'download', name: 'f', total: 1000, + prepare: async () => ({ name: 'f', pausable: true }), + makeLease: () => { const l = new L(); leases.push(l); return l; }, + run: async ({ signal, from }) => { + state.starts.push(from); + state.running = true; + try { + // Runs until told to stop, one "chunk" at a time. + for (let i = from; i < 10; i++) { + await new Promise(r => setTimeout(r, 5)); + if (signal.aborted) { const e = new Error('c'); e.name = 'AbortError'; throw e; } + if (signal.paused) { + signal.resumeFrom = i; + const e = new Error('p'); e.name = 'PausedError'; throw e; + } + } + } finally { state.running = false; } + }, + }); + return { t, leases, state }; +}; +""" + + +def test_pausing_gives_the_slot_back(tmp_path): + """The node has to get it back at once, not when the person resumes: the + whole point of a queue is that a slot nobody is using is a slot somebody + else can have.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + say('before:' + t.list()[0].status); + t.pause(t.list()[0].id); + await new Promise(r => setTimeout(r, 30)); + say('after:' + t.list()[0].status); + say('released:' + leases[0].released.join(',')); + say('leases:' + leases.length); + """, tmp_path) + assert out[0] == "before:running" + assert out[1] == "after:paused" + assert out[2] == "released:paused", "a paused transfer kept its slot" + assert out[3] == "leases:1" + + +def test_resuming_asks_for_a_new_slot_and_continues_where_it_stopped(tmp_path): + """Rejoining at the tail is the design, not an accident: a paused transfer + that could reclaim its old place would be a way to hold one.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases, state } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + t.pause(id); + await new Promise(r => setTimeout(r, 30)); + t.resume(id); + await new Promise(r => setTimeout(r, 10)); + say('queued:' + t.list()[0].status, 'leases:' + leases.length); + leases[1].grant(); + await new Promise(r => setTimeout(r, 120)); + say('end:' + t.list()[0].status); + say('starts:' + state.starts.join(',')); + """, tmp_path) + assert out[0] == "queued:queued", "a resumed transfer skipped the queue" + assert out[1] == "leases:2", "resuming did not ask for a slot again" + assert out[2] == "end:done" + starts = out[3].split(":")[1].split(",") + assert starts[0] == "0" and int(starts[1]) > 0, ( + f"resumed from {starts} — it started again from the beginning") + + +def test_a_transfer_whose_target_cannot_pause_is_not_paused(tmp_path): + """A service-worker stream is a download the browser already owns: not + writing to it stalls it outside our control and an idle worker is killed + within seconds. A button that silently restarts from zero is worse than no + button, so `pause` refuses rather than pretending.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + prepare: async () => ({ name: 'f' }), + run: async ({ signal }) => { + while (!signal.aborted) await new Promise(r => setTimeout(r, 5)); + } }); + lease.grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + say('pausable:' + t.list()[0].pausable); + t.pause(id); + await new Promise(r => setTimeout(r, 20)); + say('status:' + t.list()[0].status); + t.cancel(id); + """, tmp_path) + assert out == ["pausable:false", "status:running"] + + +def test_cancelling_a_paused_transfer_actually_ends_it(tmp_path): + """A paused run is parked on a promise. Without waking it, cancel marks the + row and leaves the work parked for the life of the page, holding its target + open — a button that lies, in the same way the first test in this file + describes.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases, state } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + t.pause(id); + await new Promise(r => setTimeout(r, 30)); + t.cancel(id); + // What matters is whether the store's own loop ends, not whether the row + // says so: the row is marked at once either way. + const settled = await Promise.race([ + t._items[0].promise.then(() => 'settled', () => 'settled'), + new Promise(r => setTimeout(() => r('parked'), 60)), + ]); + say('status:' + t.list()[0].status); + say('loop:' + settled); + say('resumed:' + state.starts.length); + """, tmp_path) + assert out[0] == "status:cancelled" + assert out[1] == "loop:settled", ( + "the run was still parked on the resume promise after a cancel — the " + "row said cancelled over work that had not stopped") + assert out[2] == "resumed:1", "cancelling started the work again" + + +def test_a_paused_transfer_still_counts_as_live(tmp_path): + """It is not finished, and its transport must not be closed under it — the + person is coming back to it.""" + out = _run(_lease_stub() + _pausable_run() + """ + const { t, leases } = mkStore(); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + t.pause(t.list()[0].id); + await new Promise(r => setTimeout(r, 30)); + say('pending:' + t.pending); + """, tmp_path) + assert out == ["pending:1"] + + +def test_an_upload_can_be_paused_without_a_prepare_step(tmp_path): + """A download learns whether it can pause from its target, because only the + target knows. An upload has no target to ask: a `File` is seekable and the + node keeps the position, so it says so outright. + + This was missed when pause shipped — the button appeared on downloads and + nowhere else, including in the desktop app where everything else works. + """ + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const leases = []; + t.start({ + kind: 'upload', name: 'f', total: 100, pausable: true, + makeLease: () => { const l = new L(); leases.push(l); return l; }, + run: async ({ signal, from }) => { + for (let i = from || 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 5)); + if (signal.paused) { + signal.resumeFrom = i; + const e = new Error('p'); e.name = 'PausedError'; throw e; + } + } + }, + }); + await new Promise(r => setTimeout(r, 5)); + leases[0].grant(); + await new Promise(r => setTimeout(r, 20)); + say('pausable:' + t.list()[0].pausable); + t.pause(t.list()[0].id); + await new Promise(r => setTimeout(r, 30)); + say('status:' + t.list()[0].status); + say('released:' + leases[0].released.join(',')); + """, tmp_path) + assert out == ["pausable:true", "status:paused", "released:paused"] + + +def test_an_upload_handed_a_lease_it_cannot_recreate_is_not_offered_pause(tmp_path): + """Pausing gives the slot back. A transfer that cannot ask for another one + would pause once and wait for ever, so the button is refused instead.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'upload', name: 'f', total: 100, pausable: true, lease, + run: async ({ signal }) => { + while (!signal.aborted) await new Promise(r => setTimeout(r, 5)); + } }); + lease.grant(); + await new Promise(r => setTimeout(r, 20)); + const id = t.list()[0].id; + t.pause(id); + await new Promise(r => setTimeout(r, 20)); + say('status:' + t.list()[0].status); + t.cancel(id); + """, tmp_path) + assert out == ["status:running"] + + +def test_pausing_one_transfer_leaves_the_others_alone(tmp_path): + """Reported: three downloads running, one upload paused, and the three + downloads lost their pause buttons. + + The button is drawn from `pausable` and the status, so this asks the store + what it says about the other three at the moment one of them pauses. + """ + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const leases = []; + const mk = (kind, name) => t.start({ + kind, name, total: 100, pausable: true, + makeLease: () => { const l = new L(); leases.push(l); return l; }, + run: async ({ signal, from }) => { + for (let i = from || 0; i < 40; i++) { + await new Promise(r => setTimeout(r, 5)); + if (signal.aborted) { const e = new Error('c'); e.name = 'AbortError'; throw e; } + if (signal.paused) { + signal.resumeFrom = i; + const e = new Error('p'); e.name = 'PausedError'; throw e; + } + } + }, + }); + mk('download', 'd1'); mk('download', 'd2'); mk('download', 'd3'); + mk('upload', 'u1'); + await new Promise(r => setTimeout(r, 5)); + for (const l of leases) l.grant(); + await new Promise(r => setTimeout(r, 20)); + const up = t.list().find(i => i.kind === 'upload'); + say('before:' + t.list().filter( + i => i.kind === 'download' && i.pausable && i.status === 'running').length); + t.pause(up.id); + await new Promise(r => setTimeout(r, 40)); + const rows = t.list(); + say('after:' + rows.filter( + i => i.kind === 'download' && i.pausable && i.status === 'running').length); + say('statuses:' + rows.map(i => i.kind[0] + ':' + i.status).join(',')); + for (const r of rows) t.cancel(r.id); + """, tmp_path) + assert out[0] == "before:3" + assert out[1] == "after:3", ( + f"pausing the upload changed the downloads — {out[2]}") + + +def test_a_paused_transfer_is_not_filed_under_finished(tmp_path): + """"Finished" was defined by exclusion — everything that is not running, + queued or preparing — so it quietly swallowed `paused` the day pausing + shipped. A transfer somebody stopped on purpose then sat beside the ones + that are actually over, offering a resume button in the section of things + that cannot be resumed. + + The three filters are lifted out of `app.js` and run, rather than described + here: a copy of them in this file would agree with a broken version by + construction. + """ + src = (STATIC / "app.js").read_text() + start = src.index(" const running = items.filter(") + block = src[start:src.index("const active =", start)] + + script = tmp_path / "groups.mjs" + script.write_text(""" +const items = [ + { id: 1, status: 'running' }, + { id: 2, status: 'queued' }, + { id: 3, status: 'preparing' }, + { id: 4, status: 'paused' }, + { id: 5, status: 'done' }, + { id: 6, status: 'failed' }, + { id: 7, status: 'cancelled' }, +]; +""" + block + """ +const seen = { running, waiting, paused, finished }; +console.log(JSON.stringify(Object.fromEntries( + Object.entries(seen).map(([k, v]) => [k, v.map(i => i.id)])))); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + groups = json.loads(proc.stdout) + + assert groups["paused"] == [4] + assert groups["finished"] == [5, 6, 7], ( + f"paused landed in {groups['finished']}") + assert groups["running"] == [1] and groups["waiting"] == [2, 3] + # Every row appears exactly once: a state added later that lands in no group + # is a transfer the panel simply does not show. + placed = sum((groups[k] for k in groups), []) + assert sorted(placed) == [1, 2, 3, 4, 5, 6, 7] + + +def test_a_paused_transfer_still_counts_as_active(tmp_path): + """The badge says how much is going on. A paused transfer is not over — the + person means to come back to it — so counting it as nothing would be a + panel that says "0" over work that is still there.""" + src = (STATIC / "app.js").read_text() + start = src.index(" const running = items.filter(") + block = src[start:src.index("\n\n", src.index("const active =", start))] + + script = tmp_path / "active.mjs" + script.write_text(""" +const items = [{ id: 1, status: 'paused' }, { id: 2, status: 'done' }]; +""" + block + """ +console.log(JSON.stringify({ active })); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["active"] == 1 + + +def test_a_row_that_cannot_pause_says_so_where_the_button_would_be(): + """Reported from Chrome: four downloads with no pause button and an upload + with one, and no way to tell why. + + The reason is real — without a granted folder the browser writes through the + service worker, a download it already owns and cannot pause — but it was + stated only in a Settings line nobody reads on the way to a download. A gap + where the row above has a button is not an explanation. + + Shown only where a folder can actually be chosen: Firefox and Safari have + none to choose, and "choose a folder" would be advice that cannot be taken. + """ + src = (STATIC / "app.js").read_text() + row = src[src.index("function TransferRow"):] + row = row[:row.index("\n}\n")] + + hint = row[row.index("!it.pausable"):] + hint = hint[:hint.index("`}")] + assert "downloads.SUPPORTED" in hint, ( + "the hint would tell a Firefox user to choose a folder it cannot offer") + assert "it.kind === 'download'" in hint, ( + "an upload is always pausable; this is about download targets") + assert "transfers.not_pausable" in hint, "the reason is not stated" + # Not a button. There is nothing to click, and a disabled one invites the + # click anyway. + assert "<button" not in hint + + +def test_the_reason_is_translated_everywhere(): + """`t()` falls back to the key, so a missing catalogue entry shows + `transfers.not_pausable` in a tooltip rather than a sentence.""" + for path in sorted((STATIC / "locales").glob("*.js")): + assert "'transfers.not_pausable'" in path.read_text(), path.name diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 879062b..fe550f9 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -349,16 +349,29 @@ def test_the_upload_itself_is_sealed(transport): "the upload must be sealed under the group key") assert "openGroup(" in body and "'file_upload_ack'" in body, ( "the ack carries the stored name and must be opened, not read") - # The message the node actually receives: everything between `this._send({` - # and its close. Read on its own, because the same field names appear a few - # lines above inside `msgpack_encode({...})`, which is the sealed half. - sent = body[body.index("this._send({"):] - sent = sent[:sent.index("});")] - assert "filename" not in sent, "the filename is on the message in clear" - assert "data" not in sent, "the bytes are on the message in clear" - assert "dir" not in sent and "root" not in sent, ( - "the destination is on the message in clear") - assert "...sealed," in sent, "the message must carry the sealed pair" + # The messages the node actually receives: everything between each + # `this._send({` and its close. Read on their own, because the same field + # names appear a few lines above inside `msgpack_encode({...})`, which is + # the sealed half. + # + # Every one of them, not the first: `uploadFile` sends a probe chunk before + # the file ("where am I?", UPLOAD_PROBE_INDEX) and it names the file too, so + # a check that stopped at the first message would have moved off the one it + # was written for the day the second appeared. + sends = [] + rest = body + while "this._send({" in rest: + rest = rest[rest.index("this._send({"):] + sends.append(rest[:rest.index("});")]) + rest = rest[len("this._send({"):] + assert len(sends) >= 2, "the probe and the chunks are both sent from here" + for sent in sends: + assert "filename" not in sent, "the filename is on the message in clear" + assert "data" not in sent, "the bytes are on the message in clear" + assert "dir" not in sent and "root" not in sent, ( + "the destination is on the message in clear") + assert "...sealed," in sent or "...probeSealed," in sent, ( + "the message must carry the sealed pair") assert "supportsSealedUpload" in body, ( "an older node must be refused before a chunk is sent, not after") diff --git a/packages/meshbay-hub/tests/test_upload_seal_client.py b/packages/meshbay-hub/tests/test_upload_seal_client.py index d6f9156..2e4bfb5 100644 --- a/packages/meshbay-hub/tests/test_upload_seal_client.py +++ b/packages/meshbay-hub/tests/test_upload_seal_client.py @@ -167,3 +167,41 @@ def test_the_client_refuses_an_older_node_before_sending_a_chunk(_gek): assert result["state"] == "rejected" assert "older MeshBay" in result["message"] assert result["frames"] == [], "a chunk was sent to a node that cannot open it" + + +def test_an_interrupted_upload_resumes_where_the_node_stopped(tmp_path, _gek): + """ + The browser asks, the node answers, and the second attempt sends only what + is missing. + + Both halves are the shipped ones: the frames come from the real + `uploadFile`, the answer comes from the real node handler. What is asserted + is the thing that used to be impossible — an upload interrupted at chunk two + of five that sends three chunks instead of five. + """ + body = bytes(range(256)) * ((CHUNK * 5) // 256 + 1) + body = body[:CHUNK * 5] + first = _run_probe(_probe_input(_gek, "send", + file={"name": "film.mkv", "data": body.hex()})) + frames = [msgpack.unpackb(bytes.fromhex(f), raw=False) + for f in first["frames"]] + assert [f["chunk_index"] for f in frames] == [-1, 0, 1, 2, 3, 4] + + # The link drops after two chunks. + session = _node_session(tmp_path, _gek) + for frame in frames[1:3]: + session._do_file_upload(frame) + assert not [m for m in session.sent if m.get("type") == "error"] + + # It comes back and asks. + session.sent.clear() + session._do_file_upload(frames[0]) + probe_ack = msgpack.packb(session.sent[-1], use_bin_type=True).hex() + + second = _run_probe(_probe_input( + _gek, "send", file={"name": "film.mkv", "data": body.hex()}, + probe_ack=probe_ack)) + resumed = [msgpack.unpackb(bytes.fromhex(f), raw=False)["chunk_index"] + for f in second["frames"]] + assert resumed == [-1, 2, 3, 4], ( + f"sent {resumed} — the answer to the probe was not used") diff --git a/packages/meshbay-hub/tests/test_versions_agree.py b/packages/meshbay-hub/tests/test_versions_agree.py new file mode 100644 index 0000000..4466c93 --- /dev/null +++ b/packages/meshbay-hub/tests/test_versions_agree.py @@ -0,0 +1,74 @@ +""" +Every package in this repository carries the same version. + +They are built, deployed and updated together — hub, node, common and the +desktop client — so a version that differs is not a statement about that +package, it is a mistake nobody has noticed yet. + +**Found on 2026-09-09, on the MNP 3.0 flag day.** `meshbay-client`'s +`package.json` had drifted to `1.0.0` while every Python package was on +`0.12.0`. That was invisible until the hub started publishing a minimum client +version and the client started comparing itself against it — at which point an +installed client announcing `1.0.0` sorted *above* a minimum of `0.13.0` and +walked straight through the gate meant to stop it. A version nobody reads is +free to be wrong; the moment something compares it, it is load-bearing. +""" + +import json +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +PACKAGES = ROOT / "packages" + + +def _python_versions() -> dict[str, str]: + found = {} + for pyproject in sorted(PACKAGES.glob("*/pyproject.toml")): + m = re.search(r'^version = "([^"]+)"', pyproject.read_text(), re.M) + if m: + found[f"{pyproject.parent.name}/pyproject.toml"] = m.group(1) + for init in sorted(PACKAGES.glob("*/src/*/__init__.py")): + m = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M) + if m: + found[f"{init.parent.name}/__init__.py"] = m.group(1) + return found + + +def _client_version() -> str | None: + pkg = PACKAGES / "meshbay-client" / "package.json" + if not pkg.exists(): + return None + return json.loads(pkg.read_text()).get("version") + + +@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present") +def test_every_package_carries_the_same_version(): + versions = _python_versions() + assert versions, "no package versions found at all — has the layout moved?" + client = _client_version() + if client is not None: + versions["meshbay-client/package.json"] = client + distinct = sorted(set(versions.values())) + assert len(distinct) == 1, ( + "packages disagree about the version: " + + ", ".join(f"{k}={v}" for k, v in sorted(versions.items()))) + + +@pytest.mark.skipif(not PACKAGES.is_dir(), reason="package layout not present") +def test_the_hub_will_not_refuse_the_client_it_ships_with(): + """`MIN_CLIENT_VERSION` is compared against a client's own version, so a + minimum above the version being built would lock out the very build being + released — the one failure this field can cause that nobody would think to + test for by hand.""" + from meshbay_hub.api.hub import MIN_CLIENT_VERSION + + client = _client_version() + if client is None: + pytest.skip("desktop client sources not present") + as_numbers = lambda v: [int(n) for n in v.split(".")] # noqa: E731 + assert as_numbers(MIN_CLIENT_VERSION) <= as_numbers(client), ( + f"the hub requires client {MIN_CLIENT_VERSION} but this tree builds " + f"{client}") diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py index 203c10c..9471b8a 100644 --- a/packages/meshbay-hub/tests/test_zip_size_limit.py +++ b/packages/meshbay-hub/tests/test_zip_size_limit.py @@ -6,11 +6,16 @@ folder as a zip" button — Files' single folder, Files' multi-folder selection, and the Photos album button (docs/photos.md §3) — so the limit is checked once, there, and holds for all of them. -Two things are worth pinning. That an oversized folder is refused *before* +Three things are worth pinning. That an oversized folder is refused *before* `_openDownloadTarget`, because a save dialog for an archive that will never be -written is worse than no dialog at all. And that a folder at exactly the limit +written is worse than no dialog at all. That a folder at exactly the limit still goes through, since an off-by-one here silently costs a whole megabyte -of allowance and nobody would ever notice. +of allowance and nobody would ever notice. And that the two limits in play do +not contradict each other: ZIP_MAX_BYTES (512 MB) bounds the archive, while +MEMORY_CEILING (100 MB, test_memory_ceiling.py) bounds what may be built in the +page — so a 400 MB zip is allowed when there is somewhere to stream it and +refused when the only route left is memory. The `confirm()` that offers the +build-in-memory path therefore only ever appears below the ceiling. """ import json @@ -30,7 +35,7 @@ pytestmark = pytest.mark.skipif( MIB = 1024 * 1024 -def _run(total_bytes, tmp_path): +def _run(total_bytes, tmp_path, picker=False): """ Call downloadDirectory over one folder holding `total_bytes`, and report what it did: the errors it set, how many times it put a question to the @@ -44,6 +49,7 @@ def _run(total_bytes, tmp_path): (tmp_path / "package.json").write_text('{"type":"module"}') script = tmp_path / "case.mjs" + picker_js = "true" if picker else "false" script.write_text(f""" const store = new Map(); globalThis.localStorage = {{ @@ -54,17 +60,53 @@ globalThis.localStorage = {{ // Node 22 defines `navigator` itself, so it is left alone; `window` is what // platform.js reaches for to decide it is not running in the desktop app. globalThis.window = globalThis; -const out = {{ errors: [], started: 0, asked: 0 }}; +// stdout carries the outcome and nothing else, so file-utils' own logging goes +// to stderr -- where it is still shown when a case fails. It logs before every +// save dialog, which is exactly what this harness provokes. +console.info = (...a) => console.error(...a); +const out = {{ errors: [], started: 0, asked: 0, dropped: 0 }}; // Reached only once the size check has passed: with no File System Access API // under Node, downloadDirectory falls through to its build-in-memory path and // asks first. Answering yes is what lets the at-the-limit case get as far as // starting a transfer, and `asked` is how the refusal proves it never did. globalThis.confirm = () => {{ out.asked += 1; return true; }}; +// With `picker`, the browser can stream to a file the person chooses, which is +// the only legal route for an archive over MEMORY_CEILING. Never exercised — +// the stubbed `transfers.start` below does not run the job — it just has to be +// a target rather than null. +if ({picker_js}) {{ + window.showSaveFilePicker = async () => ({{ + name: 'album.zip', + createWritable: async () => ({{ write: async () => {{}}, close: async () => {{}}, + abort: async () => {{}} }}), + }}); +}} const M = await import('{(sandbox / "file-utils.js").as_posix()}'); -const transfers = {{ start: () => {{ out.started += 1; }} }}; -const transport = {{ connected: true }}; +// Faithful enough to the real store: it runs `prepare` and honours what it +// returns. The target is opened there now — the row exists from the click and +// the slow part happens behind it — so a stub that only counts calls would +// never reach the size check this file is about. +const transfers = {{ start: (opts) => {{ + out.started += 1; + if (!opts.prepare) return; + Promise.resolve() + .then(() => opts.prepare()) + .then((ready) => {{ if (ready === false) {{ out.started -= 1; out.dropped += 1; }} }}) + .catch((e) => {{ out.started -= 1; out.errors.push(e.message); }}); +}} }}; +// A transport hands out transfer slots now (transfers.py's leases). The stub +// grants at once, which is what a node with no caps does: what this file is +// about is the archive limit, not the queue. +const transport = {{ + connected: true, + openTransfer: () => ({{ + tr: 'stub', state: 'granted', ahead: 0, + acquire: () => Promise.resolve(), + release: () => {{}}, + }}), +}}; // One file, in the folder itself — entriesUnder keys on `path`. const entries = [{{ id: 'f1', name: 'big.bin', path: 'album', size: {total_bytes}, added_at: 0 }}]; @@ -73,6 +115,8 @@ await M.downloadDirectory(transfers, transport, null, entries, 'album', {{ setError: (m) => out.errors.push(m), }}); +// `prepare` runs on a microtask, so let it. +await new Promise(r => setTimeout(r, 10)); out.limit = M.ZIP_MAX_BYTES; console.log(JSON.stringify(out)); """, encoding="utf-8") @@ -101,7 +145,37 @@ def test_an_oversized_folder_is_refused_before_anything_opens(tmp_path): def test_a_folder_exactly_at_the_limit_still_downloads(tmp_path): - """The bound is inclusive: `> ZIP_MAX_BYTES`, not `>=`.""" - result = _run(512 * MIB, tmp_path) + """The bound is inclusive: `> ZIP_MAX_BYTES`, not `>=`. + + Given somewhere to stream to, because 512 MB is five times MEMORY_CEILING + and building it in the page is no longer a route this code will take. That + is what the next test is about; this one is still only about the off-by-one. + """ + result = _run(512 * MIB, tmp_path, picker=True) assert result["errors"] == [] assert result["started"] == 1 + assert result["asked"] == 0, "nothing is built in memory when it can stream" + + +def test_a_zip_over_the_memory_ceiling_is_refused_when_nothing_streams(tmp_path): + """ + Between the two limits — larger than the page may hold, smaller than the + archive limit — and no way to stream it. Before the ceiling existed this + asked "build it in memory?" and, on yes, held 400 MB in the tab. + + The refusal names the memory ceiling, not the zip limit: quoting 512 MB at + someone whose folder is under 512 MB would be a message about the wrong + rule. + """ + result = _run(400 * MIB, tmp_path) + assert result["started"] == 0 + assert result["asked"] == 0, ( + "the person must not be offered a build-in-memory path above the ceiling") + assert result["errors"] and "group.zip_too_large" not in result["errors"][0] + + +def test_a_small_folder_may_still_be_built_in_memory(tmp_path): + """The floor is intact below the ceiling — that is what it is for.""" + result = _run(4 * MIB, tmp_path) + assert result["errors"] == [] + assert result["asked"] == 1 and result["started"] == 1 |