From c35f3a73eb68cdd7c4179cded3d1ead63ec5a033 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 16 Sep 2026 17:47:00 +0200 Subject: playlists: refuse a body no frame can carry, and say which one A DataChannel send() throws above the max-message-size the node advertises (aiortc: 65536), so the 1 MB body cap was unreachable and each body push swallowed the difference in a bare catch. Cap at 62 KB after sealing, name the playlist in the sync result, keep syncing the rest. Measured: 1000 tracks seal to 53 276 bytes, so the ceiling is ~1200. Co-Authored-By: Claude Opus 5 --- .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../src/meshbay_hub/static/playlist-menu.js | 17 +++++-- .../src/meshbay_hub/static/playlists.js | 54 ++++++++++++++++++++-- 12 files changed, 72 insertions(+), 9 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub') 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 4cfa0a7..cb92b18 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -336,6 +336,7 @@ export default { 'playlists.synced': 'Playlists synchronisiert', 'playlists.sync_failed': 'Synchronisierung fehlgeschlagen', 'playlists.err_no_key': 'Melde dich einmal ab und mit deinem Passwort wieder an, damit Playlists zwischen Geräten synchronisiert werden.', + 'playlists.err_too_large': '„{name}“ ist zu groß, um mit deinen anderen Geräten synchronisiert zu werden. Teile sie in Playlists von etwa {n} Titeln auf.', 'playlists.track_removed': 'Titel entfernt', 'playlists.save_queue': 'Warteschlange als Playlist speichern…', 'playlists.cancel': 'Abbrechen', 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 1616eb5..3b84c3c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -334,6 +334,7 @@ export default { 'playlists.synced': 'Playlists synced', 'playlists.sync_failed': 'Could not sync playlists', 'playlists.err_no_key': 'Sign out and sign in with your password once to sync playlists across devices.', + 'playlists.err_too_large': '"{name}" is too large to sync to your other devices. Split it into playlists of about {n} tracks.', 'playlists.track_removed': 'Track removed', 'playlists.save_queue': 'Save the queue as a playlist…', 'playlists.cancel': 'Cancel', 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 ff74857..145f464 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -334,6 +334,7 @@ export default { 'playlists.synced': 'Listas sincronizadas', 'playlists.sync_failed': 'No se pudieron sincronizar', 'playlists.err_no_key': 'Cierra sesión y vuelve a entrar con tu contraseña una vez para sincronizar las listas entre dispositivos.', + 'playlists.err_too_large': '«{name}» es demasiado grande para sincronizarse con tus otros dispositivos. Divídela en listas de unas {n} canciones.', 'playlists.track_removed': 'Pista quitada', 'playlists.save_queue': 'Guardar la cola como lista…', 'playlists.cancel': 'Cancelar', 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 52af974..a4f32de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -335,6 +335,7 @@ export default { 'playlists.synced': 'Playlists synchronisées', 'playlists.sync_failed': 'Synchronisation impossible', 'playlists.err_no_key': 'Déconnecte-toi puis reconnecte-toi avec ton mot de passe une fois, pour synchroniser les playlists entre appareils.', + 'playlists.err_too_large': "« {name} » est trop volumineuse pour être synchronisée vers tes autres appareils. Divise-la en playlists d'environ {n} morceaux.", 'playlists.track_removed': 'Morceau retiré', 'playlists.save_queue': 'Enregistrer la file comme playlist…', 'playlists.cancel': 'Annuler', 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 75b420d..1da0793 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -335,6 +335,7 @@ export default { 'playlists.synced': 'Playlist sincronizzate', 'playlists.sync_failed': 'Sincronizzazione non riuscita', 'playlists.err_no_key': 'Esci e rientra con la tua password una volta per sincronizzare le playlist tra dispositivi.', + 'playlists.err_too_large': '«{name}» è troppo grande per sincronizzarsi con i tuoi altri dispositivi. Dividila in playlist di circa {n} brani.', 'playlists.track_removed': 'Brano rimosso', 'playlists.save_queue': 'Salva la coda come playlist…', 'playlists.cancel': 'Annulla', 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 c87d795..971ecb2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -332,6 +332,7 @@ export default { 'playlists.synced': 'プレイリストを同期しました', 'playlists.sync_failed': '同期できませんでした', 'playlists.err_no_key': 'プレイリストを端末間で同期するには、一度サインアウトしてパスワードでサインインしてください。', + 'playlists.err_too_large': '「{name}」は大きすぎて他のデバイスと同期できません。{n} 曲程度のプレイリストに分けてください。', 'playlists.track_removed': '曲を削除しました', 'playlists.save_queue': 'キューをプレイリストとして保存…', 'playlists.cancel': 'キャンセル', 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 4fa3d01..dae63d2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -336,6 +336,7 @@ export default { 'playlists.synced': 'Afspeellijsten gesynchroniseerd', 'playlists.sync_failed': 'Synchroniseren mislukt', 'playlists.err_no_key': 'Meld je één keer af en met je wachtwoord weer aan om afspeellijsten tussen apparaten te synchroniseren.', + 'playlists.err_too_large': '"{name}" is te groot om met je andere apparaten te synchroniseren. Splits hem op in playlists van ongeveer {n} nummers.', 'playlists.track_removed': 'Nummer verwijderd', 'playlists.save_queue': 'Wachtrij opslaan als afspeellijst…', 'playlists.cancel': 'Annuleren', 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 d0fd387..639d518 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -345,6 +345,7 @@ export default { 'playlists.synced': 'Playlisty zsynchronizowane', 'playlists.sync_failed': 'Nie udało się zsynchronizować', 'playlists.err_no_key': 'Wyloguj się i zaloguj raz hasłem, aby synchronizować playlisty między urządzeniami.', + 'playlists.err_too_large': '„{name}” jest zbyt duża, aby zsynchronizować ją z innymi urządzeniami. Podziel ją na playlisty po około {n} utworów.', 'playlists.track_removed': 'Utwór usunięty', 'playlists.save_queue': 'Zapisz kolejkę jako playlistę…', 'playlists.cancel': 'Anuluj', 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 94c56a6..371b84f 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 @@ -336,6 +336,7 @@ export default { 'playlists.synced': 'Playlists sincronizadas', 'playlists.sync_failed': 'Não foi possível sincronizar', 'playlists.err_no_key': 'Saia e entre com sua senha uma vez para sincronizar as playlists entre dispositivos.', + 'playlists.err_too_large': '"{name}" é grande demais para sincronizar com seus outros dispositivos. Divida-a em playlists de cerca de {n} faixas.', 'playlists.track_removed': 'Faixa removida', 'playlists.save_queue': 'Salvar a fila como playlist…', 'playlists.cancel': 'Cancelar', 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 bc77d5f..96ba4fe 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 @@ -329,6 +329,7 @@ export default { 'playlists.synced': '播放列表已同步', 'playlists.sync_failed': '无法同步', 'playlists.err_no_key': '请退出并用密码重新登录一次,以便在设备间同步播放列表。', + 'playlists.err_too_large': '「{name}」太大,无法同步到你的其他设备。请拆分为约 {n} 首歌曲的播放列表。', 'playlists.track_removed': '已移除曲目', 'playlists.save_queue': '将队列保存为播放列表…', 'playlists.cancel': '取消', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js index e2fb3f9..70c105d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js @@ -197,11 +197,18 @@ function PlaylistMenuButton({ userId, lists, reload, onPlayQueue, onSync, cached // playlist subkey existed — and an AES handle is non-extractable, so // there is nothing to derive it from. One sign-in with the passphrase // fixes it for good; a code on screen does not say that. - say(r && r.ok - ? `${t('playlists.synced')} (${r.pushed || 0}↑ ${r.pulled || 0}↓)` - : (why === 'no_key' - ? t('playlists.err_no_key') - : `${t('playlists.sync_failed')}: ${why}`)); + // A playlist too large to send is reported ahead of the totals. The + // sync itself worked — everything else moved — so `ok` is true, and + // saying "synced" while one playlist silently stayed behind would be + // the lie this whole report exists to stop. + const big = (r && r.tooLarge) || []; + say(big.length + ? t('playlists.err_too_large', { name: big[0], n: 1000 }) + : (r && r.ok + ? `${t('playlists.synced')} (${r.pushed || 0}↑ ${r.pulled || 0}↓)` + : (why === 'no_key' + ? t('playlists.err_no_key') + : `${t('playlists.sync_failed')}: ${why}`))); }, }, ]); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js index 5aa501a..af359f3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js @@ -30,6 +30,28 @@ import { * what §7 refuses. */ +/** + * The largest sealed body this browser can actually send. + * + * Not a policy — a wire limit. A DataChannel `send()` throws when the frame is + * larger than the value the far end advertised, and the node's aiortc fixes + * that at 65 536 (`docs/playlists.md` §15.3). The node's own 1 MB body cap is + * therefore unreachable from here, and the same constraint is why uploads chunk + * at 48 KB. + * + * 62 KB leaves room for the frame around the bytes — the four-byte length + * prefix and the msgpack map that carries the kind, the revision and a request + * id. Measured, a thousand tracks seal to 53 276 bytes and each further track + * costs about 50, so the ceiling is near **1200 tracks in one playlist** + * (`test_playlist_store.py`, which fails if that stops being true). + * + * Checked *after* sealing rather than estimated before it: compression makes + * the length of a playlist a poor guide to the length of its blob, and a guess + * that is wrong in the permissive direction is a thrown exception in the middle + * of a sync. + */ +const MAX_BODY_BYTES = 62 * 1024; + // ── local identity ─────────────────────────────────────────────────────────── const DEVICE_KEY = 'meshbay_playlist_device'; @@ -535,7 +557,12 @@ async function saveQueueAsPlaylist(userId, name, entries) { * be reconciled must never break whatever that connection was opened for. */ async function syncWith(transport, userId) { - const result = { ok: false, pulled: 0, pushed: 0, reason: null }; + const result = { + ok: false, pulled: 0, pushed: 0, reason: null, + // Named, not counted: "one playlist is too large" is only actionable if + // the reader is told which one. + tooLarge: [], failed: [], + }; if (!transport || !transport.connected) { result.reason = 'offline'; return result; @@ -634,11 +661,30 @@ async function syncWith(transport, userId) { result.unreadable = true; } } else if (localRev > nodeRev && localRev > 0) { + // Was: one `catch {}` covering both of the cases below. A node that went + // away mid-sweep and a playlist that can never be sent are not the same + // event, and swallowing the second is the silent loss this whole design + // exists to prevent — the reader keeps adding tracks to a playlist that + // stopped leaving the browser, and nothing anywhere says so. + let sealed; try { - await transport.storeUserBlob( - kind, localRev, await seal(local, kind, userId, key)); + sealed = await seal(local, kind, userId, key); + } catch { + result.failed.push(p.name); + continue; + } + if (sealed.byteLength > MAX_BODY_BYTES) { + result.tooLarge.push(p.name); + continue; + } + try { + await transport.storeUserBlob(kind, localRev, sealed); result.pushed += 1; - } catch { /* a cap, or a node that went away mid-sweep */ } + } catch { + // A node that went away mid-sweep: the next sync pushes this, because + // the node's revision is still behind the local one. + result.failed.push(p.name); + } } } -- cgit v1.2.3