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 +49 -0
- package/docs/publishing.md +19 -0
- package/package.json +2 -2
- package/src/engines/libtorrent.js +128 -5
- package/src/library.js +51 -17
- package/src/torrent-create.js +44 -6
- package/src/web/index.html +12 -7
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
|
|
package/docs/publishing.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.#
|
|
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
|
-
|
|
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.
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
-
//
|
|
284
|
-
// bar
|
|
285
|
-
//
|
|
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
|
|
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
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
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',
|
package/src/torrent-create.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/src/web/index.html
CHANGED
|
@@ -934,9 +934,10 @@
|
|
|
934
934
|
</div>
|
|
935
935
|
${running
|
|
936
936
|
.map((add) => {
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
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
|
-
?
|
|
951
|
-
|
|
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 โ
|
|
2378
|
+
? 'hashing โ progress is below'
|
|
2374
2379
|
: 'added',
|
|
2375
2380
|
);
|
|
2376
2381
|
refresh();
|