Clean up SSH relay exec listeners (#3843)

This commit is contained in:
Neil 2026-05-30 12:02:15 -07:00 committed by GitHub
parent f66e42a2bb
commit ecb623d8d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 64 additions and 29 deletions

View File

@ -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()
}
})
})

View File

@ -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)
})
}