11import { createWriteStream } from 'node:fs' ;
2- import { mkdir } from 'node:fs/promises' ;
2+ import { mkdir , rm } from 'node:fs/promises' ;
33import * as path from 'node:path' ;
44import { Readable } from 'node:stream' ;
55import { pipeline } from 'node:stream/promises' ;
@@ -113,15 +113,84 @@ export function planArtifactFiles(pages: DeviceArtifactsPage[]): DeviceArtifactF
113113
114114const CONCURRENCY = 4 ;
115115
116+ /** Timeouts and retries for one artifact download. */
117+ export interface DownloadArtifactOptions {
118+ /** Give up on an attempt that receives no data for this long. */
119+ idleTimeoutMs ?: number ;
120+ /** Give up on an attempt that takes longer than this in total. */
121+ totalTimeoutMs ?: number ;
122+ /** How many times to try each file before reporting it failed. */
123+ attempts ?: number ;
124+ }
125+
126+ const DEFAULT_IDLE_TIMEOUT_MS = 30_000 ;
127+ const DEFAULT_TOTAL_TIMEOUT_MS = 10 * 60_000 ;
128+ const DEFAULT_ATTEMPTS = 2 ;
129+
130+ function seconds ( ms : number ) : string {
131+ return ms >= 1000 ? `${ Math . round ( ms / 1000 ) } s` : `${ ms } ms` ;
132+ }
133+
116134/**
117- * Download every file with a link into `dir`, four at a time. Returns the
118- * files that could not be fetched, with why, rather than failing the rest.
135+ * Fetch one URL to `target`. Aborts when no data arrives for `idleMs`
136+ * (including while waiting for the response headers) or when the whole
137+ * download takes longer than `totalMs`. A CDN stream can stall without
138+ * closing, and without these the command would wait for it forever.
139+ */
140+ async function downloadOnce ( url : string , target : string , idleMs : number , totalMs : number ) : Promise < void > {
141+ const controller = new AbortController ( ) ;
142+ const abort = ( reason : string ) => controller . abort ( new Error ( reason ) ) ;
143+ let idle = setTimeout ( ( ) => abort ( `timed out: no data for ${ seconds ( idleMs ) } ` ) , idleMs ) ;
144+ const total = setTimeout ( ( ) => abort ( `timed out: took longer than ${ seconds ( totalMs ) } ` ) , totalMs ) ;
145+ const resetIdle = ( ) => {
146+ clearTimeout ( idle ) ;
147+ idle = setTimeout ( ( ) => abort ( `timed out: no data for ${ seconds ( idleMs ) } ` ) , idleMs ) ;
148+ } ;
149+
150+ try {
151+ const res = await fetch ( url , { signal : controller . signal } ) ;
152+ if ( ! res . ok || ! res . body ) throw new Error ( `HTTP ${ res . status } ` ) ;
153+ resetIdle ( ) ;
154+ const body = Readable . fromWeb ( res . body as Parameters < typeof Readable . fromWeb > [ 0 ] ) ;
155+ await pipeline (
156+ body ,
157+ async function * ( source : AsyncIterable < Buffer > ) {
158+ for await ( const chunk of source ) {
159+ resetIdle ( ) ;
160+ yield chunk ;
161+ }
162+ } ,
163+ createWriteStream ( target ) ,
164+ { signal : controller . signal } ,
165+ ) ;
166+ } catch ( error ) {
167+ // Report the timeout, not the AbortError it surfaces as.
168+ if ( controller . signal . aborted && controller . signal . reason instanceof Error ) {
169+ throw controller . signal . reason ;
170+ }
171+
172+ throw error ;
173+ } finally {
174+ clearTimeout ( idle ) ;
175+ clearTimeout ( total ) ;
176+ }
177+ }
178+
179+ /**
180+ * Download every file with a link into `dir`, four at a time. Each file is
181+ * tried twice (a stalled CDN response usually succeeds on a second
182+ * request). Returns the files that could not be fetched, with why, rather
183+ * than failing the rest.
119184 */
120185export async function downloadArtifactFiles (
121186 dir : string ,
122187 files : DeviceArtifactFile [ ] ,
123188 onFile : ( file : DeviceArtifactFile ) => void = ( ) => { } ,
189+ options : DownloadArtifactOptions = { } ,
124190) : Promise < { file : string ; reason : string } [ ] > {
191+ const idleMs = options . idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS ;
192+ const totalMs = options . totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS ;
193+ const attempts = Math . max ( 1 , options . attempts ?? DEFAULT_ATTEMPTS ) ;
125194 const failed : { file : string ; reason : string } [ ] = [ ] ;
126195 const queue = [ ...files ] ;
127196 async function worker ( ) : Promise < void > {
@@ -132,17 +201,24 @@ export async function downloadArtifactFiles(
132201 }
133202
134203 const target = path . join ( dir , next . file ) ;
135- try {
136- await mkdir ( path . dirname ( target ) , { recursive : true } ) ;
137- const res = await fetch ( next . url ) ;
138- if ( ! res . ok || ! res . body ) throw new Error ( `HTTP ${ res . status } ` ) ;
139- await pipeline (
140- Readable . fromWeb ( res . body as Parameters < typeof Readable . fromWeb > [ 0 ] ) ,
141- createWriteStream ( target ) ,
142- ) ;
204+ let reason = '' ;
205+ for ( let attempt = 1 ; attempt <= attempts ; attempt ++ ) {
206+ try {
207+ await mkdir ( path . dirname ( target ) , { recursive : true } ) ;
208+ await downloadOnce ( next . url , target , idleMs , totalMs ) ;
209+ reason = '' ;
210+ break ;
211+ } catch ( error ) {
212+ reason = error instanceof Error ? error . message : String ( error ) ;
213+ }
214+ }
215+
216+ if ( reason ) {
217+ // Don't leave a half-written file that looks like a real one.
218+ await rm ( target , { force : true } ) . catch ( ( ) => { } ) ;
219+ failed . push ( { file : next . file , reason : attempts > 1 ? `${ reason } (after ${ attempts } tries)` : reason } ) ;
220+ } else {
143221 onFile ( next ) ;
144- } catch ( error ) {
145- failed . push ( { file : next . file , reason : error instanceof Error ? error . message : String ( error ) } ) ;
146222 }
147223 }
148224 }
0 commit comments