@@ -12,6 +12,8 @@ export type SshExecResult = {
1212const REMOTE_READ_ATTEMPTS = 5 ;
1313const REMOTE_READ_TIMEOUT_MS = 20000 ;
1414export 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
1618export 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