From 8044be7cc7a06b74ac75283febffd1fd24a57dd2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 11:32:49 -0700 Subject: [PATCH] fix: clean up system ssh probe listeners (#3824) --- src/main/ssh/ssh-connection.test.ts | 35 ++++++++++++ src/main/ssh/ssh-connection.ts | 89 +++++++++++++++++------------ 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index fb58ad7f27e..18ebc6e6551 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -565,6 +565,41 @@ describe('SshConnection', () => { 'printf ORCA-SYSTEM-SSH-OK' ) }) + + it('removes system SSH probe listeners after timeout', async () => { + vi.useFakeTimers() + const channel = new EventEmitter() as ReturnType + channel.stdin = { end: vi.fn(), write: vi.fn() } + channel.stderr = new EventEmitter() + channel.close = vi.fn() + spawnSystemSshCommandMock.mockReturnValueOnce(channel) + vi.mocked(resolveWithSshG).mockResolvedValueOnce({ + hostname: 'example.com', + port: 22, + identityFile: [], + forwardAgent: false, + identitiesOnly: false, + proxyUseFdpass: true + }) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + try { + const connect = expect(conn.connect()).rejects.toThrow('System SSH connection timed out') + await vi.advanceTimersByTimeAsync(30_000) + + await connect + expect(channel.close).toHaveBeenCalled() + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('error')).toBe(1) + expect(channel.listenerCount('close')).toBe(1) + expect(channel.stderr.listenerCount('data')).toBe(0) + expect( + (conn as unknown as { systemCommandChannels: Set }).systemCommandChannels.size + ).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) describe('shouldUseSystemSshTransport', () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 86ca9e2554e..397cf1e868f 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -372,47 +372,60 @@ export class SshConnection { let stdout = '' let stderr = '' let settled = false - const timeout = setTimeout(() => { + const cleanup = (): void => { + clearTimeout(timeout) + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('error', onError) + channel.off('close', onClose) + this.systemCommandChannels.delete(channel) + } + const settle = (callback: () => void): void => { + if (settled) { + return + } settled = true - channel.close() - reject(new Error('System SSH connection timed out')) + cleanup() + callback() + } + const onStdoutData = (data: Buffer): void => { + stdout += data.toString('utf-8') + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString('utf-8') + } + const onError = (err: Error): void => { + settle(() => reject(err)) + } + const onClose = (code: number | null): void => { + settle(() => { + if (this.disposed || connectGeneration !== this.connectGeneration) { + reject(new Error('SSH connection attempt was cancelled')) + return + } + if (code !== 0 || !stdout.includes('ORCA-SYSTEM-SSH-OK')) { + reject( + new Error( + `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderr ? ` stderr: ${stderr.trim()}` : ''}` + ) + ) + return + } + this.setState('connected') + resolve() + }) + } + const timeout = setTimeout(() => { + settle(() => { + channel.close() + reject(new Error('System SSH connection timed out')) + }) }, CONNECT_TIMEOUT_MS) - channel.on('data', (data: Buffer) => { - stdout += data.toString('utf-8') - }) - channel.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf-8') - }) - channel.on('error', (err: Error) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - reject(err) - }) - channel.on('close', (code: number | null) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (this.disposed || connectGeneration !== this.connectGeneration) { - reject(new Error('SSH connection attempt was cancelled')) - return - } - if (code !== 0 || !stdout.includes('ORCA-SYSTEM-SSH-OK')) { - reject( - new Error( - `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderr ? ` stderr: ${stderr.trim()}` : ''}` - ) - ) - return - } - this.setState('connected') - resolve() - }) + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('error', onError) + channel.on('close', onClose) }) } catch (err) { this.useSystemSshTransport = false