Skip to content

Commit ff5b7c0

Browse files
authored
fix(device): time out stalled artifact downloads (#240)
1 parent c68ddc6 commit ff5b7c0

2 files changed

Lines changed: 158 additions & 13 deletions

File tree

‎src/utils/device-artifacts.ts‎

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createWriteStream } from 'node:fs';
2-
import { mkdir } from 'node:fs/promises';
2+
import { mkdir, rm } from 'node:fs/promises';
33
import * as path from 'node:path';
44
import { Readable } from 'node:stream';
55
import { pipeline } from 'node:stream/promises';
@@ -113,15 +113,84 @@ export function planArtifactFiles(pages: DeviceArtifactsPage[]): DeviceArtifactF
113113

114114
const 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
*/
120185
export 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
}

‎test/unit/device-artifacts.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,73 @@ describe('dcd device artifacts', () => {
104104
[path.join('actions', '001-screenshot.jpg'), 'device.log'].sort(),
105105
);
106106
});
107+
/** A response whose body never sends a byte, and never closes. */
108+
const stalled = () => new Response(new ReadableStream({ start() {} }));
109+
110+
it('reports a stalled download as timed out instead of hanging', async () => {
111+
let calls = 0;
112+
globalThis.fetch = (async () => {
113+
calls++;
114+
return stalled();
115+
}) as typeof fetch;
116+
117+
const started = Date.now();
118+
const failed = await downloadArtifactFiles(
119+
tempDir,
120+
[{ kind: 'logs', file: 'device.log', url: 'https://stalls', bytes: 3, created_at: 't' }],
121+
undefined,
122+
{ idleTimeoutMs: 50 },
123+
);
124+
expect(Date.now() - started).to.be.lessThan(2000);
125+
expect(calls).to.equal(2);
126+
expect(failed).to.have.length(1);
127+
expect(failed[0].file).to.equal('device.log');
128+
expect(failed[0].reason).to.match(/timed out: no data for 50ms/);
129+
expect(fs.existsSync(path.join(tempDir, 'device.log'))).to.equal(false);
130+
});
131+
132+
it('times out a download that is slow overall', async () => {
133+
let tick: NodeJS.Timeout | undefined;
134+
globalThis.fetch = (async () =>
135+
new Response(
136+
new ReadableStream({
137+
// A trickle: data keeps arriving, but the file never finishes.
138+
start(controller) {
139+
tick = setInterval(() => controller.enqueue(new Uint8Array([1])), 10);
140+
},
141+
cancel() {
142+
clearInterval(tick);
143+
},
144+
}),
145+
)) as typeof fetch;
146+
147+
const failed = await downloadArtifactFiles(
148+
tempDir,
149+
[{ kind: 'logs', file: 'device.log', url: 'https://trickle', bytes: 3, created_at: 't' }],
150+
undefined,
151+
{ attempts: 1, idleTimeoutMs: 500, totalTimeoutMs: 100 },
152+
);
153+
clearInterval(tick);
154+
expect(failed.map((f) => f.reason)).to.deep.equal(['timed out: took longer than 100ms']);
155+
});
156+
157+
it('retries a failed download once', async () => {
158+
const calls: string[] = [];
159+
globalThis.fetch = (async (input: unknown) => {
160+
calls.push(String(input));
161+
return calls.length === 1 ? stalled() : new Response('log line');
162+
}) as typeof fetch;
163+
164+
const saved: string[] = [];
165+
const failed = await downloadArtifactFiles(
166+
tempDir,
167+
[{ kind: 'logs', file: 'device.log', url: 'https://flaky', bytes: 8, created_at: 't' }],
168+
(f) => saved.push(f.file),
169+
{ idleTimeoutMs: 50 },
170+
);
171+
expect(failed).to.deep.equal([]);
172+
expect(calls).to.have.length(2);
173+
expect(saved).to.deep.equal(['device.log']);
174+
expect(fs.readFileSync(path.join(tempDir, 'device.log'), 'utf8')).to.equal('log line');
175+
});
107176
});

0 commit comments

Comments
 (0)