diff options
Diffstat (limited to 'packages/meshbay-hub')
12 files changed, 172 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 7e4802a..b1eade6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -200,6 +200,8 @@ export const BLOB_LIMIT = 512 * 1024 * 1024; // ── Streaming to disk without the File System Access API ──────────────────── const SW_PATH = '/sw.js'; +// Kept in step with sw.js's own PREFIX. +const PREFIX_PATH = '/_mbdl/'; // On Firefox and Safari this worker is not a nicety, it is the only unbounded // way to write a download to disk: the File System Access API does not exist @@ -365,7 +367,91 @@ async function _claimController(budgetMs) { */ export function primeServiceWorker() { if (!STREAMS_VIA_SW) return; - serviceWorker().catch(() => {}); + serviceWorker().then((worker) => worker && _repairIfBypassed(worker)) + .catch(() => {}); +} + +// How long to wait for the worker to answer the one-byte self-test below. +// Milliseconds when it works; a page that cannot stream at all is worth four +// seconds to find out about, once, at boot. +const SELF_TEST_BUDGET_MS = 4000; +// Set for the life of this tab, so the repair below can happen at most once and +// can never become a reload loop. +const REPAIRED_KEY = 'meshbay.sw-repaired'; + +/** + * Can this document actually have a download served, or only talk to the worker? + * + * Being controlled is not the same thing, and the gap between them is a real + * failure people hit. A document fetched by a **hard** reload — Ctrl+F5, + * Ctrl+Shift+R — is loaded with the service worker bypassed. It can still be + * claimed afterwards, so `navigator.serviceWorker.controller` comes back and + * every check in `_claimController` passes; but the navigations that document + * starts keep missing the worker, and the hidden iframe a streamed download + * needs *is* a navigation. Every download then fails with "the worker did not + * answer", for the life of that page — on Firefox and Safari, the only path + * there is for a file too large to hold in memory. + * + * Reported after an operator was told, by this author, to hard-reload after + * each deployment: four downloads out of four worked on a freshly started + * browser, and the first attempt after a Ctrl+F5 failed, every time. + * + * This asks the question directly rather than inferring it: a four-byte stream + * and a hidden iframe, exactly as a real download would. + */ +async function _canServeDownloads(worker) { + const id = `selftest-${Math.random().toString(36).slice(2, 10)}`; + let readable, writable; + try { + ({ readable, writable } = new TransformStream()); + } catch { + return true; // No transferable streams: a different failure, not this one. + } + const chan = new MessageChannel(); + const serving = new Promise((resolve) => { + chan.port1.onmessage = (e) => { + if (e.data && e.data.type === 'mbdl-serving') resolve(true); + }; + }); + try { + worker.postMessage({ type: 'mbdl', id, filename: 'meshbay-selftest.bin', + size: 4, readable, port: chan.port2 }, + [readable, chan.port2]); + } catch { + return true; // Same: not the bypass this is looking for. + } + const frame = document.createElement('iframe'); + frame.hidden = true; + frame.src = `${PREFIX_PATH}${id}`; + document.body.appendChild(frame); + const served = await Promise.race([ + serving, + new Promise((r) => setTimeout(() => r(false), SELF_TEST_BUDGET_MS)), + ]); + frame.remove(); + try { chan.port1.close(); } catch { /* already gone */ } + // Never completed, so the browser has nothing to save and no file appears. + try { await writable.abort('self-test'); } catch { /* already gone */ } + return served; +} + +/** + * An ordinary reload puts the document back under the worker, so do that once. + * + * Only at boot, where nothing is in flight and the reload costs a flicker. + * Guarded by a session flag rather than a variable: the point is to survive the + * reload it triggers, and to stop rather than loop if reloading does not help. + */ +async function _repairIfBypassed(worker) { + let repaired = false; + try { repaired = sessionStorage.getItem(REPAIRED_KEY) === '1'; } catch { /* blocked */ } + if (repaired) return; + if (await _canServeDownloads(worker)) return; + console.warn('[MeshBay] this page cannot be served by the download worker — ' + + 'reloading once to put it back under the worker\u2019s control ' + + '(a hard reload leaves a page in this state)'); + try { sessionStorage.setItem(REPAIRED_KEY, '1'); } catch { /* blocked */ } + location.reload(); } async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) { @@ -458,7 +544,7 @@ async function _attemptStreamedDownload(filename, size, attempt, const frame = document.createElement('iframe'); frame.hidden = true; - frame.src = `/_mbdl/${id}`; + frame.src = `${PREFIX_PATH}${id}`; document.body.appendChild(frame); const answered = await Promise.race([ 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 1458cf2..f0ec776 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -219,7 +219,7 @@ export default { 'preview.pdf_fallback': 'Dieser Browser zeigt das PDF nicht direkt an. Laden Sie es ' + 'stattdessen herunter — entschlüsselt wurde es ohnehin hier.', 'preview.too_large': 'Diese Datei ist {size} groß, mehr als diese Seite im Arbeitsspeicher halten kann ({limit}). Laden Sie sie stattdessen herunter — ein Download wird direkt auf die Festplatte geschrieben.', - 'download.too_large_for_memory': '„{name}“ ist {size} groß. Dieser Browser kann eine Datei dieser Größe nur speichern, indem er sie direkt auf die Festplatte schreibt, und das ist hier nicht möglich — er müsste die ganze Datei im Arbeitsspeicher halten. Verwenden Sie die Desktop-App oder Chrome bzw. Edge.', + 'download.too_large_for_memory': '„{name}“ ist {size} groß. Dieser Browser kann eine Datei dieser Größe nur speichern, indem er sie direkt auf die Festplatte schreibt, und das ist hier nicht möglich — er müsste die ganze Datei im Arbeitsspeicher halten. Laden Sie die Seite neu und versuchen Sie es erneut; falls das nicht hilft, verwenden Sie die Desktop-App.', 'group.upload_indexing': 'wird indiziert …', 'video.err_transport': 'Transport nicht verbunden', 'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}', 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 b0430c0..c065b8a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -217,7 +217,7 @@ export default { 'video.close': 'Close (Esc)', 'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.', 'preview.too_large': 'This file is {size}, more than this page can hold in memory ({limit}). Download it instead — a download is written straight to disk.', - 'download.too_large_for_memory': '"{name}" is {size}. This browser can only save a file that large by streaming it to disk, and it has no way to do that here — it would have to hold the whole file in memory. Use the desktop app, or Chrome or Edge.', + 'download.too_large_for_memory': '"{name}" is {size}. This browser can only save a file that large by streaming it to disk, and it has no way to do that here — it would have to hold the whole file in memory. Reload the page and try again; if that does not help, use the desktop app.', 'group.upload_indexing': 'indexing…', 'video.err_transport': 'Transport not connected', 'video.err_mse': 'Codec not supported for streaming: {codec}', 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 adfb1cc..f655bc0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -217,7 +217,7 @@ export default { 'preview.pdf_fallback': 'Este navegador no mostrará el PDF integrado. Descárguelo ' + 'en su lugar — en cualquier caso se descifró aquí.', 'preview.too_large': 'Este archivo ocupa {size}, más de lo que esta página puede mantener en memoria ({limit}). Descárguelo en su lugar — una descarga se escribe directamente en disco.', - 'download.too_large_for_memory': '«{name}» ocupa {size}. Este navegador solo puede guardar un archivo así transmitiéndolo al disco, y aquí no puede hacerlo — tendría que mantener el archivo entero en memoria. Use la aplicación de escritorio, o Chrome o Edge.', + 'download.too_large_for_memory': '«{name}» ocupa {size}. Este navegador solo puede guardar un archivo así transmitiéndolo al disco, y aquí no puede hacerlo — tendría que mantener el archivo entero en memoria. Recargue la página e inténtelo de nuevo; si eso no ayuda, use la aplicación de escritorio.', 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte no conectado', 'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}', 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 b409927..e011873 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -218,7 +218,7 @@ export default { 'preview.pdf_fallback': 'Ce navigateur n’affichera pas le PDF directement. ' + 'Téléchargez-le plutôt — il a été déchiffré ici dans les deux cas.', 'preview.too_large': 'Ce fichier fait {size}, plus que cette page ne peut garder en mémoire ({limit}). Téléchargez-le plutôt — un téléchargement est écrit directement sur le disque.', - 'download.too_large_for_memory': '« {name} » fait {size}. Ce navigateur ne peut enregistrer un fichier de cette taille qu\'en l\'écrivant au fil de l\'eau sur le disque, ce qu\'il ne peut pas faire ici — il devrait garder le fichier entier en mémoire. Utilisez l\'application de bureau, ou Chrome ou Edge.', + 'download.too_large_for_memory': '« {name} » fait {size}. Ce navigateur ne peut enregistrer un fichier de cette taille qu\'en l\'écrivant au fil de l\'eau sur le disque, ce qu\'il ne peut pas faire ici — il devrait garder le fichier entier en mémoire. Rechargez la page et réessayez ; si cela ne suffit pas, utilisez l\'application de bureau.', 'group.upload_indexing': 'indexation…', 'video.err_transport': 'Transport non connecté', 'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}', 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 7e6246e..80e3c17 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -218,7 +218,7 @@ export default { 'preview.pdf_fallback': 'Questo browser non mostrerà il PDF nella pagina. Lo scarichi ' + 'invece — in ogni caso è stato decifrato qui.', 'preview.too_large': 'Questo file è di {size}, più di quanto questa pagina possa tenere in memoria ({limit}). Lo scarichi invece — un download viene scritto direttamente su disco.', - 'download.too_large_for_memory': '«{name}» è di {size}. Questo browser può salvare un file di queste dimensioni solo scrivendolo su disco man mano, e qui non può farlo — dovrebbe tenere l’intero file in memoria. Usi l’applicazione desktop, oppure Chrome o Edge.', + 'download.too_large_for_memory': '«{name}» è di {size}. Questo browser può salvare un file di queste dimensioni solo scrivendolo su disco man mano, e qui non può farlo — dovrebbe tenere l’intero file in memoria. Ricarichi la pagina e riprovi; se non basta, usi l’applicazione desktop.', 'group.upload_indexing': 'indicizzazione…', 'video.err_transport': 'Trasporto non connesso', 'video.err_mse': 'Codec non supportato per lo streaming: {codec}', 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 a74eec9..5dbb4fa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -215,7 +215,7 @@ export default { 'preview.pdf_fallback': 'このブラウザーはページ内に PDF を表示しません。' + 'ダウンロードしてご覧ください。いずれにせよ復号はここで行われています。', 'preview.too_large': 'このファイルは {size} で、このページがメモリに保持できる上限({limit})を超えています。代わりにダウンロードしてください。ダウンロードはディスクに直接書き込まれます。', - 'download.too_large_for_memory': '「{name}」は {size} です。このブラウザーでこの大きさのファイルを保存するにはディスクへ逐次書き出すしかありませんが、ここではそれができません — ファイル全体をメモリに保持することになります。デスクトップアプリ、または Chrome か Edge をお使いください。', + 'download.too_large_for_memory': '「{name}」は {size} です。このブラウザーでこの大きさのファイルを保存するにはディスクへ逐次書き出すしかありませんが、ここではそれができません — ファイル全体をメモリに保持することになります。ページを再読み込みしてもう一度お試しください。解決しない場合はデスクトップアプリをお使いください。', 'group.upload_indexing': 'インデックスを作成中…', 'video.err_transport': 'トランスポートが接続されていません', 'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}', 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 a7d64f7..f9c7cf3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -219,7 +219,7 @@ export default { 'preview.pdf_fallback': 'Deze browser toont de PDF niet in de pagina zelf. Download ' + 'hem in plaats daarvan — ontsleuteld werd hij hoe dan ook hier.', 'preview.too_large': 'Dit bestand is {size}, meer dan deze pagina in het geheugen kan houden ({limit}). Download het in plaats daarvan — een download wordt rechtstreeks naar schijf geschreven.', - 'download.too_large_for_memory': '“{name}” is {size}. Deze browser kan een bestand van die omvang alleen opslaan door het meteen naar schijf te schrijven, en dat kan hier niet — het hele bestand zou in het geheugen moeten. Gebruik de desktop-app, of Chrome of Edge.', + 'download.too_large_for_memory': '“{name}” is {size}. Deze browser kan een bestand van die omvang alleen opslaan door het meteen naar schijf te schrijven, en dat kan hier niet — het hele bestand zou in het geheugen moeten. Herlaad de pagina en probeer het opnieuw; als dat niet helpt, gebruik dan de desktop-app.', 'group.upload_indexing': 'indexeren…', 'video.err_transport': 'Transport niet verbonden', 'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}', 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 5bd34fb..b26f3cb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -224,7 +224,7 @@ export default { 'preview.pdf_fallback': 'Ta przeglądarka nie wyświetli pliku PDF na stronie. Proszę ' + 'go pobrać — i tak został odszyfrowany tutaj.', 'preview.too_large': 'Ten plik ma {size}, więcej niż ta strona może utrzymać w pamięci ({limit}). Proszę go zamiast tego pobrać — pobieranie jest zapisywane wprost na dysk.', - 'download.too_large_for_memory': '„{name}” ma {size}. Ta przeglądarka może zapisać plik tej wielkości tylko strumieniowo na dysk, a tutaj nie ma takiej możliwości — musiałaby utrzymać cały plik w pamięci. Proszę użyć aplikacji desktopowej albo przeglądarki Chrome lub Edge.', + 'download.too_large_for_memory': '„{name}” ma {size}. Ta przeglądarka może zapisać plik tej wielkości tylko strumieniowo na dysk, a tutaj nie ma takiej możliwości — musiałaby utrzymać cały plik w pamięci. Proszę odświeżyć stronę i spróbować ponownie; jeśli to nie pomoże, proszę użyć aplikacji desktopowej.', 'group.upload_indexing': 'indeksowanie…', 'video.err_transport': 'Transport nie jest połączony', 'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}', 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 102ec92..db5a408 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 @@ -219,7 +219,7 @@ export default { 'preview.pdf_fallback': 'Este navegador não exibirá o PDF na própria página. Baixe ' + 'o arquivo — de todo modo ele foi descriptografado aqui.', 'preview.too_large': 'Este arquivo tem {size}, mais do que esta página consegue manter na memória ({limit}). Baixe-o em vez disso — um download é gravado direto no disco.', - 'download.too_large_for_memory': '"{name}" tem {size}. Este navegador só consegue salvar um arquivo desse tamanho gravando-o direto no disco, e aqui ele não tem como — precisaria manter o arquivo inteiro na memória. Use o aplicativo para computador, ou Chrome ou Edge.', + 'download.too_large_for_memory': '"{name}" tem {size}. Este navegador só consegue salvar um arquivo desse tamanho gravando-o direto no disco, e aqui ele não tem como — precisaria manter o arquivo inteiro na memória. Recarregue a página e tente novamente; se não resolver, use o aplicativo para computador.', 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte não conectado', 'video.err_mse': 'Codec sem suporte para transmissão: {codec}', 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 6b64785..ba88f4e 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 @@ -212,7 +212,7 @@ export default { 'video.close': '关闭(Esc)', 'preview.pdf_fallback': '此浏览器不会在页面内显示该 PDF。请改为下载——无论如何它都已在本地解密。', 'preview.too_large': '该文件为 {size},超出本页面可在内存中保存的上限({limit})。请改为下载——下载会直接写入磁盘。', - 'download.too_large_for_memory': '“{name}”为 {size}。此浏览器只能通过边下边写入磁盘来保存这么大的文件,而这里无法做到——它将不得不把整个文件放在内存中。请使用桌面应用,或 Chrome、Edge。', + 'download.too_large_for_memory': '“{name}”为 {size}。此浏览器只能通过边下边写入磁盘来保存这么大的文件,而这里无法做到——它将不得不把整个文件放在内存中。请重新加载页面后重试;如果仍然无效,请使用桌面应用。', 'group.upload_indexing': '建立索引中…', 'video.err_transport': '传输未连接', 'video.err_mse': '该编解码器不支持流式播放:{codec}', diff --git a/packages/meshbay-hub/tests/test_streamed_download_reliability.py b/packages/meshbay-hub/tests/test_streamed_download_reliability.py index 9e6f77d..3033a95 100644 --- a/packages/meshbay-hub/tests/test_streamed_download_reliability.py +++ b/packages/meshbay-hub/tests/test_streamed_download_reliability.py @@ -110,6 +110,16 @@ Object.defineProperty(globalThis, 'navigator', { globalThis.window = globalThis; globalThis.isSecureContext = true; +// The self-test's repair reloads once and remembers it for the tab; both have +// to exist here or priming the worker throws instead of repairing. +const session = new Map(); +globalThis.sessionStorage = { + getItem: k => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: k => session.delete(k), +}; +log.reloads = 0; +globalThis.location = { reload: () => { log.reloads += 1; } }; globalThis.document = { createElement: () => ({ hidden: false, src: '', remove() {} }), body: { @@ -377,3 +387,67 @@ if (target) await target.writable.close(); "the stuck registration was left in place") assert out["target"] is True, ( "discarding it did not get the page a worker it could stream to") + + +# ── A page the worker cannot serve ────────────────────────────────────────── + +def test_a_page_the_worker_cannot_serve_reloads_itself_once(tmp_path): + """Being controlled is not being servable, and the gap is a real failure. + + A document fetched by a hard reload — Ctrl+F5, Ctrl+Shift+R — is loaded with + the service worker bypassed. It can be claimed afterwards, so `controller` + comes back and every check in `_claimController` passes; but the navigations + it starts keep missing the worker, and the hidden iframe a streamed download + needs is a navigation. Every download then fails with "the worker did not + answer" for the life of that page — on Firefox and Safari, the only path + there is for a file too large to hold in memory. + + Reported after an operator was told to hard-reload after each deployment: + four downloads out of four worked on a freshly started browser, and the + first attempt after a Ctrl+F5 failed, every time. An ordinary reload puts + the document back under the worker, so priming does exactly that, once. + """ + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 6000)); + out.reloads = log.reloads; + """, serve="never") + assert out["reloads"] == 1, ( + "a page that cannot be served by the worker was left that way") + + +def test_a_page_that_works_is_not_reloaded(tmp_path): + """The self-test costs milliseconds when it passes, and must cost nothing + else. Reloading a healthy page at boot would be a flicker on every visit.""" + out = _run(tmp_path, """ + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 3000)); + out.reloads = log.reloads; + """) + assert out["reloads"] == 0 + + +def test_the_repair_happens_at_most_once(tmp_path): + """The flag is in sessionStorage rather than a variable because the point is + to survive the reload it triggers. If reloading does not help, the page + stays broken and says so — it does not reload again, and again.""" + out = _run(tmp_path, """ + sessionStorage.setItem('meshbay.sw-repaired', '1'); + M.primeServiceWorker(); + await new Promise((r) => setTimeout(r, 6000)); + out.reloads = log.reloads; + """, serve="never") + assert out["reloads"] == 0, "a page that had already been repaired reloaded again" + + +def test_the_self_test_leaves_no_file_behind(tmp_path): + """It opens a real download target to ask a real question, so it must also + tear it down: a completed one would drop `meshbay-selftest.bin` into the + download folder on every page load.""" + src = DOWNLOADS.read_text() + fn = src[src.index("async function _canServeDownloads"):] + fn = fn[:fn.index("\n}\n")] + assert "writable.abort" in fn, ( + "the self-test's stream is never aborted, so the browser keeps what it " + "was given") + assert "frame.remove" in fn |