From 80a47a83291e383d4bf01aee687c7e80dcffb8fe Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 9 Jun 2026 09:54:15 +0000 Subject: [PATCH] fix(ssh): report abort after process exit Preserve cancellation semantics when a user interrupt unblocks SSH stream drains after the mux client already exited. Fixes #2180 --- packages/coding-agent/src/ssh/ssh-executor.ts | 5 ++- .../test/ssh/ssh-executor.test.ts | 42 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/ssh/ssh-executor.ts b/packages/coding-agent/src/ssh/ssh-executor.ts index 6e3a7be28..dbe553cd6 100644 --- a/packages/coding-agent/src/ssh/ssh-executor.ts +++ b/packages/coding-agent/src/ssh/ssh-executor.ts @@ -147,7 +147,10 @@ export async function executeSSH( throw event.error; } - await streamsSettled; + const streamEvent = await (abortEvent ? Promise.race([streamsSettled, abortEvent]) : streamsSettled); + if (streamEvent?.kind === "error") { + throw streamEvent.error; + } return { exitCode: event.exitCode, cancelled: false, diff --git a/packages/coding-agent/test/ssh/ssh-executor.test.ts b/packages/coding-agent/test/ssh/ssh-executor.test.ts index 085c32531..d261ff157 100644 --- a/packages/coding-agent/test/ssh/ssh-executor.test.ts +++ b/packages/coding-agent/test/ssh/ssh-executor.test.ts @@ -14,13 +14,13 @@ function createNeverClosingStream(): ReadableStream { }); } -function createBlockedChild(): ChildProcess { +function createBlockedChild(exited?: Promise): ChildProcess { const { promise } = Promise.withResolvers(); return { stdout: createNeverClosingStream(), stderr: undefined, - exited: promise, + exited: exited ?? promise, [Symbol.dispose]() {}, } as unknown as ChildProcess; } @@ -36,19 +36,49 @@ describe("executeSSH", () => { vi.restoreAllMocks(); }); - it("returns promptly when an abort races a ControlMaster stream that stays open", async () => { + function mockOpenStreamChild(exited?: Promise) { vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(); vi.spyOn(connectionManager, "buildRemoteCommand").mockResolvedValue(["remote", "sleep 60"]); vi.spyOn(sshfsMount, "hasSshfs").mockReturnValue(false); - vi.spyOn(ptree, "spawn").mockImplementation(() => createBlockedChild()); + vi.spyOn(ptree, "spawn").mockImplementation(() => createBlockedChild(exited)); + } + function startOpenStreamCommand(controller: AbortController) { const chunked = Promise.withResolvers(); - const controller = new AbortController(); const resultPromise = executeSSH({ name: "remote", host: "remote" }, "sleep 60", { signal: controller.signal, onChunk: () => chunked.resolve(), }); - await chunked.promise; + return { resultPromise, chunked: chunked.promise }; + } + + it("returns promptly when an abort races a ControlMaster stream that stays open", async () => { + mockOpenStreamChild(); + + const controller = new AbortController(); + const { resultPromise, chunked } = startOpenStreamCommand(controller); + await chunked; + + let result: Awaited | undefined; + resultPromise.then(value => { + result = value; + }); + controller.abort("user interrupt"); + await flushMicrotasks(20); + expect(result).toBeDefined(); + if (!result) return; + expect(result.cancelled).toBe(true); + expect(result.exitCode).toBeUndefined(); + expect(result.output).toContain("Command aborted"); + }); + + it("reports cancellation when abort unblocks streams after the ssh process exits", async () => { + mockOpenStreamChild(Promise.resolve(0)); + + const controller = new AbortController(); + const { resultPromise, chunked } = startOpenStreamCommand(controller); + await chunked; + await flushMicrotasks(20); let result: Awaited | undefined; resultPromise.then(value => {