@tikoci/quickchr 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * One bounded, classified download path for both `images.ts` and `packages.ts`.
3
+ *
4
+ * Why this exists (tikoci/quickchr#116, B13 of #110): both callers used to bound
5
+ * a transfer by **total duration**, with a number nothing had measured, and they
6
+ * did not even agree on that — `images.ts` aborted at a flat 120 s per attempt
7
+ * with three retries, `packages.ts` had no deadline and no retries at all.
8
+ *
9
+ * A total-duration deadline cannot tell "slow" from "stuck". It fires on a
10
+ * healthy transfer whose only sin is being large, and on the retry path it then
11
+ * re-downloads from zero — turning one slow transfer into three. That is not a
12
+ * hypothetical: two independent measurements caught it.
13
+ *
14
+ * - Run 30606079288 (hosted linux-x86, first genuinely cold cache after #104):
15
+ * `all_packages-arm64-7.22.1.zip`, 52.2 MB, exceeded 120 s at ~0.35 MB/s and
16
+ * was reported as a timeout — while the *next test in the file* logged
17
+ * `Using cached packages: 7.22.1 (arm64)`. The transfer had completed.
18
+ * - B7's local full suite: `provisioning.test.ts` spent 619 s of its 992 s with
19
+ * **zero QEMU processes alive**, on 41.5 MB images "needing two retries
20
+ * each". 41.5 MB at 0.35 MB/s sits exactly on the 120 s edge, so those
21
+ * retries were the deadline firing on healthy transfers.
22
+ *
23
+ * ## Two bounds, not one
24
+ *
25
+ * Stall detection alone is not enough — a transfer trickling at one byte per
26
+ * second would reset its stall deadline forever and never terminate. So a
27
+ * transfer is bounded by both, and the failure says which one fired:
28
+ *
29
+ * 1. {@link DOWNLOAD_STALL_MS} — a resettable deadline, reset on every chunk
30
+ * received. A *moving* transfer is never aborted for being slow.
31
+ * 2. A transfer budget derived from `content-length` and
32
+ * {@link COLD_DOWNLOAD_FLOOR_BYTES_PER_S}, so a trickle still terminates.
33
+ *
34
+ * ## Retry policy, and why "too slow" is not retriable
35
+ *
36
+ * Retrying a stall makes sense: a wedged socket is transient and the next attempt
37
+ * usually moves. Retrying a transfer that exhausted a budget already sized at
38
+ * ~3× the slowest throughput ever observed does not — it re-downloads from zero
39
+ * on a link already known to be slower than the floor, which is precisely the
40
+ * "one slow transfer becomes three" behavior this module exists to remove. So
41
+ * `DOWNLOAD_TOO_SLOW` is terminal, and it is a signal that the floor is wrong (or
42
+ * the link genuinely is), not something to paper over with another attempt.
43
+ *
44
+ * Resuming a partial transfer with a `Range` request would be strictly better
45
+ * than either, and is deliberately out of scope here (#116 bounds and classifies
46
+ * a transfer; it does not change the transport).
47
+ */
48
+
49
+ import { unlinkSync, existsSync, renameSync } from "node:fs";
50
+ import { randomUUID } from "node:crypto";
51
+ import { QuickCHRError } from "./types.ts";
52
+ import { fetchResilient } from "./net.ts";
53
+ import { createLogger, type ProgressLogger } from "./log.ts";
54
+
55
+ /**
56
+ * Throughput to assume when budgeting a COLD download from MikroTik, in bytes
57
+ * per second. Deliberately a floor, not an average.
58
+ *
59
+ * Grounded rather than guessed, in run 30606079288 — the first genuinely cold
60
+ * cache after #104 gave the image cache resolved-version keys (nothing to fall
61
+ * back to, so both all-packages zips downloaded fresh on a hosted linux-x86
62
+ * runner):
63
+ *
64
+ * all_packages-x86-7.22.1.zip 9.8 MB 16.2 s ~0.60 MB/s passed
65
+ * all_packages-arm64-7.22.1.zip 52.2 MB >120 s ~0.35 MB/s blew a flat 120 s
66
+ *
67
+ * 120 000 B/s is roughly a third of the slower observation, which keeps a
68
+ * slow-but-moving link inside its budget while a genuinely dead link still
69
+ * terminates well inside the enclosing test, file and step budgets.
70
+ *
71
+ * **This is the single home for that number.** `test/integration/timeouts.ts`
72
+ * imports it rather than keeping a copy — #116 asks the library budget and the
73
+ * test budget not to drift, and two constants asked not to drift are how they
74
+ * drift. If a better measurement turns up, change it here.
75
+ */
76
+ export const COLD_DOWNLOAD_FLOOR_BYTES_PER_S = 120_000;
77
+
78
+ /**
79
+ * How long a transfer may receive **nothing** before it is called stuck. Reset
80
+ * on every chunk, so it bounds silence, not slowness.
81
+ *
82
+ * Generous enough to ride out a TCP retransmit or a CDN hiccup on a healthy
83
+ * connection, short enough that a genuinely wedged socket fails in seconds
84
+ * rather than in the minutes a total-duration deadline took.
85
+ */
86
+ export const DOWNLOAD_STALL_MS = 30_000;
87
+
88
+ /** Fixed slack in the transfer budget for connect, TLS, and response headers —
89
+ * the part of a download whose cost does not scale with the artifact. */
90
+ export const DOWNLOAD_BUDGET_BASE_MS = 30_000;
91
+
92
+ /**
93
+ * Transfer budget for a response that arrives without a `content-length`, where
94
+ * a size-derived budget cannot be computed. Stated here rather than left
95
+ * unbounded, and named in the failure so a log says which budget applied.
96
+ *
97
+ * 15 minutes is ~2× the budget the largest artifact quickchr downloads (the
98
+ * 52.2 MB all-packages zip, ~7.3 min at the floor above) would have received.
99
+ */
100
+ export const DOWNLOAD_NO_LENGTH_BUDGET_MS = 15 * 60_000;
101
+
102
+ /** Default attempts for a retriable failure. */
103
+ export const DOWNLOAD_MAX_ATTEMPTS = 3;
104
+
105
+ /**
106
+ * Largest delay `setTimeout` can represent. Above this the 32-bit delay
107
+ * overflows and the timer fires **immediately**.
108
+ *
109
+ * This matters because the transfer budget is derived from a server-controlled
110
+ * `content-length`: a response declaring ~257 GB or more would otherwise produce
111
+ * an instant `transfer-budget` deadline, which this module treats as terminal
112
+ * `DOWNLOAD_TOO_SLOW` with no retry. Clamping turns an absurd declared size into
113
+ * a very long budget instead of an immediate bogus failure; the stall deadline
114
+ * still bounds a dead connection, and the callers' storage preflight still
115
+ * bounds what can actually be written.
116
+ */
117
+ export const DOWNLOAD_MAX_BUDGET_MS = 2_147_483_647;
118
+
119
+ /**
120
+ * Transfer budget in ms for an artifact of `bytes`, or {@link
121
+ * DOWNLOAD_NO_LENGTH_BUDGET_MS} when the size is unknown.
122
+ *
123
+ * A known size of 0 is a *known* size, not an unknown one — it gets the base and
124
+ * nothing more. Only a missing/invalid `content-length` takes the fallback,
125
+ * because that is the case where a size-derived budget cannot be computed at all.
126
+ *
127
+ * Transfer time rounds **up** to the next whole second. Flooring would
128
+ * under-budget by up to a second on every artifact, which is the direction that
129
+ * turns a completed download back into a failure — the whole defect in #116.
130
+ */
131
+ export function transferBudgetMs(bytes: number | undefined): number {
132
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {
133
+ return DOWNLOAD_NO_LENGTH_BUDGET_MS;
134
+ }
135
+ return Math.min(
136
+ DOWNLOAD_BUDGET_BASE_MS + Math.ceil((bytes / COLD_DOWNLOAD_FLOOR_BYTES_PER_S) * 1000),
137
+ DOWNLOAD_MAX_BUDGET_MS,
138
+ );
139
+ }
140
+
141
+ /** Which of the two bounds ended a transfer. */
142
+ export type DownloadDeadline = "stall" | "transfer-budget";
143
+
144
+ /** What a transfer had achieved when it ended — the evidence a CI log needs to
145
+ * answer "was this slow, stalled, or refused?" without re-running it. */
146
+ export interface TransferOutcome {
147
+ /** Bytes actually received. */
148
+ bytes: number;
149
+ /** `content-length`, when the server sent one. */
150
+ expected?: number;
151
+ elapsedMs: number;
152
+ /** Observed bytes per second over the whole transfer. */
153
+ throughputBytesPerS: number;
154
+ /** Set when a deadline ended the transfer; absent for other failures. */
155
+ deadline?: DownloadDeadline;
156
+ /** The budget the transfer was held to, in ms. */
157
+ budgetMs: number;
158
+ }
159
+
160
+ function mb(bytes: number): string {
161
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
162
+ }
163
+
164
+ /** One-line rendering of a transfer's evidence, used in every failure message. */
165
+ export function describeTransfer(o: TransferOutcome): string {
166
+ const of = o.expected === undefined ? " of unknown size" : ` of ${mb(o.expected)}`;
167
+ const rate = `${(o.throughputBytesPerS / 1024 / 1024).toFixed(3)} MB/s`;
168
+ const budget = `budget ${(o.budgetMs / 1000).toFixed(0)}s`;
169
+ const which = o.expected === undefined ? `${budget}, no content-length` : budget;
170
+ return `${mb(o.bytes)}${of} in ${(o.elapsedMs / 1000).toFixed(1)}s (${rate}, ${which})`;
171
+ }
172
+
173
+ /** Thrown when a deadline ends a transfer. Carries the evidence for the caller
174
+ * and for the retry decision — a stall is retriable, an exhausted budget is not. */
175
+ export class DownloadDeadlineError extends Error {
176
+ readonly outcome: TransferOutcome;
177
+ /** `stallMs` is the deadline that was actually in force, not the default —
178
+ * a message naming a deadline the transfer was never held to is exactly the
179
+ * misleading evidence #116 exists to remove. */
180
+ constructor(outcome: TransferOutcome, url: string, stallMs: number = DOWNLOAD_STALL_MS) {
181
+ super(
182
+ outcome.deadline === "stall"
183
+ ? `Download stalled: no data for ${(stallMs / 1000).toFixed(1)}s — ${describeTransfer(outcome)} from ${url}`
184
+ : `Download exceeded its transfer budget — ${describeTransfer(outcome)} from ${url}`,
185
+ );
186
+ this.name = "DownloadDeadlineError";
187
+ this.outcome = outcome;
188
+ }
189
+ }
190
+
191
+ export interface DownloadOptions {
192
+ logger?: ProgressLogger;
193
+ /** Attempts for retriable failures. Defaults to {@link DOWNLOAD_MAX_ATTEMPTS}. */
194
+ maxAttempts?: number;
195
+ /** Override the silence deadline. Tests use this; callers should not need to. */
196
+ stallMs?: number;
197
+ /**
198
+ * Override the transfer budget, bypassing {@link transferBudgetMs}.
199
+ *
200
+ * A test lever, like `stallMs`: the real budget starts at
201
+ * {@link DOWNLOAD_BUDGET_BASE_MS}, so exercising the trickle branch honestly
202
+ * would cost 30 s of wall clock per assertion. The derived value is pinned
203
+ * separately as a pure function, and `downloadToFile` is asserted to *use* it
204
+ * when this is absent. Production callers should not set it.
205
+ */
206
+ budgetMs?: number;
207
+ }
208
+
209
+ /** True for an HTTP status worth another attempt: server-side, or a CDN's
210
+ * transient 408/429. Every other 4xx is the server telling us we are wrong. */
211
+ function isRetriableStatus(status: number): boolean {
212
+ if (status === 408 || status === 429) return true;
213
+ return status >= 500;
214
+ }
215
+
216
+ /**
217
+ * Stream `url` to `destPath`, bounded by a stall deadline and a transfer budget.
218
+ *
219
+ * Writes to `<destPath>.part` and renames only on a complete, length-verified
220
+ * transfer, so an interrupted download can never leave a truncated file that a
221
+ * later `existsSync()` would treat as cached.
222
+ *
223
+ * @throws {QuickCHRError} `DOWNLOAD_STALLED` when the connection went silent,
224
+ * `DOWNLOAD_TOO_SLOW` when a moving transfer could not finish inside its
225
+ * size-derived budget, `DOWNLOAD_FAILED` for everything else. All three carry
226
+ * bytes/expected/elapsed/throughput.
227
+ */
228
+ export async function downloadToFile(
229
+ url: string,
230
+ destPath: string,
231
+ opts: DownloadOptions = {},
232
+ ): Promise<TransferOutcome> {
233
+ const log = opts.logger ?? createLogger();
234
+ const maxAttempts = opts.maxAttempts ?? DOWNLOAD_MAX_ATTEMPTS;
235
+ const stallMs = opts.stallMs ?? DOWNLOAD_STALL_MS;
236
+ // Unique per call, not a fixed `<dest>.part`. Both callers guard only with
237
+ // `existsSync(destPath)`, so two concurrent downloads of the same artifact —
238
+ // two `quickchr start`s, or parallel tests — reach this together. On a shared
239
+ // path they would interleave writes into one file, the length check could
240
+ // still pass, and `renameSync` would publish the corrupt result to the cache
241
+ // path that every later run treats as valid. A failing attempt's cleanup
242
+ // would also delete the other's in-flight file.
243
+ //
244
+ // A unique suffix removes the collision; it deliberately does not deduplicate
245
+ // the work. Two concurrent downloads both transfer and both rename, which is
246
+ // wasteful but correct (last writer wins, and either file is complete).
247
+ // Coalescing them needs a lock around the whole call at both call sites, which
248
+ // is a bigger design change than #116 is scoped for.
249
+ const partPath = `${destPath}.${process.pid}-${randomUUID().slice(0, 8)}.part`;
250
+
251
+ let lastError: Error | undefined;
252
+
253
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
254
+ try {
255
+ const outcome = await attemptDownload(url, partPath, stallMs, opts.budgetMs, log);
256
+ // Rename, not copy: `<dest>.part` is in the destination's own directory,
257
+ // so this is a same-filesystem move and the artifact appears at its final
258
+ // path atomically. Copying would re-write every byte (52 MB for the
259
+ // largest artifact) for no benefit.
260
+ renameSync(partPath, destPath);
261
+ log.status(` Saved (${describeTransfer(outcome)})`);
262
+ return outcome;
263
+ } catch (e) {
264
+ removeIfPresent(partPath);
265
+ // Terminal: a client error, or a budget already sized at ~3x the slowest
266
+ // throughput we have ever measured. Retrying either just spends the
267
+ // budget again — see the retry-policy note at the top of this file.
268
+ if (e instanceof QuickCHRError) throw e;
269
+ if (e instanceof DownloadDeadlineError && e.outcome.deadline === "transfer-budget") {
270
+ throw new QuickCHRError("DOWNLOAD_TOO_SLOW", e.message);
271
+ }
272
+ lastError = e instanceof Error ? e : new Error(String(e));
273
+ if (attempt < maxAttempts) {
274
+ log.status(` ${lastError.message}`);
275
+ log.status(` Retrying (attempt ${attempt + 1}/${maxAttempts}) in ${attempt * 2}s...`);
276
+ await Bun.sleep(attempt * 2000);
277
+ }
278
+ }
279
+ }
280
+
281
+ const detail = lastError?.message ?? "unknown error";
282
+ // These messages land in CI logs and are the point of the bite, so say
283
+ // "1 attempt" rather than "all 1 attempts".
284
+ const tries = maxAttempts === 1 ? "1 attempt" : `all ${maxAttempts} attempts`;
285
+ if (lastError instanceof DownloadDeadlineError) {
286
+ throw new QuickCHRError("DOWNLOAD_STALLED", `Download stalled on ${tries}: ${detail}`);
287
+ }
288
+ throw new QuickCHRError("DOWNLOAD_FAILED", `Download failed after ${tries}: ${detail}`);
289
+ }
290
+
291
+ async function attemptDownload(
292
+ url: string,
293
+ partPath: string,
294
+ stallMs: number,
295
+ budgetOverrideMs: number | undefined,
296
+ log: ProgressLogger,
297
+ ): Promise<TransferOutcome> {
298
+ const controller = new AbortController();
299
+ const started = Bun.nanoseconds();
300
+ let fired: DownloadDeadline | undefined;
301
+ let stallTimer: ReturnType<typeof setTimeout> | undefined;
302
+ let budgetTimer: ReturnType<typeof setTimeout> | undefined;
303
+ let bytes = 0;
304
+ let expected: number | undefined;
305
+ // Until the headers arrive we do not know the size, so the connect phase is
306
+ // held to the stall deadline alone and the budget starts with the body.
307
+ let budgetMs = budgetOverrideMs ?? transferBudgetMs(undefined);
308
+
309
+ const elapsedMs = () => (Bun.nanoseconds() - started) / 1e6;
310
+ const outcome = (): TransferOutcome => {
311
+ const ms = elapsedMs();
312
+ return {
313
+ bytes,
314
+ expected,
315
+ elapsedMs: ms,
316
+ throughputBytesPerS: ms > 0 ? bytes / (ms / 1000) : 0,
317
+ deadline: fired,
318
+ budgetMs,
319
+ };
320
+ };
321
+ const armStall = () => {
322
+ if (stallTimer) clearTimeout(stallTimer);
323
+ stallTimer = setTimeout(() => {
324
+ fired = "stall";
325
+ controller.abort();
326
+ }, stallMs);
327
+ };
328
+ const clearTimers = () => {
329
+ if (stallTimer) clearTimeout(stallTimer);
330
+ if (budgetTimer) clearTimeout(budgetTimer);
331
+ };
332
+
333
+ armStall();
334
+ try {
335
+ const response = await fetchResilient(url, { signal: controller.signal });
336
+
337
+ if (!response.ok) {
338
+ // Release the connection rather than leaving the body for GC — the
339
+ // retriable branch would otherwise repeat that once per attempt.
340
+ await response.body?.cancel().catch(() => {});
341
+ // Non-retriable client errors are the server telling us the request is
342
+ // wrong; a retry cannot change that. Surface immediately.
343
+ if (!isRetriableStatus(response.status)) {
344
+ throw new QuickCHRError(
345
+ "DOWNLOAD_FAILED",
346
+ `Download failed: HTTP ${response.status} for ${url}`,
347
+ );
348
+ }
349
+ throw new Error(`HTTP ${response.status}`);
350
+ }
351
+
352
+ // An absent, blank or malformed `content-length` means *unknown*, never
353
+ // zero. `Number("")` is 0 and finite, which would both collapse the budget
354
+ // to the base and make the truncation check below fire on the first byte —
355
+ // reporting a healthy transfer as truncated.
356
+ const lengthText = response.headers.get("content-length")?.trim();
357
+ expected = lengthText === undefined || lengthText === "" ? undefined : Number(lengthText);
358
+ if (expected !== undefined && (!Number.isInteger(expected) || expected < 0)) {
359
+ expected = undefined;
360
+ }
361
+ budgetMs = budgetOverrideMs ?? transferBudgetMs(expected);
362
+ budgetTimer = setTimeout(() => {
363
+ fired = "transfer-budget";
364
+ controller.abort();
365
+ }, budgetMs);
366
+
367
+ log.status(
368
+ expected === undefined
369
+ ? ` Transferring (size unknown, budget ${(budgetMs / 1000).toFixed(0)}s)`
370
+ : ` Transferring ${mb(expected)} (budget ${(budgetMs / 1000).toFixed(0)}s)`,
371
+ );
372
+
373
+ if (!response.body) throw new Error("response had no body");
374
+
375
+ // Streamed rather than buffered through `arrayBuffer()`: per-chunk arrival
376
+ // is what makes "moving" observable at all, and it is also what the old
377
+ // `Bun.write(path, response)` note in images.ts was working around.
378
+ const sink = Bun.file(partPath).writer();
379
+ try {
380
+ for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
381
+ bytes += chunk.byteLength;
382
+ sink.write(chunk);
383
+ armStall();
384
+ }
385
+ await sink.end();
386
+ } catch (e) {
387
+ // Close the sink without masking the failure that got us here — an
388
+ // aborted body leaves an open fd otherwise.
389
+ try {
390
+ await sink.end();
391
+ } catch {
392
+ // Already failed; the .part file is removed by the caller regardless.
393
+ }
394
+ throw e;
395
+ }
396
+
397
+ // No artifact quickchr downloads is empty, so a zero-byte body is always a
398
+ // failure however the server framed it. Without this an unknown-length
399
+ // response — which is exactly what Bun hands back for a *malformed*
400
+ // content-length (measured: `4096.5` and `-5` both yield 0 bytes) — would
401
+ // have no length to verify against, and the empty file would be renamed
402
+ // into the cache as a complete artifact. Retriable: an empty body is
403
+ // usually a transient server or proxy fault.
404
+ if (bytes === 0) {
405
+ throw new Error(`empty transfer — server sent no body (${describeTransfer(outcome())})`);
406
+ }
407
+
408
+ // Defense in depth. Measured against a raw stub: when a server declares a
409
+ // length and then closes early, Bun's fetch already throws ECONNRESET while
410
+ // reading the body, so this rarely fires. It still has to be here — a
411
+ // short-but-clean framing would otherwise be written out as a complete
412
+ // artifact and cached forever.
413
+ if (expected !== undefined && bytes !== expected) {
414
+ throw new Error(`truncated transfer — ${describeTransfer(outcome())}`);
415
+ }
416
+ clearTimers();
417
+ return outcome();
418
+ } catch (e) {
419
+ clearTimers();
420
+ if (fired !== undefined) throw new DownloadDeadlineError(outcome(), url, stallMs);
421
+ throw e;
422
+ }
423
+ }
424
+
425
+ function removeIfPresent(path: string): void {
426
+ if (existsSync(path)) {
427
+ try {
428
+ unlinkSync(path);
429
+ } catch {
430
+ // Best effort: a leftover .part is never mistaken for the finished file.
431
+ }
432
+ }
433
+ }