diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
| commit | 2916aa376009283305a7acec4aafa3c96544499e (patch) | |
| tree | bc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/tests/harness | |
| parent | 25f62e169e049f0757ef611d5b409e7523958dee (diff) | |
| download | meshbay-2916aa376009283305a7acec4aafa3c96544499e.tar.gz | |
playlists: make the writes actually leave the browser
Reported from a phone: signing in with the same account showed no
playlists. syncWith was called from exactly one place in the interface,
so creating a playlist, deleting one, removing a track and saving the
queue all wrote to IndexedDB and stopped there. The store pushes itself
now, coalesced, so a new mutation cannot forget to.
Silence was the real defect. The node audited only successes, so a
refusal left no trace and user_blob_list none at all; the background
push swallowed its reason; the interface said nothing. All three report
now, and "Sync now" says what happened either way.
An unreadable blob on a node was treated as a fetch failure and returned
before the push — permanent, once the node held anything. It is an
absence: the client is the authority, and it gets overwritten.
A sign-in reconciles whatever this browser already holds, a pending push
is flushed when the page goes away, and a push that did not land is
retried once.
no_key is spelled out: a client that signs in with its remembered device
key only ever has a bundle key persisted before the playlist subkey
existed, and an AES handle is non-extractable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
| -rwxr-xr-x | packages/meshbay-hub/tests/harness/playlist_store_probe.py | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py index 8dfa4b1..f7573b7 100755 --- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py @@ -203,7 +203,167 @@ function fakeNode() { stillThere: holder.rows.has(bodyKind(eveningId)), }); + // ── every mutation reaches a node, not just the one that used to ────── + // + // The defect this exists for: `syncWith` was called from exactly one place + // in the interface, so creating a playlist, deleting one, removing a track + // and saving the queue all wrote to IndexedDB and stopped there. A second + // device saw nothing — which is the one thing playlists are for. Found in + // the node's own audit log: two events, ever. + const auto = fakeNode(); + P.setPlaylistTransport(async () => auto); + const seen = () => auto.stored.map((s) => s.kind).sort(); + const wait = () => new Promise((r) => setTimeout(r, 2300)); // > the debounce + + const p1 = await P.createPlaylist(USER, 'Depuis le menu'); + await wait(); + steps.push({ step: 'create pushes', kinds: seen() }); + + auto.stored.length = 0; + await P.addTracks(USER, p1, [track(21)], 'g1'); + await wait(); + steps.push({ step: 'add pushes', kinds: seen() }); + + auto.stored.length = 0; + await P.removeTrackAt(USER, p1, 0); + await wait(); + steps.push({ step: 'remove pushes', kinds: seen() }); + + auto.stored.length = 0; + const p2 = await P.saveQueueAsPlaylist(USER, 'Depuis la file', [track(31), track(32)]); + await wait(); + steps.push({ step: 'save-queue pushes', kinds: seen() }); + + auto.stored.length = 0; + await P.deletePlaylist(USER, p2); + await wait(); + steps.push({ step: 'delete pushes', kinds: seen(), + bodyGone: !auto.rows.has(bodyKind(p2)) }); + + // A burst is one push, not one per write. + auto.stored.length = 0; + for (let i = 0; i < 5; i++) await P.addTracks(USER, p1, [track(40 + i)], 'g1'); + await wait(); + steps.push({ step: 'a burst is coalesced', writes: auto.stored.length }); + + // ── a push that does not land is retried, once ──────────────────────── + // + // Shortened from twenty seconds: a retry nobody can wait for is a retry + // nobody has checked, and this feature has already shipped one whole path + // that no test ever called. + P.setPushTimings({ debounceMs: 100, retryMs: 400 }); + let refuse = true; + const flaky = fakeNode(); + const flakyStore = flaky.storeUserBlob.bind(flaky); + flaky.storeUserBlob = async (...a) => { + if (refuse) throw new Error('Server busy, retry shortly'); + return flakyStore(...a); + }; + // Sync *passes*, not store calls: one pass writes a body per playlist plus + // the manifest, so counting writes says nothing about how many times the + // push was attempted — which is the whole question for "does it loop". + // Every pass begins with exactly one listing. + let passes = 0; + const flakyList = flaky.listUserBlobs.bind(flaky); + flaky.listUserBlobs = async (...a) => { passes++; return flakyList(...a); }; + P.setPlaylistTransport(async () => flaky); + + await P.addTracks(USER, p1, [track(50)], 'g1'); + await new Promise((r) => setTimeout(r, 250)); + const afterFirst = { stored: flaky.stored.length, last: P.lastSync() }; + refuse = false; // the node comes back + await new Promise((r) => setTimeout(r, 900)); + steps.push({ step: 'a failed push is retried once', + afterFirstTry: afterFirst.stored, + firstReason: afterFirst.last.reason, + afterRetry: flaky.stored.length, + ok: P.lastSync().ok }); + + // ── and only once ───────────────────────────────────────────────────── + refuse = true; + flaky.stored.length = 0; + passes = 0; + await P.addTracks(USER, p1, [track(51)], 'g1'); + await new Promise((r) => setTimeout(r, 2000)); // room for four retries + steps.push({ step: 'a retry does not loop', + stored: flaky.stored.length, passes }); + + // ── closing the page sends what is pending ──────────────────────────── + refuse = false; + P.setPushTimings({ debounceMs: 60000, retryMs: 60000 }); // never on its own + flaky.stored.length = 0; + await P.addTracks(USER, p1, [track(52)], 'g1'); + const beforeFlush = flaky.stored.length; + await P.flushPush(); + steps.push({ step: 'flush sends a pending push', + beforeFlush, afterFlush: flaky.stored.length }); + P.setPushTimings({ debounceMs: 100, retryMs: 400 }); + + // ── a fresh device finds them ───────────────────────────────────────── + // + // What the phone did not do. `auto` now holds this account's playlists; a + // browser that has never seen them must end up with them. + P.setPlaylistTransport(async () => auto); + const before = (await P.listPlaylists(USER)).length; + await P.forgetLocal(USER); + const emptied = (await P.listPlaylists(USER)).length; + const pulled = await P.pullOnce(USER); + steps.push({ step: 'a fresh device pulls once', + before, emptied, result: pulled, + lists: (await P.listPlaylists(USER)).map((p) => p.name).sort() }); + + // A device that already holds playlists must pull too. Bounding this to an + // empty device left out the ordinary case — nine playlists here, a tenth + // made somewhere else — which would then have waited for a Music tab. + const elsewhere = fakeNode(); + const mani = { v: 1, rev: 50, playlists: { + 'made-elsewhere': { name: 'Faite ailleurs', rev: 9, body_rev: 1, count: 1, + device: 'aaa', updated_at: 1, deleted: false } } }; + elsewhere.rows.set(MANIFEST_KIND, { + rev: 50, blob: await seal(mani, MANIFEST_KIND, USER, key) }); + elsewhere.rows.set(bodyKind('made-elsewhere'), { rev: 1, blob: await seal( + { v: 1, id: 'made-elsewhere', rev: 1, device: 'aaa', + tracks: [{ id: 'z1', g: 'g9', hv: 1, n: 'z.flac', s: 1, p: 'P', + t: 'Ailleurs', a: 'A', b: 'B', d: 1, tn: 1 }] }, + bodyKind('made-elsewhere'), USER, key) }); + P.setPlaylistTransport(async () => elsewhere); + const held = (await P.listPlaylists(USER)).length; + const pulled2 = await P.pullOnce(USER); + steps.push({ step: 'a device that already has playlists pulls too', + held, result: pulled2, + found: (await P.listPlaylists(USER)).map((p) => p.name).sort() }); + P.setPlaylistTransport(async () => auto); + + // ── a node holding something this browser cannot read ───────────────── + // + // The defect that made the phone report land: `open()` throwing on the + // node's manifest was treated as a fetch failure and returned before the + // push. The first write landed because the node was empty; every sync + // after it took that path and pushed nothing, for ever. Unreadable is an + // absence, not an error — the client is the authority. + const junkKey = await crypto.subtle.importKey( + 'raw', new Uint8Array(32).fill(200), { name: 'AES-GCM' }, false, + ['encrypt', 'decrypt']); + const wedged = fakeNode(); + wedged.rows.set(MANIFEST_KIND, { + rev: 1, + blob: await seal({ v: 1, rev: 1, playlists: {} }, + MANIFEST_KIND, USER, junkKey) }); + const wedgedResult = await P.syncWith(wedged, USER); + let readBack = null; + try { + readBack = await open(wedged.rows.get(MANIFEST_KIND).blob, + MANIFEST_KIND, USER, key); + } catch { /* still unreadable means it was not overwritten */ } + steps.push({ + step: 'an unreadable manifest does not wedge the sync', + result: wedgedResult, + overwritten: !!readBack, + names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [], + }); + // ── a session with no HKDF handle degrades rather than failing ───────── + P.setPlaylistTransport(null); P.forgetPlaylistKey(); session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session const r3 = await P.syncWith(fakeNode(), USER); |