From 64b42e4ad5e72c25e35f5df725a5d86f4190251c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:54:38 -0700 Subject: [PATCH] fix(updater): stop promising session survival the update card can't verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users reported taking the update card at its word — "Your terminal sessions won't be interrupted during the update." — hitting Quit & Install, and coming back to every terminal gone, with in-flight agent work lost. The card printed that line unconditionally, from a hardcoded string, with nothing behind it. Only the PTY daemon carries sessions across a quit. When no daemon provider is attached, or the provider is degraded so fresh spawns bypass it, terminals are running on fallback local PTYs that die with the app — exactly the case where the reassurance is false and the user has the most to lose. Gate the copy on a new `pty:management:sessionPersistence` probe: - daemon attached and healthy -> keep the existing reassurance, - fallback local PTYs or a degraded daemon -> "may not be preserved", - unanswered, failed, or a web client that can't see the host daemon -> say nothing at all rather than guess in either direction. The web preload returns `null` because the daemon serving those terminals lives on the paired host and this client cannot verify it. --- src/main/ipc/pty-management.test.ts | 29 +++++ src/main/ipc/pty-management.ts | 12 +++ src/preload/api/pty-management-api.ts | 2 + src/preload/index.ts | 3 +- .../UpdateCard.session-persistence.test.tsx | 101 ++++++++++++++++++ src/renderer/src/components/UpdateCard.tsx | 39 +++++-- .../hooks/useTerminalSessionPersistence.ts | 31 ++++++ src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/renderer/src/web/web-preload-api.ts | 4 +- 13 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/UpdateCard.session-persistence.test.tsx create mode 100644 src/renderer/src/hooks/useTerminalSessionPersistence.ts diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index d1c2689000b..cb4783c1ec9 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -239,6 +239,35 @@ describe('pty:management IPC handlers', () => { }) }) + describe('sessionPersistence', () => { + async function readAvailability(): Promise { + const { registerDaemonManagementHandlers } = await importFresh() + registerDaemonManagementHandlers() + const result = (await buildHandlerMap()['pty:management:sessionPersistence']({})) as { + available: boolean + } + return result.available + } + + it('reports preservation while the daemon owns terminals', async () => { + getDaemonProviderMock.mockReturnValue(await makeRouter(makeAdapter(5, []))) + + await expect(readAvailability()).resolves.toBe(true) + }) + + it('reports no preservation when terminals fell back to local PTYs', async () => { + getDaemonProviderMock.mockReturnValue(null) + + await expect(readAvailability()).resolves.toBe(false) + }) + + it('reports no preservation while fresh spawns bypass the daemon', async () => { + getDaemonProviderMock.mockReturnValue(await makeDegradedProvider(makeAdapter(5, []))) + + await expect(readAvailability()).resolves.toBe(false) + }) + }) + describe('killAll', () => { // Why: the handler sleeps POLL_INTERVAL_MS between listSessions polls. // Fake timers let the tests drive that loop deterministically — without diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 6260b8454af..2e46224ac07 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -38,6 +38,11 @@ function isDaemonDegraded(): boolean { ) } +// Why: no daemon provider means terminals run on fallback local PTYs that die with the app, so nothing survives a quit-and-install. +function isSessionPersistenceAvailable(): boolean { + return getDaemonProvider() !== null && !isDaemonDegraded() +} + async function collectSessions(adapters: DaemonPtyAdapter[]): Promise { const results = await Promise.allSettled( adapters.map(async (adapter) => { @@ -57,6 +62,13 @@ export function registerDaemonManagementHandlers(): void { ipcMain.removeHandler('pty:management:killOne') ipcMain.removeHandler('pty:management:restart') ipcMain.removeHandler('pty:management:macTccAttribution') + ipcMain.removeHandler('pty:management:sessionPersistence') + + // Why: lets the update card stop promising sessions survive an install it has no mechanism to keep them through. + ipcMain.handle( + 'pty:management:sessionPersistence', + async (): Promise<{ available: boolean }> => ({ available: isSessionPersistenceAvailable() }) + ) // Why: lets Settings warn that macOS privacy grants no longer reach daemon terminals (STA-3491). ipcMain.handle( diff --git a/src/preload/api/pty-management-api.ts b/src/preload/api/pty-management-api.ts index 10fef2d2303..e704a8eefa5 100644 --- a/src/preload/api/pty-management-api.ts +++ b/src/preload/api/pty-management-api.ts @@ -27,4 +27,6 @@ export type PtyManagementApi = { killOne: (args: { sessionId: string }) => Promise<{ success: boolean }> restart: () => Promise<{ success: boolean }> macTccAttribution: () => Promise<{ health: PtyManagementMacTccAttributionHealth }> + // `false`: terminals run on local PTYs that die with the app, so no session survives quit/update. `null`: unverifiable here. + sessionPersistence: () => Promise<{ available: boolean | null }> } diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a00c6d647..d1dc3bcba6a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1347,7 +1347,8 @@ const api = { killAll: () => ipcRenderer.invoke('pty:management:killAll'), killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), restart: () => ipcRenderer.invoke('pty:management:restart'), - macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') + macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution'), + sessionPersistence: () => ipcRenderer.invoke('pty:management:sessionPersistence') } }, diff --git a/src/renderer/src/components/UpdateCard.session-persistence.test.tsx b/src/renderer/src/components/UpdateCard.session-persistence.test.tsx new file mode 100644 index 00000000000..3df0924c7f7 --- /dev/null +++ b/src/renderer/src/components/UpdateCard.session-persistence.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../store' +import { UpdateCard } from './UpdateCard' + +const sessionPersistence = vi.fn() + +function mockApi(): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { relaunch: vi.fn() }, + settings: { set: vi.fn().mockResolvedValue(undefined) }, + shell: { openUrl: vi.fn() }, + ui: { set: vi.fn().mockResolvedValue(undefined), writeClipboardText: vi.fn() }, + pty: { management: { sessionPersistence } }, + updater: { + check: vi.fn(), + dismissNudge: vi.fn(), + dismissAvailableUpdate: vi.fn().mockResolvedValue(undefined), + download: vi.fn(), + getLinuxPackageInstallInstructions: vi.fn().mockResolvedValue({ ok: false }), + showLinuxPackage: vi.fn(), + quitAndInstall: vi.fn().mockResolvedValue(undefined) + } + } + }) +} + +function renderAvailableUpdate(): void { + useAppStore.setState({ + updateStatus: { state: 'available', version: '1.4.200', changelog: null }, + updateChangelog: null, + dismissedUpdateVersion: null, + updateCardCollapsed: false, + updateReassuranceSeen: false + }) + render() +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + sessionPersistence.mockReset() + mockApi() + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +describe('UpdateCard session-preservation promise', () => { + it('drops the no-interruption promise when terminals run on fallback local PTYs', async () => { + sessionPersistence.mockResolvedValue({ available: false }) + renderAvailableUpdate() + + await waitFor(() => expect(screen.getAllByText(/may not be preserved/).length).toBe(2)) + expect(screen.queryByText(/won't be interrupted/)).toBeNull() + expect( + screen.getByText('Terminal sessions may not be preserved during this update.') + ).toBeTruthy() + expect(screen.getByText('Sessions may not be preserved.')).toBeTruthy() + }) + + it('keeps the promise when the daemon owns the sessions', async () => { + sessionPersistence.mockResolvedValue({ available: true }) + renderAvailableUpdate() + + await waitFor(() => expect(screen.getByText("Sessions won't be interrupted.")).toBeTruthy()) + expect( + screen.getByText("Your terminal sessions won't be interrupted during the update.") + ).toBeTruthy() + }) + + it('claims nothing while persistence is still unknown', async () => { + sessionPersistence.mockReturnValue(new Promise(() => {})) + renderAvailableUpdate() + + await waitFor(() => expect(screen.getByText('Update Available')).toBeTruthy()) + expect(screen.queryByText(/interrupted/)).toBeNull() + expect(screen.queryByText(/preserved/)).toBeNull() + }) + + it('claims nothing when the persistence probe fails', async () => { + sessionPersistence.mockRejectedValue(new Error('ipc unavailable')) + renderAvailableUpdate() + + await waitFor(() => expect(screen.getByText('Update Available')).toBeTruthy()) + expect(screen.queryByText(/interrupted/)).toBeNull() + expect(screen.queryByText(/preserved/)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index bb2b2d194c3..6ab94e149d9 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: keeps the updater state machine and its presentation variants in one file. */ import { useCallback, useEffect, useRef, useState } from 'react' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' +import { useTerminalSessionPersistence } from '@/hooks/useTerminalSessionPersistence' import { useAppStore } from '../store' import { Card } from './ui/card' import { Button } from './ui/button' @@ -169,6 +170,11 @@ export function UpdateCard() { // ── Prefers-reduced-motion ────────────────────────────────────────── const prefersReducedMotion = usePrefersReducedMotion() + // Why: the card must not promise sessions survive the install unless the daemon that preserves them is serving this app. + const sessionsPreserved = useTerminalSessionPersistence( + status.state === 'available' || status.state === 'downloading' + ) + const clearAnimationTimers = useCallback(() => { if (dismissAnimationTimerRef.current !== null) { window.clearTimeout(dismissAnimationTimerRef.current) @@ -597,15 +603,18 @@ export function UpdateCard() { ) })() - // One-time reassurance tip that updating won't kill running terminals; persisted once seen. + // One-time tip about what the update does to running terminals; persisted once seen, and silent while persistence is unknown. const showReassurance = - !reassuranceSeen && (status.state === 'available' || status.state === 'downloading') + !reassuranceSeen && + sessionsPreserved !== null && + (status.state === 'available' || status.state === 'downloading') return (

- {translate( - 'auto.components.UpdateCard.b1d867f4fb', - "Your terminal sessions won't be interrupted during the update." - )} + {sessionsPreserved + ? translate( + 'auto.components.UpdateCard.b1d867f4fb', + "Your terminal sessions won't be interrupted during the update." + ) + : translate( + 'auto.components.UpdateCard.6bcb39fd76', + 'Terminal sessions may not be preserved during this update.' + )}