Archived
fix(sessions): close extension dialog lifecycle edge cases
Post-review hardening for the extension dialog feature: - dispose() and closeActive() now settle startup-parked session_start dialogs before awaiting pending opens, so daemon shutdown or closing a session whose open is parked on a dialog can no longer block behind the dialog timeout (infinite with extensionDialogsTimeoutMs: 0). - A failed create now drops its dead dialog cards, closes the early-subscribed socket, and ignores late dialog frames instead of leaving an unanswerable card on the failed row. - The pending-start status resync checks its staleness guard before applying the unordered snapshot, so a late response can no longer clobber the post-swap session state. - The dialog countdown no longer queues one screen-reader announcement per second (decorative; the daemon-owned dialog.closed event is the real signal) and no longer renders "1h 60m" near hour boundaries.
This commit is contained in:
@@ -597,4 +597,37 @@ describe("PiSessionService session_start dialog startup reachability", () => {
|
||||
expect(confirmAnswers).toEqual([false]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("dispose settles a startup-parked dialog instead of blocking behind its timeout", async () => {
|
||||
const { service, store, events, confirmAnswers } = startupDialogService();
|
||||
// The open flow registers in pendingSessionOpens, which dispose awaits:
|
||||
// without settling the dialog first, disposal would ride its timeout.
|
||||
const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
|
||||
await parkOnStartupDialog(store);
|
||||
|
||||
await service.dispose();
|
||||
|
||||
expect(confirmAnswers).toEqual([false]);
|
||||
const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
|
||||
expect(closedEvents).toHaveLength(1);
|
||||
expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
|
||||
// The released open completed inside dispose's awaited window; the late
|
||||
// messages read neither hangs nor rejects the test run.
|
||||
await Promise.allSettled([opening]);
|
||||
});
|
||||
|
||||
it("closing a session whose open is parked on a session_start dialog settles the dialog first", async () => {
|
||||
const { service, store, events, confirmAnswers } = startupDialogService();
|
||||
const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
|
||||
await parkOnStartupDialog(store);
|
||||
|
||||
await service.stop(ACTIVE_SESSION_ID);
|
||||
|
||||
expect(confirmAnswers).toEqual([false]);
|
||||
const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
|
||||
expect(closedEvents).toHaveLength(1);
|
||||
expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
|
||||
await Promise.allSettled([opening]);
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1005,6 +1005,9 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.clearUnreadPublicationRetry();
|
||||
clearInterval(this.heartbeat);
|
||||
this.clearCompactionDrainTimers();
|
||||
// Same startup-park hazard as closeActive(): settle `session_start` dialogs
|
||||
// of sessions still binding extensions before awaiting their pending opens.
|
||||
for (const sessionId of this.startupSessions.keys()) this.endSessionExtensionDialogs(sessionId);
|
||||
const pendingOpens = this.pendingSessionOpenPromises();
|
||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||
const activeSessions = Array.from(new Set(this.active.values()));
|
||||
@@ -2592,6 +2595,10 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
|
||||
private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise<void> {
|
||||
// A session whose open is parked on a `session_start` dialog holds its
|
||||
// pending open until the dialog settles; settle it first so closing cannot
|
||||
// block behind the dialog timeout (which `0` makes infinite).
|
||||
if (this.startupSessions.has(sessionId)) this.endSessionExtensionDialogs(sessionId);
|
||||
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
|
||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||
const active = this.active.get(sessionId);
|
||||
|
||||
Reference in New Issue
Block a user