@tikoci/quickchr 0.4.5 → 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.
- package/DESIGN.md +260 -7
- package/MANUAL.md +44 -12
- package/package.json +1 -1
- package/src/cli/index.ts +31 -0
- package/src/index.ts +1 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +3 -39
- package/src/lib/net.ts +59 -3
- package/src/lib/packages.ts +6 -12
- package/src/lib/platform.ts +153 -19
- package/src/lib/qemu.ts +23 -4
- package/src/lib/quickchr.ts +266 -26
- package/src/lib/settings-file.ts +52 -0
- package/src/lib/settings.ts +20 -42
- package/src/lib/types.ts +37 -1
- package/src/lib/versions.ts +99 -22
|
@@ -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
|
+
}
|