fix: serialize session lifecycle transitions

This commit is contained in:
User
2026-07-15 01:37:42 +02:00
parent 08b1f4909e
commit 6747f6f8a0
10 changed files with 1402 additions and 101 deletions
+62
View File
@@ -55,6 +55,17 @@ async function captureReplay(options: { query?: string; lastEventId?: string }):
}
}
async function expectStreamEnd(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
const result = await Promise.race([
reader.read(),
new Promise<{ timeout: true }>((resolve) =>
setTimeout(() => resolve({ timeout: true }), 1_000),
),
]);
expect(result).not.toEqual({ timeout: true });
expect("done" in result && result.done).toBe(true);
}
test("SSE emits ids and honors the native Last-Event-ID replay cursor", async () => {
const body = await captureReplay({ lastEventId: "1" });
@@ -80,3 +91,54 @@ test("SSE uses the newer valid cursor when header and query are both present", a
expect(body).not.toContain('"two"');
expect(body).toContain("id: 3\nevent: info\n");
});
test("clear ends every live SSE response and a cursor reconnect replays post-clear events", async () => {
const hub = new SseHub();
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }), {
hub,
thtRunner: {} as any,
});
await app.listen({ port: 0, host: "127.0.0.1" });
const port = (app.server.address() as { port: number }).port;
const controllers = [new AbortController(), new AbortController(), new AbortController()];
try {
const responses = await Promise.all(controllers.slice(0, 2).map((controller) => fetch(
`http://127.0.0.1:${port}/sessions/s1/events`,
{ signal: controller.signal },
)));
const readers = responses.map((response) => response.body!.getReader());
expect(hub.publish("s1", "info", { type: "info", text: "before" })).toBe(1);
const initial = await Promise.all(readers.map((reader) =>
readUntil(reader, (text) => text.includes('"before"'))));
expect(initial.every((body) => body.includes("id: 1\nevent: info\n"))).toBe(true);
hub.clear("s1");
await Promise.all(readers.map(expectStreamEnd));
expect(hub.publish("s1", "info", { type: "info", text: "after" })).toBe(2);
expect(hub.publish("s1", "ui_request", {
type: "ui_request",
ui_request: { id: "gate-1", widget: "select" },
})).toBe(3);
const reconnected = await fetch(
`http://127.0.0.1:${port}/sessions/s1/events`,
{
headers: { "Last-Event-ID": "1" },
signal: controllers[2].signal,
},
);
const reconnectReader = reconnected.body!.getReader();
const replay = await readUntil(reconnectReader, (text) => text.includes('"gate-1"'));
expect(replay).toContain("id: 2\nevent: info\n");
expect(replay).toContain('data: {"type":"info","text":"after"}');
expect(replay).toContain("id: 3\nevent: ui_request\n");
expect(replay).toContain('"ui_request":{"id":"gate-1","widget":"select"}');
await reconnectReader.cancel();
} finally {
for (const controller of controllers) controller.abort();
await app.close();
}
});