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

@@ -5,6 +5,87 @@ All notable changes to Shade are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.8.2] — 2026-05-08 — Per-`from` decrypt serialization + per-connection bridge dedup
Two interlocking robustness fixes for the first-contact / duplicate-fan-out
class of failures Prism reported. Either fix on its own would help; together
they make the receiver path tolerant of any combination of relay duplicates
and concurrent dispatchers.
**(1) `Shade.receive(from, env)` now serializes its ratchet/storage
step per `from`.** The send path has had a per-address `encryptChains`
mutex since V1 — receive did not. Concurrent decrypts for the same peer
raced the `SessionManager` ratchet (mutated in place) and the
`StorageProvider` (which is not required to be a concurrent-safe
writer — `bun:sqlite` throws `database is locked`, IndexedDB throws
transaction conflicts). Symptom in production: a single relay PUT that
fans out 8× over a WS bridge gets dispatched as 8 parallel
`shade.receive` calls; one wins the X3DH prekey race, the other 7 fail
with `database is locked` or `one-time prekey not found: <id>`, and the
post-decrypt side effects (`markPeerVerified`,
`BroadcastChannel.addMember`, paired-reply `inbox.send`) get lost in
the rubble. The decrypt step is now chained off a per-`from` promise
queue. Crucially, the user-facing **message handlers run outside the
queue** — streams + file-RPC issue nested `shade.receive` calls for the
same peer from inside their handlers (e.g. `stream-end` arrives while a
write-RPC is still waiting on chunks), and holding the queue across the
handler would self-deadlock. Only the atomic ratchet+storage step is
protected.
**(2) Bridge handlers (WS + SSE) now run a per-connection msgId
LRU dedup.** Cursor-based delivery already de-duplicates in the happy
path, but the gate is a defense-in-depth against any subtle re-entry of
`flushTo` (event-storm, future refactor, fallback-timer race). The chain
that drives flush is now also wrapped in `.catch(() => {})` so a
transient `ws.send` / SSE write rejection doesn't poison every future
push on the connection.
Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
handshake is unblocked even when the receiver runs multiple bridges or
the relay double-fires `inbox.blob_stored`.
### Fixed
#### `@shade/sdk` — `Shade.receive` per-`from` serialization
- `Shade` gains a private `decryptChains: Map<string, Promise<unknown>>`
mirroring the existing `encryptChains` on the send path.
- `Shade.receive(from, env)` chains its `manager.decrypt(from, env)`
call off the prior decrypt promise for the same `from`. The
post-decrypt control-plaintext check and user `messageHandlers` run
*outside* the chain so nested `shade.receive` calls from inside a
handler don't self-deadlock (streams + file-RPC depend on this).
- The stored chain is `decryptPromise.catch(() => undefined)` so a
rejection in one decrypt doesn't sabotage the next; this caller
still sees its own rejection through the original promise.
- External signature unchanged.
#### `@shade/inbox-server` — bridge per-connection msgId dedup
- New internal `DeliveredIdLru` (4096-entry bounded set, FIFO eviction)
per WS / SSE connection. `flushTo` skips emit when a row's `msgId` is
already in the LRU. Long-poll handlers don't need it (each request is
isolated).
- `pendingFlushPromise` chains in both WS and SSE handlers now
terminate in `.catch(() => {})` so a transient emit failure doesn't
silently kill the connection's flush loop.
### Tests
- `packages/shade-transport-bridge/tests/bridge.test.ts` — new
"Bridge dedup" describe block: storms `inbox.blob_stored` 10× for one
PUT and asserts WS / SSE both deliver exactly one frame.
- `packages/shade-sdk/tests/sdk.test.ts` — new
"concurrent receive(from, env) for same `from` does not race the
ratchet" exercises 8 parallel `bob.receive('alice', env)` for the
same envelope and asserts:
1. at least one fulfills with the right plaintext;
2. no rejection mentions `database is locked`;
3. the next legitimate message still decrypts (ratchet intact).
### Migration
None. Drop-in. Bridges and receivers behave identically on non-
duplicate paths; the new gates only kick in when a duplicate would
otherwise have been emitted / dispatched.
## [4.8.1] — 2026-05-08 — `SHADE_DISABLE_RATE_LIMIT` env var for single-tenant deploys
The standalone server's `routes.ts` and `inbox-server`'s