Skip to content

Commit 59b2c62

Browse files
committed
fix(integration): retry stalled scp transfers
1 parent bf80187 commit 59b2c62

2 files changed

Lines changed: 54 additions & 5 deletions

File tree

tests/integration/remote/ssh.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, expect, test } from "bun:test";
2-
import { DEFAULT_SSH_COMMAND_TIMEOUT_MS, remoteTarRootPattern, sshCommandTimeoutMs } from "./ssh";
2+
import {
3+
DEFAULT_SCP_ATTEMPTS,
4+
DEFAULT_SCP_COMMAND_TIMEOUT_MS,
5+
DEFAULT_SSH_COMMAND_TIMEOUT_MS,
6+
remoteTarRootPattern,
7+
sshCommandTimeoutMs
8+
} from "./ssh";
39

410
describe("SSH archive helpers", () => {
511
test("converts archive paths and globs into tar includes relative to root", () => {
@@ -14,4 +20,9 @@ describe("SSH archive helpers", () => {
1420
expect(sshCommandTimeoutMs()).toBe(DEFAULT_SSH_COMMAND_TIMEOUT_MS);
1521
expect(sshCommandTimeoutMs({ timeoutMs: 1234 })).toBe(1234);
1622
});
23+
24+
test("bounds stalled SCP attempts below the full remote command timeout", () => {
25+
expect(DEFAULT_SCP_ATTEMPTS).toBeGreaterThan(1);
26+
expect(DEFAULT_SCP_COMMAND_TIMEOUT_MS).toBeLessThan(DEFAULT_SSH_COMMAND_TIMEOUT_MS);
27+
});
1728
});

tests/integration/remote/ssh.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type SshExecResult = {
1212
const REMOTE_READ_ATTEMPTS = 5;
1313
const REMOTE_READ_TIMEOUT_MS = 20000;
1414
export const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
15+
export const DEFAULT_SCP_COMMAND_TIMEOUT_MS = 90 * 1000;
16+
export const DEFAULT_SCP_ATTEMPTS = 4;
1517

1618
export function sshCommandTimeoutMs(options: { timeoutMs?: number } = {}): number {
1719
return options.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS;
@@ -125,14 +127,14 @@ export class SshHost {
125127

126128
async copyTo(localPath: string, remotePath: string): Promise<void> {
127129
this.logger.info(`scp ${localPath} -> ${this.host}:${remotePath}`);
128-
await run(
130+
await this.scpWithRetries(
129131
[
130132
"scp",
131133
...this.connectionArgs(),
132134
localPath,
133135
`${this.user}@${this.host}:${remotePath}`
134136
],
135-
{ timeoutMs: DEFAULT_SSH_COMMAND_TIMEOUT_MS }
137+
`scp ${localPath} to ${this.host}:${remotePath}`
136138
);
137139
}
138140

@@ -197,18 +199,37 @@ export class SshHost {
197199
if (archiveResult.exitCode !== 0) {
198200
throw new Error(archiveResult.stderr.trim() || archiveResult.stdout.trim() || `failed to archive host paths on ${this.host}`);
199201
}
200-
await run(
202+
await this.scpWithRetries(
201203
[
202204
"scp",
203205
...this.connectionArgs(),
204206
`${this.user}@${this.host}:${remoteTar}`,
205207
localPath
206208
],
207-
{ timeoutMs: DEFAULT_SSH_COMMAND_TIMEOUT_MS }
209+
`scp ${this.host}:${remoteTar} to ${localPath}`
208210
);
209211
await this.exec(`rm -f ${shellEscape(remoteTar)}`);
210212
}
211213

214+
private async scpWithRetries(cmd: string[], description: string): Promise<void> {
215+
let lastFailure = "";
216+
for (let attempt = 1; attempt <= DEFAULT_SCP_ATTEMPTS; attempt += 1) {
217+
const result = await runAllowFailure(cmd, { timeoutMs: DEFAULT_SCP_COMMAND_TIMEOUT_MS });
218+
if (result.exitCode === 0) {
219+
return;
220+
}
221+
222+
lastFailure = formatCommandResult(result);
223+
if (attempt < DEFAULT_SCP_ATTEMPTS) {
224+
this.logger.warn(`${description} failed on attempt ${attempt}/${DEFAULT_SCP_ATTEMPTS}; retrying: ${compactCommandOutput(lastFailure)}`);
225+
await this.waitForSsh(45000).catch(() => undefined);
226+
await Bun.sleep(attempt * 2000);
227+
}
228+
}
229+
230+
throw new Error(`${description} failed after ${DEFAULT_SCP_ATTEMPTS} attempts\n${lastFailure}`);
231+
}
232+
212233
/**
213234
* Starts a long-running shell command remotely without holding the SSH session open.
214235
*
@@ -231,3 +252,20 @@ exit "$status"
231252
await this.exec(`nohup ${shellEscape(remoteScriptPath)} >/dev/null 2>&1 &`);
232253
}
233254
}
255+
256+
function formatCommandResult(result: SshExecResult): string {
257+
const parts = [`exit ${result.exitCode}`];
258+
const stdout = result.stdout.trim();
259+
const stderr = result.stderr.trim();
260+
if (stdout) {
261+
parts.push(`stdout:\n${stdout}`);
262+
}
263+
if (stderr) {
264+
parts.push(`stderr:\n${stderr}`);
265+
}
266+
return parts.join("\n\n");
267+
}
268+
269+
function compactCommandOutput(value: string): string {
270+
return value.replace(/\s+/g, " ").trim().slice(0, 500) || "<empty>";
271+
}

0 commit comments

Comments
 (0)