From c777bb19bd580640b2d4eb32ef055063efb5b030 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:50:32 -0700 Subject: [PATCH] fix(ssh): require matching PTY exit incarnation --- .../ssh-pty-provider-exit-race.test.ts | 41 +++++++++++++++++++ src/main/providers/ssh-pty-spawn-exit-race.ts | 4 +- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/main/providers/ssh-pty-provider-exit-race.test.ts b/src/main/providers/ssh-pty-provider-exit-race.test.ts index 22a19985d21..89b48042f84 100644 --- a/src/main/providers/ssh-pty-provider-exit-race.test.ts +++ b/src/main/providers/ssh-pty-provider-exit-race.test.ts @@ -130,6 +130,47 @@ it('rejects an SSH reattach whose matching exit shares the attach reply batch', }) }) +it('does not classify a legacy exit as current while reattach resolves its incarnation', async () => { + const mux = { + request: vi.fn(), + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false) + } + const provider = new SshPtyProvider('conn-1', mux as never) + let resolveAttach: (() => void) | undefined + mux.request.mockImplementation((method: string, _params, options) => { + if (method !== 'pty.attach') { + return Promise.resolve(undefined) + } + return new Promise((resolve) => { + resolveAttach = () => { + const result = { incarnationId: 'incarnation-current' } + options?.beforeResolve?.(result) + resolve(result) + } + }) + }) + + const spawn = provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'ssh:conn-1@@pty-existing' + }) + await vi.waitFor(() => expect(resolveAttach).toBeTypeOf('function')) + const notify = mux.onNotification.mock.calls[0]?.[0] + notify?.('pty.exit', { id: 'pty-existing', code: 0 }) + resolveAttach?.() + + await expect(spawn).resolves.toMatchObject({ + id: 'ssh:conn-1@@pty-existing', + incarnationId: 'incarnation-current', + isReattach: true + }) + await expect(provider.probePtyLiveness('ssh:conn-1@@pty-existing')).resolves.toBe(true) +}) + it('returns a provisional source activation lease to reconnect authority', async () => { const mux = { request: vi.fn(), diff --git a/src/main/providers/ssh-pty-spawn-exit-race.ts b/src/main/providers/ssh-pty-spawn-exit-race.ts index 0658e3e23eb..a7c2dd51e76 100644 --- a/src/main/providers/ssh-pty-spawn-exit-race.ts +++ b/src/main/providers/ssh-pty-spawn-exit-race.ts @@ -29,9 +29,7 @@ export class SshPtySpawnExitRaceTracker { return operation.exits.some( (exit) => exit.relayPtyId === result.id && - (!exit.incarnationId || - !result.incarnationId || - exit.incarnationId === result.incarnationId) + (!result.incarnationId || exit.incarnationId === result.incarnationId) ) }