fix(backend): generation-aware SSE event ids — stale cursors can no longer eat events
Audit finding 3.1 (high, 3/3 reviewer consensus). Event ids restart at 1 when the backend restarts; a browser auto-reconnect carrying the old numeric Last-Event-ID was honored whenever the new process had already emitted that many events, silently suppressing fresh events (same ids, different content). The previous guard only caught cursor > lastId. Wire ids are now "<generation>:<seq>" (generation = per-hub instance token; seq = the existing per-session monotonic counter). The hub parses raw header/query candidates itself: other-generation and legacy bare- number cursors are stale → replay from the beginning; same-generation cursors keep the newest-valid-wins behavior. EventSource treats ids as opaque, so no frontend change. Finding 3.2 (eviction) resolved by NOT evicting: close keeps the seq counter on purpose (sessions reopen; monotonicity is what makes old cursors detectable) — documented at the call site; buffers are emptied by clear() and ring-bounded at 200. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,16 +16,6 @@ const RESUME_FAILURE_MESSAGE =
|
||||
const DWH_UNREACHABLE_MESSAGE =
|
||||
"Cannot start a session: the data warehouse is unreachable. Check the VPN connection and try again.";
|
||||
|
||||
function eventCursor(...values: unknown[]): number {
|
||||
let cursor = 0;
|
||||
for (const value of values.flatMap((item) => Array.isArray(item) ? item : [item])) {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) continue;
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) cursor = Math.max(cursor, parsed);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
export function sessionRoutes(
|
||||
app: FastifyInstance,
|
||||
d: {
|
||||
@@ -370,6 +360,9 @@ export function sessionRoutes(
|
||||
} catch {
|
||||
return storageFailure(reply);
|
||||
} finally {
|
||||
// clear, NOT forget: a closed session can be reopened, and the per-session seq
|
||||
// monotonicity is what keeps a browser's old cursor detectable. The buffer is
|
||||
// emptied here; only delete discards the id counter.
|
||||
d.hub.clear(id);
|
||||
}
|
||||
return { closed: true };
|
||||
@@ -383,10 +376,6 @@ export function sessionRoutes(
|
||||
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
|
||||
} catch { return storageFailure(reply); }
|
||||
const rt = d.mgr.get(id);
|
||||
const afterId = eventCursor(
|
||||
req.headers["last-event-id"],
|
||||
(req.query as { lastEventId?: unknown }).lastEventId,
|
||||
);
|
||||
// Add CORS headers manually: reply.raw.writeHead bypasses Fastify's onSend hooks
|
||||
// (where @fastify/cors injects headers), so we must set them explicitly here.
|
||||
const origin = (req.headers.origin as string | undefined) ?? "*";
|
||||
@@ -401,10 +390,12 @@ export function sessionRoutes(
|
||||
// Send the handshake immediately. Without this, Node waits for the first event body and
|
||||
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
|
||||
reply.raw.flushHeaders();
|
||||
const send = (event: string, data: object, eventId: number) =>
|
||||
const send = (event: string, data: object, eventId: string) =>
|
||||
reply.raw.write(`id: ${eventId}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
const off = d.hub.subscribe(id, send, {
|
||||
afterId,
|
||||
// Raw cursor candidates: the hub parses "<generation>:<seq>" and treats any
|
||||
// other-generation (or legacy numeric) cursor as stale → replay from the start.
|
||||
after: [req.headers["last-event-id"], (req.query as { lastEventId?: unknown }).lastEventId],
|
||||
pending: rt?.bridge.pendingWidget() ?? null,
|
||||
// clear()/forget() end every old transport so native EventSource reconnects with its
|
||||
// Last-Event-ID instead of remaining attached to a subscriber callback that no longer exists.
|
||||
|
||||
Reference in New Issue
Block a user