diff --git a/src/client/src/components/ExtensionDialogCard.test.ts b/src/client/src/components/ExtensionDialogCard.test.ts index e0af15e..87f1117 100644 --- a/src/client/src/components/ExtensionDialogCard.test.ts +++ b/src/client/src/components/ExtensionDialogCard.test.ts @@ -141,13 +141,25 @@ describe("extension-dialog-card countdown", () => { vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z")); const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" })); const root = renderRoot(card); - const status = requiredElement(root.querySelector("[role='status']"), "countdown status"); + const countdown = requiredElement(root.querySelector(".countdown"), "countdown"); - expect(status.textContent).toBe("Auto-cancels in 1m 30s"); + expect(countdown.textContent).toBe("Auto-cancels in 1m 30s"); await vi.advanceTimersByTimeAsync(30_000); await card.updateComplete; - expect(status.textContent).toBe("Auto-cancels in 1m 0s"); + expect(countdown.textContent).toBe("Auto-cancels in 1m 0s"); + }); + + it("is decorative: no live region announcing every second", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z")); + const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" })); + const root = renderRoot(card); + + // A ticking live region would queue a screen-reader announcement per + // second; the daemon-owned dialog.closed event is the real signal. + expect(requiredElement(root.querySelector(".countdown"), "countdown").getAttribute("role")).toBeNull(); + expect(root.querySelector("[aria-live]")).toBeNull(); }); it("renders no countdown when the dialog waits forever", async () => { @@ -155,7 +167,7 @@ describe("extension-dialog-card countdown", () => { const card = await mountOpenDialog(openDialog()); const root = renderRoot(card); - expect(root.querySelector("[role='status']")).toBeNull(); + expect(root.querySelector(".countdown")).toBeNull(); }); it("stops ticking once the dialog closes", async () => { @@ -219,6 +231,11 @@ describe("extensionDialogCountdownText", () => { expect(extensionDialogCountdownText("2026-07-27T11:02:00.000Z", now)).toBe("Auto-cancels in 1h 2m"); }); + it("never rounds the minutes up to 60 near an hour boundary", () => { + expect(extensionDialogCountdownText("2026-07-27T11:59:55.000Z", now)).toBe("Auto-cancels in 1h 59m"); + expect(extensionDialogCountdownText("2026-07-27T12:59:40.000Z", now)).toBe("Auto-cancels in 2h 59m"); + }); + it("stays display-only once the deadline has passed", () => { expect(extensionDialogCountdownText("2026-07-27T09:59:59.000Z", now)).toBe("Auto-cancel imminent"); }); diff --git a/src/client/src/components/ExtensionDialogCard.ts b/src/client/src/components/ExtensionDialogCard.ts index 2b861c2..fb51096 100644 --- a/src/client/src/components/ExtensionDialogCard.ts +++ b/src/client/src/components/ExtensionDialogCard.ts @@ -58,7 +58,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM const seconds = Math.ceil(remainingMs / 1000); if (seconds >= 3600) { const hours = Math.floor(seconds / 3600); - const minutes = Math.round((seconds % 3600) / 60); + // Floor, not round: rounding yields "1h 60m" in the last half-minute of an hour. + const minutes = Math.floor((seconds % 3600) / 60); return `Auto-cancels in ${String(hours)}h ${String(minutes)}m`; } if (seconds >= 60) { @@ -129,7 +130,10 @@ export class ExtensionDialogCard extends LitElement {

${dialog.title}

${countdown === undefined ? null - : html`${countdown}`} + // Decorative only — no live region: a polite region would queue one + // announcement per second. The daemon-owned dialog.closed event is + // the real signal, and the settled card announces the outcome. + : html`${countdown}`} ${this.renderOpenBody(dialog)} diff --git a/src/client/src/controllers/sessionController.startupDialogs.test.ts b/src/client/src/controllers/sessionController.startupDialogs.test.ts index e533596..3b116bf 100644 --- a/src/client/src/controllers/sessionController.startupDialogs.test.ts +++ b/src/client/src/controllers/sessionController.startupDialogs.test.ts @@ -298,4 +298,68 @@ describe("SessionController session_start dialog startup reachability", () => { resolveBackendSession(harness); await start; }); + + it("drops a mid-startup status snapshot that lands after the readiness swap", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + const resyncRequest = deferred(); + let resyncIssued = false; + const harness = pendingStartController(state, { + status: (session) => { + // The first backend status call is the subscribe-time resync; hold it + // until after the swap. Later calls (the readiness join) answer fresh. + if (sessionLookupId(session) === BACKEND_SESSION_ID && !resyncIssued) { + resyncIssued = true; + return resyncRequest.promise; + } + return Promise.resolve(status(sessionLookupId(session))); + }, + }); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); + + resolveBackendSession(harness); + await start; + await vi.waitFor(() => { expect(harness.state.current.selectedSession?.id).toBe(BACKEND_SESSION_ID); }); + + // The stale snapshot — issued before the swap and claiming dialog-1 is + // still open — must not clobber the real session's fresher state. + resyncRequest.resolve(statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-1")])); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.state.current.pendingDialogs).toEqual([]); + expect(harness.state.current.sessionStatuses[BACKEND_SESSION_ID]?.pendingDialogs ?? []).toEqual([]); + }); + + it("drops the dead card, closes the socket, and ignores late frames when the create fails mid-startup", async () => { + const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; + let answerCalled = false; + const harness = pendingStartController(state, { + answerDialog: () => { + answerCalled = true; + return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID))); + }, + }); + const closeSpy = vi.spyOn(harness.socket, "close"); + const { start, tempId } = beginPendingStart(harness); + reportBackendSessionId(harness, tempId); + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") }); + expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); + + closeSpy.mockClear(); + harness.startRequest.reject(new Error("create exploded")); + await start; + + expect(harness.state.current.error).toBe("Failed to start session: create exploded"); + expect(harness.state.current.pendingDialogs).toEqual([]); + expect(closeSpy).toHaveBeenCalled(); + + // Late frames from the dead session are dropped, and no answer can leave. + harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") }); + expect(harness.state.current.pendingDialogs).toEqual([]); + await harness.controller.answerDialog("dialog-2", true); + expect(answerCalled).toBe(false); + }); }); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 127c9c8..362e219 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1214,9 +1214,15 @@ export class SessionController { const pending = this.pendingSessionStarts.get(tempId); if (pending === undefined) return; this.pendingSessionStarts.delete(tempId); + const wasDiscarded = pending.discarded; + // The pending start is dead: stop routing its dialog frames (a card on the + // failed row could never be answered) and drop the early-subscribed socket + // so it stops reconnecting against a session that may not exist. + pending.discarded = true; + if (this.getState().selectedSession?.id === tempId) this.socket.close(); const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId); const isCurrentPendingStart = this.isCurrentPendingStart(pending); - if (pending.discarded || !isCurrentPendingStart) { + if (wasDiscarded || !isCurrentPendingStart) { if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); return; } @@ -1228,6 +1234,9 @@ export class SessionController { sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions], sessionActivities: { ...state.sessionActivities, [tempId]: activity }, activity: state.selectedSession?.id === tempId ? activity : state.activity, + // Open cards on the failed row are dead: the create is gone, so no + // answer could ever reach the daemon. Settled outcomes stay as history. + ...(state.selectedSession?.id === tempId ? { pendingDialogs: [] } : {}), error: `Failed to start session: ${message}`, }); this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); @@ -1585,8 +1594,11 @@ export class SessionController { if (backendSessionId === undefined) return; void this.api.status({ id: backendSessionId, cwd: pending.cwd }, pending.machineId).then( (status) => { - this.applyStatus(status); + // Guard before applying: this unordered snapshot can land after the + // readiness swap made the real session selected, and a stale replace + // must not clobber the socket's fresher dialog state. if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return; + this.applyStatus(status); const state = this.getState(); const knownIds = new Set([ ...state.pendingDialogs.map((pendingDialog) => pendingDialog.dialogId), diff --git a/src/server/sessions/piSessionService.extensionDialogs.test.ts b/src/server/sessions/piSessionService.extensionDialogs.test.ts index 47e805d..d682550 100644 --- a/src/server/sessions/piSessionService.extensionDialogs.test.ts +++ b/src/server/sessions/piSessionService.extensionDialogs.test.ts @@ -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(); + }); }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 01a3ef3..f8f7552 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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 { + // 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);