pmtiles-swarm 0.14.2 → 0.15.1

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,58 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.15.1
11
+ ### 🐞 Bug fixes
12
+ - **The downloaded bar showed nothing on an archive that was plainly downloading.** A column on
13
+ that bar covers many pieces, and the sidecar reduced "held" by `all` — so a column lit only when
14
+ every piece beneath it had arrived, and an archive 18% complete showed an empty bar. Fixed in
15
+ pmtiles-torrent 0.4.4, which reports a proportion; the console now shades those columns by it,
16
+ with a floor so the first few percent of a download are visible rather than indistinguishable
17
+ from none.
18
+
19
+ Peer bars had the opposite fault and are fixed the same way, so a peer holding a little no longer
20
+ reads as a seed.
21
+
22
+ Renders correctly against either sidecar: the newer one never rounds a non-empty column below 2,
23
+ so values above 1 identify the new encoding.
24
+
25
+ ## 0.15.0
26
+ ### ✨ Features and improvements
27
+ - **The DHT routing table is remembered between runs**, which is the difference between publishing
28
+ reliably and gambling on each start. Field measurements on a domestic connection: a fresh socket
29
+ usually found one node and never recovered, while roughly one start in seven found sixteen within
30
+ five seconds — and no amount of retrying rescued a bad one.
31
+
32
+ This is what libtorrent does, and why the libtorrent engine's DHT works on hosts where a fresh
33
+ bittorrent-dht socket does not: it saves its table and reloads it rather than bootstrapping cold
34
+ every time. Saved to `dht-nodes.json` in the data directory (`mutable.statePath`), written every
35
+ five minutes and on shutdown, and never overwritten with an empty table — a bad run must not
36
+ replace a good table with its own nothing.
37
+
38
+ The bootstrap list also now matches libtorrent's rather than the library default, adding
39
+ `dht.libtorrent.org:25401` and `router.bitcomet.com:6881`. Remembered nodes are tried first and
40
+ the hostnames stay behind them, since a saved table can be entirely stale.
41
+ - **A DHT socket that cannot reach the network is replaced rather than retried.** Retrying on a bad
42
+ socket failed at 30s, 60s, 2m and 4m in the field while a restart succeeded, so after two futile
43
+ cycles the publisher now opens a new socket itself instead of waiting for someone to restart the
44
+ service. A socket that is finding peers is never replaced, whatever its puts are doing.
45
+
46
+ ## 0.14.3
47
+ ### 🐞 Bug fixes
48
+ - **The publisher waits for a usable routing table rather than a single node.** A freshly
49
+ bootstrapped table holds one entry — the bootstrap host, which stores nothing — so publishing
50
+ against it failed once per category with "No nodes to query" before any retry could help. It now
51
+ waits for eight, for up to two minutes, and says what it is waiting for as it goes.
52
+
53
+ Two minutes rather than longer because this turned out to be bimodal rather than slow: a socket
54
+ that can reach the DHT fills its table in a few seconds, and one that cannot is still empty ten
55
+ minutes later. Waiting past that buys nothing and delays saying so.
56
+
57
+ Worth knowing where that comes from, since the log looks like a swarm problem and is not: on a
58
+ multi-WAN router each new UDP socket is assigned a gateway by the load balancer and then keeps
59
+ it, so a socket that lands on a WAN with no working return path never recovers — which is why
60
+ retrying could not rescue a bad run and only a restart changed the outcome.
61
+
10
62
  ## 0.14.2
11
63
  ### 🐞 Bug fixes
12
64
  - **Waiting for the DHT's `ready` event was not enough.** It fires when the bootstrap lookup
package/README.md CHANGED
@@ -115,8 +115,31 @@ subscribers forward, and they fail differently, so publishing both is cheap insu
115
115
 
116
116
  - **RSS** — easy to consume, understood by existing clients, needs a server that stays up.
117
117
  - **BEP 46** — an ed25519-signed DHT record naming the current infohash, addressed by public key
118
- rather than infohash (`magnet:?xs=urn:btpk:…`). No server needed, but the record expires and must
119
- be republished. See [src/mutable.js](src/mutable.js).
118
+ rather than infohash (`magnet:?xs=urn:btpk:…`). No server needed at all.
119
+
120
+ Publishing those records is built in. The node that builds gets a key — `pmtiles-swarm
121
+ publisher-key` — and announces the newest archive in each category, salted by category name so one
122
+ keypair addresses all of them. Records expire from the DHT after about two hours, so it republishes
123
+ on a timer; that timer is the feature rather than an optimisation. Serving nodes need nothing: they
124
+ receive the public half on the catalog entry and hand it out in the TileJSON, which is why a
125
+ serving tier can be compromised without anyone being able to publish.
126
+
127
+ ```json
128
+ { "mutable": { "publish": true, "keyPath": "/etc/pmtiles-swarm/publisher.pem" } }
129
+ ```
130
+
131
+ The routing table is remembered between runs (`dht-nodes.json` in the data directory), which
132
+ matters more than it sounds: bootstrapping from hostnames alone was measured working about one
133
+ start in seven on a domestic connection, and a saved table turns that into every start. It is the
134
+ same thing libtorrent does, and why its DHT works on hosts where a fresh socket does not.
135
+
136
+ **What a style should point at** is then the category's TileJSON URL with that magnet in its
137
+ fragment — the console's Categories page has a **For a style** row that gives you the whole
138
+ string. A fragment is never sent in an HTTP request, so ordinary clients fetch the TileJSON and
139
+ ignore it, while a swarm-aware one reads the magnet before making any call and can start when the
140
+ server cannot answer. With `xs=urn:btpk:` rather than an infohash it does not go stale on the next
141
+ build either. See [docs/serving-tiles.md](docs/serving-tiles.md) and
142
+ [docs/security.md](docs/security.md), because that key behaves unlike every other secret here.
120
143
 
121
144
  ## Configuration
122
145
 
@@ -742,9 +765,13 @@ another node, watched web locations including directory listings and date templa
742
765
  limits and what is left of them, in-place settings reload and process restart, and two-node
743
766
  subscription sync in both directions over both RSS and the catalog API.
744
767
 
745
- Not yet exercised: the qBittorrent engine against a real instance, watch-folder imports, and
746
- BEP 46 publish/resolve against a live DHT (the crypto and magnet handling are tested; interop
747
- with libtorrent's encoding is not).
768
+ Running in production: watch-folder imports feeding a nightly planet build, an 18-archive library
769
+ of roughly 2.5 TB behind HAProxy, and BEP 46 records published to a live DHT — 54 nodes stored the
770
+ last one.
771
+
772
+ Not yet exercised: the qBittorrent engine against a real instance, and **resolving** a BEP 46
773
+ record from outside the publishing network. Nodes accept the records and report storing them, which
774
+ is not the same claim as a stranger reading one back, and only the second matters to a subscriber.
748
775
 
749
776
  ## License and attribution
750
777
 
@@ -779,4 +779,12 @@ rebuild, which fail differently, so publishing both is cheap insurance:
779
779
  public key rather than infohash (`magnet:?xs=urn:btpk:…`). No server required, but the
780
780
  record expires and must be republished.
781
781
 
782
- See [subscribing.md](subscribing.md).
782
+ Publishing those records is built in: give the building node a key with `pmtiles-swarm
783
+ publisher-key`, set `mutable.publish`, and it announces the newest archive in each category
784
+ and republishes on a timer. Serving nodes need nothing — they receive the public half on
785
+ the catalog entry and hand it out in the TileJSON.
786
+
787
+ See [subscribing.md](subscribing.md) for the shape of it,
788
+ [serving-tiles.md](serving-tiles.md#a-fragment-that-survives-a-rebuild) for what a style
789
+ should then point at, and [security.md](security.md#the-publisher-key-is-not-a-credential)
790
+ for why that key wants treating like a signing key rather than an API token.
@@ -419,9 +419,29 @@ RSS and BEP 46 fail in opposite ways, which is the argument for publishing both:
419
419
  | RSS | a server that stays up | the server goes away |
420
420
  | BEP 46 | periodic republishing | the record expires (hours, not days) |
421
421
 
422
- **Status:** the crypto and magnet handling are tested — 32-byte keys, 64-byte signatures
423
- verifying, roundtrip stable. Publishing and resolving against a live DHT, and interop
424
- with libtorrent's exact value encoding, are **not yet verified**.
422
+ **This is built in.** The code above is what a host application would call; a node only
423
+ needs a key and a setting:
424
+
425
+ ```sh
426
+ pmtiles-swarm publisher-key > /etc/pmtiles-swarm/publisher.pem
427
+ chmod 400 /etc/pmtiles-swarm/publisher.pem
428
+ ```
429
+
430
+ ```json
431
+ { "mutable": { "publish": true, "keyPath": "/etc/pmtiles-swarm/publisher.pem" } }
432
+ ```
433
+
434
+ It then announces the newest archive in each category, salted by category name so one key
435
+ addresses all of them, and republishes before the records expire. Only the node that builds
436
+ needs the key — see
437
+ [running-as-a-service.md](running-as-a-service.md#the-publisher-key) for setting it up and
438
+ [security.md](security.md#the-publisher-key-is-not-a-credential) for why it is not a
439
+ credential.
440
+
441
+ **Status:** publishing is verified against a live DHT — records stored by 54 nodes at the
442
+ last check. **Resolving** one from outside the publishing network is not yet verified:
443
+ nodes accepting a record is not the same claim as a stranger reading it back, and only the
444
+ second matters to a subscriber.
425
445
 
426
446
  ## Making the archives serveable
427
447
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
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",
package/src/config.js CHANGED
@@ -438,6 +438,15 @@ const DEFAULTS = {
438
438
  * own, and two sockets cannot hold one port.
439
439
  */
440
440
  dhtPort: 0,
441
+ /**
442
+ * Where to remember the DHT routing table between runs.
443
+ *
444
+ * Defaults to `dht-nodes.json` in the data directory. Bootstrapping from
445
+ * hostnames alone is unreliable enough that a node doing it on every start
446
+ * is gambling each time; libtorrent saves its table for the same reason,
447
+ * which is why its DHT works on hosts where a fresh socket does not.
448
+ */
449
+ statePath: undefined,
441
450
  },
442
451
  /**
443
452
  * What this node has served, at `GET /api/stats`.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Keeping a DHT routing table across restarts.
3
+ *
4
+ * Bootstrapping is not reliably quick. Measured on a domestic connection, a
5
+ * fresh socket usually found one node and never recovered, while roughly one
6
+ * attempt in seven found sixteen within five seconds — and no amount of
7
+ * retrying rescued a bad one. A node that has to bootstrap from nothing on
8
+ * every start is therefore gambling on each start.
9
+ *
10
+ * libtorrent does not gamble: it saves its routing table and reloads it, which
11
+ * is why the libtorrent engine's DHT works on hosts where a fresh
12
+ * bittorrent-dht socket does not. This does the same thing — a table that
13
+ * worked once is remembered, so later starts begin with peers that answered
14
+ * recently rather than with three hostnames.
15
+ */
16
+
17
+ import fs from 'node:fs/promises';
18
+
19
+ /**
20
+ * Bootstrap hosts, as libtorrent uses rather than the shorter default.
21
+ *
22
+ * More names is more chances that one answers, and they cost nothing when the
23
+ * saved table already works. `dht.libtorrent.org` in particular is absent from
24
+ * bittorrent-dht's defaults.
25
+ */
26
+ export const BOOTSTRAP = [
27
+ 'router.bittorrent.com:6881',
28
+ 'dht.transmissionbt.com:6881',
29
+ 'router.utorrent.com:6881',
30
+ 'dht.libtorrent.org:25401',
31
+ 'router.bitcomet.com:6881',
32
+ ];
33
+
34
+ /**
35
+ * Nodes to start from: whatever was saved, plus the bootstrap hosts.
36
+ * @param {string} [path] - Where the table was saved.
37
+ * @returns {Promise<Array>} - Addresses for the `bootstrap` option.
38
+ */
39
+ export async function loadNodes(path) {
40
+ if (!path) return [...BOOTSTRAP];
41
+ try {
42
+ const saved = JSON.parse(await fs.readFile(path, 'utf8'));
43
+ const nodes = (saved.nodes ?? [])
44
+ .filter((node) => node?.host && node?.port)
45
+ .map((node) => `${node.host}:${node.port}`);
46
+ // The hostnames stay in the list. A saved table can be entirely stale —
47
+ // a laptop that moved networks, a node that was off for a week — and
48
+ // falling back to bootstrapping is better than starting nowhere.
49
+ return [...nodes, ...BOOTSTRAP];
50
+ } catch {
51
+ // No file yet, or an unreadable one. Neither is worth reporting: the
52
+ // bootstrap hosts are a complete answer on their own.
53
+ return [...BOOTSTRAP];
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Saves the current routing table.
59
+ * @param {string} [path] - Where to write it.
60
+ * @param {object} dht - The DHT to read.
61
+ * @returns {Promise<number>} - How many nodes were saved.
62
+ */
63
+ export async function saveNodes(path, dht) {
64
+ if (!path || !dht?.toJSON) return 0;
65
+ const nodes = dht.toJSON().nodes ?? [];
66
+ // An empty table is not worth writing, and writing it would replace a good
67
+ // table from a previous run with the results of a bad one.
68
+ if (nodes.length === 0) return 0;
69
+
70
+ const body = JSON.stringify({ savedAt: new Date().toISOString(), nodes });
71
+ // Written then renamed, so a crash mid-write cannot leave a truncated file
72
+ // that the next start has to fail on.
73
+ await fs.writeFile(`${path}.tmp`, body);
74
+ await fs.rename(`${path}.tmp`, path);
75
+ return nodes.length;
76
+ }
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
+ import path from 'node:path';
3
4
  import { parseArgs } from 'node:util';
4
5
  import { createApp } from './api.js';
5
6
  import { assertSafeToListen, createAuth } from './auth.js';
@@ -272,29 +273,60 @@ PMTILES_SWARM_PUBLIC_URL
272
273
  import('bittorrent-dht').then((m) => m.default),
273
274
  ]);
274
275
  const pem = await fs.readFile(config.mutable.keyPath, 'utf8');
275
- const dht = new DHT();
276
- // Bound explicitly rather than left to bind implicitly on first send,
277
- // so the port is predictable and can be forwarded if you want this to
278
- // be a reachable DHT node. 0 takes an ephemeral one, which is all
279
- // publishing needs.
280
- await new Promise((resolve, reject) => {
281
- dht.once('error', reject);
282
- dht.listen(config.mutable.dhtPort ?? 0, resolve);
283
- });
284
- console.log(`[mutable] DHT on UDP ${dht.address().port}`);
276
+ // A factory rather than an instance, so the publisher can replace a
277
+ // socket that proves unable to reach the DHT. Bound explicitly rather
278
+ // than left to bind implicitly on first send, so the port is
279
+ // predictable and can be forwarded; 0 takes an ephemeral one, which is
280
+ // all publishing needs — and is what lets a replacement differ from
281
+ // what it replaced.
282
+ const { loadNodes, saveNodes } = await import('./dht-state.js');
283
+ const statePath =
284
+ config.mutable.statePath ?? path.join(config.dataDir, 'dht-nodes.json');
285
+
286
+ const openDht = async () => {
287
+ // Started from a table that worked before where there is one, the way
288
+ // libtorrent does. Bootstrapping from hostnames alone is unreliable
289
+ // enough that a node doing it on every start is gambling each time.
290
+ const bootstrap = await loadNodes(statePath);
291
+ const dht = new DHT({ bootstrap });
292
+ await new Promise((resolve, reject) => {
293
+ dht.once('error', reject);
294
+ dht.listen(config.mutable.dhtPort ?? 0, resolve);
295
+ });
296
+ console.log(
297
+ `[mutable] DHT on UDP ${dht.address().port} ` +
298
+ `(${bootstrap.length} bootstrap addresses)`,
299
+ );
300
+ return dht;
301
+ };
302
+
285
303
  publisher = new MutablePublisher({
286
304
  catalog,
287
- dht,
305
+ createDht: openDht,
288
306
  key: publisherKeyFromPem(pem),
289
307
  intervalMs: (config.mutable.republishSeconds ?? 1800) * 1000,
290
308
  });
309
+ // Saved periodically rather than only on the way out, because the run
310
+ // that finds a good table is often the one that is later killed rather
311
+ // than stopped, and a table nobody wrote down is a table nobody keeps.
312
+ const saveTable = setInterval(() => {
313
+ publisher
314
+ .saveTable((dht) => saveNodes(statePath, dht))
315
+ .catch(() => {});
316
+ }, 5 * 60_000);
317
+ saveTable.unref?.();
318
+
291
319
  stoppers.unshift({
292
320
  label: 'mutable publisher',
293
- stop: () => {
321
+ stop: async () => {
322
+ clearInterval(saveTable);
323
+ const saved = await publisher
324
+ .saveTable((dht) => saveNodes(statePath, dht))
325
+ .catch(() => 0);
326
+ if (saved) console.log(`[mutable] remembered ${saved} DHT nodes`);
294
327
  publisher.stop();
295
- dht.destroy();
296
328
  },
297
- ms: 2000,
329
+ ms: 3000,
298
330
  });
299
331
  publisher.start();
300
332
  } catch (error) {
package/src/publisher.js CHANGED
@@ -50,12 +50,48 @@ import { mutableMagnet, publishInfoHash } from './mutable.js';
50
50
  /** Republish well inside the ~2h a DHT keeps an item. */
51
51
  const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
52
52
 
53
- /** How long to wait for the DHT to bootstrap before publishing anyway. */
54
- const DEFAULT_READY_MS = 60_000;
53
+ /**
54
+ * How long to wait for the DHT to find peers before publishing anyway.
55
+ *
56
+ * Minutes rather than seconds, because bootstrapping is not uniformly fast and
57
+ * publishing early does not fail gracefully -- it fails once per category with
58
+ * a message that reads like something is broken.
59
+ *
60
+ * Not longer, because in practice this is bimodal rather than slow: a socket
61
+ * that can reach the DHT fills its table in a few seconds, and one that cannot
62
+ * is still empty ten minutes later. Observed on a multi-WAN router, where each
63
+ * new socket gets a gateway assigned by the load balancer and is then stuck
64
+ * with it -- so retrying never rescued a bad socket, and only a restart
65
+ * re-rolled it. Waiting past a couple of minutes buys nothing and delays
66
+ * saying so.
67
+ */
68
+ const DEFAULT_READY_MS = 2 * 60_000;
69
+
70
+ /** How often to say that it is still waiting, so a slow start is legible. */
71
+ const WAITING_LOG_MS = 60_000;
55
72
 
56
73
  /** First retry delay after an attempt where nothing was published. */
57
74
  const RETRY_MS = 30_000;
58
75
 
76
+ /**
77
+ * Failed cycles before opening a new DHT socket.
78
+ *
79
+ * A socket that cannot reach the DHT never recovers: retrying on it failed at
80
+ * 30s, 60s, 2m and 4m in the field, while a restart -- a new socket, a new
81
+ * source port, a new NAT state -- worked roughly one attempt in seven. So when
82
+ * retrying is demonstrably futile, re-roll rather than wait for a human.
83
+ */
84
+ const REROLL_AFTER = 2;
85
+
86
+ /**
87
+ * Nodes wanted before a first publish is worth attempting.
88
+ *
89
+ * One is what a freshly bootstrapped table holds, and a put against it fails
90
+ * with "No nodes to query" -- the entry is the bootstrap host, not a peer that
91
+ * will store anything.
92
+ */
93
+ const MINIMUM_NODES = 8;
94
+
59
95
  export class MutablePublisher {
60
96
  #catalog;
61
97
  #dht;
@@ -64,6 +100,10 @@ export class MutablePublisher {
64
100
  #timer = null;
65
101
  #retryTimer = null;
66
102
  #stopped = false;
103
+ #createDht = null;
104
+ #ownsDht = false;
105
+ /** Consecutive cycles that published nothing, for deciding to re-roll. */
106
+ #failedCycles = 0;
67
107
  #log;
68
108
  /** Last published infohash per category, so an unchanged build stays quiet. */
69
109
  #published = new Map();
@@ -71,14 +111,19 @@ export class MutablePublisher {
71
111
  /**
72
112
  * @param {object} options - Wiring.
73
113
  * @param {object} options.catalog - Catalog to read categories from.
74
- * @param {object} options.dht - A bittorrent-dht instance.
114
+ * @param {object} [options.dht] - A bittorrent-dht instance to use as-is.
115
+ * @param {Function} [options.createDht] - Opens a fresh one, already listening.
116
+ * Given this, the publisher owns the socket and replaces it when it proves
117
+ * unable to reach the DHT.
75
118
  * @param {object} options.key - Keypair from `publisherKeyFromPem`.
76
119
  * @param {number} [options.intervalMs] - Republish interval.
77
120
  * @param {Function} [options.log] - Where to report.
78
121
  */
79
- constructor({ catalog, dht, key, intervalMs, log = console.log }) {
122
+ constructor({ catalog, dht, createDht, key, intervalMs, log = console.log }) {
80
123
  this.#catalog = catalog;
81
- this.#dht = dht;
124
+ this.#dht = dht ?? null;
125
+ this.#createDht = createDht ?? null;
126
+ this.#ownsDht = Boolean(createDht) && !dht;
82
127
  this.#key = key;
83
128
  this.#intervalMs = intervalMs ?? DEFAULT_INTERVAL_MS;
84
129
  this.#log = log;
@@ -149,7 +194,10 @@ export class MutablePublisher {
149
194
  }
150
195
  }
151
196
  if (done.length === 0 && this.#current().size > 0) {
197
+ this.#failedCycles += 1;
152
198
  this.#scheduleRetry(options.retryDelayMs ?? RETRY_MS);
199
+ } else if (done.length > 0) {
200
+ this.#failedCycles = 0;
153
201
  }
154
202
  return done;
155
203
  }
@@ -175,6 +223,7 @@ export class MutablePublisher {
175
223
  * Starts publishing, and keeps republishing before the records expire.
176
224
  * @param {object} [options] - Timing.
177
225
  * @param {number} [options.readyMs] - Grace before the first publish.
226
+ * @param {number} [options.retryMs] - First retry delay, for tests.
178
227
  * @returns {void}
179
228
  */
180
229
  start(options = {}) {
@@ -183,7 +232,7 @@ export class MutablePublisher {
183
232
  // bet on how long bootstrapping takes -- one this lost in the field, where
184
233
  // fifteen seconds was not enough and every category failed on the first
185
234
  // attempt.
186
- this.#firstPublish(options.readyMs ?? DEFAULT_READY_MS).catch((error) =>
235
+ this.#firstPublish(options.readyMs ?? DEFAULT_READY_MS, options.retryMs).catch((error) =>
187
236
  this.#log(`[mutable] first publish failed: ${error.message}`),
188
237
  );
189
238
 
@@ -205,23 +254,24 @@ export class MutablePublisher {
205
254
  * @param {number} readyMs - How long to wait before going ahead regardless.
206
255
  * @returns {Promise<void>} - Resolves once the first attempt is done.
207
256
  */
208
- async #firstPublish(readyMs) {
257
+ async #firstPublish(readyMs, retryMs) {
258
+ if (!this.#dht && this.#createDht) this.#dht = await this.#createDht();
209
259
  const nodes = await this.#whenDhtReady(readyMs);
210
260
  if (this.#stopped) return;
211
- if (nodes === 0) {
261
+ if (nodes !== null && nodes < MINIMUM_NODES) {
212
262
  // Named rather than left to be inferred. A routing table that is still
213
263
  // empty after a minute means the bootstrap queries are not being
214
264
  // answered, and every "No nodes to query" after this is that same fact
215
265
  // reported once per category.
216
266
  this.#log(
217
- `[mutable] the DHT found no peers in ${Math.round(readyMs / 1000)}s. Publishing ` +
218
- 'will fail until it does — check that outbound UDP is not blocked, and that ' +
219
- 'the bootstrap hosts resolve',
267
+ `[mutable] the DHT found only ${nodes} nodes in ${Math.round(readyMs / 60000)} ` +
268
+ 'minutes. Publishing will keep retrying — if it never succeeds, check that ' +
269
+ 'outbound UDP is not blocked and that the bootstrap hosts resolve',
220
270
  );
221
271
  } else if (nodes) {
222
272
  this.#log(`[mutable] DHT ready with ${nodes} nodes`);
223
273
  }
224
- await this.publishAll({ force: true });
274
+ await this.publishAll({ force: true, retryDelayMs: retryMs });
225
275
  }
226
276
 
227
277
  /**
@@ -248,10 +298,25 @@ export class MutablePublisher {
248
298
  });
249
299
  }
250
300
 
301
+ // Waited out rather than given up on. A table with a single bootstrap
302
+ // entry is not enough to store a record -- the put needs somewhere to put
303
+ // it -- so this holds until there are a few, saying so as it goes.
251
304
  let count = this.#nodeCount();
252
- while (count === 0 && Date.now() < deadline && !this.#stopped) {
305
+ let lastSaid = Date.now();
306
+ while (count !== null && count < MINIMUM_NODES && Date.now() < deadline && !this.#stopped) {
307
+ if (Date.now() - lastSaid >= WAITING_LOG_MS) {
308
+ lastSaid = Date.now();
309
+ this.#log(
310
+ `[mutable] waiting for the DHT (${count} nodes so far). ` +
311
+ 'Bootstrapping can take several minutes on a home connection',
312
+ );
313
+ }
314
+ // Never past the deadline: sleeping a flat two seconds overshoots a
315
+ // short wait by the whole interval, which makes the wait unpredictable
316
+ // and the behaviour hard to test.
317
+ const remaining = deadline - Date.now();
253
318
  await new Promise((resolve) => {
254
- const timer = setTimeout(resolve, 1000);
319
+ const timer = setTimeout(resolve, Math.max(10, Math.min(2000, remaining)));
255
320
  timer.unref?.();
256
321
  });
257
322
  count = this.#nodeCount();
@@ -281,17 +346,64 @@ export class MutablePublisher {
281
346
  */
282
347
  #scheduleRetry(delayMs) {
283
348
  if (this.#stopped || this.#retryTimer) return;
284
- const timer = setTimeout(() => {
349
+ const timer = setTimeout(async () => {
285
350
  this.#retryTimer = null;
286
351
  if (this.#stopped) return;
287
- this.publishAll({ retryDelayMs: Math.min(delayMs * 2, this.#intervalMs) }).catch(
288
- (error) => this.#log(`[mutable] retry failed: ${error.message}`),
289
- );
352
+ try {
353
+ await this.#rerollIfHopeless();
354
+ await this.publishAll({
355
+ retryDelayMs: Math.min(delayMs * 2, this.#intervalMs),
356
+ });
357
+ } catch (error) {
358
+ this.#log(`[mutable] retry failed: ${error.message}`);
359
+ }
290
360
  }, delayMs);
291
361
  timer.unref?.();
292
362
  this.#retryTimer = timer;
293
363
  }
294
364
 
365
+ /**
366
+ * Lets a caller read the current DHT, whichever socket that now is.
367
+ *
368
+ * Needed because this may replace the socket it was given, so a caller
369
+ * holding the original would save a table belonging to a socket that has
370
+ * been closed.
371
+ * @param {Function} save - Receives the live DHT.
372
+ * @returns {Promise<*>} - Whatever `save` returns, or 0 with no socket.
373
+ */
374
+ async saveTable(save) {
375
+ if (!this.#dht) return 0;
376
+ return save(this.#dht);
377
+ }
378
+
379
+ /**
380
+ * Replaces the DHT socket when retrying on it is demonstrably futile.
381
+ *
382
+ * Only when this publisher opened the socket, and only while its table is
383
+ * still unusable — a socket that is finding peers is working, whatever the
384
+ * puts are doing, and swapping it would throw away a good one.
385
+ * @returns {Promise<void>} - Resolves once any replacement is ready.
386
+ */
387
+ async #rerollIfHopeless() {
388
+ if (!this.#ownsDht || this.#failedCycles < REROLL_AFTER) return;
389
+ const count = this.#nodeCount();
390
+ if (count === null || count >= MINIMUM_NODES) return;
391
+
392
+ this.#log(
393
+ `[mutable] this DHT socket has found ${count} nodes and is not recovering — ` +
394
+ 'opening a new one',
395
+ );
396
+ try {
397
+ this.#dht?.destroy?.();
398
+ } catch {
399
+ // A socket being replaced is not worth reporting if it objects to closing.
400
+ }
401
+ this.#dht = await this.#createDht();
402
+ this.#failedCycles = 0;
403
+ const found = await this.#whenDhtReady(DEFAULT_READY_MS);
404
+ if (found !== null) this.#log(`[mutable] new DHT socket has ${found} nodes`);
405
+ }
406
+
295
407
  /**
296
408
  * Stops republishing.
297
409
  * @returns {void}
@@ -302,5 +414,13 @@ export class MutablePublisher {
302
414
  if (this.#retryTimer) clearTimeout(this.#retryTimer);
303
415
  this.#timer = null;
304
416
  this.#retryTimer = null;
417
+ // Only what this publisher opened: a caller that supplied a socket owns it.
418
+ if (this.#ownsDht) {
419
+ try {
420
+ this.#dht?.destroy?.();
421
+ } catch {
422
+ // Shutting down; nothing useful to do about a socket that will not close.
423
+ }
424
+ }
305
425
  }
306
426
  }
@@ -2894,9 +2894,17 @@
2894
2894
  };
2895
2895
  const [r, g, b] = rgb(accent);
2896
2896
 
2897
- // Availability is a count, so it shades: the rarer the piece, the
2898
- // fainter the column. The other two are yes or no.
2899
- const ceiling = kind === 'availability' ? Math.max(1, ...bytesIn) : 1;
2897
+ // Availability is a count, so it shades by rarity. The other two are
2898
+ // proportions now: a column covers many pieces — 178,000 of them
2899
+ // across a bar a thousand wide on a 698 GiB archive — so "held" is a
2900
+ // fraction of its slice rather than a yes or no. Reduced any other way
2901
+ // the bar only tells the truth at 0% and 100%.
2902
+ //
2903
+ // A sidecar older than pmtiles-torrent 0.4.4 still sends 0 or 1, and
2904
+ // is recognised by nothing exceeding 1: the newer one never rounds a
2905
+ // non-empty bucket below 2, precisely so this test works.
2906
+ const ceiling = kind === 'availability' ? Math.max(1, ...bytesIn) : 255;
2907
+ const proportional = kind !== 'availability' && Math.max(0, ...bytesIn) > 1;
2900
2908
 
2901
2909
  for (let index = 0; index < bytesIn.length; index += 1) {
2902
2910
  const value = bytesIn[index];
@@ -2905,7 +2913,15 @@
2905
2913
  image.data[at + 3] = 0;
2906
2914
  continue;
2907
2915
  }
2908
- const strength = kind === 'availability' ? 0.35 + 0.65 * (value / ceiling) : 1;
2916
+ // A floor, because a column holding a little is worth seeing: without
2917
+ // one, the first few percent of a download are indistinguishable
2918
+ // from none at all.
2919
+ const strength =
2920
+ kind === 'availability'
2921
+ ? 0.35 + 0.65 * (value / ceiling)
2922
+ : proportional
2923
+ ? Math.max(0.18, value / 255)
2924
+ : 1;
2909
2925
  image.data[at] = r;
2910
2926
  image.data[at + 1] = g;
2911
2927
  image.data[at + 2] = b;