@coderook/cli 0.34.10 → 0.35.0

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.
@@ -3,8 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.Downloader = exports.DownloadCancelled = void 0;
6
+ exports.Downloader = exports.DownloadFailed = exports.DownloadCancelled = void 0;
7
7
  exports.underway = underway;
8
+ exports.plainReason = plainReason;
8
9
  exports.applyMode = applyMode;
9
10
  /**
10
11
  * Bringing a project down: the file list for a version, then its bytes.
@@ -34,6 +35,25 @@ async function exists(target) {
34
35
  return false;
35
36
  }
36
37
  }
38
+ /** How many bytes are at `target`, or nothing when there is no file. */
39
+ async function sizeOnDisk(target) {
40
+ try {
41
+ return (await (0, promises_1.stat)(target)).size;
42
+ }
43
+ catch {
44
+ return 0;
45
+ }
46
+ }
47
+ /**
48
+ * A running hash of what is already at `target`, to carry on from. Streamed,
49
+ * so resuming a large file does not first hold all of it.
50
+ */
51
+ async function digestPrefix(target) {
52
+ const hash = (0, node_crypto_1.createHash)("sha256");
53
+ for await (const block of (0, node_fs_1.createReadStream)(target))
54
+ hash.update(block);
55
+ return hash;
56
+ }
37
57
  /** The digest of what is on disk, or null when nothing is there to read. */
38
58
  async function digestOf(target) {
39
59
  try {
@@ -101,6 +121,90 @@ class DownloadCancelled extends Error {
101
121
  }
102
122
  }
103
123
  exports.DownloadCancelled = DownloadCancelled;
124
+ /**
125
+ * One transfer that could not be completed, named, with the reason in words.
126
+ *
127
+ * What people saw instead was the single word `terminated` — undici's message
128
+ * for a body the other end stopped sending — with no file, no cause and no
129
+ * hint that anything had been tried. Eight clones started seconds after the
130
+ * API was redeployed lost their connections mid-file, and four of them ended
131
+ * on exactly that. The status and code travel with it, because callers ask
132
+ * whether a failure was a refusal or the network.
133
+ */
134
+ class DownloadFailed extends Error {
135
+ what;
136
+ reason;
137
+ attempts;
138
+ status;
139
+ code;
140
+ constructor(what, reason, attempts, cause) {
141
+ super(`Could not download ${what}: ${reason}` +
142
+ (attempts > 1 ? ` (tried ${attempts} times).` : "."), { cause });
143
+ this.what = what;
144
+ this.reason = reason;
145
+ this.attempts = attempts;
146
+ this.status = cause.status;
147
+ this.code = cause.code;
148
+ }
149
+ }
150
+ exports.DownloadFailed = DownloadFailed;
151
+ /** What each network failure means, for somebody who has not met the code. */
152
+ const PLAIN = {
153
+ ENOTFOUND: "CodeRook's address could not be looked up, so this computer may be offline",
154
+ EAI_AGAIN: "CodeRook's address could not be looked up, so this computer may be offline",
155
+ ECONNREFUSED: "the connection was refused",
156
+ ECONNRESET: "the connection was dropped",
157
+ EPIPE: "the connection was dropped",
158
+ ETIMEDOUT: "the connection timed out",
159
+ ENETUNREACH: "there is no route to the internet from here",
160
+ EHOSTUNREACH: "there is no route to the internet from here",
161
+ UND_ERR_CONNECT_TIMEOUT: "the connection timed out",
162
+ UND_ERR_SOCKET: "the connection closed before it finished arriving",
163
+ UND_ERR_RES_CONTENT_LENGTH_MISMATCH: "the connection closed before it finished arriving",
164
+ };
165
+ /**
166
+ * Why one request failed, said so that a person can act on it.
167
+ *
168
+ * Node keeps the real reason in `cause`, and for a failed happy-eyeballs
169
+ * connection in `cause.errors`; the top-level message is `fetch failed` or
170
+ * `terminated` whatever happened. The code goes in brackets after the words,
171
+ * for whoever needs to tell a dead network from a server that hung up.
172
+ */
173
+ function plainReason(error) {
174
+ if (!(error instanceof Error))
175
+ return String(error);
176
+ const status = error.status;
177
+ if (typeof status === "number") {
178
+ /* The fallback message is only the route; the service's own words are better. */
179
+ return error.message.startsWith("/v1/")
180
+ ? `CodeRook answered ${status}`
181
+ : `CodeRook answered ${status}: ${error.message}`;
182
+ }
183
+ if (error.name === "TimeoutError") {
184
+ return `it had not finished arriving after ${REQUEST_DEADLINE_MS / 60_000} minutes`;
185
+ }
186
+ const cause = error.cause;
187
+ const causes = cause?.errors?.length ? cause.errors : cause ? [cause] : [];
188
+ const codes = [...new Set(causes.map((one) => one.code).filter(Boolean))];
189
+ const said = codes.map((code) => PLAIN[code]).find(Boolean);
190
+ const detail = causes
191
+ .map((one) => [one.code, one.message].filter(Boolean).join(": "))
192
+ .filter(Boolean)[0];
193
+ if (error.message === "terminated") {
194
+ return (`${said ?? "the connection closed before it finished arriving"}` +
195
+ (detail ? ` (${detail})` : ""));
196
+ }
197
+ if (error.message === "fetch failed") {
198
+ return (`CodeRook could not be reached${said ? `: ${said}` : ""}` +
199
+ (detail ? ` (${detail})` : ""));
200
+ }
201
+ return error.message;
202
+ }
203
+ /** Where `content-range` says a partial answer starts, or null. */
204
+ function rangeStart(headers) {
205
+ const match = /^bytes (\d+)-\d+\/(?:\d+|\*)$/.exec(headers.get("content-range") ?? "");
206
+ return match ? Number(match[1]) : null;
207
+ }
104
208
  class Downloader {
105
209
  credentials;
106
210
  aborted = false;
@@ -117,36 +221,73 @@ class Downloader {
117
221
  throw new DownloadCancelled();
118
222
  }
119
223
  /**
120
- * One request, repeated while repeating it might help.
224
+ * Do `work` until it succeeds, it fails in a way repeating cannot help, or
225
+ * the attempts run out.
121
226
  *
122
227
  * A restore makes at least one request per file and, for a file kept in
123
228
  * pieces, one per piece — three thousand seven hundred and fifty-six for a
124
229
  * single file here. At those numbers a dropped connection is not a risk but
125
230
  * a certainty, and without this the whole restore ends on the first one.
231
+ *
232
+ * `work` is the whole transfer, body included. Retrying only until the
233
+ * headers arrived was the gap that ended clones: `fetch()` resolves on the
234
+ * headers, so a connection cut while the bytes were still coming — which is
235
+ * what a redeploy does to every connection it holds — escaped the loop and
236
+ * ended the whole run on one file.
237
+ *
238
+ * `progressed` lets a transfer that moved forward before failing start its
239
+ * count again, so a long file over a connection that keeps dropping is
240
+ * finished rather than abandoned; one that makes no progress still stops
241
+ * after `RETRY_ATTEMPTS`. `halt` stops it without retrying, for a run that
242
+ * has already failed elsewhere. `what` names the transfer in the error a
243
+ * person finally sees.
126
244
  */
127
- async request(route) {
245
+ async repeat(work, options = {}) {
128
246
  let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
129
- for (let attempt = 1;; attempt += 1) {
247
+ let tries = 0;
248
+ for (let attempt = 1, stalled = 1;; attempt += 1, stalled += 1) {
130
249
  this.check();
250
+ options.halt?.throwIfAborted();
251
+ tries = attempt;
131
252
  try {
132
- return await this.attempt(route);
253
+ return await work(attempt);
133
254
  }
134
255
  catch (error) {
135
256
  // Its own cancellation first: `worthRetrying` knows nothing of it.
136
- if (error instanceof DownloadCancelled)
137
- throw error;
138
- if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
139
- throw error;
257
+ if (error instanceof DownloadCancelled || this.aborted)
258
+ throw new DownloadCancelled();
259
+ if (options.halt?.aborted)
260
+ throw options.halt.reason;
261
+ if (await options.progressed?.()) {
262
+ stalled = 0;
263
+ wait = retry_js_1.RETRY_FIRST_WAIT_MS;
264
+ }
265
+ if (stalled >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error)) {
266
+ if (!options.what)
267
+ throw error;
268
+ throw new DownloadFailed(options.what, plainReason(error), tries, error);
269
+ }
140
270
  try {
141
- await (0, retry_js_1.pauseFor)(this.controller.signal, wait);
271
+ await (0, retry_js_1.pauseFor)(options.halt
272
+ ? AbortSignal.any([this.controller.signal, options.halt])
273
+ : this.controller.signal, wait);
142
274
  }
143
275
  catch {
144
- throw new DownloadCancelled();
276
+ if (this.aborted)
277
+ throw new DownloadCancelled();
278
+ throw options.halt?.reason ?? error;
145
279
  }
146
280
  wait *= 2;
147
281
  }
148
282
  }
149
283
  }
284
+ /** A whole response body and its headers, under the retry rule. */
285
+ async bytes(route, what, halt) {
286
+ return this.repeat(async () => {
287
+ const reply = await this.attempt(route, REQUEST_DEADLINE_MS, halt);
288
+ return { body: Buffer.from(await reply.arrayBuffer()), headers: reply.headers };
289
+ }, { what, halt });
290
+ }
150
291
  async attempt(route,
151
292
  /*
152
293
  Longer where the service has to build the answer before it can send it.
@@ -158,7 +299,9 @@ class Downloader {
158
299
  to assemble and longer under load; three minutes is a request that has
159
300
  failed, not one that is still working.
160
301
  */
161
- deadline = REQUEST_DEADLINE_MS) {
302
+ deadline = REQUEST_DEADLINE_MS,
303
+ /* The run's own stop, when a sibling transfer has already failed it. */
304
+ halt, extra) {
162
305
  const token = await this.credentials.token();
163
306
  if (!token)
164
307
  throw new Error("Sign in again before downloading");
@@ -167,6 +310,7 @@ class Downloader {
167
310
  authorization: `Bearer ${token}`,
168
311
  "user-agent": "CodeRook/0.1",
169
312
  ...(0, identify_js_1.clientHeaders)(),
313
+ ...extra,
170
314
  },
171
315
  /*
172
316
  The download's own cancellation, and a deadline.
@@ -180,6 +324,7 @@ class Downloader {
180
324
  signal: AbortSignal.any([
181
325
  this.controller.signal,
182
326
  AbortSignal.timeout(deadline),
327
+ ...(halt ? [halt] : []),
183
328
  ]),
184
329
  });
185
330
  if (!response.ok) {
@@ -190,8 +335,9 @@ class Downloader {
190
335
  */
191
336
  const tooOld = (0, identify_js_1.readRefusal)(response.status, text);
192
337
  if (tooOld) {
193
- throw new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
194
- `update from ${tooOld.upgradeUrl}.`);
338
+ /* With its status, so it is not retried as though it were the network. */
339
+ throw Object.assign(new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
340
+ `update from ${tooOld.upgradeUrl}.`), { status: response.status });
195
341
  }
196
342
  let message = `${route} failed (${response.status})`;
197
343
  let code;
@@ -226,30 +372,29 @@ class Downloader {
226
372
  * These routes are reads, so repeating the whole request is safe and is the
227
373
  * only way to replace a truncated body.
228
374
  */
229
- async json(route) {
230
- let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
231
- for (let attempt = 1;; attempt += 1) {
232
- this.check();
233
- try {
234
- return await (await this.attempt(route)).json();
235
- }
236
- catch (error) {
237
- if (error instanceof DownloadCancelled)
238
- throw error;
239
- if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
240
- throw error;
241
- try {
242
- await (0, retry_js_1.pauseFor)(this.controller.signal, wait);
243
- }
244
- catch {
245
- throw new DownloadCancelled();
246
- }
247
- wait *= 2;
248
- }
375
+ async json(route, what) {
376
+ let tries = 0;
377
+ try {
378
+ return await this.repeat(async (attempt) => {
379
+ tries = attempt;
380
+ return (await (await this.attempt(route)).json());
381
+ });
382
+ }
383
+ catch (error) {
384
+ /*
385
+ A refusal keeps its own words: "not found" is the answer, and callers
386
+ read its status. Only a failure that never reached an answer is
387
+ renamed, because that is the one that arrived as a bare `terminated`.
388
+ */
389
+ if (error instanceof DownloadCancelled)
390
+ throw error;
391
+ if (typeof error.status === "number")
392
+ throw error;
393
+ throw new DownloadFailed(what, plainReason(error), tries, error);
249
394
  }
250
395
  }
251
396
  async versions(repositoryId) {
252
- const body = await this.json(`/v1/repositories/${repositoryId}/versions`);
397
+ const body = await this.json(`/v1/repositories/${repositoryId}/versions`, "the list of versions");
253
398
  return (body.versions ?? []).map((row) => ({
254
399
  id: String(row.id ?? ""),
255
400
  sequence: Number(row.sequence ?? 0),
@@ -277,11 +422,17 @@ class Downloader {
277
422
  * files to `git fast-import`, which wants them one after another anyway.
278
423
  */
279
424
  async archive(repositoryId, versionId) {
280
- const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
281
- return new Uint8Array(await response.arrayBuffer());
425
+ /*
426
+ Under the retry rule like everything else. It had none: one dropped
427
+ connection during an archive of hundreds of megabytes ended the import.
428
+ */
429
+ return this.repeat(async () => {
430
+ const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
431
+ return new Uint8Array(await response.arrayBuffer());
432
+ }, { what: "the version's archive" });
282
433
  }
283
434
  async files(repositoryId, versionId) {
284
- const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
435
+ const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`, "the version's list of files");
285
436
  return (body.files ?? []).map((row) => {
286
437
  const pieces = Array.isArray(row.chunks) ? row.chunks : null;
287
438
  const packed = row.pack && typeof row.pack === "object"
@@ -394,8 +545,8 @@ class Downloader {
394
545
  if (packId && restoredPacks.has(packId))
395
546
  continue;
396
547
  if (packId) {
397
- const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
398
- const body = Buffer.from(await reply.arrayBuffer());
548
+ const reply = await this.bytes(`/v1/repositories/${repositoryId}/objects/${packId}`, packName(packed.get(packId) ?? []));
549
+ const body = reply.body;
399
550
  const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
400
551
  const expectedPackDigest = reply.headers.get("x-coderook-sha256");
401
552
  if (expectedPackDigest && packDigest !== expectedPackDigest) {
@@ -463,8 +614,8 @@ class Downloader {
463
614
  try {
464
615
  for (const [packId, members] of packs) {
465
616
  this.check();
466
- const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
467
- const body = Buffer.from(await reply.arrayBuffer());
617
+ const reply = await this.bytes(`/v1/repositories/${repositoryId}/objects/${packId}`, packName(members));
618
+ const body = reply.body;
468
619
  const expected = reply.headers.get("x-coderook-sha256");
469
620
  if (expected && (0, node_crypto_1.createHash)("sha256").update(body).digest("hex") !== expected) {
470
621
  throw new Error("A solid pack did not arrive intact");
@@ -506,8 +657,7 @@ class Downloader {
506
657
  can say it happened.
507
658
  */
508
659
  recoveredFromGzip = [];
509
- async fetchInto(repositoryId, versionId, file, target) {
510
- const whole = (0, node_crypto_1.createHash)("sha256");
660
+ async fetchInto(repositoryId, versionId, file, target, halt) {
511
661
  /*
512
662
  A sealed file is read whole, by path, whatever shape it is stored in.
513
663
 
@@ -516,51 +666,55 @@ class Downloader {
516
666
  that cannot match the digest this file was listed with.
517
667
  */
518
668
  const pieces = file.sealed ? [] : (file.chunks ?? []);
669
+ if (!pieces.length) {
670
+ const digest = await this.streamTo(file.objectId && !file.sealed
671
+ ? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
672
+ : `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
673
+ `?path=${encodeURIComponent(file.path)}`, file.path, target, halt);
674
+ if (file.sha256 && digest !== file.sha256) {
675
+ if (await recoverGzipFile(target, file.sha256)) {
676
+ this.recoveredFromGzip.push(file.path);
677
+ return file.sha256;
678
+ }
679
+ }
680
+ return digest;
681
+ }
682
+ const whole = (0, node_crypto_1.createHash)("sha256");
519
683
  let recovered = false;
520
- // Captured so the generators below do not need `this` rebound.
521
- const request = (route) => this.request(route);
684
+ // Captured so the generator below does not need `this` rebound.
685
+ const fetchPiece = (route, what) => this.bytes(route, what, halt);
522
686
  const check = () => this.check();
523
- const source = pieces.length
524
- ? (async function* () {
525
- for (let at = 0; at < pieces.length; at += 1) {
526
- check();
527
- const piece = pieces[at];
528
- const reply = await request(`/v1/repositories/${repositoryId}/objects/${piece.objectId}`);
529
- let body = Buffer.from(await reply.arrayBuffer());
530
- /*
531
- Checked against what the upload recorded for this piece, before
532
- a byte of it reaches the file. Without the digest the only
533
- available check was the whole file's, which says that something
534
- in seven gigabytes is wrong and nothing about what.
535
- */
536
- const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
537
- if (piece.sha256 && digest !== piece.sha256) {
538
- const plain = gunzippedMatching(body, piece.sha256);
539
- if (!plain) {
540
- throw new Error(`${file.path}: piece ${at + 1} of ${pieces.length} ` +
541
- `did not arrive intact`);
542
- }
543
- recovered = true;
544
- body = plain;
687
+ /*
688
+ Each piece is its own retried request, so a connection lost partway
689
+ through a large file costs the piece that was arriving and no more: the
690
+ ones already written stay written, and the file carries on from there.
691
+ */
692
+ const source = (async function* () {
693
+ for (let at = 0; at < pieces.length; at += 1) {
694
+ check();
695
+ const piece = pieces[at];
696
+ const reply = await fetchPiece(`/v1/repositories/${repositoryId}/objects/${piece.objectId}`, `${file.path} (piece ${at + 1} of ${pieces.length})`);
697
+ let body = reply.body;
698
+ /*
699
+ Checked against what the upload recorded for this piece, before
700
+ a byte of it reaches the file. Without the digest the only
701
+ available check was the whole file's, which says that something
702
+ in seven gigabytes is wrong and nothing about what.
703
+ */
704
+ const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
705
+ if (piece.sha256 && digest !== piece.sha256) {
706
+ const plain = gunzippedMatching(body, piece.sha256);
707
+ if (!plain) {
708
+ throw new Error(`${file.path}: piece ${at + 1} of ${pieces.length} ` +
709
+ `did not arrive intact`);
545
710
  }
546
- whole.update(body);
547
- yield body;
548
- }
549
- })()
550
- : (async function* () {
551
- const reply = await request(file.objectId && !file.sealed
552
- ? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
553
- : `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
554
- `?path=${encodeURIComponent(file.path)}`);
555
- if (!reply.body)
556
- return;
557
- for await (const block of node_stream_1.Readable.fromWeb(reply.body)) {
558
- check();
559
- const buffer = Buffer.from(block);
560
- whole.update(buffer);
561
- yield buffer;
711
+ recovered = true;
712
+ body = plain;
562
713
  }
563
- })();
714
+ whole.update(body);
715
+ yield body;
716
+ }
717
+ })();
564
718
  /*
565
719
  `pipeline` rather than a write loop: it applies backpressure, and it
566
720
  destroys the file handle when the source throws. A partially written
@@ -568,16 +722,91 @@ class Downloader {
568
722
  up holding something that looks finished.
569
723
  */
570
724
  await (0, promises_2.pipeline)(source, (0, node_fs_1.createWriteStream)(target));
571
- const digest = whole.digest("hex");
572
725
  if (recovered)
573
726
  this.recoveredFromGzip.push(file.path);
574
- if (!pieces.length && file.sha256 && digest !== file.sha256) {
575
- if (await recoverGzipFile(target, file.sha256)) {
576
- this.recoveredFromGzip.push(file.path);
577
- return file.sha256;
727
+ return whole.digest("hex");
728
+ }
729
+ /**
730
+ * Stream one whole object to `target`, carrying on where a dropped
731
+ * connection left it, and answer with the digest of what was written.
732
+ *
733
+ * The body is part of the attempt. A connection cut halfway through used to
734
+ * escape the retry loop entirely, because `fetch()` had already resolved on
735
+ * the headers.
736
+ *
737
+ * Where the service said it serves byte ranges, a retry asks only for what
738
+ * is missing: what reached the disk is measured and hashed again from
739
+ * there, rather than trusted from a count that the failed stream may not
740
+ * have flushed. Anything other than a partial answer starting exactly at
741
+ * that byte is taken from the beginning, so a resumed file is never two
742
+ * different answers spliced together; the digest is checked by the caller
743
+ * either way.
744
+ */
745
+ async streamTo(route, what, target, halt) {
746
+ let resumable = false;
747
+ let before = 0;
748
+ let hash = (0, node_crypto_1.createHash)("sha256");
749
+ const check = () => this.check();
750
+ return this.repeat(async (attempt) => {
751
+ let from = 0;
752
+ if (attempt > 1 && resumable) {
753
+ from = await sizeOnDisk(target);
578
754
  }
579
- }
580
- return digest;
755
+ let reply;
756
+ try {
757
+ reply = await this.attempt(route, REQUEST_DEADLINE_MS, halt, from > 0 ? { range: `bytes=${from}-` } : undefined);
758
+ }
759
+ catch (error) {
760
+ /*
761
+ Nothing left to send from there — the whole body had landed and
762
+ only its ending was lost. Taken again from the start rather than
763
+ guessed complete, because only the digest can say it is.
764
+ */
765
+ if (from === 0 || error.status !== 416)
766
+ throw error;
767
+ from = 0;
768
+ reply = await this.attempt(route, REQUEST_DEADLINE_MS, halt);
769
+ }
770
+ before = from;
771
+ const append = from > 0 && reply.status === 206 && rangeStart(reply.headers) === from;
772
+ if (reply.status !== 206) {
773
+ resumable = reply.headers.get("accept-ranges") === "bytes";
774
+ }
775
+ hash = append ? await digestPrefix(target) : (0, node_crypto_1.createHash)("sha256");
776
+ const sink = (0, node_fs_1.createWriteStream)(target, { flags: append ? "a" : "w" });
777
+ if (!reply.body) {
778
+ sink.end();
779
+ await new Promise((resolve, reject) => {
780
+ sink.once("finish", resolve).once("error", reject);
781
+ });
782
+ }
783
+ else {
784
+ await (0, promises_2.pipeline)(node_stream_1.Readable.fromWeb(reply.body), new node_stream_1.Transform({
785
+ transform(block, _encoding, done) {
786
+ try {
787
+ check();
788
+ }
789
+ catch (error) {
790
+ done(error);
791
+ return;
792
+ }
793
+ const buffer = Buffer.from(block);
794
+ hash.update(buffer);
795
+ done(null, buffer);
796
+ },
797
+ }), sink);
798
+ }
799
+ return hash.digest("hex");
800
+ }, {
801
+ what,
802
+ halt,
803
+ /*
804
+ Counted again from nothing when this attempt moved the file on —
805
+ judged by what reached the disk, since that is where the next
806
+ attempt starts.
807
+ */
808
+ progressed: async () => resumable && (await sizeOnDisk(target)) > before,
809
+ });
581
810
  }
582
811
  /**
583
812
  * Write a version into `destination`.
@@ -609,6 +838,15 @@ class Downloader {
609
838
  const staging = `${destination}.incoming`;
610
839
  await (0, promises_1.rm)(staging, { recursive: true, force: true });
611
840
  const manifest = {};
841
+ /*
842
+ Stops the other lanes once one transfer has failed for good.
843
+
844
+ Without it the first failure rejected the run while five other lanes
845
+ went on writing into the staging folder — so the cleanup below removed
846
+ it, and the lanes still running made it again. That is how failed clones
847
+ left `.incoming` folders behind.
848
+ */
849
+ const halt = new AbortController();
612
850
  try {
613
851
  let written = 0;
614
852
  let bytes = 0;
@@ -688,8 +926,8 @@ class Downloader {
688
926
  so fetch the decoded pack once, verify it, and materialise every
689
927
  member before releasing the buffer.
690
928
  */
691
- const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
692
- const body = Buffer.from(await reply.arrayBuffer());
929
+ const reply = await this.bytes(`/v1/repositories/${repositoryId}/objects/${packId}`, packName(packed.get(packId) ?? []), halt.signal);
930
+ const body = reply.body;
693
931
  const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
694
932
  const expectedPackDigest = reply.headers.get("x-coderook-sha256");
695
933
  if (expectedPackDigest && packDigest !== expectedPackDigest) {
@@ -719,7 +957,7 @@ class Downloader {
719
957
  const restoreFile = async (file) => {
720
958
  const full = (0, safe_path_js_1.inside)(staging, file.path);
721
959
  await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
722
- const digest = await this.fetchInto(repositoryId, versionId, file, full);
960
+ const digest = await this.fetchInto(repositoryId, versionId, file, full, halt.signal);
723
961
  /*
724
962
  The whole file's digest, checked after every piece has already been
725
963
  checked individually. Both are needed: the pieces catch a corrupt or
@@ -782,30 +1020,53 @@ class Downloader {
782
1020
  });
783
1021
  }
784
1022
  };
1023
+ /*
1024
+ The first failure that outlasted its retries, which is what the run
1025
+ reports. Whatever the other lanes throw while being stopped is only
1026
+ the echo of it.
1027
+ */
1028
+ let failure = null;
785
1029
  const lane = async () => {
786
1030
  for (;;) {
1031
+ if (halt.signal.aborted)
1032
+ return;
787
1033
  const at = next;
788
1034
  next += 1;
789
1035
  if (at >= jobs.length)
790
1036
  return;
791
- this.check();
792
1037
  const job = jobs[at];
793
1038
  const size = sizeOf(job);
794
1039
  await roomFor(size);
1040
+ if (halt.signal.aborted)
1041
+ return;
795
1042
  flying += size;
796
1043
  try {
1044
+ this.check();
797
1045
  if (job.kind === "pack")
798
1046
  await restorePack(job.id);
799
1047
  else
800
1048
  await restoreFile(job.file);
801
1049
  }
1050
+ catch (error) {
1051
+ if (!failure)
1052
+ failure = { error };
1053
+ halt.abort(new Error("stopped: another file failed"));
1054
+ return;
1055
+ }
802
1056
  finally {
803
1057
  flying -= size;
804
1058
  wakeAll();
805
1059
  }
806
1060
  }
807
1061
  };
1062
+ /*
1063
+ Every lane settled before anything else happens — on failure most of
1064
+ all, so that nothing is still writing into the staging folder when it
1065
+ is removed.
1066
+ */
808
1067
  await Promise.all(Array.from({ length: Math.min(LANES, jobs.length) }, () => lane()));
1068
+ if (failure)
1069
+ throw failure.error;
809
1070
  /*
810
1071
  Everything has arrived and verified, so it can go into place. Each
811
1072
  file is renamed over its destination individually: a rename within
@@ -882,6 +1143,19 @@ class Downloader {
882
1143
  }
883
1144
  }
884
1145
  exports.Downloader = Downloader;
1146
+ /**
1147
+ * A solid pack named by what is in it. Its object id means nothing to the
1148
+ * person reading the error; the files they will be missing do.
1149
+ */
1150
+ function packName(members) {
1151
+ const first = members[0]?.path;
1152
+ if (!first)
1153
+ return "a pack of small files";
1154
+ const others = members.length - 1;
1155
+ return others > 0
1156
+ ? `${first} and ${others} other small file${others === 1 ? "" : "s"} stored with it`
1157
+ : first;
1158
+ }
885
1159
  /** The gunzipped bytes when they hash to `expected`, otherwise null. */
886
1160
  function gunzippedMatching(body, expected) {
887
1161
  if (body.length < 2 || body[0] !== 0x1f || body[1] !== 0x8b)