Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
367 changes: 367 additions & 0 deletions tests/connection-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,367 @@
/**
* Connection Timeout Tests
*
* Tests verifying timeout behavior for SSH connections and command execution
* in the ssh-mcp-server. These tests use mock SSH2 clients to simulate
* timeout scenarios without requiring real SSH infrastructure.
*/

import { strict as assert } from "node:assert";
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import { EventEmitter } from "node:events";
import { connections, StoredConnection } from "../src/connections.js";
import type { ConnectConfig } from "ssh2";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Minimal mock that behaves like an ssh2 Client for event-driven tests. */
function createMockClient(): EventEmitter & {
connect: (cfg: ConnectConfig) => void;
end: () => void;
destroy: () => void;
exec: (cmd: string, cb: (err: Error | null, stream: EventEmitter | null) => void) => void;
} {
const emitter = new EventEmitter() as any;
emitter.connect = mock.fn((_cfg: ConnectConfig) => {});
emitter.end = mock.fn(() => {});
emitter.destroy = mock.fn(() => {});
emitter.exec = mock.fn(
(_cmd: string, cb: (err: Error | null, stream: EventEmitter | null) => void) => {
cb(null, createMockStream());
}
);
return emitter;
}

/** Minimal mock stream returned by client.exec(). */
function createMockStream(): EventEmitter & { destroy: () => void; stderr: EventEmitter } {
const stream = new EventEmitter() as any;
stream.destroy = mock.fn(() => {});
stream.stderr = new EventEmitter();
return stream;
}

function makeStoredConnection(
overrides: Partial<StoredConnection> = {}
): StoredConnection {
const client = createMockClient() as any;
return {
client,
connectConfig: { host: "test-host", port: 22, username: "user" },
ready: true,
host: "test-host",
...overrides,
};
}

// ---------------------------------------------------------------------------
// Test Suite
// ---------------------------------------------------------------------------

describe("Connection Timeout Behavior", () => {
beforeEach(() => {
connections.clear();
});

afterEach(() => {
connections.clear();
});

// -----------------------------------------------------------------------
// 1. Outer connection timeout fires when the server never responds
// -----------------------------------------------------------------------
it("should timeout when the SSH server never becomes ready", async () => {
// Simulate the outer-timeout logic from connect.ts:
// If neither "ready" nor "error" fires before outerTimeout, the client
// is destroyed and an error result is produced.
const client = createMockClient();
const connectTimeoutMs = 100; // very short for testing
const outerTimeout = connectTimeoutMs + 50; // mirrors (readyTimeout + 5s) logic

const result = await new Promise<{ text: string; isError: boolean }>((resolve) => {
let settled = false;

const finish = (text: string, isError: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ text, isError });
};

const timer = setTimeout(() => {
client.destroy();
finish(
`Connection timed out after ${outerTimeout}ms. The host may be unreachable or firewalled.`,
true
);
}, outerTimeout);

client.on("ready", () => {
finish("Connected", false);
});

client.on("error", (err: Error) => {
finish(`SSH connection failed: ${err.message}`, true);
});

// Simulate connecting but server never responds (no "ready" event)
client.connect({ host: "unreachable.example.com", port: 22, readyTimeout: connectTimeoutMs });
});

assert.equal(result.isError, true);
assert.match(result.text, /timed out/i);
assert.match(result.text, new RegExp(String(outerTimeout)));
});

// -----------------------------------------------------------------------
// 2. Command execution timeout terminates the command and returns partial output
// -----------------------------------------------------------------------
it("should timeout a long-running command and return partial output", async () => {
const stored = makeStoredConnection();
connections.set("timeout-test", stored);

const commandTimeout = 100;

const result = await new Promise<{ text: string; isError: boolean }>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;

const finish = (text: string, isError: boolean) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve({ text, isError });
};

// Replicate execute.ts command-timeout logic
const mockStream = createMockStream();
let stdout = "";

timer = setTimeout(() => {
mockStream.destroy();
finish(
`Command timed out after ${commandTimeout}ms.\n\nPartial stdout:\n${stdout}\n\nPartial stderr:\n`,
true
);
}, commandTimeout);

mockStream.on("data", (data: Buffer) => {
stdout += data.toString();
});

// Simulate partial output arriving before timeout
mockStream.emit("data", Buffer.from("partial line 1\n"));
mockStream.emit("data", Buffer.from("partial line 2\n"));
// The command never closes — timeout should fire
});

assert.equal(result.isError, true);
assert.match(result.text, /timed out/i);
assert.match(result.text, /partial line 1/);
assert.match(result.text, /partial line 2/);
});

// -----------------------------------------------------------------------
// 3. readyTimeout in ConnectConfig is derived from connectTimeout param
// -----------------------------------------------------------------------
it("should set readyTimeout from the connectTimeout parameter", () => {
// The buildConnectConfig function sets readyTimeout = connectTimeout ?? 20000
// and the outer timeout is readyTimeout + 5000.
const defaultReadyTimeout = 20000;
const defaultOuterTimeout = defaultReadyTimeout + 5000;

assert.equal(defaultOuterTimeout, 25000, "default outer timeout should be 25000ms");

const customConnectTimeout = 5000;
const customOuterTimeout = customConnectTimeout + 5000;

assert.equal(customOuterTimeout, 10000, "custom outer timeout should be connectTimeout + 5000ms");

// Verify the relationship: outerTimeout always exceeds readyTimeout
assert.ok(
customOuterTimeout > customConnectTimeout,
"outer timeout must exceed readyTimeout to give ssh2 a chance to fire its own timeout"
);
});

// -----------------------------------------------------------------------
// 4. Graceful timeout handling: settled flag prevents double resolution
// -----------------------------------------------------------------------
it("should not resolve twice when both error and timeout fire", async () => {
const client = createMockClient();
let resolveCount = 0;

await new Promise<void>((resolve) => {
let settled = false;

const finish = (_text: string, _isError: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolveCount++;
resolve();
};

const timer = setTimeout(() => {
client.destroy();
finish("Timed out", true);
}, 50);

client.on("error", (err: Error) => {
finish(`Error: ${err.message}`, true);
});

// Fire error first, then let the timeout also fire
client.emit("error", new Error("ECONNREFUSED"));
});

// Even though timeout could still fire, settled flag should prevent a second resolution
// Wait beyond the timeout to be sure
await new Promise((r) => setTimeout(r, 100));

assert.equal(resolveCount, 1, "finish() should only resolve once due to settled guard");
});

// -----------------------------------------------------------------------
// 5. Timeout fires after "ready" event race: connection becomes ready just in time
// -----------------------------------------------------------------------
it("should succeed when ready fires before the outer timeout", async () => {
const client = createMockClient();
const outerTimeout = 200;

const result = await new Promise<{ text: string; isError: boolean }>((resolve) => {
let settled = false;

const finish = (text: string, isError: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ text, isError });
};

const timer = setTimeout(() => {
client.destroy();
finish("Connection timed out", true);
}, outerTimeout);

client.on("ready", () => {
finish("Connected successfully", false);
});

client.connect({ host: "fast-host.example.com", port: 22 });

// Simulate ready firing after 50ms (well within the 200ms timeout)
setTimeout(() => {
client.emit("ready");
}, 50);
});

assert.equal(result.isError, false);
assert.match(result.text, /Connected successfully/);
});

// -----------------------------------------------------------------------
// 6. Broken-pipe detection: stored.ready is false after close/end events
// -----------------------------------------------------------------------
it("should mark connection as not ready after close or end events", () => {
const stored = makeStoredConnection({ ready: true });
connections.set("pipe-test", stored);

assert.equal(stored.ready, true, "connection starts as ready");

// Simulate what connect.ts does: listen for close/end to set ready = false
stored.client.on("close", () => {
stored.ready = false;
});
stored.client.on("end", () => {
stored.ready = false;
});

// Emit close event
(stored.client as EventEmitter).emit("close");
assert.equal(stored.ready, false, "ready should be false after close event");

// Reset and test end event
stored.ready = true;
(stored.client as EventEmitter).emit("end");
assert.equal(stored.ready, false, "ready should be false after end event");
});

// -----------------------------------------------------------------------
// 7. Executing on a not-ready connection returns an error immediately
// -----------------------------------------------------------------------
it("should return an error when executing on a stale connection", () => {
const stored = makeStoredConnection({ ready: false });
connections.set("stale-conn", stored);

// Replicate the guard from execute.ts
const conn = connections.get("stale-conn")!;
assert.ok(conn, "connection should exist in map");
assert.equal(conn.ready, false);

// The execute tool checks `stored.ready` and returns an error without
// attempting to run the command.
if (!conn.ready) {
connections.delete("stale-conn");
conn.client.end();
}

assert.equal(
connections.has("stale-conn"),
false,
"stale connection should be removed from the map"
);
});

// -----------------------------------------------------------------------
// 8. Default timeout values are correct
// -----------------------------------------------------------------------
it("should use correct default timeout values", () => {
// Default connectTimeout (readyTimeout) is 20000ms
const defaultConnectTimeout = 20000;
// Default command timeout is 30000ms
const defaultCommandTimeout = 30000;
// Outer timeout is readyTimeout + 5000ms
const defaultOuterTimeout = defaultConnectTimeout + 5000;

assert.equal(defaultConnectTimeout, 20000, "default connect timeout should be 20s");
assert.equal(defaultCommandTimeout, 30000, "default command timeout should be 30s");
assert.equal(defaultOuterTimeout, 25000, "default outer timeout should be 25s");

// Verify keepalive settings from config.ts
const keepaliveInterval = 10000;
const keepaliveCountMax = 3;
// Total keepalive tolerance = interval * countMax = 30s
const keepaliveTolerance = keepaliveInterval * keepaliveCountMax;
assert.equal(keepaliveTolerance, 30000, "keepalive tolerance should be 30s");
});

// -----------------------------------------------------------------------
// 9. Existing connection is replaced on reconnect (timeout-related cleanup)
// -----------------------------------------------------------------------
it("should close and replace an existing connection when reconnecting", () => {
const oldClient = createMockClient();
const oldStored = makeStoredConnection({ client: oldClient as any });
connections.set("reuse-id", oldStored);

assert.equal(connections.size, 1);

// Replicate connect.ts: close existing, then create new
const existing = connections.get("reuse-id");
if (existing) {
existing.client.end();
connections.delete("reuse-id");
}

assert.equal(connections.size, 0, "old connection should be removed");
assert.equal((oldClient.end as any).mock.callCount(), 1, "old client.end() should have been called");

// Add the new connection
const newStored = makeStoredConnection();
connections.set("reuse-id", newStored);
assert.equal(connections.size, 1, "new connection should be in the map");
assert.notEqual(connections.get("reuse-id"), oldStored, "should be a different connection object");
});
});