From 41e2b79cb1bc9d188853aeff5a55cd2237268587 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 15 Aug 2026 13:13:08 +0200 Subject: feat(files): transfers that outlive the page, and selection instead of per-row menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads and uploads were state inside GroupPage. Leaving a group unmounted the component, its cleanup closed the DataChannel, and a half-written file was all you had — which is also why only one thing could be in flight at a time. They live in a module-level store now. A group page hands its transport over on the way out rather than closing it, and the last transfer using it closes it; signing out is the one thing that cancels everything, because those transfers are moving data on a token about to stop being ours. The store is plain JavaScript with no browser globals, so test_transfers.py runs it under Node and pins the parts that are timing and lifetime rather than markup: that a cancel stops the work instead of greying out a row, that a stalled transfer reads as stalled rather than reporting its own historical average, and that a released transport is closed by the last transfer and not before. The widget by the bell shows each transfer with its rate and a cancel button, so the Files panel no longer carries progress bars — you can watch a 40 GB archive from the chat, or from another group. Selection replaces the per-row menu: a Select toggle puts checkboxes on files and folders, and ⋮ Actions acts on what is ticked. Ticks survive walking into another folder, so a selection can span directories. Downloads start together and run together. Videos offer Play only — View did the same thing, which is the sort of duplication that makes people wonder what the difference is. Uploads had to become parallel-safe for any of this to mean anything: their acks were matched by arrival order, so two at once credited each other's progress. The node names the file in every ack, so they are keyed by name now — with the same file twice refused, since the node keys its own upload state that way too. Two mistakes worth recording. The selection column went into the body rows and not the header, because that edit matched nothing and I had not made it assert; the columns were misaligned until a screenshot showed it. And the Actions menu opened leftwards from a button at the right edge of the toolbar, half of it off-screen. Co-Authored-By: Claude Opus 5 --- .../src/meshbay_hub/static/transport.js | 41 ++++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 326aa8e..24c08b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -42,6 +42,12 @@ const UPLOAD_CHUNK_SIZE = 48 * 1024; const UPLOAD_WINDOW = 32; const UPLOAD_BUFFER_HIGH = 1024 * 1024; +function _aborted() { + const err = new Error('Cancelled'); + err.name = 'AbortError'; + return err; +} + const JOIN_REFUSALS = { code_required: 'This node does not know this browser yet. Ask the node operator ' + 'for a pairing code (meshbay-node operator pair).', @@ -73,7 +79,10 @@ class MeshBayTransport { this._onStreamData = null; this._onStreamEnd = null; this._onIndexSync = null; - this._onUploadAck = null; + // filename → the uploader waiting on it. Keyed rather than FIFO because + // several uploads may be in flight at once and their acks interleave; the + // node names the file in every one. + this._uploaders = new Map(); } get connected() { return this._connected; } @@ -533,10 +542,12 @@ class MeshBayTransport { * free one rather than replacing anything. The ack says which, and that is what * this returns. */ - async uploadFile(file, { chunkSize, onProgress } = {}) { - // One at a time: the acks are matched by arrival, so two uploads sharing the - // channel would credit each other's progress and finish at the wrong moment. - if (this._onUploadAck) throw new Error('Another upload is already running'); + async uploadFile(file, { chunkSize, onProgress, signal } = {}) { + // 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. + if (this._uploaders.has(file.name)) { + throw new Error(`${file.name} is already being uploaded`); + } const size = chunkSize || UPLOAD_CHUNK_SIZE; const total = Math.max(1, Math.ceil(file.size / size)); let acked = 0; @@ -544,7 +555,7 @@ class MeshBayTransport { let failure = null; const acks = []; - this._onUploadAck = (msg) => { + this._uploaders.set(file.name, (msg) => { if (msg.type === 'error') { failure = new Error(msg.detail || 'Upload refused'); } else if (msg.stored_as) { @@ -554,15 +565,17 @@ class MeshBayTransport { if (onProgress) onProgress(Math.min(file.size, acked * size), file.size); const waiter = acks.shift(); if (waiter) waiter(); - }; + }); const nextAck = () => new Promise(r => acks.push(r)); try { for (let i = 0; i < total; i++) { + if (signal && signal.aborted) throw _aborted(); // 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) { + if (signal && signal.aborted) throw _aborted(); await new Promise(r => setTimeout(r, 20)); } while (i - acked >= UPLOAD_WINDOW) { @@ -587,7 +600,7 @@ class MeshBayTransport { if (failure) throw failure; } } finally { - this._onUploadAck = null; + this._uploaders.delete(file.name); } return stored || {}; } @@ -772,9 +785,15 @@ class MeshBayTransport { // While an upload is in flight the acks are its own, and there are many of // them: they must not be handed to whatever request happens to be oldest in // the pending map. - if (this._onUploadAck - && (msg.type === 'file_upload_ack' || msg.type === 'error')) { - this._onUploadAck(msg); + if (msg.type === 'file_upload_ack' && this._uploaders.has(msg.filename)) { + this._uploaders.get(msg.filename)(msg); + return; + } + // An error carries no filename. With one upload running it is that + // upload's; with several there is no way to tell, so they all hear it and + // stop — which is the safe reading of an error on a shared channel. + if (msg.type === 'error' && this._uploaders.size) { + for (const handler of [...this._uploaders.values()]) handler(msg); return; } if (msg.type === 'chat_msg' && this._onChat) { -- cgit v1.2.3