summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/file-utils.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js96
1 files changed, 85 insertions, 11 deletions
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();