Skip to content

Commit 0126319

Browse files
la14-1louisgvclaude
authored
fix: add killWithTimeout to waitForCloudInit SSH processes across all clouds (#2425)
Without per-process timeouts, if the user's network drops during cloud-init polling, the CLI hangs forever while billing continues. Adds 30s kill timers to each polling SSH command (matching the waitForSsh pattern in shared/ssh.ts) and 330s to DO's streaming SSH. Agent: code-health Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 72ccb09 commit 0126319

4 files changed

Lines changed: 71 additions & 21 deletions

File tree

packages/cli/src/aws/aws.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,12 +1078,22 @@ export async function waitForCloudInit(maxAttempts = 60): Promise<void> {
10781078
],
10791079
},
10801080
);
1081+
// Per-process timeout: if the network drops during cloud-init polling,
1082+
// `await proc.exited` blocks forever. Kill after 30s so the retry loop
1083+
// can continue and the user isn't left with a hung CLI.
1084+
const timer = setTimeout(() => killWithTimeout(proc), 30_000);
10811085
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
1082-
const [stdout] = await Promise.all([
1083-
new Response(proc.stdout).text(),
1084-
new Response(proc.stderr).text(),
1085-
]);
1086-
const exitCode = await proc.exited;
1086+
let stdout: string;
1087+
let exitCode: number;
1088+
try {
1089+
[stdout] = await Promise.all([
1090+
new Response(proc.stdout).text(),
1091+
new Response(proc.stderr).text(),
1092+
]);
1093+
exitCode = await proc.exited;
1094+
} finally {
1095+
clearTimeout(timer);
1096+
}
10871097
if (exitCode === 0 && stdout.includes("done")) {
10881098
logStepDone();
10891099
logInfo("Cloud-init complete");

packages/cli/src/digitalocean/digitalocean.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,7 +1053,16 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise<v
10531053
],
10541054
},
10551055
);
1056-
const exitCode = await proc.exited;
1056+
// The remote script has its own 5-min timeout (150 × 2s), but if the
1057+
// network drops mid-stream `await proc.exited` blocks forever. Kill
1058+
// after 330s (5min + 30s grace) to match the remote timeout.
1059+
const streamTimer = setTimeout(() => killWithTimeout(proc), 330_000);
1060+
let exitCode: number;
1061+
try {
1062+
exitCode = await proc.exited;
1063+
} finally {
1064+
clearTimeout(streamTimer);
1065+
}
10571066
if (exitCode === 0) {
10581067
logInfo("Cloud-init complete");
10591068
return;
@@ -1082,12 +1091,23 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise<v
10821091
],
10831092
},
10841093
);
1094+
// Per-process timeout: if the network drops during cloud-init polling,
1095+
// `await proc.exited` blocks forever. Kill after 30s so the retry loop
1096+
// can continue and the user isn't left with a hung CLI.
1097+
const timer = setTimeout(() => killWithTimeout(proc), 30_000);
10851098
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
1086-
const [stdout] = await Promise.all([
1087-
new Response(proc.stdout).text(),
1088-
new Response(proc.stderr).text(),
1089-
]);
1090-
if ((await proc.exited) === 0 && stdout.includes("done")) {
1099+
let stdout: string;
1100+
let pollExitCode: number;
1101+
try {
1102+
[stdout] = await Promise.all([
1103+
new Response(proc.stdout).text(),
1104+
new Response(proc.stderr).text(),
1105+
]);
1106+
pollExitCode = await proc.exited;
1107+
} finally {
1108+
clearTimeout(timer);
1109+
}
1110+
if (pollExitCode === 0 && stdout.includes("done")) {
10911111
logStepDone();
10921112
logInfo("Cloud-init complete");
10931113
return;

packages/cli/src/gcp/gcp.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -863,12 +863,22 @@ export async function waitForCloudInit(maxAttempts = 60): Promise<void> {
863863
],
864864
},
865865
);
866+
// Per-process timeout: if the network drops during cloud-init polling,
867+
// `await proc.exited` blocks forever. Kill after 30s so the retry loop
868+
// can continue and the user isn't left with a hung CLI.
869+
const timer = setTimeout(() => killWithTimeout(proc), 30_000);
866870
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
867-
await Promise.all([
868-
new Response(proc.stdout).text(),
869-
new Response(proc.stderr).text(),
870-
]);
871-
if ((await proc.exited) === 0) {
871+
let exitCode: number;
872+
try {
873+
await Promise.all([
874+
new Response(proc.stdout).text(),
875+
new Response(proc.stderr).text(),
876+
]);
877+
exitCode = await proc.exited;
878+
} finally {
879+
clearTimeout(timer);
880+
}
881+
if (exitCode === 0) {
872882
logStepDone();
873883
logInfo("Startup script completed");
874884
return;

packages/cli/src/hetzner/hetzner.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -519,12 +519,22 @@ export async function waitForCloudInit(ip?: string, maxAttempts = 60): Promise<v
519519
],
520520
},
521521
);
522+
// Per-process timeout: if the network drops during cloud-init polling,
523+
// `await proc.exited` blocks forever. Kill after 30s so the retry loop
524+
// can continue and the user isn't left with a hung CLI.
525+
const timer = setTimeout(() => killWithTimeout(proc), 30_000);
522526
// Drain both pipes before awaiting exit to prevent pipe buffer deadlock
523-
const [stdout] = await Promise.all([
524-
new Response(proc.stdout).text(),
525-
new Response(proc.stderr).text(),
526-
]);
527-
const exitCode = await proc.exited;
527+
let stdout: string;
528+
let exitCode: number;
529+
try {
530+
[stdout] = await Promise.all([
531+
new Response(proc.stdout).text(),
532+
new Response(proc.stderr).text(),
533+
]);
534+
exitCode = await proc.exited;
535+
} finally {
536+
clearTimeout(timer);
537+
}
528538
if (exitCode === 0 && stdout.includes("done")) {
529539
logStepDone();
530540
logInfo("Cloud-init complete");

0 commit comments

Comments
 (0)