release(v4.8.2): per-from receive serialization + per-connection bridge dedup
Some checks failed
Test / test (push) Has been cancelled

Two interlocking robustness fixes for the duplicate-fan-out / first-contact
class of failures Prism reported.

1. `Shade.receive(from, env)` now queues its `manager.decrypt` step
   per `from` so concurrent dispatches can't race the SessionManager
   ratchet or the StorageProvider (sqlite "database is locked", IDB
   transaction conflicts). User message handlers run *outside* the
   queue so streams + file-RPC's nested `shade.receive` calls don't
   self-deadlock.

2. Bridge WS + SSE handlers now run a per-connection bounded msgId
   LRU as defense-in-depth against any flushTo re-entry (event-storm,
   future refactor). Pending-flush chains are wrapped in `.catch(() =>
   {})` so a transient `ws.send` rejection no longer poisons the
   connection's flush loop.

Tests: storming `inbox.blob_stored` 10× per PUT yields exactly one WS/
SSE frame; 8 concurrent `bob.receive('alice', envelope)` calls keep
the ratchet intact and never surface "database is locked".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:13:46 +02:00
parent 680d6386f3
commit 8c606ad498
30 changed files with 362 additions and 56 deletions

View File

@@ -131,6 +131,53 @@ describe('createShade — happy path', () => {
await expect(alice.send('nobody', 'ghost')).rejects.toThrow();
});
test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => {
// Reproduces the Prism FR scenario: a single PUT is fanned out
// multiple times by the relay (or any duplicating transport), the
// receiver dispatches several `shade.receive(from, env)` in
// parallel, and the underlying SessionManager + StorageProvider
// would race on the ratchet (and on storage writes — sqlite throws
// "database is locked", IDB throws transaction conflicts) without
// per-`from` serialization. We pre-establish a session, then fire
// the same envelope at `bob.receive` from many concurrent callers
// and verify all of them either decrypt to the same plaintext or
// surface a benign "already-consumed" error. Crucially: no
// unhandled storage races, no ratchet corruption, and the next
// legitimate message still decrypts.
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
const env1 = await alice.send('bob', 'first');
expect(await bob.receive('alice', env1)).toBe('first');
const env2 = await alice.send('bob', 'second');
// Fan the same envelope out to 8 concurrent receives — exactly the
// shape of the relay duplicate fan-out described in the FR.
const dispatches = await Promise.allSettled(
Array.from({ length: 8 }, () => bob.receive('alice', env2)),
);
// At least one must have succeeded with the right plaintext; the
// others may legitimately reject (replay protection / OTPK
// already-consumed) but MUST NOT corrupt the ratchet or throw
// "database is locked".
const fulfilled = dispatches.filter((d) => d.status === 'fulfilled') as Array<
PromiseFulfilledResult<string>
>;
expect(fulfilled.length).toBeGreaterThan(0);
expect(fulfilled[0]!.value).toBe('second');
for (const d of dispatches) {
if (d.status === 'rejected') {
const msg = String((d.reason as Error)?.message ?? d.reason);
expect(msg).not.toMatch(/database is locked/i);
}
}
// Ratchet must still advance — the next legitimate message decrypts.
const env3 = await alice.send('bob', 'third');
expect(await bob.receive('alice', env3)).toBe('third');
});
test('verify fingerprint matches pinned identity', async () => {
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });