pmtiles-swarm 0.35.4 โ†’ 0.36.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,48 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.36.0
11
+ ### โœจ Features and improvements
12
+ - **Hashing an archive now happens in a process of its own.** Building the torrent for a 698 GiB
13
+ archive ran inside the libtorrent sidecar, competing with the session for the disk and for
14
+ Python's interpreter lock while every archive on the node was being served from that same disk.
15
+ It also could not be stopped: libtorrent's hashing never checks for interruption, and the sidecar
16
+ cannot be ended to end a hash because it holds the session and every torrent seeding from it. A
17
+ build started by a misclick ran its full six hours.
18
+
19
+ It is now `libtorrent_sidecar.py --create`, started per hash, holding no session and no port.
20
+ Killing it costs the hash and nothing else, and hashing only ever reads, so the archive is
21
+ untouched. It reports the piece it has reached as it goes, so a caller can draw a real fraction
22
+ rather than "hashing 698 GiB ยท 3m".
23
+
24
+ Requires pmtiles-torrent 0.8.0. Also picks up 0.7.5, which stops an archive that is hashing its
25
+ store from reporting itself as "paused" โ€” libtorrent hashes one store at a time and flags every
26
+ torrent queued behind it as paused, so a library busy verifying itself read as one somebody had
27
+ stopped.
28
+
29
+ ### ๐Ÿž Bug fixes
30
+
31
+ ## 0.35.5
32
+ ### โœจ Features and improvements
33
+
34
+ ### ๐Ÿž Bug fixes
35
+ - **Requires pmtiles-torrent 0.7.4, which stops archives dropping out of the engine a few more with
36
+ every restart.** Seen here as `[restore] <archive>: mismatching info-hash`, beginning with one
37
+ archive and reaching eighteen of twenty. An archive that failed this way was never handed to the
38
+ engine at all, so the console showed it at 0% with no state, a recheck answered `no such
39
+ torrent`, and its data sat complete on the disk the whole time โ€” the preview rendered from it
40
+ perfectly well.
41
+
42
+ The sidecar was writing resume data under the wrong torrent's name: saving it was the last thing
43
+ still popping libtorrent's alert queue on its own thread while the alert pump popped on another,
44
+ and the pump's next pop freed the batch that loop was reading. `add` then refuses such a file
45
+ with "mismatching info-hash". 0.7.2 did not introduce it but made the pump pop far more often,
46
+ which is why it appeared immediately after that upgrade.
47
+
48
+ Restarting on this version is the whole recovery: an add refused over resume data is retried
49
+ without it, and the recheck finds every byte already on disk. Nothing is downloaded again,
50
+ though rechecking a large archive is not quick.
51
+
10
52
  ## 0.35.4
11
53
  ### โœจ Features and improvements
12
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.35.4",
3
+ "version": "0.36.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.2",
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,122 @@ 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
631
+ ? `killed by ${signal}`
632
+ : `exited with code ${code}`;
633
+ finish(
634
+ new Error(
635
+ `hashing produced nothing (${why})${stderr ? `: ${stderr.trim()}` : ''}`,
636
+ ),
637
+ );
638
+ });
639
+
640
+ child.stdin.on('error', () => {});
641
+ child.stdin.end(JSON.stringify(params));
642
+ });
643
+ }
644
+
520
645
  /**
521
646
  * Reads one piece, promoted ahead of the normal picker.
522
647
  *