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 +52 -0
- package/README.md +32 -5
- package/docs/publishing.md +9 -1
- package/docs/subscribing.md +23 -3
- package/package.json +1 -1
- package/src/config.js +9 -0
- package/src/dht-state.js +76 -0
- package/src/index.js +46 -14
- package/src/publisher.js +138 -18
- package/src/web/index.html +20 -4
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
|
|
119
|
-
|
|
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
|
-
|
|
746
|
-
BEP 46
|
|
747
|
-
|
|
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
|
|
package/docs/publishing.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/docs/subscribing.md
CHANGED
|
@@ -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
|
-
**
|
|
423
|
-
|
|
424
|
-
|
|
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.
|
|
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`.
|
package/src/dht-state.js
ADDED
|
@@ -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
|
-
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// publishing needs
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
|
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
|
|
218
|
-
'
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
288
|
-
|
|
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
|
}
|
package/src/web/index.html
CHANGED
|
@@ -2894,9 +2894,17 @@
|
|
|
2894
2894
|
};
|
|
2895
2895
|
const [r, g, b] = rgb(accent);
|
|
2896
2896
|
|
|
2897
|
-
// Availability is a count, so it shades
|
|
2898
|
-
//
|
|
2899
|
-
|
|
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
|
-
|
|
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;
|