fix(ssh): cancelled controlmaster stream waits

Return promptly when the ssh executor receives a user interrupt while ControlMaster-owned streams remain open.

Fixes #2180
This commit is contained in:
roboomp
2026-06-09 09:46:52 +00:00
parent 4b5200a163
commit daee87f00e
3 changed files with 126 additions and 4 deletions
+57 -4
View File
@@ -42,6 +42,42 @@ export interface SSHResult {
artifactId?: string;
}
type SSHExitEvent = { kind: "exit"; exitCode: number } | { kind: "error"; error: unknown };
function sshExitEvent(exitCode: number): SSHExitEvent {
return { kind: "exit", exitCode };
}
function sshErrorEvent(error: unknown): SSHExitEvent {
return { kind: "error", error };
}
function createAbortWaiter(
signal: AbortSignal | undefined,
streamAbort: AbortController,
): { promise: Promise<ptree.AbortError> | undefined; cleanup: () => void } {
if (!signal) {
return { promise: undefined, cleanup: () => {} };
}
const { promise, resolve } = Promise.withResolvers<ptree.AbortError>();
const onAbort = () => {
const error = new ptree.AbortError(signal.reason, "<cancelled>");
if (!streamAbort.signal.aborted) {
streamAbort.abort(error);
}
resolve(error);
};
if (signal.aborted) {
onAbort();
return { promise, cleanup: () => {} };
}
signal.addEventListener("abort", onAbort, { once: true });
return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
}
function quoteForCompatShell(command: string): string {
if (command.length === 0) {
return "''";
@@ -94,19 +130,34 @@ export async function executeSSH(
maxColumns: resolveOutputMaxColumns(settings),
});
const streams = [child.stdout.pipeTo(sink.createInput())];
const streamAbort = new AbortController();
const abortWaiter = createAbortWaiter(options?.signal, streamAbort);
const streamOptions = { signal: streamAbort.signal };
const streams = [child.stdout.pipeTo(sink.createInput(), streamOptions)];
if (child.stderr) {
streams.push(child.stderr.pipeTo(sink.createInput()));
streams.push(child.stderr.pipeTo(sink.createInput(), streamOptions));
}
await Promise.allSettled(streams).catch(() => {});
const streamsSettled = Promise.allSettled(streams).then(() => {});
try {
const exitEvent = child.exited.then(sshExitEvent, sshErrorEvent);
const abortEvent = abortWaiter.promise?.then(sshErrorEvent);
const event = await (abortEvent ? Promise.race([exitEvent, abortEvent]) : exitEvent);
if (event.kind === "error") {
throw event.error;
}
await streamsSettled;
return {
exitCode: await child.exited,
exitCode: event.exitCode,
cancelled: false,
...(await sink.dump()),
};
} catch (err) {
if (!streamAbort.signal.aborted) {
streamAbort.abort(err);
}
void streamsSettled;
if (err instanceof ptree.Exception) {
if (err instanceof ptree.TimeoutError) {
return {
@@ -129,5 +180,7 @@ export async function executeSSH(
};
}
throw err;
} finally {
abortWaiter.cleanup();
}
}