diff --git a/src/renderer/src/components/terminal/window-close-running-work.test.ts b/src/renderer/src/components/terminal/window-close-running-work.test.ts index ba8f71ec0cd..8275ddd67d8 100644 --- a/src/renderer/src/components/terminal/window-close-running-work.test.ts +++ b/src/renderer/src/components/terminal/window-close-running-work.test.ts @@ -81,22 +81,52 @@ describe('assessWindowCloseRunningWork', () => { }) }) - it('warns on quit about a live process on a paired runtime host', async () => { + // #29916: a paired runtime owns its terminals and keeps them after this client quits. + it('does not warn on quit, or probe, for a live process on a paired runtime host', async () => { + setState([RUNTIME_PTY, OWNERLESS_RUNTIME_PTY]) + answerWith({ [RUNTIME_PTY]: BUSY, [OWNERLESS_RUNTIME_PTY]: BUSY }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'none' + }) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + it('does not warn on quit when a paired runtime host is unreachable', async () => { + setState([RUNTIME_PTY]) + answerWith({ [RUNTIME_PTY]: UNVERIFIABLE }) + + await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + kind: 'none' + }) + }) + + it('still warns when closing the window over a live process on a paired runtime host', async () => { setState([RUNTIME_PTY]) answerWith({ [RUNTIME_PTY]: BUSY }) - await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + await expect(assessWindowCloseRunningWork({ isQuitting: false })).resolves.toEqual({ kind: 'running' }) }) - it('counts an owner-less remote pty as remote work', async () => { + it('still warns when closing the window over an owner-less runtime pty', async () => { setState([OWNERLESS_RUNTIME_PTY]) - answerWith({ [OWNERLESS_RUNTIME_PTY]: BUSY }) + answerWith({ [OWNERLESS_RUNTIME_PTY]: UNVERIFIABLE }) + + await expect(assessWindowCloseRunningWork({ isQuitting: false })).resolves.toEqual({ + kind: 'unverifiable' + }) + }) + + it('warns on quit about an SSH relay pty alongside a busy paired runtime pty', async () => { + setState([SSH_PTY, RUNTIME_PTY]) + answerWith({ [SSH_PTY]: BUSY, [RUNTIME_PTY]: BUSY }) await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ kind: 'running' }) + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(1) }) // The crux of docs/reference/ssh-execution-boundary.md: an unreachable host is `unverifiable`, @@ -155,7 +185,7 @@ describe('assessWindowCloseRunningWork', () => { setState([SSH_PTY, RUNTIME_PTY]) answerWith({ [SSH_PTY]: UNVERIFIABLE, [RUNTIME_PTY]: BUSY }) - await expect(assessWindowCloseRunningWork({ isQuitting: true })).resolves.toEqual({ + await expect(assessWindowCloseRunningWork({ isQuitting: false })).resolves.toEqual({ kind: 'running' }) }) diff --git a/src/renderer/src/components/terminal/window-close-running-work.ts b/src/renderer/src/components/terminal/window-close-running-work.ts index ccb9a34c593..75bc302e4a2 100644 --- a/src/renderer/src/components/terminal/window-close-running-work.ts +++ b/src/renderer/src/components/terminal/window-close-running-work.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import { isRemoteExecutionHostPtyId } from '../../../../shared/remote-execution-host-pty-id' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { collectTabPtyIds } from './running-terminal-close-guard' import { probePtyRunningWork } from './pty-running-work-probe' @@ -32,10 +32,12 @@ export type WindowCloseRunningWork = * * Two deliberate asymmetries: * - * - **Quit only considers remote ptys.** Quitting is an unambiguous instruction to end this + * - **Quit only considers SSH relay ptys.** Quitting is an unambiguous instruction to end this * machine's processes (#524), but it is not an instruction to end execution on someone else's: - * the client detaches while the relay keeps running, and a target with a bounded grace period - * then SIGKILLs that work once the countdown expires. + * an SSH relay (`ssh:` ids) keeps its terminals only for its grace period after the client + * detaches, then kills them. A paired runtime (`remote:` ids: `orca serve`, managed SSH/WSL + * orcad) owns its terminals outright and keeps them after the client quits, so quitting + * endangers nothing there, reachable or not (#29916). * - **Only a remote `unverifiable` warns.** A local probe has no transport to lose, so its failure * means the pty is gone. A remote one that cannot be reached is the case * `docs/reference/ssh-execution-boundary.md` exists to protect: loss of contact is not evidence @@ -51,7 +53,7 @@ export async function assessWindowCloseRunningWork(params: { .flatMap((tab) => collectTabPtyIds(state, tab.id)) ) const candidatePtyIds = params.isQuitting - ? [...ptyIds].filter(isRemoteExecutionHostPtyId) + ? [...ptyIds].filter((ptyId) => parseAppSshPtyId(ptyId) !== null) : [...ptyIds] if (candidatePtyIds.length === 0) { return { kind: 'none' }