fix(ssh): preserve verdicts across synthetic kill exits

This commit is contained in:
Brennan Benson
2026-08-16 19:19:29 -07:00
parent a5628889c5
commit d4a0e7f1af
2 changed files with 59 additions and 0 deletions
+46
View File
@@ -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'
+13
View File
@@ -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 })