diff --git a/src/main/ipc/pty-ssh-stop-verdict.test.ts b/src/main/ipc/pty-ssh-stop-verdict.test.ts index aee780e8a37..caed84c506e 100644 --- a/src/main/ipc/pty-ssh-stop-verdict.test.ts +++ b/src/main/ipc/pty-ssh-stop-verdict.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers, @@ -111,6 +112,23 @@ describe('stopping a PTY whose SSH provider is unregistered', () => { deletePtyOwnership('ssh-detached-stop') }) + it('preserves lost-contact evidence for renderer IPC teardown', async () => { + const ptyId = 'ssh-renderer-detached' + setPtyOwnership(ptyId, 'ssh-dropped') + const { runtime } = installController() + try { + await handlers.get('pty:kill')!(null, { id: ptyId }) + + expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith( + ptyId, + SSH_PROVIDER_UNREGISTERED_REASON + ) + expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined) + } finally { + deletePtyOwnership(ptyId) + } + }) + it('still confirms a stop the owning provider actually performed', async () => { const daemon = installObservableDaemonTestProvider() vi.spyOn(getLocalPtyProvider(), 'listProcesses').mockResolvedValue([]) @@ -149,6 +167,34 @@ describe('stopping a PTY whose SSH provider is unregistered', () => { } }) + it('preserves lost-contact evidence when fire-and-forget shutdown rejects', async () => { + const connectionId = 'ssh-async-kill-drop' + const ptyId = 'ssh-async-kill-pty' + const provider = { + onExit: vi.fn(() => () => {}), + shutdown: vi.fn(async () => { + throw new Error('relay disconnected during kill') + }) + } + registerSshPtyProvider(connectionId, provider as never) + setPtyOwnership(ptyId, connectionId) + try { + const { controller, runtime } = installController() + + expect(controller.kill(ptyId)).toBe(true) + await vi.waitFor(() => + expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith( + ptyId, + 'relay disconnected during kill' + ) + ) + expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined) + } finally { + deletePtyOwnership(ptyId) + unregisterSshPtyProvider(connectionId) + } + }) + it('reports a provider-observed survivor as live', async () => { const connectionId = 'ssh-still-live' const ptyId = 'ssh-still-live-pty' diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 58f8559396b..fe99b841756 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5559,6 +5559,12 @@ export function registerPtyHandlers( // Why: close runtime tails without clearing provider ownership, so // a retry can still target a PTY that survived the failed shutdown. if (!retired) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) } }) @@ -5572,6 +5578,12 @@ export function registerPtyHandlers( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) if (!retiredRejectedPtyIds.has(ptyId)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) } }) @@ -7604,6 +7616,7 @@ export function registerPtyHandlers( // provider is unregistered; hydrated app-scoped ids can also arrive // before ownership is rebuilt. Tombstone instead of falling back local. const incarnationId = finishPtyShutdown(args.id, connectionId, store) + runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) runtime?.onPtyExit(args.id, -1, incarnationId) rememberSyntheticKillExit(args.id) sendPtyExitToRenderer({ id: args.id, code: -1 })