fix: settle system ssh aborts (#3842)

This commit is contained in:
Neil 2026-05-30 12:05:25 -07:00 committed by GitHub
parent f470168cba
commit 3afebb7b4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 155 additions and 24 deletions

View File

@ -1,4 +1,5 @@
import { EventEmitter } from 'events'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi, beforeEach } from 'vitest'
const { existsSyncMock, spawnMock } = vi.hoisted(() => ({
@ -19,6 +20,7 @@ import {
findSystemSsh,
spawnSystemSsh,
spawnSystemSshCommand,
uploadDirectoryViaSystemSsh,
writeFileViaSystemSsh
} from './ssh-system-fallback'
import type { SshTarget } from '../../shared/ssh-types'
@ -62,6 +64,37 @@ function createEventedProcess(): EventedProcess {
return proc
}
function createMockChildProcess(): EventEmitter & {
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
pid: number
kill: ReturnType<typeof vi.fn>
killed: boolean
exitCode: number | null
} {
const child = new EventEmitter() as EventEmitter & {
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
pid: number
kill: ReturnType<typeof vi.fn>
killed: boolean
exitCode: number | null
}
child.stdin = new PassThrough()
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.pid = 12345
child.killed = false
child.exitCode = null
child.kill = vi.fn(() => {
child.killed = true
return true
})
return child
}
describe('findSystemSsh', () => {
beforeEach(() => {
existsSyncMock.mockReset()
@ -256,3 +289,60 @@ describe('spawnSystemSsh', () => {
expect(typeof result.onExit).toBe('function')
})
})
describe('system SSH operation aborts', () => {
beforeEach(() => {
existsSyncMock.mockReset()
spawnMock.mockReset()
existsSyncMock.mockImplementation((p: string) => p === '/usr/bin/ssh')
})
it('rejects directory uploads when aborted even if child processes do not close', async () => {
const tarCreate = createMockChildProcess()
const sshExtract = createMockChildProcess()
spawnMock.mockReturnValueOnce(tarCreate).mockReturnValueOnce(sshExtract)
const controller = new AbortController()
const uploadPromise = uploadDirectoryViaSystemSsh(
createTarget(),
'/tmp/local-relay',
'/tmp/remote-relay',
{ signal: controller.signal }
)
controller.abort()
const outcome = await Promise.race([
uploadPromise.then(
() => 'resolved',
(error: Error) => error.name
),
new Promise<string>((resolve) => setTimeout(() => resolve('pending'), 0))
])
expect(outcome).toBe('AbortError')
expect(tarCreate.kill).toHaveBeenCalledTimes(1)
expect(sshExtract.kill).toHaveBeenCalledTimes(1)
})
it('rejects remote file writes when aborted even if ssh never closes', async () => {
const sshProcess = createMockChildProcess()
spawnMock.mockReturnValueOnce(sshProcess)
const controller = new AbortController()
const writePromise = writeFileViaSystemSsh(createTarget(), '/tmp/remote-file', 'contents', {
signal: controller.signal
})
controller.abort()
const outcome = await Promise.race([
writePromise.then(
() => 'resolved',
(error: Error) => error.name
),
new Promise<string>((resolve) => setTimeout(() => resolve('pending'), 0))
])
expect(outcome).toBe('AbortError')
expect(sshProcess.kill).toHaveBeenCalledTimes(1)
})
})

View File

@ -109,25 +109,25 @@ export async function uploadDirectoryViaSystemSsh(
}
)
const abort = (): void => {
killProcess(tarCreate)
killProcess(sshExtract)
}
options?.signal?.addEventListener('abort', abort, { once: true })
let tarResult: ProcessResult | null = null
let sshResult: ProcessResult | null = null
try {
;[tarResult, sshResult] = await Promise.all([
waitForProcess(tarCreate, 'local tar relay upload'),
waitForProcess(sshExtract, 'system ssh relay upload'),
pipeline(tarCreate.stdout!, sshExtract.stdin!)
]).then(([tar, ssh]) => [tar, ssh])
;[tarResult, sshResult] = await awaitWithSystemSshAbort(
options?.signal,
() => {
killProcess(tarCreate)
killProcess(sshExtract)
},
Promise.all([
waitForProcess(tarCreate, 'local tar relay upload'),
waitForProcess(sshExtract, 'system ssh relay upload'),
pipeline(tarCreate.stdout!, sshExtract.stdin!)
]).then(([tar, ssh]) => [tar, ssh] as const)
)
} catch (err) {
killProcess(tarCreate)
killProcess(sshExtract)
throw err
} finally {
options?.signal?.removeEventListener('abort', abort)
}
if (tarResult?.stderr.trim()) {
@ -146,17 +146,15 @@ export async function writeFileViaSystemSsh(
): Promise<void> {
throwIfAborted(options?.signal)
const channel = spawnSystemSshCommand(target, `cat > ${shellEscape(remotePath)}`)
const abort = (): void => {
channel.close()
}
options?.signal?.addEventListener('abort', abort, { once: true })
const closePromise = waitForChannelClose(channel, `write ${remotePath}`)
channel.stdin.end(contents)
try {
await closePromise
} finally {
options?.signal?.removeEventListener('abort', abort)
const closePromise = awaitWithSystemSshAbort(
options?.signal,
() => channel.close(),
waitForChannelClose(channel, `write ${remotePath}`)
)
if (!options?.signal?.aborted) {
channel.stdin.end(contents)
}
await closePromise
}
export function buildSshArgs(target: SshTarget): string[] {
@ -368,11 +366,54 @@ function killProcess(proc: ChildProcess): void {
}
}
async function awaitWithSystemSshAbort<T>(
signal: AbortSignal | undefined,
abortChildren: () => void,
operation: Promise<T>
): Promise<T> {
if (!signal) {
return operation
}
let abortReject: ((error: Error) => void) | null = null
let suppressLateOperationError = false
const abortPromise = new Promise<never>((_resolve, reject) => {
abortReject = reject
})
const abort = (): void => {
// Why: abort is connection teardown; do not wait for stubborn system ssh/tar
// children to emit close after we've already signaled them.
abortChildren()
suppressLateOperationError = true
abortReject?.(createAbortError())
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
}
try {
return await Promise.race([
operation.catch((error: unknown) => {
if (suppressLateOperationError) {
return new Promise<never>(() => {})
}
throw error
}),
abortPromise
])
} finally {
signal.removeEventListener('abort', abort)
}
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (!signal?.aborted) {
return
}
throw createAbortError()
}
function createAbortError(): Error & { name: string } {
const error = new Error('System SSH operation was cancelled') as Error & { name: string }
error.name = 'AbortError'
throw error
return error
}