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/src/meshbay_hub/static/files-app.js | |
| 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/src/meshbay_hub/static/files-app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/files-app.js | 42 |
1 files changed, 39 insertions, 3 deletions
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')); |