fix(updater): stop promising session survival the update card can't verify

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.
This commit is contained in:
Neil
2026-08-22 19:54:43 -07:00
parent 063b804298
commit 64b42e4ad5
13 changed files with 220 additions and 11 deletions
+29
View File
@@ -239,6 +239,35 @@ describe('pty:management IPC handlers', () => {
})
})
describe('sessionPersistence', () => {
async function readAvailability(): Promise<boolean> {
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
+12
View File
@@ -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<DaemonSessionInfo[]> {
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(
+2
View File
@@ -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 }>
}
+2 -1
View File
@@ -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')
}
},
@@ -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(<UpdateCard />)
}
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()
})
})
+30 -9
View File
@@ -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() {
<SimpleCardContent
version={status.version}
releaseUrl={releaseUrl}
sessionsPreserved={sessionsPreserved}
onUpdate={handleUpdate}
onClose={handleDismissWithAnimation}
/>
)
})()
// 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 (
<div
@@ -618,10 +627,15 @@ export function UpdateCard() {
<div className="flex items-center gap-3 p-3">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
<Button
@@ -751,11 +765,14 @@ function RichCardContent({
function SimpleCardContent({
version,
releaseUrl,
sessionsPreserved,
onUpdate,
onClose
}: {
version: string
releaseUrl?: string
/** null = daemon persistence unverified; say nothing rather than guess. */
sessionsPreserved: boolean | null
onUpdate: () => void
onClose: () => void
}) {
@@ -782,9 +799,13 @@ function SimpleCardContent({
})}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")}
</p>
{sessionsPreserved !== null && (
<p className="text-xs leading-relaxed text-muted-foreground">
{sessionsPreserved
? translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")
: translate('auto.components.UpdateCard.6930f48207', 'Sessions may not be preserved.')}
</p>
)}
{releaseUrl && (
<button
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
/**
* Whether the daemon that carries terminal sessions across a quit is serving this app.
* `null` = not answered yet or the probe failed; the caller must claim nothing either way.
*/
export function useTerminalSessionPersistence(enabled: boolean): boolean | null {
const [available, setAvailable] = useState<boolean | null>(null)
useEffect(() => {
if (!enabled) {
return
}
let cancelled = false
void (async () => {
try {
const result = await window.api.pty.management.sessionPersistence()
if (!cancelled && typeof result.available === 'boolean') {
setAvailable(result.available)
}
} catch {
// Why: an unanswered probe stays unknown rather than inventing either verdict.
}
})()
return () => {
cancelled = true
}
}, [enabled])
return available
}
+2
View File
@@ -2121,6 +2121,8 @@
"ec8fe71cfc": "Update",
"44324ef542": "Release notes",
"fdd4a364fa": "Sessions won't be interrupted.",
"6930f48207": "Sessions may not be preserved.",
"6bcb39fd76": "Terminal sessions may not be preserved during this update.",
"05ad78a6d1": "Orca v{{value0}} is ready.",
"318d3b4bc7": "Dismiss update",
"9abc59f814": "Update Available",
+2
View File
@@ -1958,6 +1958,8 @@
"ec8fe71cfc": "Actualizar",
"44324ef542": "Notas de la versión",
"fdd4a364fa": "Las sesiones no se interrumpirán.",
"6930f48207": "Es posible que las sesiones no se conserven.",
"6bcb39fd76": "Es posible que las sesiones de terminales no se conserven durante esta actualización.",
"05ad78a6d1": "Orca v{{value0}} está lista.",
"318d3b4bc7": "Descartar actualización",
"9abc59f814": "Actualización disponible",
+2
View File
@@ -1958,6 +1958,8 @@
"ec8fe71cfc": "更新",
"44324ef542": "リリースノート",
"fdd4a364fa": "セッションは中断されません。",
"6930f48207": "セッションが保持されない可能性があります。",
"6bcb39fd76": "このアップデート中にターミナルセッションが保持されない可能性があります。",
"05ad78a6d1": "Orca v{{value0}} の準備ができています。",
"318d3b4bc7": "更新を閉じる",
"9abc59f814": "利用可能なアップデート",
+2
View File
@@ -1960,6 +1960,8 @@
"ec8fe71cfc": "업데이트",
"44324ef542": "릴리스 노트",
"fdd4a364fa": "세션은 중단되지 않습니다.",
"6930f48207": "세션이 유지되지 않을 수 있습니다.",
"6bcb39fd76": "이 업데이트 중에 터미널 세션이 유지되지 않을 수 있습니다.",
"05ad78a6d1": "Orca v{{value0}}이(가) 준비되었습니다.",
"318d3b4bc7": "업데이트 닫기",
"9abc59f814": "업데이트 가능",
+2
View File
@@ -1958,6 +1958,8 @@
"ec8fe71cfc": "更新",
"44324ef542": "发行说明",
"fdd4a364fa": "会话不会被中断。",
"6930f48207": "会话可能不会被保留。",
"6bcb39fd76": "本次更新期间终端会话可能不会被保留。",
"05ad78a6d1": "Orca v{{value0}} 已准备就绪。",
"318d3b4bc7": "关闭更新",
"9abc59f814": "可用更新",
+3 -1
View File
@@ -3415,7 +3415,9 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
killOne: () => Promise.resolve({ success: false }),
restart: () => Promise.resolve({ success: false }),
// Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden.
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const })
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }),
// Why: the daemon serving these terminals runs on the paired host, so this client cannot verify preservation.
sessionPersistence: () => Promise.resolve({ available: null })
}
}
}