From ecb623d8d3e8eb0cf7e31e698c6b6a206cfb1397 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 12:02:15 -0700 Subject: [PATCH] Clean up SSH relay exec listeners (#3843) --- src/main/ssh/ssh-relay-deploy-helpers.test.ts | 30 +++++++++ src/main/ssh/ssh-relay-deploy-helpers.ts | 63 ++++++++++--------- 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index b0e281293..a954e03ab 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -166,5 +166,35 @@ describe('execCommand', () => { await Promise.resolve() expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() await expect(commandPromise).rejects.toThrow('remote host rebooted') + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + }) + + it('cleans command channel listeners when a command times out', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, 'sleep 60') + + await Promise.resolve() + const rejection = expect(commandPromise).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(30_000) + + await rejection + expect(channel.close).toHaveBeenCalledOnce() + expect(channel.listenerCount('error')).toBe(0) + expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(0) + expect(channel.stderr.listenerCount('error')).toBe(0) + expect(channel.stderr.listenerCount('data')).toBe(0) + } finally { + vi.useRealTimers() + } }) }) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 57d4c203e..9e2be8c15 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -247,45 +247,50 @@ export async function execCommand(conn: SshConnection, command: string): Promise let stderr = '' let settled = false - const timeout = setTimeout(() => { - if (!settled) { - settled = true - channel.close() - reject(new Error(`Command "${command}" timed out after ${EXEC_TIMEOUT_MS / 1000}s`)) - } - }, EXEC_TIMEOUT_MS) - - const fail = (err: Error): void => { + const cleanup = (): void => { + clearTimeout(timeout) + channel.off('error', fail) + channel.stderr.off('error', fail) + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => { if (settled) { return } settled = true - clearTimeout(timeout) - reject(err) + cleanup() + fn(val as never) } + const fail = (err: Error): void => { + settle(reject, err) + } + const onStdoutData = (data: Buffer): void => { + stdout += data.toString('utf-8') + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString('utf-8') + } + const onClose = (code: number): void => { + if (code !== 0) { + settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${stderr.trim()}`)) + } else { + settle(resolve, stdout) + } + } + const timeout = setTimeout(() => { + channel.close() + settle(reject, new Error(`Command "${command}" timed out after ${EXEC_TIMEOUT_MS / 1000}s`)) + }, EXEC_TIMEOUT_MS) // Why: remote reboot tears down exec channels with stream errors. Without // scoped listeners, Node treats those as uncaught exceptions. channel.on('error', fail) channel.stderr.on('error', fail) - channel.on('data', (data: Buffer) => { - stdout += data.toString('utf-8') - }) - channel.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf-8') - }) - channel.on('close', (code: number) => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (code !== 0) { - reject(new Error(`Command "${command}" failed (exit ${code}): ${stderr.trim()}`)) - } else { - resolve(stdout) - } - }) + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('close', onClose) }) }