Skip to content

Commit dd9f92f

Browse files
committed
🤖 Add timeout requirement to SSHRuntime.execSSHCommand()
- Add timeoutMs parameter to execSSHCommand() to prevent network hangs - Implement timeout with clearTimeout cleanup on success/error - Update resolvePath() to use 5 second timeout (should be near-instant) - Add class-level comment documenting timeout requirement for all SSH operations - Timeouts should be either set literally or forwarded from upstream This prevents indefinite hangs on network issues, dropped connections, or unresponsive SSH servers. Generated with `cmux`
1 parent 839ff27 commit dd9f92f

File tree

1 file changed

+25
-2
lines changed

1 file changed

+25
-2
lines changed

src/runtime/SSHRuntime.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export interface SSHRuntimeConfig {
5353
* - Supports SSH config aliases, ProxyJump, ControlMaster, etc.
5454
* - No password prompts (assumes key-based auth or ssh-agent)
5555
* - Atomic file writes via temp + rename
56+
*
57+
* IMPORTANT: All SSH operations MUST include a timeout to prevent hangs from network issues.
58+
* Timeouts should be either set literally for internal operations or forwarded from upstream
59+
* for user-initiated operations.
5660
*/
5761
export class SSHRuntime implements Runtime {
5862
private readonly config: SSHRuntimeConfig;
@@ -322,21 +326,34 @@ export class SSHRuntime implements Runtime {
322326
// Uses bash to expand ~ and readlink -m to normalize without checking existence
323327
// readlink -m canonicalizes the path (handles .., ., //) without requiring it to exist
324328
const command = `bash -c 'readlink -m ${shescape.quote(filePath)}'`;
325-
return this.execSSHCommand(command);
329+
// Use 5 second timeout for path resolution (should be near-instant)
330+
return this.execSSHCommand(command, 5000);
326331
}
327332

328333
/**
329334
* Execute a simple SSH command and return stdout
335+
* @param command - The command to execute on the remote host
336+
* @param timeoutMs - Timeout in milliseconds (required to prevent network hangs)
330337
* @private
331338
*/
332-
private async execSSHCommand(command: string): Promise<string> {
339+
private async execSSHCommand(command: string, timeoutMs: number): Promise<string> {
333340
const sshArgs = this.buildSSHArgs();
334341
sshArgs.push(this.config.host, command);
335342

336343
return new Promise((resolve, reject) => {
337344
const proc = spawn("ssh", sshArgs);
338345
let stdout = "";
339346
let stderr = "";
347+
let timedOut = false;
348+
349+
// Set timeout to prevent hanging on network issues
350+
const timer = setTimeout(() => {
351+
timedOut = true;
352+
proc.kill();
353+
reject(
354+
new RuntimeErrorClass(`SSH command timed out after ${timeoutMs}ms: ${command}`, "network")
355+
);
356+
}, timeoutMs);
340357

341358
proc.stdout?.on("data", (data: Buffer) => {
342359
stdout += data.toString();
@@ -347,6 +364,9 @@ export class SSHRuntime implements Runtime {
347364
});
348365

349366
proc.on("close", (code) => {
367+
clearTimeout(timer);
368+
if (timedOut) return; // Already rejected
369+
350370
if (code !== 0) {
351371
reject(new RuntimeErrorClass(`SSH command failed: ${stderr.trim()}`, "network"));
352372
return;
@@ -357,6 +377,9 @@ export class SSHRuntime implements Runtime {
357377
});
358378

359379
proc.on("error", (err) => {
380+
clearTimeout(timer);
381+
if (timedOut) return; // Already rejected
382+
360383
reject(
361384
new RuntimeErrorClass(
362385
`Cannot execute SSH command: ${getErrorMessage(err)}`,

0 commit comments

Comments
 (0)