Skip to content

fix(sse): allocate event ids atomically in the session store (#317) - #325

Open
mcollina wants to merge 1 commit into
mainfrom
fix/317-atomic-sse-event-ids
Open

mcollina wants to merge 1 commit into
mainfrom
fix/317-atomic-sse-event-ids

Conversation

@mcollina

Copy link
Copy Markdown
Member

Problem

sendSSEToStreams picked the next SSE event id by reading the session, bumping eventId on that copy, and then passing the result to addMessage. Broadcast delivery doesn't await this, so deliveries that overlap all read the same counter. Four mcpBroadcastNotification calls in a Promise.all sent id: 1 four times, on both the memory and the Redis backend. That makes Last-Event-ID resumption ambiguous. On Redis it also loses history, because XADD rejects the duplicate stream ids.

Fix

The store now allocates the event id. SessionStore.addMessage(sessionId, message) assigns the next id, records the message under it, and returns the id. It returns null when the session no longer exists.

  • MemorySessionStore: the counter is incremented on the stored session object, synchronously and before any await, so concurrent callers can't see the same value.
  • RedisSessionStore: a single Lua script does HINCRBY eventId, then XADD under that id, then XTRIM, then updates lastEventId/lastActivity and refreshes the TTL. Because the id is allocated and appended in one step, stream ids also stay ascending. If allocation and append were separate calls, XADD could still reject a lower id that arrives late. The script returns early when the session hash is gone, so an expired session is no longer recreated without a TTL. The old pipeline did recreate it.
  • routes/mcp.ts: both call sites (live streams, and history-only delivery when no stream is attached) now use the id that the store returns.

addMessage changes signature: the eventId argument is removed and the method now returns the id. SessionStore is exported as a type, but the plugin only ever builds its own stores, so no option for plugging in a custom store breaks.

Tests

  • test/sse-event-id-concurrency.test.ts (new) runs the repro from the issue against both backends. It opens a session and a GET stream, sends 4 concurrent broadcasts, and asserts the ids are unique and equal to 1..4. It then reconnects with Last-Event-ID: 1 and asserts that 2, 3 and 4 are replayed. Before the fix it failed with ["1","1","1","1"] on both backends.
  • In test/memory-session-store.test.ts and test/redis-session-store.test.ts, concurrent addMessage calls get distinct ids and none are lost from history, and addMessage on a missing session returns null. The Redis test also checks that no keys get created.
  • The existing store tests are updated to the new signature.

npm run lint, npm run typecheck and npm run test (772 tests) all pass.

Fixes #317

🤖 Generated with Claude Code

https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

sendSSEToStreams read the session, bumped eventId on that copy and then
called addMessage with the result. Broadcast delivery does not await it,
so overlapping deliveries read the same counter and every event of a
burst went out as `id: 1`. That made Last-Event-ID resumption ambiguous,
and on Redis the duplicate XADD ids were rejected, losing history.

SessionStore.addMessage(sessionId, message) now allocates the next id
itself and returns it (null when the session is gone):

- MemorySessionStore increments the counter on the stored session object
  synchronously, before any await.
- RedisSessionStore runs one Lua script: HINCRBY the counter, XADD under
  that id, XTRIM, update lastEventId/lastActivity and the TTL. Doing the
  allocation and the append in one step also keeps stream ids ascending,
  and the script no longer recreates a session hash that has expired.

Both call sites in routes/mcp.ts use the returned id.

Fixes #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent broadcasts give a legacy SSE session duplicate event ids

1 participant