pmtiles-swarm 0.35.5 โ†’ 0.37.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.37.0
11
+ ### โœจ Features and improvements
12
+ - **An archive being hashed can now be cancelled, and says how far through it is.** 0.36.0 moved
13
+ hashing into a process of its own, which made both possible; this connects them to the console.
14
+ A local add is now registered with an AbortController, so `DELETE /api/adds?url=<path>` ends the
15
+ hasher and the Cancel button beside it works. Nothing is lost by pressing it: the archive is the
16
+ caller's own file and hashing only ever read it.
17
+
18
+ Shutdown reaches these too. A hasher left behind when the node exits is an orphan reading the
19
+ disk for hours, answering to nothing.
20
+
21
+ The piece the hasher has reached is converted to bytes against the file size โ€” the console draws
22
+ one progress bar for adds and labels it in bytes โ€” so a long hash reads `hashing 698 GiB ยท 41.2%
23
+ ยท 12m` instead of `hashing 698 GiB ยท 3m`. The hourly log line carries the percentage as well. An
24
+ add hashing in this process rather than out of it still reports no figure, because there is none
25
+ to report, and the bar is left off rather than pinned at zero.
26
+
27
+ ### ๐Ÿž Bug fixes
28
+ - **Cancelling a hash no longer answers by hashing the same archive here instead.** A creator that
29
+ fails falls back to hashing in the node's own process, deliberately, since a torrent matters more
30
+ than the format of a torrent โ€” and a cancelled hash arrives as a creator that failed. So the
31
+ button would have answered "stop reading 698 GiB" by reading 698 GiB again, in the process
32
+ serving tiles, where nothing can interrupt it at all. A creator that fails for any other reason
33
+ still falls back.
34
+ - **A cancel arriving during the MD5 pass is no longer ignored until it finishes.** With `md5` on,
35
+ the archive is read twice, and only the second read took a signal โ€” so cancelling during the
36
+ first one waited out most of an hour of disk on a planet archive before it took effect.
37
+
38
+ ## 0.36.0
39
+ ### โœจ Features and improvements
40
+ - **Hashing an archive now happens in a process of its own.** Building the torrent for a 698 GiB
41
+ archive ran inside the libtorrent sidecar, competing with the session for the disk and for
42
+ Python's interpreter lock while every archive on the node was being served from that same disk.
43
+ It also could not be stopped: libtorrent's hashing never checks for interruption, and the sidecar
44
+ cannot be ended to end a hash because it holds the session and every torrent seeding from it. A
45
+ build started by a misclick ran its full six hours.
46
+
47
+ It is now `libtorrent_sidecar.py --create`, started per hash, holding no session and no port.
48
+ Killing it costs the hash and nothing else, and hashing only ever reads, so the archive is
49
+ untouched. It reports the piece it has reached as it goes, so a caller can draw a real fraction
50
+ rather than "hashing 698 GiB ยท 3m".
51
+
52
+ Requires pmtiles-torrent 0.8.0. Also picks up 0.7.5, which stops an archive that is hashing its
53
+ store from reporting itself as "paused" โ€” libtorrent hashes one store at a time and flags every
54
+ torrent queued behind it as paused, so a library busy verifying itself read as one somebody had
55
+ stopped.
56
+
57
+ ### ๐Ÿž Bug fixes
58
+
10
59
  ## 0.35.5
11
60
  ### โœจ Features and improvements
12
61
 
@@ -79,6 +79,25 @@ A magnet, a `.torrent` URL and an uploaded `.torrent` are metadata rather than
79
79
  data, so there is nothing slow to wait for: those still answer `201` with the
80
80
  entry.
81
81
 
82
+ ### Stopping one that is running
83
+
84
+ `DELETE /api/adds?url=โ€ฆ` stops an add before it has produced a torrent, and the
85
+ console offers it as a Cancel button beside each one. The identifier is whatever
86
+ the add was started with โ€” the URL for a remote one, the path for a local one.
87
+
88
+ What it costs differs by kind. A **remote** add is deleting a partial download,
89
+ deliberately: somebody said stop, and leaving a few hundred gigabytes behind
90
+ after that is waste nobody will ever find again. A **local** add deletes
91
+ nothing. The archive is your own file and hashing only ever read it, so
92
+ cancelling costs the hashing done so far and not a byte of data. If `publishDir`
93
+ moved the file, that already happened โ€” it is the cheap irreversible step and it
94
+ goes first โ€” so the archive stays where it was published to.
95
+
96
+ `GET /api/adds` reports each one's progress while it runs. A local add hashing
97
+ out of process says how far through the archive it is; one that has fallen back
98
+ to hashing in the node's own process reports no figure at all, because there is
99
+ none to report, and cancelling it stops the wait rather than the read.
100
+
82
101
  ### When the source URL is not published
83
102
 
84
103
  Adding from a URL registers that URL as a web seed by default, because it is by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.35.5",
3
+ "version": "0.37.0",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -46,7 +46,7 @@
46
46
  "maplibre-gl": "^6.2.0",
47
47
  "parse-torrent": "^11.0.24",
48
48
  "pmtiles": "^4.4.1",
49
- "pmtiles-torrent": "^0.7.4",
49
+ "pmtiles-torrent": "^0.8.0",
50
50
  "webtorrent": "^3.0.21"
51
51
  },
52
52
  "engines": {
@@ -491,13 +491,23 @@ export class LibtorrentEngine {
491
491
 
492
492
  /**
493
493
  * Creates a torrent from a local file.
494
+ *
495
+ * In a process of its own rather than over the pipe. libtorrent's hashing
496
+ * never checks for interruption, so a hash running inside the sidecar could
497
+ * not be stopped -- and the sidecar itself cannot be ended to stop one,
498
+ * because it holds the session and every torrent seeding from it. A 698 GiB
499
+ * build started by a misclick therefore ran its full six hours, saturating
500
+ * the disk the rest of the library was being served from.
501
+ *
502
+ * A process started for one hash can simply be killed. Hashing only reads,
503
+ * so nothing is left half-written, and the archive is untouched.
494
504
  * @param {string} filePath - The file to hash.
495
- * @param {object} [options] - Piece length, trackers, web seeds, format.
505
+ * @param {object} [options] - Piece length, trackers, web seeds, format,
506
+ * `signal` to cancel with, and `onProgress({piece, pieces})`.
496
507
  * @returns {Promise<object>} - The torrent file and what it describes.
497
508
  */
498
509
  async createTorrent(filePath, options = {}) {
499
- const result = await this.#call(
500
- 'create',
510
+ const result = await this.#hashApart(
501
511
  {
502
512
  path: filePath,
503
513
  pieceLength: options.pieceLength,
@@ -508,8 +518,7 @@ export class LibtorrentEngine {
508
518
  createdBy: options.createdBy,
509
519
  format: options.format ?? 'hybrid',
510
520
  },
511
- // Hashing a large archive takes as long as it takes.
512
- options.timeoutMs ?? 6 * 60 * 60 * 1000,
521
+ options,
513
522
  );
514
523
  return {
515
524
  ...result,
@@ -517,6 +526,120 @@ export class LibtorrentEngine {
517
526
  };
518
527
  }
519
528
 
529
+ /**
530
+ * Runs one `--create` to completion, or until it is no longer wanted.
531
+ * @param {object} params - What to hash and how.
532
+ * @param {object} options - signal, onProgress, timeoutMs.
533
+ * @returns {Promise<object>} - The sidecar's result object.
534
+ */
535
+ #hashApart(params, options) {
536
+ return new Promise((resolve, reject) => {
537
+ const script = this.#options.script ?? resolveSidecar();
538
+ const child = spawn(this.#options.python, [script, '--create'], {
539
+ stdio: ['pipe', 'pipe', 'pipe'],
540
+ });
541
+
542
+ let settled = false;
543
+ let result;
544
+ let failure;
545
+ let pending = '';
546
+ let stderr = '';
547
+
548
+ /**
549
+ * Ends this hash once, whatever ends it.
550
+ * @param {Error} [error] - Why, if it failed.
551
+ */
552
+ const finish = (error) => {
553
+ if (settled) return;
554
+ settled = true;
555
+ clearTimeout(timer);
556
+ options.signal?.removeEventListener('abort', cancel);
557
+ if (error) reject(error);
558
+ else resolve(result);
559
+ };
560
+
561
+ const cancel = () => {
562
+ // The hash cannot be asked to stop, so it is ended. Nothing is lost:
563
+ // hashing reads and this process holds nothing else.
564
+ child.kill();
565
+ // Said the same way however it was cancelled. An AbortController with
566
+ // no reason gives "This operation was aborted", which in a log next to
567
+ // an archive name explains nothing; callers that care which kind of
568
+ // stop this was read `signal.reason`, which is carried as the cause.
569
+ finish(
570
+ new Error('hashing was cancelled', { cause: options.signal?.reason }),
571
+ );
572
+ };
573
+
574
+ // Hashing a large archive takes as long as it takes, but not forever:
575
+ // a hash that has stopped reporting is stuck, and holding the add open
576
+ // for six hours to discover that helps nobody.
577
+ const timer = setTimeout(
578
+ () => {
579
+ child.kill();
580
+ finish(new Error('hashing timed out'));
581
+ },
582
+ options.timeoutMs ?? 6 * 60 * 60 * 1000,
583
+ );
584
+ timer.unref?.();
585
+
586
+ if (options.signal?.aborted) return cancel();
587
+ options.signal?.addEventListener('abort', cancel, { once: true });
588
+
589
+ child.stdout.setEncoding('utf8');
590
+ child.stdout.on('data', (chunk) => {
591
+ pending += chunk;
592
+ const lines = pending.split('\n');
593
+ pending = lines.pop() ?? '';
594
+ for (const line of lines) {
595
+ if (!line.trim()) continue;
596
+ let message;
597
+ try {
598
+ message = JSON.parse(line);
599
+ } catch {
600
+ // A line that is not ours. Python's own output on the way to a
601
+ // crash arrives here, and is worth keeping for the error.
602
+ stderr += `${line}\n`;
603
+ continue;
604
+ }
605
+ if (message.event === 'progress') {
606
+ options.onProgress?.({
607
+ piece: message.piece,
608
+ pieces: message.pieces,
609
+ });
610
+ } else if (message.ok) {
611
+ result = message.result;
612
+ } else if (message.ok === false) {
613
+ failure = new Error(message.error ?? 'hashing failed');
614
+ }
615
+ }
616
+ });
617
+
618
+ child.stderr.setEncoding('utf8');
619
+ child.stderr.on('data', (chunk) => {
620
+ stderr += chunk;
621
+ });
622
+
623
+ child.on('error', (error) =>
624
+ finish(new Error(`could not start the hasher: ${error.message}`)),
625
+ );
626
+
627
+ child.on('close', (code, signal) => {
628
+ if (failure) return finish(failure);
629
+ if (result) return finish();
630
+ const why = signal ? `killed by ${signal}` : `exited with code ${code}`;
631
+ finish(
632
+ new Error(
633
+ `hashing produced nothing (${why})${stderr ? `: ${stderr.trim()}` : ''}`,
634
+ ),
635
+ );
636
+ });
637
+
638
+ child.stdin.on('error', () => {});
639
+ child.stdin.end(JSON.stringify(params));
640
+ });
641
+ }
642
+
520
643
  /**
521
644
  * Reads one piece, promoted ahead of the normal picker.
522
645
  *
package/src/library.js CHANGED
@@ -272,17 +272,25 @@ export class Library {
272
272
  options.onValidated?.({ path: absolute, kind: identified.kind });
273
273
 
274
274
  // Tracked from here so the console has something to show while the hash
275
- // runs. Registered without an AbortController, unlike a remote add: there
276
- // is no cancelling a hash in progress, since neither libtorrent's creator
277
- // nor create-torrent takes a signal. cancelAdd() skips entries without a
278
- // controller, so this is inert there rather than a button that lies.
275
+ // runs, and so it can be stopped. Hashing an archive this size is minutes
276
+ // at best and hours for a planet, all of it reading the disk everything
277
+ // else is served from; realising it was the wrong file should not mean
278
+ // waiting it out or killing the process.
279
+ //
280
+ // What it cancels is the hash. A publishDir move has already happened by
281
+ // here โ€” deliberately, since it is the cheap irreversible step โ€” so a
282
+ // cancelled add leaves the archive where it was published to, not where it
283
+ // was picked from. Nothing else is touched: hashing only ever reads.
284
+ const controller = new AbortController();
279
285
  const { size } = await fs.stat(absolute).catch(() => ({ size: undefined }));
280
286
  this.#running.set(requested, {
287
+ controller,
281
288
  name: path.basename(absolute),
282
289
  startedAt: new Date().toISOString(),
283
- // No byte count: hashing reports nothing until it is done, so a progress
284
- // bar here would sit at zero and then vanish. `total` is the size it is
285
- // working through, which with the start time is enough to show it moving.
290
+ // Absent until the hasher says otherwise, rather than zero: a zero is
291
+ // drawn as a bar that has not moved, and an out-of-process hasher is not
292
+ // guaranteed โ€” config can ask for v1, and create-torrent reports nothing
293
+ // at all. Then it is honestly unknown rather than stuck at 0%.
286
294
  received: undefined,
287
295
  total: size,
288
296
  phase: 'hashing',
@@ -302,6 +310,21 @@ export class Library {
302
310
  webSeeds: [...new Set(webSeeds)],
303
311
  comment: options.comment,
304
312
  md5: options.md5 ?? this.#config.md5,
313
+ signal: controller.signal,
314
+ // Pieces in, bytes out. The console draws one progress bar for adds and
315
+ // labels it in bytes, and a piece count means nothing beside a download
316
+ // measured in gigabytes โ€” so it is converted here, where the file size
317
+ // is known, rather than teaching the console a second unit. Accurate to
318
+ // within one piece, which at 4 MiB against a planet archive is four
319
+ // parts in a million.
320
+ onHashProgress: ({ piece, pieces }) => {
321
+ const state = this.#running.get(requested);
322
+ if (!state || !pieces || !size) return;
323
+ state.received = Math.min(
324
+ size,
325
+ Math.round(((piece + 1) / pieces) * size),
326
+ );
327
+ },
305
328
  });
306
329
 
307
330
  return await this.#register(created, {
@@ -380,6 +403,10 @@ export class Library {
380
403
  * trade-off: v2 clients gain per-file merkle trees and 16 KiB block
381
404
  * verification, and v1 clients see an ordinary torrent. An engine that is
382
405
  * only seeding is still the right one to ask for that.
406
+ *
407
+ * Everything the caller passes goes through, `signal` and `onProgress`
408
+ * included. Those are what make a hash out here cancellable and visible, and
409
+ * naming the fields it forwards would quietly drop them.
383
410
  * @returns {Function | undefined} - A creator, or undefined to use the default.
384
411
  */
385
412
  #creator() {
@@ -616,13 +643,18 @@ export class Library {
616
643
  }
617
644
 
618
645
  /**
619
- * Stops an in-flight remote add, and discards what it had downloaded.
646
+ * Stops an in-flight add, and discards what it had downloaded.
647
+ *
648
+ * Local adds too, now that hashing happens in a process of its own and that
649
+ * process can be ended. A local add discards nothing โ€” the archive is the
650
+ * caller's own file and was only ever read โ€” so what it costs is the hashing
651
+ * done so far and nothing else.
620
652
  *
621
- * Only a remote one. A local add is listed alongside them but holds no
622
- * controller, because a hash in progress cannot be interrupted โ€” neither
623
- * libtorrent's creator nor create-torrent takes a signal. It is skipped
624
- * below rather than special-cased, and reports as nothing cancelled.
625
- * @param {string} [url] - The source URL, or all of them when omitted.
653
+ * An add hashing in this process rather than out of it still cannot be
654
+ * interrupted, and libtorrent's hashing never checks for interruption
655
+ * either. Cancelling such an add stops the caller waiting and frees the
656
+ * console of it; the read finishes on its own.
657
+ * @param {string} [url] - The source URL or path, or all of them when omitted.
626
658
  * @returns {string[]} - The URLs cancelled.
627
659
  */
628
660
  cancelAdd(url) {
@@ -679,10 +711,12 @@ export class Library {
679
711
  return [...this.#running.entries()].map(([url, state]) => ({
680
712
  url,
681
713
  name: state.name,
682
- // Left absent rather than zeroed for a hash, which reports nothing until
683
- // it finishes. A zero here is indistinguishable from a download that has
684
- // not moved, and the console draws the two differently.
685
- received: state.phase === 'hashing' ? undefined : (state.received ?? 0),
714
+ // Absent means unknown, not zero. A hash reports where it has got to
715
+ // only when it runs out of process; the in-process fallback reports
716
+ // nothing at all, and a bar pinned at 0% for forty minutes says "stuck"
717
+ // when the honest answer is "no idea, still working". A download always
718
+ // knows, so it is 0 there from the start.
719
+ received: state.received ?? (state.phase === 'hashing' ? undefined : 0),
686
720
  total: state.total,
687
721
  startedAt: state.startedAt,
688
722
  phase: state.phase ?? 'fetching',
@@ -19,7 +19,9 @@ import { DEFAULT_SUFFIX } from './incomplete.js';
19
19
  * @property {number} [pieceLength] - Piece size in bytes; must be a power of two.
20
20
  * @property {string[]} [trackers] - Announce URLs.
21
21
  * @property {string[]} [webSeeds] - BEP 19 url-list entries.
22
- * @property {AbortSignal} [signal] - Cancels a download in progress.
22
+ * @property {AbortSignal} [signal] - Cancels a download or a hash in progress.
23
+ * @property {Function} [onProgress] - Called with {phase, received, total} as the archive arrives.
24
+ * @property {Function} [onHashProgress] - Called with {piece, pieces} as it is hashed.
23
25
  * @property {boolean} [md5] - Also compute an MD5 of the archive.
24
26
  * @property {boolean} [includeSourceAsWebSeed] - Publish the source URL as a web seed. Default true.
25
27
  * @property {string} [comment] - Free-text comment.
@@ -64,9 +66,22 @@ export async function createTorrentFromFile(filePath, options = {}) {
64
66
  `[hash] ${what}: reading ${stat.size} bytes ${passes} to build the torrent`,
65
67
  );
66
68
  const startedAt = Date.now();
69
+
70
+ // Where the hash has got to, for the heartbeat. A hasher that reports pieces
71
+ // turns "still hashing after 41m" into something that says whether waiting
72
+ // longer is worth it; one that does not says exactly what it said before.
73
+ let reached;
74
+ const onHashProgress = (progress) => {
75
+ reached = progress;
76
+ options.onHashProgress?.(progress);
77
+ };
78
+
67
79
  const heartbeat = setInterval(() => {
68
80
  const minutes = Math.round((Date.now() - startedAt) / 60000);
69
- console.log(`[hash] ${what}: still hashing after ${minutes}m`);
81
+ const far = reached?.pieces
82
+ ? ` (${(((reached.piece + 1) / reached.pieces) * 100).toFixed(1)}%)`
83
+ : '';
84
+ console.log(`[hash] ${what}: still hashing after ${minutes}m${far}`);
70
85
  }, 60000);
71
86
  heartbeat.unref?.();
72
87
  options.onProgress?.({
@@ -78,12 +93,14 @@ export async function createTorrentFromFile(filePath, options = {}) {
78
93
  try {
79
94
  // A second read of the archive, which is why it is opt-in. Nothing else
80
95
  // here touches these bytes again once the piece hashes are done.
81
- const md5Digest = options.md5 ? await md5File(filePath) : undefined;
96
+ const md5Digest = options.md5
97
+ ? await md5File(filePath, options.signal)
98
+ : undefined;
82
99
  const built = await buildTorrent(
83
100
  filePath,
84
101
  path.basename(filePath),
85
102
  stat.size,
86
- { ...options, md5Digest },
103
+ { ...options, md5Digest, onHashProgress },
87
104
  );
88
105
  const seconds = Math.round((Date.now() - startedAt) / 1000);
89
106
  console.log(`[hash] ${what}: torrent built in ${seconds}s`);
@@ -618,13 +635,18 @@ function delay(ms, signal) {
618
635
  * on disk it is a second full read. See docs/internals.md โ€” "Why the MD5 is
619
636
  * opt-in".
620
637
  * @param {string} filePath - The file to hash.
638
+ * @param {AbortSignal} [signal] - Stops the read.
621
639
  * @returns {Promise<string>} - Lowercase hex digest.
622
640
  */
623
- async function md5File(filePath) {
641
+ async function md5File(filePath, signal) {
624
642
  const { createReadStream } = await import('node:fs');
625
643
  const { createHash } = await import('node:crypto');
626
644
  const hash = createHash('md5');
627
- await pipelineAsync(createReadStream(filePath), hash);
645
+ // Cancellable like the piece hashing it precedes. This is a second full read
646
+ // of the archive, so a cancel arriving during it would otherwise be ignored
647
+ // for as long as the pass it was meant to stop โ€” for a planet archive, most
648
+ // of an hour of disk after somebody pressed the button.
649
+ await pipelineAsync(createReadStream(filePath), hash, { signal });
628
650
  return hash.digest('hex');
629
651
  }
630
652
 
@@ -655,6 +677,13 @@ async function buildTorrent(input, name, size, options) {
655
677
  private: options.private ?? false,
656
678
  createdBy: 'pmtiles-swarm',
657
679
  format: options.format ?? 'hybrid',
680
+ // Both only mean anything to a creator that hashes out of process,
681
+ // which is the whole reason there is one. `onProgress` here is the
682
+ // creator's own shape โ€” {piece, pieces} โ€” and is deliberately not the
683
+ // {phase, received, total} one this module reports to its caller; they
684
+ // describe different things and were briefly the same name.
685
+ signal: options.signal,
686
+ onProgress: options.onHashProgress,
658
687
  });
659
688
 
660
689
  const madeBy = await parseTorrent(built.torrentFile);
@@ -670,6 +699,15 @@ async function buildTorrent(input, name, size, options) {
670
699
  md5: options.md5Digest,
671
700
  };
672
701
  } catch (error) {
702
+ // Unless what stopped it was somebody stopping it.
703
+ //
704
+ // Falling back here would answer "cancel this hash" by starting the same
705
+ // hash again in this process, where it cannot be cancelled at all โ€” so
706
+ // the button would make a 698 GiB read strictly worse than leaving it
707
+ // alone. Cancelling is a decision about the archive, not a fault to
708
+ // route around.
709
+ if (options.signal?.aborted) throw error;
710
+
673
711
  // A torrent is more important than the format of a torrent.
674
712
  //
675
713
  // Said with what it costs, though, because the fallback is not a smaller
@@ -934,9 +934,10 @@
934
934
  </div>
935
935
  ${running
936
936
  .map((add) => {
937
- // Hashing reports nothing until it is done, so there is no
938
- // percentage to draw โ€” only how big the file is and how long it
939
- // has been going. A bar pinned at zero for twenty minutes says
937
+ // A hash says how far through it is when it runs out of process,
938
+ // and says nothing when it falls back to hashing in this one. So
939
+ // the percentage is drawn where there is one and left out where
940
+ // there is not โ€” a bar pinned at zero for twenty minutes says
940
941
  // "stuck" when the honest answer is "no idea, still working".
941
942
  const hashing = add.phase === 'hashing';
942
943
  const pct =
@@ -947,9 +948,13 @@
947
948
  ? duration(Date.now() - new Date(add.startedAt).getTime())
948
949
  : null;
949
950
  const value = hashing
950
- ? `hashing${add.total ? ` ${bytes(add.total)}` : ''}${
951
- elapsed ? ` ยท ${elapsed}` : ''
952
- }`
951
+ ? [
952
+ `hashing${add.total ? ` ${bytes(add.total)}` : ''}`,
953
+ pct == null ? null : `${pct.toFixed(1)}%`,
954
+ elapsed,
955
+ ]
956
+ .filter(Boolean)
957
+ .join(' ยท ')
953
958
  : pct == null
954
959
  ? bytes(add.received)
955
960
  : `${pct.toFixed(1)}%`;
@@ -2370,7 +2375,7 @@ Every piece is hashed against the ` +
2370
2375
  kind === 'url'
2371
2376
  ? 'fetching โ€” watch the log'
2372
2377
  : kind === 'path'
2373
- ? 'hashing โ€” watch the log'
2378
+ ? 'hashing โ€” progress is below'
2374
2379
  : 'added',
2375
2380
  );
2376
2381
  refresh();