fix(sessions): harden notification inbox reconnects

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 01:49:06 +02:00
parent ada6f0ce1f
commit 503c2c743d
9 changed files with 402 additions and 43 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep extension notifications discoverable in a session-scoped inbox with background badges, reconnect recovery, and explicit dismissal.
+2 -2
View File
@@ -399,8 +399,8 @@ export class ChatView extends LitElement {
const polite = announcements.filter((announcement) => announcement.severity !== "error");
const assertive = announcements.filter((announcement) => announcement.severity === "error");
return html`
<div class="visually-hidden notification-live" aria-live="polite" aria-atomic="false">${polite.map((announcement) => html`<span data-announcement-id=${announcement.id}>${notificationSeverityLabel(announcement.severity)} notification: ${announcement.message}</span>`)}</div>
<div class="visually-hidden notification-live" aria-live="assertive" aria-atomic="false">${assertive.map((announcement) => html`<span data-announcement-id=${announcement.id}>Error notification: ${announcement.message}</span>`)}</div>
<div class="visually-hidden notification-live" aria-live="polite" aria-atomic="false">${repeat(polite, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>${notificationSeverityLabel(announcement.severity)} notification: ${announcement.message}</span>`)}</div>
<div class="visually-hidden notification-live" aria-live="assertive" aria-atomic="false">${repeat(assertive, (announcement) => announcement.id, (announcement) => html`<span data-announcement-id=${announcement.id}>Error notification: ${announcement.message}</span>`)}</div>
`;
}
@@ -33,6 +33,42 @@ function inboxEvent(): SessionNotificationInboxEvent {
}
describe("SessionController notification event boundary", () => {
it("refetches the bounded notification snapshot when the selected socket first opens", async () => {
const socket = new EmitSocket();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const refreshSelectedSession = vi.fn(() => Promise.resolve());
const bridge: SessionNotificationSessionBridge = {
prepareSelectedSession: vi.fn(),
clearSelectedSession: vi.fn(),
refreshSelectedSession,
applyInboxEvent: vi.fn(),
shouldFilterLegacyNotification: vi.fn(() => true),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{
socket,
notifications: bridge,
api: {
...defaultApi,
messages: vi.fn(() => Promise.resolve(emptyPage)),
status: vi.fn(() => Promise.resolve(status(oldSession.id))),
streamSnapshot: vi.fn(() => Promise.resolve({ seq: 0, partial: null })),
},
},
);
await controller.selectSession(oldSession, { updateUrl: false });
expect(refreshSelectedSession).toHaveBeenCalledOnce();
socket.open();
expect(refreshSelectedSession).toHaveBeenCalledTimes(2);
expect(refreshSelectedSession).toHaveBeenLastCalledWith(oldSession, "local");
});
it("handles inbox events before transcript watermarking and filters only marked legacy output with support", async () => {
const socket = new EmitSocket();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
@@ -55,10 +55,18 @@ export class FakeSocket implements SessionEventSocket {
export class EmitSocket implements SessionEventSocket {
readonly connectedSessionIds: string[] = [];
private handler: ((event: SessionUiEvent) => void) | undefined;
private onInitialOpen: (() => void) | undefined;
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void {
connect(
session: SessionRef,
onEvent: (event: SessionUiEvent) => void,
_onReconnect?: () => void,
_machineId?: string,
onInitialOpen?: () => void,
): void {
this.connectedSessionIds.push(session.id);
this.handler = onEvent;
this.onInitialOpen = onInitialOpen;
}
setHandler(onEvent: (event: SessionUiEvent) => void): void {
@@ -69,8 +77,13 @@ export class EmitSocket implements SessionEventSocket {
this.handler?.(event);
}
open(): void {
this.onInitialOpen?.();
}
close(): void {
this.handler = undefined;
this.onInitialOpen = undefined;
}
}
@@ -20,7 +20,13 @@ const MESSAGE_PAGE_SIZE = 100;
const BULK_FALLBACK_CONCURRENCY = 4;
export interface SessionEventSocket {
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
connect(
session: SessionRef,
onEvent: (event: SessionUiEvent) => void,
onReconnect?: () => void,
machineId?: string,
onInitialOpen?: () => void,
): void;
setHandler(onEvent: (event: SessionUiEvent) => void): void;
close(): void;
}
@@ -206,7 +212,8 @@ export class SessionController {
session,
(event) => buffered.push(event),
() => { void this.refreshSelectedSession(session.id); },
selectedMachineId(this.getState()),
machineId,
() => { void this.notifications?.refreshSelectedSession(session, machineId); },
);
await this.requestSelectedSessionRefresh({ session, machineId, selectionSeq: seq });
if (!this.isCurrentRefreshTarget({ session, machineId, selectionSeq: seq })) return;
@@ -123,7 +123,12 @@ function createHarness(initialState = capableState(), overrides: Partial<Session
(patch) => { state = { ...state, ...patch }; },
{ api, onBackgroundError: vi.fn() },
);
return { controller, api, get state() { return state; } };
return {
controller,
api,
get state() { return state; },
replaceState(next: AppState) { state = next; },
};
}
describe("SessionNotificationController capability and joins", () => {
@@ -184,6 +189,66 @@ describe("SessionNotificationController capability and joins", () => {
expect(harness.controller.shouldFilterLegacyNotification("local", "notification-1")).toBe(true);
});
it("lets the selected live event announce before its matching global summary can trigger a snapshot", async () => {
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const state = {
...capableState(),
notificationCatalogsByMachine: {
local: {
machineId: "local",
status: "fresh" as const,
daemonInstanceId: first.daemonInstanceId,
catalogRevision: first.catalogRevision,
summariesBySessionId: { [session.id]: first.summary },
},
},
};
const notificationInbox = vi.fn(() => Promise.resolve(first));
const harness = createHarness(state, { notificationInbox });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
const event = addedEvent(entry(2, "warning"), 2, 2);
harness.controller.applySummaryEvent("local", {
type: "notifications.summary",
daemonInstanceId: event.daemonInstanceId,
catalogRevision: event.catalogRevision,
summary: event.summary,
});
await Promise.resolve();
expect(notificationInbox).toHaveBeenCalledOnce();
harness.controller.applyInboxEvent("local", event);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.announcements).toMatchObject([
{ severity: "warning", message: "notice 2" },
]);
expect(notificationInbox).toHaveBeenCalledOnce();
});
it("refetches the selected inbox when a newly opened global socket finds a newer catalog revision", async () => {
const first = inboxSnapshot([entry(1)], { inboxRevision: 1, catalogRevision: 1 });
const second = inboxSnapshot([entry(2, "error"), entry(1)], { inboxRevision: 2, catalogRevision: 2 });
const notificationInbox = vi.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second);
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
daemonInstanceId: second.daemonInstanceId,
catalogRevision: second.catalogRevision,
sessions: [second.summary],
}));
const harness = createHarness(capableState(), { notificationInbox, notificationCatalog });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
harness.controller.globalSocketOpened("local");
await vi.waitFor(() => { expect(notificationInbox).toHaveBeenCalledTimes(2); });
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications.map((notification) => notification.id)).toEqual([
"daemon-a:2",
"daemon-a:1",
]);
});
it("hydrates missing selected-machine project workspaces without changing selection", async () => {
const project = { id: "project-1", name: "Repo", path: "/repo", createdAt: "now" };
const next = {
@@ -224,19 +289,70 @@ describe("SessionNotificationController capability and joins", () => {
expect(harness.state.selectedNotificationInbox).toMatchObject({ sessionId: "session-2", status: "loading", notifications: [] });
});
it("hides a selected remote inbox when the machine becomes unreachable and ignores an in-flight snapshot", async () => {
const remoteMachine: Machine = { ...localMachine, id: "remote-a", name: "Remote", kind: "remote", baseUrl: "https://remote.example.test/" };
const runtime = capableState().machineRuntimes["local"];
if (runtime === undefined) throw new Error("expected capable runtime fixture");
const initial = {
...capableState(),
machines: [remoteMachine],
selectedMachine: remoteMachine,
machineRuntimes: { [remoteMachine.id]: { ...runtime, machineId: remoteMachine.id } },
};
const pendingInbox = deferred<SessionNotificationInboxSnapshot>();
const notificationInbox = vi.fn()
.mockResolvedValueOnce(inboxSnapshot())
.mockImplementationOnce(() => pendingInbox.promise);
const harness = createHarness(initial, { notificationInbox });
harness.controller.prepareSelectedSession(session, remoteMachine.id);
await harness.controller.refreshSelectedSession(session, remoteMachine.id);
expect(selectedNotificationView(harness.state.selectedNotificationInbox)?.notifications).toHaveLength(1);
const refresh = harness.controller.refreshSelectedSession(session, remoteMachine.id);
const previous = harness.state;
const offline: AppState = {
...previous,
machineStatuses: {
[remoteMachine.id]: {
machineId: remoteMachine.id,
ok: false,
checkedAt: "2026-07-18T00:01:00.000Z",
status: "offline",
},
},
};
harness.replaceState(offline);
harness.controller.syncEnvironment(previous, offline);
expect(harness.state.selectedNotificationInbox?.status).toBe("stale");
expect(selectedNotificationView(harness.state.selectedNotificationInbox)).toBeUndefined();
pendingInbox.resolve(inboxSnapshot([entry(2, "error")], { inboxRevision: 2, catalogRevision: 2 }));
await refresh;
expect(harness.state.selectedNotificationInbox?.status).toBe("stale");
expect(selectedNotificationView(harness.state.selectedNotificationInbox)).toBeUndefined();
});
});
describe("SessionNotificationController optimistic mutations", () => {
it("optimistically dismisses one card, reconciles the response, and rolls back/refetches on failure", async () => {
const dismiss = deferred<SessionNotificationInboxSnapshot>();
const refreshAfterFailure = deferred<SessionNotificationInboxSnapshot>();
const initialInbox = inboxSnapshot([entry(2, "warning"), entry(1)]);
const notificationInbox = vi.fn()
.mockResolvedValueOnce(inboxSnapshot([entry(2, "warning"), entry(1)]))
.mockResolvedValueOnce(initialInbox)
.mockImplementationOnce(() => refreshAfterFailure.promise);
const dismissNotification = vi.fn()
.mockImplementationOnce(() => dismiss.promise)
.mockRejectedValueOnce(new Error("offline"));
const harness = createHarness(capableState(), { notificationInbox, dismissNotification });
const notificationCatalog = vi.fn(() => Promise.resolve<SessionNotificationCatalogSnapshot>({
daemonInstanceId: initialInbox.daemonInstanceId,
catalogRevision: initialInbox.catalogRevision,
sessions: [initialInbox.summary],
}));
const harness = createHarness(capableState(), { notificationInbox, dismissNotification, notificationCatalog });
harness.controller.prepareSelectedSession(session, "local");
await harness.controller.refreshSelectedSession(session, "local");
@@ -6,6 +6,7 @@ import {
freshNotificationCatalog,
installSelectedNotificationSnapshot,
loadingSelectedNotificationInbox,
notificationSummaryIsEmpty,
notificationTargetsEqual,
selectedNotificationView,
type SelectedSessionNotificationInbox,
@@ -143,9 +144,11 @@ export class SessionNotificationController {
applyInboxEvent(machineId: string, event: SessionNotificationInboxEvent): void {
this.acceptedSupportByMachine.add(machineId);
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
const target = this.selectedTarget;
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) return;
if (target?.machineId !== machineId || target.sessionId !== event.summary.sessionId || target.cwd !== event.summary.cwd) {
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
return;
}
const join = this.selectedJoin;
if (join?.generation === this.selectedGeneration) {
join.events.push(event);
@@ -153,6 +156,7 @@ export class SessionNotificationController {
}
const result = applySelectedNotificationEvent(this.getState().selectedNotificationInbox, target, event);
if (result.changed) this.setState({ selectedNotificationInbox: result.value });
this.applyCatalogSummary(machineId, inboxSummaryEvent(event));
if (result.needsRefresh) this.scheduleSelectedRefresh(target);
}
@@ -163,7 +167,10 @@ export class SessionNotificationController {
join.events.push(event);
return;
}
this.applyCatalogSummary(machineId, event);
// The matching per-session event carries the notification text and must win
// the live-announcement race. Initial-open and reconnect snapshots still
// reconcile selected state through the catalog join.
this.applyCatalogSummary(machineId, event, false);
this.ensureSelectedSupport(machineId);
}
@@ -193,10 +200,14 @@ export class SessionNotificationController {
else if (!wasEligible || next.notificationCatalogsByMachine[machineId]?.status !== "fresh") void this.refreshCatalog(machineId);
}
const selected = this.selectedTarget;
if (selected !== undefined && this.machineSupportsNotifications(selected.machineId) && this.machineIsReachable(selected.machineId)) {
this.ensureSelectedProjection(selected);
if (!this.machineSupportsNotificationsInState(previous, selected.machineId) || next.selectedNotificationInbox?.status !== "fresh") {
void this.refreshSelectedSession({ id: selected.sessionId, cwd: selected.cwd }, selected.machineId);
if (selected !== undefined) {
if (!this.machineSupportsNotifications(selected.machineId) || !this.machineIsReachable(selected.machineId)) {
this.markSelectedStale(selected);
} else {
this.ensureSelectedProjection(selected);
if (!this.machineSupportsNotificationsInState(previous, selected.machineId) || next.selectedNotificationInbox?.status !== "fresh") {
void this.refreshSelectedSession({ id: selected.sessionId, cwd: selected.cwd }, selected.machineId);
}
}
}
}
@@ -285,16 +296,21 @@ export class SessionNotificationController {
try {
const snapshot = await this.api.notificationInbox({ id: target.sessionId, cwd: target.cwd }, target.machineId);
if (!this.isCurrentTarget(target, operation.generation)) return;
if (!this.machineSupportsNotifications(target.machineId) || !this.machineIsReachable(target.machineId)) {
this.markSelectedStale(target);
return;
}
this.acceptedSupportByMachine.add(target.machineId);
let inbox = installSelectedNotificationSnapshot(this.getState().selectedNotificationInbox, target, snapshot);
this.applyCatalogSummary(target.machineId, snapshotSummaryEvent(snapshot));
const catalogEvents = [snapshotSummaryEvent(snapshot)];
for (const event of [...join.events].sort((left, right) => left.summary.inboxRevision - right.summary.inboxRevision)) {
const result = applySelectedNotificationEvent(inbox, target, event);
inbox = result.value;
this.applyCatalogSummary(target.machineId, inboxSummaryEvent(event));
catalogEvents.push(inboxSummaryEvent(event));
if (result.needsRefresh) operation.trailing = true;
}
this.setState({ selectedNotificationInbox: inbox });
for (const event of catalogEvents) this.applyCatalogSummary(target.machineId, event);
} catch (error) {
if (this.isCurrentTarget(target, operation.generation)) {
const current = this.getState().selectedNotificationInbox;
@@ -350,7 +366,7 @@ export class SessionNotificationController {
} while (operation.trailing && this.machineIsKnown(machineId) && this.machineIsReachable(machineId));
}
private applyCatalogSummary(machineId: string, event: SessionNotificationSummaryEvent): void {
private applyCatalogSummary(machineId: string, event: SessionNotificationSummaryEvent, reconcileSelected = true): void {
const join = this.catalogJoins.get(machineId);
if (join !== undefined) {
join.events.push(event);
@@ -358,7 +374,7 @@ export class SessionNotificationController {
}
const current = this.getState().notificationCatalogsByMachine[machineId];
const result = applyNotificationCatalogEvent(current, machineId, event);
if (result.changed) this.setCatalog(machineId, result.value);
if (result.changed) this.setCatalog(machineId, result.value, reconcileSelected);
if (result.needsRefresh) this.scheduleCatalogRefresh(machineId);
}
@@ -369,6 +385,10 @@ export class SessionNotificationController {
): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target)) return;
if (!this.machineSupportsNotifications(target.machineId) || !this.machineIsReachable(target.machineId)) {
this.setState({ selectedNotificationInbox: { ...removeOverlay(current), status: "stale" } });
return;
}
const shouldInstall = current.daemonInstanceId !== snapshot.daemonInstanceId
|| current.summary === undefined
|| snapshot.summary.inboxRevision >= current.summary.inboxRevision;
@@ -389,6 +409,8 @@ export class SessionNotificationController {
private ensureSelectedSupport(machineId: string): void {
const target = this.selectedTarget;
if (target?.machineId !== machineId) return;
const current = this.getState().selectedNotificationInbox;
if (current !== undefined && notificationTargetsEqual(current, target) && current.status === "fresh") return;
this.ensureSelectedProjection(target);
void this.refreshSelectedSession({ id: target.sessionId, cwd: target.cwd }, machineId);
}
@@ -412,10 +434,36 @@ export class SessionNotificationController {
});
}
private setCatalog(machineId: string, projection: SessionNotificationCatalogProjection): void {
private setCatalog(machineId: string, projection: SessionNotificationCatalogProjection, reconcileSelected = true): void {
const current = this.getState().notificationCatalogsByMachine;
if (current[machineId] === projection) return;
this.setState({ notificationCatalogsByMachine: { ...current, [machineId]: projection } });
if (reconcileSelected) this.reconcileSelectedWithCatalog(projection);
}
private reconcileSelectedWithCatalog(catalog: SessionNotificationCatalogProjection): void {
if (catalog.status !== "fresh" || catalog.daemonInstanceId === undefined) return;
const target = this.selectedTarget;
if (target?.machineId !== catalog.machineId || !this.machineIsReachable(target.machineId)) return;
const inbox = this.getState().selectedNotificationInbox;
if (inbox === undefined || !notificationTargetsEqual(inbox, target) || inbox.status !== "fresh" || inbox.daemonInstanceId === undefined || inbox.summary === undefined) {
this.scheduleSelectedRefresh(target);
return;
}
if (inbox.daemonInstanceId !== catalog.daemonInstanceId) {
this.scheduleSelectedRefresh(target);
return;
}
const catalogSummary = catalog.summariesBySessionId[target.sessionId];
if (catalogSummary === undefined) {
if (!notificationSummaryIsEmpty(inbox.summary)) this.scheduleSelectedRefresh(target);
return;
}
if (catalogSummary.cwd !== target.cwd
|| catalogSummary.inboxRevision > inbox.summary.inboxRevision
|| (catalogSummary.inboxRevision === inbox.summary.inboxRevision && !notificationSummariesEqual(catalogSummary, inbox.summary))) {
this.scheduleSelectedRefresh(target);
}
}
private markCatalogStale(machineId: string): void {
@@ -424,6 +472,12 @@ export class SessionNotificationController {
this.setCatalog(machineId, { ...current, status: "stale" });
}
private markSelectedStale(target: SessionNotificationTarget): void {
const current = this.getState().selectedNotificationInbox;
if (current === undefined || !notificationTargetsEqual(current, target) || current.status === "stale") return;
this.setState({ selectedNotificationInbox: { ...current, status: "stale" } });
}
private pruneRemovedMachines(machines: readonly Machine[]): void {
const machineIds = new Set(machines.map((machine) => machine.id));
if (machineIds.size === 0) machineIds.add("local");
@@ -540,6 +594,18 @@ function workspaceHydrationId(machineId: string, projectId: string): string {
return JSON.stringify([machineId, projectId]);
}
function notificationSummariesEqual(
left: SessionNotificationInboxSnapshot["summary"],
right: SessionNotificationInboxSnapshot["summary"],
): boolean {
return left.sessionId === right.sessionId
&& left.cwd === right.cwd
&& left.inboxRevision === right.inboxRevision
&& left.retainedCount === right.retainedCount
&& left.discardedCount === right.discardedCount
&& left.highestSeverity === right.highestSeverity;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+110 -10
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseRealtimeSocketEvent, parseSessionSocketEvent } from "./sessionSocket";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RealtimeSocket, SessionSocket, parseRealtimeSocketEvent, parseSessionSocketEvent } from "./sessionSocket";
function notification(order = 1) {
return {
@@ -23,16 +23,20 @@ function summary() {
};
}
function inboxEvent() {
return {
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "added", notification: notification() },
};
}
describe("notification socket guards", () => {
it("accepts validated per-session and global notification events", () => {
expect(parseSessionSocketEvent({
type: "notifications.inbox",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
dismissThrough: { order: 1, overflowWatermark: 0 },
delta: { kind: "added", notification: notification() },
})).toMatchObject({ type: "notifications.inbox", delta: { kind: "added" } });
expect(parseSessionSocketEvent(inboxEvent())).toMatchObject({ type: "notifications.inbox", delta: { kind: "added" } });
expect(parseRealtimeSocketEvent({
type: "notifications.summary",
@@ -64,3 +68,99 @@ describe("notification socket guards", () => {
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
});
});
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly instances: FakeWebSocket[] = [];
readyState = 1;
onopen: (() => void) | null = null;
onmessage: ((event: { data: MessageEvent["data"] }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
close(): void {
this.readyState = 3;
}
}
describe("socket instance isolation", () => {
const setTimeoutSpy = vi.fn(() => 1);
beforeEach(() => {
FakeWebSocket.instances.length = 0;
setTimeoutSpy.mockClear();
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: setTimeoutSpy });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("drops queued session frames and close callbacks from a replaced machine socket", async () => {
const socket = new SessionSocket();
const oldHandler = vi.fn();
const newHandler = vi.fn();
const onInitialOpen = vi.fn();
const target = { id: "session-1", cwd: "/repo" };
socket.connect(target, oldHandler, undefined, "machine-a");
const oldSocket = FakeWebSocket.instances[0];
if (oldSocket === undefined) throw new Error("expected old session socket");
const staleClose = oldSocket.onclose;
oldSocket.onmessage?.({ data: JSON.stringify(inboxEvent()) });
socket.connect(target, newHandler, undefined, "machine-b", onInitialOpen);
staleClose?.();
await Promise.resolve();
await Promise.resolve();
expect(oldHandler).not.toHaveBeenCalled();
expect(newHandler).not.toHaveBeenCalled();
expect(setTimeoutSpy).not.toHaveBeenCalled();
const newSocket = FakeWebSocket.instances[1];
if (newSocket === undefined) throw new Error("expected replacement session socket");
newSocket.onopen?.();
expect(onInitialOpen).toHaveBeenCalledOnce();
newSocket.onmessage?.({ data: JSON.stringify(inboxEvent()) });
await Promise.resolve();
await Promise.resolve();
expect(newHandler).toHaveBeenCalledOnce();
});
it("does not attribute a queued global frame to a replacement machine", async () => {
const socket = new RealtimeSocket();
const oldHandler = vi.fn();
const newHandler = vi.fn();
const event = {
type: "notifications.summary",
daemonInstanceId: "daemon-a",
catalogRevision: 1,
summary: summary(),
};
socket.connect(oldHandler, undefined, "machine-a");
const oldSocket = FakeWebSocket.instances[0];
if (oldSocket === undefined) throw new Error("expected old realtime socket");
oldSocket.onmessage?.({ data: JSON.stringify(event) });
socket.connect(newHandler, undefined, "machine-b");
await Promise.resolve();
await Promise.resolve();
expect(oldHandler).not.toHaveBeenCalled();
expect(newHandler).not.toHaveBeenCalled();
const newSocket = FakeWebSocket.instances[1];
if (newSocket === undefined) throw new Error("expected replacement realtime socket");
newSocket.onmessage?.({ data: JSON.stringify(event) });
await Promise.resolve();
await Promise.resolve();
expect(newHandler).toHaveBeenCalledOnce();
});
});
+29 -13
View File
@@ -13,14 +13,22 @@ export class SessionSocket {
private shouldReconnect = false;
private hasOpened = false;
private onReconnect: (() => void) | undefined;
private onInitialOpen: (() => void) | undefined;
private machineId = "local";
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
connect(
session: SessionRef,
onEvent: (event: SessionUiEvent) => void,
onReconnect?: () => void,
machineId = "local",
onInitialOpen?: () => void,
): void {
this.close();
this.machineId = machineId;
this.session = session;
this.onEvent = onEvent;
this.onReconnect = onReconnect;
this.onInitialOpen = onInitialOpen;
this.shouldReconnect = true;
this.open();
}
@@ -37,23 +45,29 @@ export class SessionSocket {
this.session = undefined;
this.onEvent = undefined;
this.onReconnect = undefined;
this.onInitialOpen = undefined;
this.hasOpened = false;
this.machineId = "local";
}
private open(): void {
if (this.session === undefined || this.session.id === "" || this.session.cwd === "" || !this.shouldReconnect) return;
const socket = sessionEvents(this.session, this.machineId);
const session = this.session;
if (session === undefined || session.id === "" || session.cwd === "" || !this.shouldReconnect) return;
const socket = sessionEvents(session, this.machineId);
this.socket = socket;
socket.onopen = () => {
if (this.socket !== socket) return;
this.reconnectDelay = 500;
if (this.hasOpened) this.onReconnect?.();
const isReconnect = this.hasOpened;
this.hasOpened = true;
if (isReconnect) this.onReconnect?.();
else this.onInitialOpen?.();
};
socket.onmessage = (message) => void this.handleMessage(message.data, this.session);
socket.onmessage = (message) => void this.handleMessage(message.data, socket, session);
socket.onerror = () => { socket.close(); };
socket.onclose = () => {
if (this.socket === socket) this.socket = undefined;
if (this.socket !== socket) return;
this.socket = undefined;
this.scheduleReconnect();
};
}
@@ -66,10 +80,10 @@ export class SessionSocket {
this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay);
}
private async handleMessage(data: MessageEvent["data"], session: SessionRef | undefined): Promise<void> {
private async handleMessage(data: MessageEvent["data"], socket: WebSocket, session: SessionRef): Promise<void> {
const event = parseSessionSocketEvent(await parseSocketEvent(data));
if (event === undefined) return;
if (event.type === "notifications.inbox" && (session?.id !== event.summary.sessionId || session.cwd !== event.summary.cwd)) return;
if (this.socket !== socket || event === undefined) return;
if (event.type === "notifications.inbox" && (session.id !== event.summary.sessionId || session.cwd !== event.summary.cwd)) return;
this.onEvent?.(event);
}
}
@@ -107,13 +121,15 @@ export class RealtimeSocket {
const socket = realtimeEvents(this.machineId);
this.socket = socket;
socket.onopen = () => {
if (this.socket !== socket) return;
this.reconnectDelay = 500;
this.onOpen?.();
};
socket.onmessage = (message) => void this.handleMessage(message.data);
socket.onmessage = (message) => void this.handleMessage(message.data, socket);
socket.onerror = () => { socket.close(); };
socket.onclose = () => {
if (this.socket === socket) this.socket = undefined;
if (this.socket !== socket) return;
this.socket = undefined;
this.scheduleReconnect();
};
}
@@ -126,9 +142,9 @@ export class RealtimeSocket {
this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay);
}
private async handleMessage(data: MessageEvent["data"]): Promise<void> {
private async handleMessage(data: MessageEvent["data"], socket: WebSocket): Promise<void> {
const event = parseRealtimeSocketEvent(await parseSocketEvent(data));
if (event !== undefined) this.onEvent?.(event);
if (this.socket === socket && event !== undefined) this.onEvent?.(event);
}
}