aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js173
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js96
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css64
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js105
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js213
-rw-r--r--packages/meshbay-hub/tests/test_layout_measured.py119
-rw-r--r--packages/meshbay-hub/tests/test_memory_ceiling.py36
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py213
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py12
21 files changed, 1090 insertions, 82 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 9d64292..ff066cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -4,7 +4,7 @@ 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';
@@ -157,69 +157,67 @@ function TransferWidget() {
}, [open]);
const running = items.filter(i => i.status === 'running');
+ const waiting = items.filter(i => i.status === 'queued');
+ const finished = items.filter(
+ i => i.status !== 'running' && i.status !== 'queued');
+ const active = running.length + waiting.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],
+ ['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>
@@ -228,6 +226,79 @@ 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.status === 'running' || it.status === 'queued') && 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 === '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 === '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,
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 a942fd5..61002d6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -164,6 +164,25 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
return { writable: await handle.createWritable(), name: handle.name || filename };
} 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;
}
}
@@ -193,17 +212,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;
@@ -216,14 +265,14 @@ async function _fetchChunkResilient(transport, fileId, index) {
}
async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk,
- writable, signal) {
+ writable, signal, tr = '') {
const results = writable ? null : new Array(totalChunks);
let nextSend = 0, nextRecv = 0;
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++;
}
};
@@ -254,7 +303,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;
}
@@ -272,6 +321,20 @@ 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) {
+ // The target FIRST, then the slot — and that order is load-bearing.
+ //
+ // Asking for the slot first looks better (the widget could draw a row while
+ // the target is being chosen) and is wrong: a granted slot has to be taken up
+ // within the node's acceptance deadline, and opening a target can take thirty
+ // seconds of streamed-download timeouts, or as long as somebody leaves a Save
+ // As dialog open. The node then revokes the grant and passes it to the next
+ // in the queue — `transfer: reclaimed … (not_taken_up)` in its log — and this
+ // download starts fetching under a `tr` that is no longer granted.
+ //
+ // Measured, not reasoned: three downloads started, one arrived, and the
+ // node's log named the reason. Do not move this again without moving the
+ // deadline, and the deadline exists so a client that dies between asking and
+ // starting does not hold a slot nobody can use.
let target;
try {
target = await _openDownloadTarget(entry.name, entry.size);
@@ -289,21 +352,25 @@ async function downloadEntry(transfers, transport, gek, entry) {
if (target === false) return; // the picker was dismissed
const openRef = { url: null };
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
transfers.start({
kind: 'download', name: (target && target.name) || entry.name,
total: entry.size, transport,
+ // Asked for here, once there is somewhere to write: see the note above.
+ lease: transport.openTransfer({
+ kind: 'download', bytes: entry.size, chunks: totalChunks }),
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);
+ run: async ({ signal, onProgress, lease }) => {
let done = 0;
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);
await target.writable.close();
} catch (err) {
await target.writable.abort().catch(() => {});
@@ -311,7 +378,8 @@ async function downloadEntry(transfers, transport, gek, entry) {
}
} else {
const chunks = await pipelinedDownload(
- transport, gek, entry.id, totalChunks, onChunk, null, signal);
+ transport, gek, entry.id, totalChunks, onChunk, null, signal,
+ lease && lease.tr);
const blob = new Blob(chunks);
_saveBlob(blob, entry.name);
openRef.url = URL.createObjectURL(blob);
@@ -391,10 +459,15 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
transfers.start({
kind: 'download', name: (target && target.name) || suggested,
total: totalBytes, transport,
+ // **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.
+ lease: transport.openTransfer({
+ kind: 'download', bytes: totalBytes, chunks: files.length }),
open: target
? (target.open || null)
: () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); },
- run: async ({ signal, onProgress }) => {
+ run: async ({ signal, onProgress, lease }) => {
const writable = target ? target.writable : null;
const parts = writable ? null : [];
let written = 0;
@@ -414,7 +487,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();
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 36c5af3..fb9d801 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.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,15 @@ function FilesPanel({
for (const file of files) {
transfers.start({
kind: 'upload', name: file.name, total: file.size, transport,
- run: async ({ signal, onProgress }) => {
+ lease: transport.openTransfer({ kind: 'upload', bytes: file.size }),
+ 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
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 8ed1ef0..f5c0c78 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',
@@ -576,6 +578,16 @@ export default {
'transfers.open': 'Öffnen',
'transfers.done': 'Abgeschlossen',
'transfers.cancelled': 'Abgebrochen',
+ '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_finished': 'Abgeschlossen',
+ 'transfers.cancel_one': '{name} abbrechen',
+ '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',
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 285ff7a..e3ed844 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',
@@ -692,6 +694,16 @@ export default {
'transfers.open': 'Open',
'transfers.done': 'Finished',
'transfers.cancelled': 'Cancelled',
+ '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_finished': 'Finished',
+ 'transfers.cancel_one': 'Cancel {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',
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 f74c763..c509599 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',
@@ -572,6 +574,16 @@ export default {
'transfers.open': 'Abrir',
'transfers.done': 'Terminada',
'transfers.cancelled': 'Cancelada',
+ '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_finished': 'Finalizados',
+ 'transfers.cancel_one': 'Cancelar {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',
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 0c5103f..49a1071 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',
@@ -575,6 +577,16 @@ export default {
'transfers.open': 'Ouvrir',
'transfers.done': 'Terminé',
'transfers.cancelled': 'Annulé',
+ '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_finished': 'Terminés',
+ 'transfers.cancel_one': 'Annuler {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é',
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 ebac541..8f1ef1e 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',
@@ -575,6 +577,16 @@ export default {
'transfers.open': 'Apri',
'transfers.done': 'Completato',
'transfers.cancelled': 'Annullato',
+ '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_finished': 'Completati',
+ 'transfers.cancel_one': 'Annulla {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',
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 1b49ddb..7ea9789 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',
@@ -567,6 +569,16 @@ export default {
'transfers.open': '開く',
'transfers.done': '完了',
'transfers.cancelled': 'キャンセル済み',
+ 'transfers.waiting_own_slots': '待機中 — 自分の枠がすべて使用中です',
+ 'transfers.waiting_node': '待機中 — 前に {n} 件',
+ 'transfers.summary': '実行中 {running} · 待機中 {waiting}',
+ 'transfers.group_running': '実行中',
+ 'transfers.group_waiting': '待機中',
+ 'transfers.group_finished': '完了',
+ 'transfers.cancel_one': '{name} をキャンセル',
+ 'transfers.eta_seconds': '残り {n} 秒',
+ 'transfers.eta_minutes': '残り {n} 分',
+ 'transfers.eta_hours': '残り {n} 時間',
'transfers.failed': '失敗',
'group.select': '選択',
'group.select_done': '完了',
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 7cf4cd4..74cd269 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',
@@ -576,6 +578,16 @@ export default {
'transfers.open': 'Openen',
'transfers.done': 'Voltooid',
'transfers.cancelled': 'Geannuleerd',
+ '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_finished': 'Voltooid',
+ 'transfers.cancel_one': '{name} annuleren',
+ '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',
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 6edffd5..584e5f2 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',
@@ -588,6 +590,16 @@ export default {
'transfers.open': 'Otwórz',
'transfers.done': 'Zakończony',
'transfers.cancelled': 'Anulowany',
+ '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_finished': 'Zakończone',
+ 'transfers.cancel_one': 'Anuluj {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',
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 3a6fa22..b034117 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',
@@ -574,6 +576,16 @@ export default {
'transfers.open': 'Abrir',
'transfers.done': 'Concluída',
'transfers.cancelled': 'Cancelada',
+ '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_finished': 'Concluídos',
+ 'transfers.cancel_one': 'Cancelar {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',
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 f43ef72..ffe2cb0 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',
@@ -555,6 +557,16 @@ export default {
'transfers.open': '打开',
'transfers.done': '已完成',
'transfers.cancelled': '已取消',
+ 'transfers.waiting_own_slots': '等待中 — 您的通道已占满',
+ 'transfers.waiting_node': '等待中 — 前面还有 {n} 个',
+ 'transfers.summary': '进行中 {running} · 等待中 {waiting}',
+ 'transfers.group_running': '进行中',
+ 'transfers.group_waiting': '等待中',
+ 'transfers.group_finished': '已完成',
+ 'transfers.cancel_one': '取消 {name}',
+ 'transfers.eta_seconds': '剩余 {n} 秒',
+ 'transfers.eta_minutes': '剩余 {n} 分钟',
+ 'transfers.eta_hours': '剩余 {n} 小时',
'transfers.failed': '失败',
'group.select': '选择',
'group.select_done': '完成',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index dfed44b..22fcd3f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -4243,3 +4243,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/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
index fa34c67..46f75a2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
@@ -22,6 +22,12 @@
const SPEED_WINDOW_MS = 5000;
+function _abortError() {
+ const err = new Error('Cancelled');
+ err.name = 'AbortError';
+ return err;
+}
+
let _nextId = 1;
export class TransferStore {
@@ -53,8 +59,18 @@ 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.
@@ -66,6 +82,12 @@ 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 => it.status === 'running' || it.status === 'queued').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,12 +104,18 @@ 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({ kind, name, total = 0, transport = null, run, open = null,
+ lease = null }) {
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: lease && lease.state !== 'granted' ? 'queued' : 'running',
+ ahead: (lease && lease.ahead) || 0,
error: '',
samples: [{ t: this._now(), done: 0 }],
signal: { aborted: false },
@@ -113,8 +141,39 @@ 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) {
+ 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();
+ };
+ }
+
const promise = Promise.resolve()
- .then(() => run({ signal: item.signal, onProgress }))
+ .then(async () => {
+ if (item.lease) {
+ await item.lease.acquire();
+ if (item.signal.aborted) throw _abortError();
+ item.status = 'running';
+ this._emit();
+ }
+ return run({ signal: item.signal, onProgress, lease: item.lease });
+ })
.then(() => {
if (item.signal.aborted) finish('cancelled');
else {
@@ -125,7 +184,8 @@ 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;
@@ -146,8 +206,12 @@ export class TransferStore {
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 || (item.status !== 'running' && item.status !== 'queued')) return;
item.signal.aborted = true;
+ if (item.lease) item.lease.release('cancelled');
// Marked at once. The work stops when it next looks, but a cancelled
// transfer should not keep reporting progress in the meantime.
item.status = 'cancelled';
@@ -157,19 +221,24 @@ export class TransferStore {
cancelAll() {
for (const it of this._items) {
- if (it.status === 'running') this.cancel(it.id);
+ if (it.status === 'running' || it.status === 'queued') 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 => it.status === 'running' || it.status === 'queued');
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
+ && (it.status === 'running' || it.status === 'queued'));
}
/**
@@ -211,6 +280,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..54a1302 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -283,6 +283,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 +438,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 +507,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 +1024,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 +1161,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 +1249,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 +2378,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.
@@ -2273,6 +2463,7 @@ class MeshBayTransport {
upload_id: uploadId,
chunk_index: i,
total_chunks: total,
+ ...(tr ? { tr } : {}),
...sealed,
});
}
@@ -2992,6 +3183,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/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">&#8595;</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">&#10005;</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">&#9776;</button>
+ <a class="nav-brand" href="#/">MeshBay</a>
+ </div>
+ <div class="nav-right">
+ <div class="transfer-wrap">
+ <button class="nav-notif transfer-btn">&#8595;</button>
+ <div class="transfer-panel">
+ <div class="transfer-head">
+ <span class="transfer-head-title">Transfers</span>
+ <span class="transfer-head-summary">2 running &middot; 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">&#8595;</span>
+ <span class="transfer-name">Some Saga S03E01 - A Long Enough Title.mp4</span>
+ <button class="transfer-cancel">&#10005;</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 &middot; 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">&#8595;</span>
+ <span class="transfer-name">Another File With A Long Name.mkv</span>
+ <button class="transfer-cancel">&#10005;</button>
+ </div>
+ <div class="dl-progress dl-waiting"></div>
+ <div class="transfer-meta"><span>Waiting &mdash; your slots are busy</span><span>1.2 GB</span></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <a class="nav-notif" href="#/">&#128276;</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
index 9966e48..8430627 100644
--- a/packages/meshbay-hub/tests/test_memory_ceiling.py
+++ b/packages/meshbay-hub/tests/test_memory_ceiling.py
@@ -92,9 +92,15 @@ const downloads = {{
}};
globalThis.window = {{}};
if ({json.dumps(picker)}) {{
- window.showSaveFilePicker = async () => ({{
- name: 'p', createWritable: async () => ({{}}),
- }});
+ 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}
@@ -207,3 +213,27 @@ def test_the_guard_is_what_the_preview_uses_too(target_fn):
"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": "stream", "name": "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
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index 3316615..e805afc 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -222,3 +222,216 @@ 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")]
+ assert fn.index("_openDownloadTarget") < fn.index("openTransfer"), (
+ "downloadEntry asks for a transfer slot before it has anywhere to "
+ "write — the grant expires before the download can use it")
diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py
index 44ad59e..e970c55 100644
--- a/packages/meshbay-hub/tests/test_zip_size_limit.py
+++ b/packages/meshbay-hub/tests/test_zip_size_limit.py
@@ -81,7 +81,17 @@ if ({picker_js}) {{
const M = await import('{(sandbox / "file-utils.js").as_posix()}');
const transfers = {{ start: () => {{ out.started += 1; }} }};
-const transport = {{ connected: true }};
+// 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 }}];