mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1958,6 +1958,8 @@
|
||||
"ec8fe71cfc": "更新",
|
||||
"44324ef542": "リリースノート",
|
||||
"fdd4a364fa": "セッションは中断されません。",
|
||||
"6930f48207": "セッションが保持されない可能性があります。",
|
||||
"6bcb39fd76": "このアップデート中にターミナルセッションが保持されない可能性があります。",
|
||||
"05ad78a6d1": "Orca v{{value0}} の準備ができています。",
|
||||
"318d3b4bc7": "更新を閉じる",
|
||||
"9abc59f814": "利用可能なアップデート",
|
||||
|
||||
@@ -1960,6 +1960,8 @@
|
||||
"ec8fe71cfc": "업데이트",
|
||||
"44324ef542": "릴리스 노트",
|
||||
"fdd4a364fa": "세션은 중단되지 않습니다.",
|
||||
"6930f48207": "세션이 유지되지 않을 수 있습니다.",
|
||||
"6bcb39fd76": "이 업데이트 중에 터미널 세션이 유지되지 않을 수 있습니다.",
|
||||
"05ad78a6d1": "Orca v{{value0}}이(가) 준비되었습니다.",
|
||||
"318d3b4bc7": "업데이트 닫기",
|
||||
"9abc59f814": "업데이트 가능",
|
||||
|
||||
@@ -1958,6 +1958,8 @@
|
||||
"ec8fe71cfc": "更新",
|
||||
"44324ef542": "发行说明",
|
||||
"fdd4a364fa": "会话不会被中断。",
|
||||
"6930f48207": "会话可能不会被保留。",
|
||||
"6bcb39fd76": "本次更新期间终端会话可能不会被保留。",
|
||||
"05ad78a6d1": "Orca v{{value0}} 已准备就绪。",
|
||||
"318d3b4bc7": "关闭更新",
|
||||
"9abc59f814": "可用更新",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user