aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 17:47:00 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 17:47:00 +0200
commitc35f3a73eb68cdd7c4179cded3d1ead63ec5a033 (patch)
treeeabf5919a19646d5410adfe0e184cc608e1a9c48
parent3d4080e9a1b79e99428a80391e224ea73cf4274d (diff)
downloadmeshbay-c35f3a73eb68cdd7c4179cded3d1ead63ec5a033.tar.gz
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 <noreply@anthropic.com>
-rw-r--r--docs/playlists.md29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlists.js54
-rwxr-xr-xpackages/meshbay-hub/tests/harness/playlist_store_probe.py61
-rw-r--r--packages/meshbay-hub/tests/test_playlist_store.py37
15 files changed, 189 insertions, 19 deletions
diff --git a/docs/playlists.md b/docs/playlists.md
index 488a2de..45e36fe 100644
--- a/docs/playlists.md
+++ b/docs/playlists.md
@@ -1229,17 +1229,26 @@ reads §15.1 as more than it is.
`UPLOAD_CHUNK_SIZE` is 48 KB going up while `CHUNK_SIZE` is 1 MB coming down,
which is the same constraint seen from both ends.
- So `USER_BLOB_BODY_MAX = 1 MB` is a cap the client can never reach. The real
- limit is about fifteen 4 KB pages — near **225 tracks in one playlist**, at the
- ~270 bytes a track measures once compressed. Past that the push fails:
- visibly, since the sync result is reported now, but in the browser's own
- words. Reading is unaffected — a node answers with a whole body, and 256 KB is
- measured coming back.
+ So `USER_BLOB_BODY_MAX = 1 MB` is a cap the client can never reach. Measured
+ rather than derived: a thousand tracks with varied metadata seal to **53 276
+ bytes**, and each further track costs about 50, so one playlist tops out near
+ **1200 tracks**. (An earlier draft of this paragraph said 225, by reading §4's
+ ~270 bytes a track *raw* as the *sealed* figure. `test_playlist_store.py`
+ measures it now, and fails if a thousand tracks stop fitting.) Reading is
+ unaffected — a node answers with a whole body, and 256 KB is measured coming
+ back.
- Three ways out, none taken: cap the client with a sentence a reader can act on
- (small, safe, keeps the limit); split a long body over several blob kinds (no
- protocol change, but the kind namespace must then carry part numbers); or raise
- what the node advertises (aiortc moved 256 KB both ways here despite
+ **Taken:** the client caps itself at 62 KB of sealed body, checked after
+ sealing because compression makes a track count a poor guide to a blob length.
+ Over it, that one playlist is skipped and **named** in the sync result; the
+ rest of the sweep goes as usual and the sync is not a failure. This replaced a
+ bare `catch {}` around each body push that swallowed the difference between a
+ node that went away mid-sweep and a playlist that could never be sent — the
+ second being exactly the silent loss the design exists to prevent.
+
+ Two ways further out, neither taken: split a long body over several blob kinds
+ (no protocol change, but the kind namespace must then carry part numbers); or
+ raise what the node advertises (aiortc moved 256 KB both ways here despite
advertising 64 KB — one line, and a protocol change that belongs in its own
work, with uploads to re-examine alongside it).
- **O7 is now decided, not deferred**: there is no `/playlists` page, and the
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);
+ }
}
}
diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
index f7573b7..83c00ad 100755
--- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
@@ -362,6 +362,67 @@ function fakeNode() {
names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [],
});
+ // ── what a playlist costs, sealed ─────────────────────────────────────
+ //
+ // The cap below is in bytes, but the only number a reader can act on is a
+ // number of tracks — so it is measured here rather than quoted from the
+ // design doc. (Quoting it is how "225 tracks" got written down: §4's
+ // figure is ~270 bytes a track *raw* and ~60 *sealed*, and the raw one was
+ // read for the sealed one.)
+ //
+ // Varied metadata on purpose. A fixture where every track shares an artist
+ // and an album measures deflate's opinion of its own regularity: the first
+ // attempt at this, 400 tracks differing only by id, sealed to 41 bytes a
+ // track and would have let the case below pass while testing nothing.
+ const WORDS = ['aube', 'ciel', 'verre', 'nord', 'ombre', 'pluie', 'fer',
+ 'sel', 'onze', 'rive', 'brume', 'cendre', 'axe', 'lune'];
+ const hex = (n) => [...crypto.getRandomValues(new Uint8Array(n))]
+ .map((b) => b.toString(16).padStart(2, '0')).join('');
+ const word = () => WORDS[Math.floor(Math.random() * WORDS.length)];
+ const storedTrack = (i) => {
+ const artist = `${word()} ${word()}`;
+ const album = `${word()} ${word()} ${1970 + (i % 50)}`;
+ const title = `${word()} ${word()} ${word()}`;
+ return { id: hex(16), g: 'g1', hv: 1, n: `${i % 20} - ${title}.flac`,
+ s: 30000000 + i, p: `${artist}/${album}`, t: title, a: artist,
+ b: album, d: 180 + (i % 300), tn: (i % 20) + 1 };
+ };
+ const sizes = {};
+ for (const n of [100, 500, 1000]) {
+ const tracks = [];
+ for (let i = 0; i < n; i++) tracks.push(storedTrack(i));
+ const sealed = await seal({ v: 1, id: 'measure', rev: 1, device: 'aaa', tracks },
+ bodyKind('measure'), USER, key);
+ sizes[n] = sealed.length;
+ }
+ steps.push({ step: 'what a playlist costs sealed', sizes });
+
+ // ── a playlist too large for one frame is named, not swallowed ────────
+ //
+ // A DataChannel `send()` throws above the max-message-size the far end
+ // advertised, which for the node's aiortc is 65536 — so the node's 1 MB
+ // body cap is unreachable and the real limit is a count of tracks
+ // (docs/playlists.md §15.3). The push used to swallow every per-body
+ // failure in one bare `catch {}`: the playlist stopped leaving the browser
+ // and nothing anywhere said so, which is the silent loss this design
+ // exists to prevent.
+ const bigNode = fakeNode();
+ const longId = await P.createPlaylist(USER, 'Trop longue');
+ const many = [];
+ for (let i = 0; i < 1500; i++) {
+ const st = storedTrack(i);
+ many.push({ ...track(i), id: st.id, name: st.n, display_title: st.t,
+ path: st.p, artist: st.a, album: st.b, duration: st.d });
+ }
+ await P.addTracks(USER, longId, many, 'g1');
+ const bigResult = await P.syncWith(bigNode, USER);
+ steps.push({ step: 'a playlist too large for one frame',
+ result: bigResult,
+ bigKind: bodyKind(longId),
+ // Everything else must still have gone: one oversized
+ // playlist is not a broken sync.
+ stored: bigNode.stored.map((e) => ({ kind: e.kind, bytes: e.bytes })) });
+
// ── a session with no HKDF handle degrades rather than failing ─────────
P.setPlaylistTransport(null);
P.forgetPlaylistKey();
diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py
index 59afaee..ce094c4 100644
--- a/packages/meshbay-hub/tests/test_playlist_store.py
+++ b/packages/meshbay-hub/tests/test_playlist_store.py
@@ -280,3 +280,40 @@ def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(step
r = steps["a session from before the HKDF handle"]["result"]
assert r["ok"] is False and r["reason"] == "no_key"
assert r["pushed"] == 0
+
+
+def test_what_a_playlist_costs_sealed_is_measured_not_quoted(steps):
+ """The ceiling the UI promises comes from here, not from the design doc.
+
+ §4 measured ~270 bytes a track *raw* and ~60 *sealed*; the raw figure was
+ then read for the sealed one and "about 225 tracks" written down, which is
+ five times too strict. So the number is measured where it is used.
+ """
+ sizes = steps["what a playlist costs sealed"]["sizes"]
+ per_track = (sizes["1000"] - sizes["100"]) / 900
+ assert 30 < per_track < 90, f"a track now costs {per_track:.0f} sealed bytes"
+ # The sentence shown to a reader is "playlists of about 1000 tracks". If
+ # compression regresses, or a field is added to what is stored, that
+ # sentence becomes false — and this is what says so.
+ assert sizes["1000"] < 62 * 1024, (
+ f"a thousand tracks no longer fit in one frame: {sizes['1000']} bytes")
+
+
+def test_a_playlist_too_large_for_a_frame_is_named(steps):
+ # Not "a sync failed": which playlist, so the reader can act. A bare
+ # `catch {}` per body is what let this stop leaving the browser in silence.
+ s = steps["a playlist too large for one frame"]
+ assert s["result"]["tooLarge"] == ["Trop longue"]
+ kinds = [e["kind"] for e in s["stored"]]
+ assert s["bigKind"] not in kinds, "the oversized body was handed to send() anyway"
+
+
+def test_one_oversized_playlist_does_not_break_the_rest_of_the_sync(steps):
+ s = steps["a playlist too large for one frame"]
+ assert s["result"]["ok"] is True
+ assert s["result"]["failed"] == []
+ kinds = [e["kind"] for e in s["stored"]]
+ # The manifest last, and the other bodies before it: one playlist that
+ # cannot be sent must not hold back the four that can.
+ assert "playlists" in kinds
+ assert len([k for k in kinds if k.startswith("playlist:")]) >= 4