pmtiles-swarm 0.14.2 → 0.15.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 +37 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.15.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **The DHT routing table is remembered between runs**, which is the difference between publishing
|
|
13
|
+
reliably and gambling on each start. Field measurements on a domestic connection: a fresh socket
|
|
14
|
+
usually found one node and never recovered, while roughly one start in seven found sixteen within
|
|
15
|
+
five seconds — and no amount of retrying rescued a bad one.
|
|
16
|
+
|
|
17
|
+
This is what libtorrent does, and why the libtorrent engine's DHT works on hosts where a fresh
|
|
18
|
+
bittorrent-dht socket does not: it saves its table and reloads it rather than bootstrapping cold
|
|
19
|
+
every time. Saved to `dht-nodes.json` in the data directory (`mutable.statePath`), written every
|
|
20
|
+
five minutes and on shutdown, and never overwritten with an empty table — a bad run must not
|
|
21
|
+
replace a good table with its own nothing.
|
|
22
|
+
|
|
23
|
+
The bootstrap list also now matches libtorrent's rather than the library default, adding
|
|
24
|
+
`dht.libtorrent.org:25401` and `router.bitcomet.com:6881`. Remembered nodes are tried first and
|
|
25
|
+
the hostnames stay behind them, since a saved table can be entirely stale.
|
|
26
|
+
- **A DHT socket that cannot reach the network is replaced rather than retried.** Retrying on a bad
|
|
27
|
+
socket failed at 30s, 60s, 2m and 4m in the field while a restart succeeded, so after two futile
|
|
28
|
+
cycles the publisher now opens a new socket itself instead of waiting for someone to restart the
|
|
29
|
+
service. A socket that is finding peers is never replaced, whatever its puts are doing.
|
|
30
|
+
|
|
31
|
+
## 0.14.3
|
|
32
|
+
### 🐞 Bug fixes
|
|
33
|
+
- **The publisher waits for a usable routing table rather than a single node.** A freshly
|
|
34
|
+
bootstrapped table holds one entry — the bootstrap host, which stores nothing — so publishing
|
|
35
|
+
against it failed once per category with "No nodes to query" before any retry could help. It now
|
|
36
|
+
waits for eight, for up to two minutes, and says what it is waiting for as it goes.
|
|
37
|
+
|
|
38
|
+
Two minutes rather than longer because this turned out to be bimodal rather than slow: a socket
|
|
39
|
+
that can reach the DHT fills its table in a few seconds, and one that cannot is still empty ten
|
|
40
|
+
minutes later. Waiting past that buys nothing and delays saying so.
|
|
41
|
+
|
|
42
|
+
Worth knowing where that comes from, since the log looks like a swarm problem and is not: on a
|
|
43
|
+
multi-WAN router each new UDP socket is assigned a gateway by the load balancer and then keeps
|
|
44
|
+
it, so a socket that lands on a WAN with no working return path never recovers — which is why
|
|
45
|
+
retrying could not rescue a bad run and only a restart changed the outcome.
|
|
46
|
+
|
|
10
47
|
## 0.14.2
|
|
11
48
|
### 🐞 Bug fixes
|
|
12
49
|
- **Waiting for the DHT's `ready` event was not enough.** It fires when the bootstrap lookup
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
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
|
}
|