import type { ChildProcess } from 'node:child_process' import type { SystemSshCommandChannel } from './system-ssh-command' export type ProcessResult = { label: string; stderr: string } /** * `timeoutMs` bounds a remote consumer that never returns. Windows PowerShell 5.1 cannot drain a * large redirected stdin over a non-pty ssh exec (#16432): the remote process stays alive at idle * CPU, writes nothing, and never closes — so without a bound this promise is simply never settled * and the caller waits forever with no error to show. */ export function waitForChannelClose( channel: SystemSshCommandChannel, label: string, timeoutMs?: number ): Promise { return new Promise((resolve, reject) => { let stderr = '' let timer: ReturnType | null = null const cleanup = (): void => { if (timer) { clearTimeout(timer) timer = null } channel.stderr.off('data', onStderrData) channel.off('error', onError) channel.off('close', onClose) } const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => { cleanup() fn(val as never) } if (timeoutMs !== undefined) { timer = setTimeout(() => { // Settle before closing: the close we request would otherwise come back as a SIGTERM // failure and mask the timeout, which is the only diagnosis a wedged remote gives. settle( reject, new Error( `${label} timed out after ${timeoutMs}ms with no response from the remote host: ${stderr.trim()}` ) ) channel.close() }, timeoutMs) timer.unref?.() } const onStderrData = (data: Buffer): void => { stderr += data.toString('utf-8') } const onError = (err: Error): void => { settle(reject, err) } const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) return } settle(resolve) } channel.stderr.on('data', onStderrData) channel.on('error', onError) channel.on('close', onClose) }) } export function waitForProcess(proc: ChildProcess, label: string): Promise { return new Promise((resolve, reject) => { let stderr = '' const cleanup = (): void => { proc.stderr?.off('data', onStderrData) proc.off('error', onError) proc.off('close', onClose) } const settle = (fn: typeof resolve | typeof reject, val: ProcessResult | Error): void => { cleanup() fn(val as never) } const onStderrData = (data: Buffer): void => { stderr += data.toString('utf-8') } const onError = (err: Error): void => { settle(reject, err) } const onClose = (code: number | null): void => { if (code !== 0) { settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) return } settle(resolve, { label, stderr }) } proc.stderr?.on('data', onStderrData) proc.on('error', onError) proc.on('close', onClose) }) } export function killProcess(proc: ChildProcess): void { if (proc.exitCode !== null || proc.killed) { return } try { proc.kill('SIGTERM') } catch { // Process may already be dead } } export async function awaitWithSystemSshAbort( signal: AbortSignal | undefined, abortChildren: () => void, operation: Promise ): Promise { if (!signal) { return operation } let abortReject: ((error: Error) => void) | null = null let suppressLateOperationError = false const abortPromise = new Promise((_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?.( Object.assign(createAbortError(), { // The child was signaled, but this fast abort path intentionally does // not wait for process exit; callers holding remote locks must retain them. sshChannelCloseConfirmed: false }) ) } signal.addEventListener('abort', abort, { once: true }) if (signal.aborted) { abort() } try { return await Promise.race([ operation.catch((error: unknown) => { if (suppressLateOperationError) { return new Promise(() => {}) } throw error }), abortPromise ]) } finally { signal.removeEventListener('abort', abort) } } export 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' return error }