From 32f92250079864893a17ee6f57239ee669e72682 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:43:32 -0700 Subject: [PATCH] fix(ssh): keep a reattached PTY live when its delivery cancellation is unproven On the restoreRequired retry path the first attach already answered with an incarnation, so the remote PTY is proven alive and only its delivery record was retired. Filing that as ambiguous exit evidence recorded a live agent as possibly dead. Report it as restore-required and accept the PTY as live. Both errors already map to deliveryUnresumable in the renderer and to a 'detached' lease, so the pane behaves exactly as before. --- .../ssh-pty-restore-required-reattach.test.ts | 10 ++++++---- src/main/providers/ssh-pty-session-reattach.ts | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/providers/ssh-pty-restore-required-reattach.test.ts b/src/main/providers/ssh-pty-restore-required-reattach.test.ts index db4c20deab8..abcd779e50b 100644 --- a/src/main/providers/ssh-pty-restore-required-reattach.test.ts +++ b/src/main/providers/ssh-pty-restore-required-reattach.test.ts @@ -303,7 +303,7 @@ describe('SSH PTY reattach when the relay requires source restoration', () => { expect(mux.request.mock.calls.filter((call) => call[0] === 'pty.attach')).toHaveLength(2) }) - it('does not install a second activation when the first cancellation is unverifiable', async () => { + it('keeps the PTY live when the first activation cancellation is unproven', async () => { const mux = createMockMux() const activation = { status: 'pending', @@ -339,10 +339,12 @@ describe('SSH PTY reattach when the relay requires source restoration', () => { const message = await spawnError(provider) - expect(message).toContain(SSH_PTY_LIVENESS_UNVERIFIABLE_ERROR) + // The attach answered with an incarnation, so the PTY is proven live; only the retry is blocked. + expect(message).toContain(SSH_PTY_RESTORE_REQUIRED_ERROR) + expect(message).not.toContain(SSH_PTY_LIVENESS_UNVERIFIABLE_ERROR) expect(message).not.toContain(SSH_SESSION_EXPIRED_ERROR) - expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(false) - await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBeNull() + expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(true) + await expect(provider.probePtyLiveness('ssh:conn-1@@pty-old')).resolves.toBe(true) expect(mux.request.mock.calls.filter((call) => call[0] === 'pty.attach')).toHaveLength(1) expect(mux.request.mock.calls.filter((call) => call[0] === 'pty.spawn')).toHaveLength(0) expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index 2b2aa8d04c2..5fb8cc2460c 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -246,8 +246,11 @@ export async function reattachSshPtySessionForSpawn( if (unresumable.sourceActivationLease) { const canceled = await unresumable.sourceActivationLease.rollback() if (!canceled) { + // Why: the attach answered with an incarnation, so the PTY is live; only its delivery + // is stuck, and an unproven cancellation just blocks the second attach. + args.acceptLivePty(unresumable.id) throw new Error( - `${SSH_PTY_LIVENESS_UNVERIFIABLE_ERROR}: ${toRelaySshPtyId(args.connectionId, unresumable.id)}` + `${SSH_PTY_RESTORE_REQUIRED_ERROR}: ${toRelaySshPtyId(args.connectionId, unresumable.id)}` ) } }