mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
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>
This commit is contained in:
@@ -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("");
|
||||
},
|
||||
);
|
||||
@@ -54,10 +54,12 @@ export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user