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:
Federico Jaramillo Martinez
2026-07-29 07:52:21 +02:00
parent 5759201a39
commit 5420869c52
6 changed files with 145 additions and 8 deletions
@@ -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();
});
});
+7
View File
@@ -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);