release(v4.8.2): per-from receive serialization + per-connection bridge dedup
Some checks failed
Test / test (push) Has been cancelled
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:
81
CHANGELOG.md
81
CHANGELOG.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user