fix: prevent timer leaks and event-loop stalls in SSH timeout handling (#2200)

- Unref the SIGKILL timer in killWithTimeout() so it doesn't keep the
  event loop alive for 5 extra seconds after a timed-out process exits
- Wrap all setTimeout/clearTimeout pairs in try/finally across 6 cloud
  providers (12 call sites) to guarantee cleanup on exceptions
- Add missing 60s timeout guard to runSpriteSilent() which could hang
  indefinitely on unresponsive sprite processes

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-04 19:04:47 -08:00 committed by GitHub
parent 2fea3de685
commit 701e3af56e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 129 additions and 91 deletions

View file

@ -1047,10 +1047,13 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise<void
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
try {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
}
} finally {
clearTimeout(timer);
}
}
@ -1075,17 +1078,20 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number): Promi
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
try {
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
}
return stdout.trim();
} finally {
clearTimeout(timer);
}
return stdout.trim();
}
export async function uploadFile(localPath: string, remotePath: string): Promise<void> {

View file

@ -389,14 +389,15 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise<void
}
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
const exitCode = await proc.exited;
clearTimeout(timer);
// Brief sleep to let gateway release connection slot
await sleep(1000);
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
try {
const exitCode = await proc.exited;
// Brief sleep to let gateway release connection slot
await sleep(1000);
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
}
} finally {
clearTimeout(timer);
}
}
@ -426,20 +427,21 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number): Promi
}
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
clearTimeout(timer);
await sleep(1000);
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
try {
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
await sleep(1000);
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
}
return stdout.trim();
} finally {
clearTimeout(timer);
}
return stdout.trim();
}
/**

View file

@ -984,11 +984,13 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string):
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
try {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
}
} finally {
clearTimeout(timer);
}
}
@ -1016,18 +1018,20 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number, ip?: s
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
try {
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
}
return stdout.trim();
} finally {
clearTimeout(timer);
}
return stdout.trim();
}
export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise<void> {

View file

@ -848,10 +848,13 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise<void
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
try {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
}
} finally {
clearTimeout(timer);
}
}
@ -879,17 +882,20 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number): Promi
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
try {
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
}
return stdout.trim();
} finally {
clearTimeout(timer);
}
return stdout.trim();
}
export async function uploadFile(localPath: string, remotePath: string): Promise<void> {

View file

@ -511,11 +511,13 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string):
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
try {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`);
}
} finally {
clearTimeout(timer);
}
}
@ -543,18 +545,20 @@ export async function runServerCapture(cmd: string, timeoutSecs?: number, ip?: s
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
try {
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
const [stdout] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`run_server_capture failed (exit ${exitCode})`);
}
return stdout.trim();
} finally {
clearTimeout(timer);
}
return stdout.trim();
}
export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise<void> {

View file

@ -120,7 +120,7 @@ export function killWithTimeout(
} catch {
return;
}
setTimeout(() => {
const sigkillTimer = setTimeout(() => {
try {
if (!proc.killed) {
proc.kill(9);
@ -129,6 +129,9 @@ export function killWithTimeout(
/* already dead */
}
}, gracePeriodMs);
// Don't let this timer keep the event loop alive — the process may already
// be dead from SIGTERM, so there's no reason to block exit for 5 seconds.
sigkillTimer.unref();
}
// ─── TCP Pre-Check ───────────────────────────────────────────────────────────

View file

@ -461,10 +461,13 @@ export async function runSprite(cmd: string, timeoutSecs?: number): Promise<void
);
const timeout = (timeoutSecs || 300) * 1000;
const timer = setTimeout(() => killWithTimeout(proc), timeout);
const exitCode = await proc.exited;
clearTimeout(timer);
if (exitCode !== 0) {
throw new Error(`sprite exec failed (exit ${exitCode}): ${cmd.slice(0, 80)}`);
try {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`sprite exec failed (exit ${exitCode}): ${cmd.slice(0, 80)}`);
}
} finally {
clearTimeout(timer);
}
});
}
@ -492,9 +495,15 @@ async function runSpriteSilent(cmd: string): Promise<void> {
],
},
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`sprite exec (silent) failed (exit ${exitCode})`);
// 60s timeout — silent commands should not hang indefinitely
const timer = setTimeout(() => killWithTimeout(proc), 60_000);
try {
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`sprite exec (silent) failed (exit ${exitCode})`);
}
} finally {
clearTimeout(timer);
}
}
@ -629,8 +638,12 @@ export async function destroyServer(name?: string): Promise<void> {
const stderrText = new Response(proc.stderr).text();
// 60s timeout — sprite destroy should not hang indefinitely
const timer = setTimeout(() => killWithTimeout(proc), 60_000);
const exitCode = await proc.exited;
clearTimeout(timer);
let exitCode: number;
try {
exitCode = await proc.exited;
} finally {
clearTimeout(timer);
}
if (exitCode !== 0) {
logError(`Failed to destroy sprite '${target}'`);
logError(`Delete it manually: sprite destroy ${target}`);