From d0fd2e532059eeaec4c9e9e510fd65e1a3c6e1ed Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:50:06 +0000 Subject: [PATCH] fix(client): close failed SSH tunnels without crashing callers (#7876) Route SSH stdin stream errors through the existing tunnel failure handler so pending writes cannot emit an unhandled EPIPE. Reproduce the peer abort using real OpenSSH in an isolated caller process. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/client/src/node/ssh-tunnel.test.ts | 55 +++++++++++++++++++++ packages/client/src/node/ssh-tunnel.ts | 6 ++- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/node/ssh-tunnel.test.ts diff --git a/packages/client/src/node/ssh-tunnel.test.ts b/packages/client/src/node/ssh-tunnel.test.ts new file mode 100644 index 0000000000..6bdfe27fe4 --- /dev/null +++ b/packages/client/src/node/ssh-tunnel.test.ts @@ -0,0 +1,55 @@ +import { execFile, spawnSync } from "node:child_process"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const run = promisify(execFile); +const hasSsh = !spawnSync("ssh", ["-V"]).error; + +test.skipIf(!hasSsh)( + "an SSH peer abort during a pending write closes the tunnel without crashing its caller", + async () => { + // Isolate the caller so an unhandled stream error fails this assertion instead + // of crashing the test worker. Both the TCP peer and OpenSSH are real. + const { stdout } = await run( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "-e", + ` + import { createServer, createConnection } from 'node:net'; + import { once } from 'node:events'; + import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)}; + + const remote = createServer(socket => socket.destroy()); + remote.listen(0, '127.0.0.1'); + await once(remote, 'listening'); + const tunnel = await createSshTunnel({ + host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767, + }); + const [host, port] = tunnel.endpoint.split(':'); + const client = createConnection({ host, port: Number(port) }); + client.on('error', () => undefined); + try { + const closed = new Promise(resolve => client.once('close', resolve)); + await once(client, 'connect'); + client.write(Buffer.alloc(8 * 1024 * 1024, 65)); + await closed; + console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() })); + } finally { + client.destroy(); + tunnel.close(); + await new Promise(resolve => remote.close(resolve)); + } + `, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(stdout)).toEqual({ + closed: true, + failure: expect.any(String), + }); + expect(JSON.parse(stdout).failure).not.toBe(""); + }, +); diff --git a/packages/client/src/node/ssh-tunnel.ts b/packages/client/src/node/ssh-tunnel.ts index 33900d0baa..a67c0a91c4 100644 --- a/packages/client/src/node/ssh-tunnel.ts +++ b/packages/client/src/node/ssh-tunnel.ts @@ -54,10 +54,12 @@ export function createSshTunnel(target: SshTransportTarget): Promise child.stderr.on("data", (chunk: Buffer | string) => { stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); }); - child.on("error", (error) => { + const fail = (error: Error) => { failure = error.message; acceptedSocket.destroy(error); - }); + }; + child.on("error", fail); + child.stdin.on("error", fail); child.on("exit", (code, signal) => { if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); acceptedSocket.destroy(failure ? new Error(failure) : undefined);