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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -14,13 +14,13 @@ function createNeverClosingStream(): ReadableStream<Uint8Array> {
|
||||
});
|
||||
}
|
||||
|
||||
function createBlockedChild<In extends TestStdin>(): ChildProcess<In> {
|
||||
function createBlockedChild<In extends TestStdin>(exited?: Promise<number>): ChildProcess<In> {
|
||||
const { promise } = Promise.withResolvers<number>();
|
||||
|
||||
return {
|
||||
stdout: createNeverClosingStream(),
|
||||
stderr: undefined,
|
||||
exited: promise,
|
||||
exited: exited ?? promise,
|
||||
[Symbol.dispose]() {},
|
||||
} as unknown as ChildProcess<In>;
|
||||
}
|
||||
@@ -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<number>) {
|
||||
vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue();
|
||||
vi.spyOn(connectionManager, "buildRemoteCommand").mockResolvedValue(["remote", "sleep 60"]);
|
||||
vi.spyOn(sshfsMount, "hasSshfs").mockReturnValue(false);
|
||||
vi.spyOn(ptree, "spawn").mockImplementation(<In extends TestStdin>() => createBlockedChild<In>());
|
||||
vi.spyOn(ptree, "spawn").mockImplementation(<In extends TestStdin>() => createBlockedChild<In>(exited));
|
||||
}
|
||||
|
||||
function startOpenStreamCommand(controller: AbortController) {
|
||||
const chunked = Promise.withResolvers<void>();
|
||||
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<typeof resultPromise> | 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<typeof resultPromise> | undefined;
|
||||
resultPromise.then(value => {
|
||||
|
||||
Reference in New Issue
Block a user