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:
@@ -1,6 +1,12 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { SseHub } from "../src/sse/sse-hub.js";
|
||||
|
||||
// Wire ids are "<generation>:<seq>": seq is the per-session monotonic counter,
|
||||
// generation identifies the hub instance (process). Tests derive both from
|
||||
// published ids instead of hardcoding the random generation.
|
||||
const seqOf = (id: string) => Number(id.split(":")[1]);
|
||||
const genOf = (id: string) => id.split(":")[0];
|
||||
|
||||
test("publish assigns monotonic ids and a subscriber replays only ids newer than its cursor", () => {
|
||||
const hub = new SseHub();
|
||||
const firstId = hub.publish("s1", "text_delta", { text: "one" });
|
||||
@@ -10,14 +16,15 @@ test("publish assigns monotonic ids and a subscriber replays only ids newer than
|
||||
hub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => sent.push({ event, data, id }),
|
||||
{ afterId: firstId },
|
||||
{ after: [firstId] },
|
||||
);
|
||||
const thirdId = hub.publish("s1", "text_delta", { text: "three" });
|
||||
|
||||
expect([firstId, secondId, thirdId]).toEqual([1, 2, 3]);
|
||||
expect([firstId, secondId, thirdId].map(seqOf)).toEqual([1, 2, 3]);
|
||||
expect(new Set([firstId, secondId, thirdId].map(genOf)).size).toBe(1);
|
||||
expect(sent).toEqual([
|
||||
{ event: "text_delta", data: { text: "two" }, id: 2 },
|
||||
{ event: "text_delta", data: { text: "three" }, id: 3 },
|
||||
{ event: "text_delta", data: { text: "two" }, id: secondId },
|
||||
{ event: "text_delta", data: { text: "three" }, id: thirdId },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -38,33 +45,36 @@ test("clear closes every stale subscriber once and replays post-clear events fro
|
||||
(event, data, id) => secondStale.push({ event, data, id }),
|
||||
{ close: () => { closeCalls[1] += 1; offSecond(); } },
|
||||
);
|
||||
expect(hub.publish("s1", "info", { text: "before" })).toBe(1);
|
||||
const beforeId = hub.publish("s1", "info", { text: "before" });
|
||||
expect(seqOf(beforeId)).toBe(1);
|
||||
|
||||
hub.clear("s1");
|
||||
offFirst();
|
||||
offSecond();
|
||||
expect(closeCalls).toEqual([1, 1]);
|
||||
expect(hub.publish("s1", "info", { text: "after" })).toBe(2);
|
||||
expect(hub.publish("s1", "ui_request", {
|
||||
const afterId = hub.publish("s1", "info", { text: "after" });
|
||||
expect(seqOf(afterId)).toBe(2);
|
||||
const gateId = hub.publish("s1", "ui_request", {
|
||||
type: "ui_request",
|
||||
ui_request: { id: "gate-1", widget: "select" },
|
||||
})).toBe(3);
|
||||
});
|
||||
expect(seqOf(gateId)).toBe(3);
|
||||
const resumed: any[] = [];
|
||||
hub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => resumed.push({ event, data, id }),
|
||||
{ afterId: 1 },
|
||||
{ after: [beforeId] },
|
||||
);
|
||||
|
||||
const before = [{ event: "info", data: { text: "before" }, id: 1 }];
|
||||
const before = [{ event: "info", data: { text: "before" }, id: beforeId }];
|
||||
expect(firstStale).toEqual(before);
|
||||
expect(secondStale).toEqual(before);
|
||||
expect(resumed).toEqual([
|
||||
{ event: "info", data: { text: "after" }, id: 2 },
|
||||
{ event: "info", data: { text: "after" }, id: afterId },
|
||||
{
|
||||
event: "ui_request",
|
||||
data: { type: "ui_request", ui_request: { id: "gate-1", widget: "select" } },
|
||||
id: 3,
|
||||
id: gateId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -86,95 +96,118 @@ test("forget closes every subscriber once and resets the per-session id sequence
|
||||
(event, data, id) => secondStale.push({ event, data, id }),
|
||||
{ close: () => { closeCalls[1] += 1; offSecond(); } },
|
||||
);
|
||||
expect(hub.publish("s1", "info", { text: "before" })).toBe(1);
|
||||
const beforeId = hub.publish("s1", "info", { text: "before" });
|
||||
expect(seqOf(beforeId)).toBe(1);
|
||||
|
||||
hub.forget("s1");
|
||||
expect(closeCalls).toEqual([1, 1]);
|
||||
expect(hub.publish("s1", "info", { text: "after" })).toBe(1);
|
||||
const afterId = hub.publish("s1", "info", { text: "after" });
|
||||
expect(seqOf(afterId)).toBe(1);
|
||||
const fresh: any[] = [];
|
||||
hub.subscribe("s1", (event, data, id) => fresh.push({ event, data, id }));
|
||||
|
||||
const before = [{ event: "info", data: { text: "before" }, id: 1 }];
|
||||
const before = [{ event: "info", data: { text: "before" }, id: beforeId }];
|
||||
expect(firstStale).toEqual(before);
|
||||
expect(secondStale).toEqual(before);
|
||||
expect(fresh).toEqual([{ event: "info", data: { text: "after" }, id: 1 }]);
|
||||
expect(fresh).toEqual([{ event: "info", data: { text: "after" }, id: afterId }]);
|
||||
});
|
||||
|
||||
test("a buffered pending gate is emitted exactly once and matched by descriptor id", () => {
|
||||
const hub = new SseHub();
|
||||
const descriptor = { id: "gate-1", widget: "select", title: "Original" };
|
||||
hub.publish("s1", "ui_request", { type: "ui_request", ui_request: descriptor });
|
||||
const gateId = hub.publish("s1", "ui_request", { type: "ui_request", ui_request: descriptor });
|
||||
const replayed: any[] = [];
|
||||
|
||||
hub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => replayed.push({ event, data, id }),
|
||||
{ afterId: 0, pending: { ...descriptor, title: "Runtime copy" } },
|
||||
{ after: [], pending: { ...descriptor, title: "Runtime copy" } },
|
||||
);
|
||||
|
||||
expect(replayed).toEqual([{
|
||||
event: "ui_request",
|
||||
data: { type: "ui_request", ui_request: descriptor },
|
||||
id: 1,
|
||||
id: gateId,
|
||||
}]);
|
||||
|
||||
const alreadySeen: any[] = [];
|
||||
hub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => alreadySeen.push({ event, data, id }),
|
||||
{ afterId: 1, pending: descriptor },
|
||||
{ after: [gateId], pending: descriptor },
|
||||
);
|
||||
expect(alreadySeen).toEqual([]);
|
||||
});
|
||||
|
||||
test("an unbuffered pending gate receives one fresh buffered id", () => {
|
||||
const hub = new SseHub();
|
||||
hub.publish("s1", "info", { text: "ready" });
|
||||
const readyId = hub.publish("s1", "info", { text: "ready" });
|
||||
const first: any[] = [];
|
||||
|
||||
hub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => first.push({ event, data, id }),
|
||||
{ afterId: 1, pending: { id: "gate-1", widget: "select" } },
|
||||
{ after: [readyId], pending: { id: "gate-1", widget: "select" } },
|
||||
);
|
||||
expect(first).toEqual([{
|
||||
event: "ui_request",
|
||||
data: { type: "ui_request", ui_request: { id: "gate-1", widget: "select" } },
|
||||
id: 2,
|
||||
id: `${genOf(readyId)}:2`,
|
||||
}]);
|
||||
|
||||
const reconnect: any[] = [];
|
||||
hub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => reconnect.push({ event, data, id }),
|
||||
{ afterId: 1, pending: { id: "gate-1", widget: "select" } },
|
||||
{ after: [readyId], pending: { id: "gate-1", widget: "select" } },
|
||||
);
|
||||
expect(reconnect).toEqual(first);
|
||||
});
|
||||
|
||||
test("a cursor newer than this hub generation replays low ids and a buffered gate once", () => {
|
||||
const hub = new SseHub();
|
||||
const descriptor = { id: "gate-fresh", widget: "select", title: "Choose" };
|
||||
hub.publish("s1", "info", { type: "info", text: "fresh generation" });
|
||||
hub.publish("s1", "ui_request", { type: "ui_request", ui_request: descriptor });
|
||||
test("a cursor from a previous hub generation is stale and replays from the beginning", () => {
|
||||
// Simulates a backend restart: the browser auto-reconnects with the Last-Event-ID it
|
||||
// got from the OLD process. The new hub must ignore it even when its own seqs have
|
||||
// already reached (or passed) that number — same ids, different content.
|
||||
const oldHub = new SseHub();
|
||||
oldHub.publish("s1", "info", { text: "old-1" });
|
||||
const oldCursor = oldHub.publish("s1", "info", { text: "old-2" });
|
||||
|
||||
const newHub = new SseHub();
|
||||
const freshFirst = newHub.publish("s1", "info", { text: "new-1" });
|
||||
const freshSecond = newHub.publish("s1", "info", { text: "new-2" });
|
||||
const replayed: any[] = [];
|
||||
|
||||
hub.subscribe(
|
||||
newHub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => replayed.push({ event, data, id }),
|
||||
{ afterId: 900, pending: { ...descriptor } },
|
||||
{ after: [oldCursor] },
|
||||
);
|
||||
|
||||
expect(replayed).toEqual([
|
||||
{
|
||||
event: "info",
|
||||
data: { type: "info", text: "fresh generation" },
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
event: "ui_request",
|
||||
data: { type: "ui_request", ui_request: descriptor },
|
||||
id: 2,
|
||||
},
|
||||
{ event: "info", data: { text: "new-1" }, id: freshFirst },
|
||||
{ event: "info", data: { text: "new-2" }, id: freshSecond },
|
||||
]);
|
||||
});
|
||||
|
||||
test("legacy bare-number and too-new same-generation cursors replay from the beginning", () => {
|
||||
const hub = new SseHub();
|
||||
const descriptor = { id: "gate-fresh", widget: "select", title: "Choose" };
|
||||
const firstId = hub.publish("s1", "info", { type: "info", text: "fresh generation" });
|
||||
const gateId = hub.publish("s1", "ui_request", { type: "ui_request", ui_request: descriptor });
|
||||
const expected = [
|
||||
{ event: "info", data: { type: "info", text: "fresh generation" }, id: firstId },
|
||||
{ event: "ui_request", data: { type: "ui_request", ui_request: descriptor }, id: gateId },
|
||||
];
|
||||
|
||||
const legacy: any[] = [];
|
||||
hub.subscribe("s1", (event, data, id) => legacy.push({ event, data, id }), { after: ["2"] });
|
||||
expect(legacy).toEqual(expected);
|
||||
|
||||
const tooNew: any[] = [];
|
||||
hub.subscribe(
|
||||
"s1",
|
||||
(event, data, id) => tooNew.push({ event, data, id }),
|
||||
{ after: [`${genOf(firstId)}:900`], pending: { ...descriptor } },
|
||||
);
|
||||
expect(tooNew).toEqual(expected);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user