fix: harden resume and SSE replay

This commit is contained in:
User
2026-07-15 00:06:00 +02:00
parent e5366d14a6
commit 2b4797f133
18 changed files with 871 additions and 102 deletions
+68 -24
View File
@@ -1,48 +1,92 @@
type Send = (event: string, data: object) => void;
type Send = (event: string, data: object, id: number) => void;
/**
* Per-session ring buffer of recent events.
*
* When the browser opens the SSE connection slightly after session creation
* (React re-render, navigation, etc.), events produced by the Pi process in
* that gap would be lost. The buffer replays them to late subscribers.
*
* `system_event` with `event: "agent_end"` acts as the natural sentinel:
* once a subscriber sees it, the buffer for that session is safe to clear.
* Event ids are transport identity: they are monotonically increasing for the lifetime of a
* session id, including across a cold Resume that clears the old buffer and subscribers.
*/
const BUFFER_LIMIT = 200;
interface BufferedEvent { event: string; data: object }
interface BufferedEvent {
id: number;
event: string;
data: object;
}
interface SubscribeOptions {
afterId?: number;
pending?: object | null;
}
function descriptorId(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const id = (value as { id?: unknown }).id;
return typeof id === "string" && id.trim() !== "" ? id : null;
}
function bufferedGateId(item: BufferedEvent): string | null {
if (item.event !== "ui_request") return null;
const request = (item.data as { ui_request?: unknown }).ui_request;
return descriptorId(request);
}
export class SseHub {
private subs = new Map<string, Set<Send>>();
private buffers = new Map<string, BufferedEvent[]>();
private lastIds = new Map<string, number>();
subscribe(sessionId: string, send: Send, pending?: object | null): () => void {
subscribe(
sessionId: string,
send: Send,
options: SubscribeOptions = {},
): () => void {
if (!this.subs.has(sessionId)) this.subs.set(sessionId, new Set());
this.subs.get(sessionId)!.add(send);
// Replay buffered events so late subscribers don't miss the session lifecycle.
const buf = this.buffers.get(sessionId);
if (buf) for (const { event, data } of buf) send(event, data);
const afterId = Number.isSafeInteger(options.afterId) && (options.afterId ?? 0) >= 0
? options.afterId ?? 0
: 0;
const buf = this.buffers.get(sessionId) ?? [];
for (const item of buf) {
if (item.id > afterId) send(item.event, item.data, item.id);
}
// A pending gate normally already exists in the buffer. If ring-buffer eviction removed it,
// give the fallback a fresh transport id and buffer it for future cursor-based reconnects.
// Matching is by stable descriptor identity only, never by event content.
const pendingId = descriptorId(options.pending);
const pendingBuffered = pendingId !== null && buf.some((item) => bufferedGateId(item) === pendingId);
if (pendingId !== null && !pendingBuffered) {
const data = { type: "ui_request", ui_request: options.pending! };
const item = this.buffer(sessionId, "ui_request", data);
send(item.event, item.data, item.id);
}
// Match the live-event shape (hub.publish sends the full ClientEvent):
// both carry { type, ui_request } so re-emit and live widgets are identical.
if (pending) send("ui_request", { type: "ui_request", ui_request: pending });
return () => this.subs.get(sessionId)?.delete(send);
}
publish(sessionId: string, event: string, data: object): void {
// Buffer the event for late subscribers.
let buf = this.buffers.get(sessionId);
if (!buf) { buf = []; this.buffers.set(sessionId, buf); }
buf.push({ event, data });
if (buf.length > BUFFER_LIMIT) buf.splice(0, buf.length - BUFFER_LIMIT);
for (const s of this.subs.get(sessionId) ?? []) s(event, data);
publish(sessionId: string, event: string, data: object): number {
const item = this.buffer(sessionId, event, data);
for (const send of this.subs.get(sessionId) ?? []) send(event, data, item.id);
return item.id;
}
/** Clear buffer and subscribers for a finished session. */
private buffer(sessionId: string, event: string, data: object): BufferedEvent {
const id = (this.lastIds.get(sessionId) ?? 0) + 1;
this.lastIds.set(sessionId, id);
let buf = this.buffers.get(sessionId);
if (!buf) {
buf = [];
this.buffers.set(sessionId, buf);
}
const item = { id, event, data };
buf.push(item);
if (buf.length > BUFFER_LIMIT) buf.splice(0, buf.length - BUFFER_LIMIT);
return item;
}
/** Clear buffered/runtime bindings while retaining session event-id monotonicity. */
clear(sessionId: string): void {
this.buffers.delete(sessionId);
this.subs.delete(sessionId);