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);