pmtiles-swarm 0.92.0 → 0.95.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
@@ -2,11 +2,94 @@
2
2
 
3
3
  ## master
4
4
  ### ✨ Features and improvements
5
- - _...Add new stuff here..._
5
+ - **`init --systemd` regenerates the unit without touching the configuration.** The unit is derived
6
+ from the configuration and from how many archives the library holds, and both move — a watched
7
+ folder added later belongs in `ReadWritePaths`, and a grown library needs longer to write its
8
+ resume data than the unit allows. Running it again used to be refused, which sent people to
9
+ `--force`, which replaces `swarm.config.json` outright: tokens, stacks, feeds and all, to
10
+ regenerate a file beside it. It now reads the configuration, writes only the unit, and says so —
11
+ along with the `diff` to run before replacing what is installed, since `ReadWritePaths` is derived
12
+ and a path added to the installed unit by hand will not be in the new one.
6
13
 
7
14
  ### 🐞 Bug fixes
8
15
  - _...Add new stuff here..._
9
16
 
17
+ ## 0.95.0
18
+ ### ✨ Features and improvements
19
+ - **The defaults now assume a library of hundreds, not a handful.** `tiles.maxOpenArchives` goes
20
+ from 16 to 128 and `tiles.directoryCacheEntries` from 200 to 2000. Sixteen was set for the most
21
+ expensive kind of open archive and then applied to every kind, which is wrong for the node this is
22
+ increasingly used to build: a stack assembled from a provider's file index names several hundred
23
+ sources, and a bake walks every one of them. At sixteen such a run spent most of its time
24
+ reopening archives it had just closed, and each reopen re-reads a header and a directory.
25
+
26
+ So the limit is now three limits, because the handles cost different things. A complete archive is
27
+ a file descriptor and the unit allows 65535 of them. A **cache-mode** archive carries a piece cache
28
+ sized from the torrent's piece length — at 16 MiB pieces a hundred of them is gigabytes — so
29
+ `tiles.maxOpenSwarmArchives` keeps the old ceiling of 16 and is counted separately. A **URL or
30
+ bucket** archive holds an HTTP reader and nothing else, and costs a network round trip to reopen,
31
+ so `tiles.maxOpenRemoteArchives` is 64. A node serving only cache-mode archives behaves as before.
32
+
33
+ - **The unit's stop timeout is derived from the library it was written for.** Stopping writes resume
34
+ data for every archive and the node allows two seconds apiece, so a fixed five minutes covered a
35
+ library of 145 and no more — silently, because outgrowing it produces no error, just a library
36
+ that comes back at 0% and re-hashes for hours. `init --systemd` now counts the catalog and sizes
37
+ `TimeoutStopSec` to fit, and a node whose library has outgrown a default unit says so at startup.
38
+
39
+
40
+ ### 🐞 Bug fixes
41
+ - **A sidecar left running by a previous start is now killed rather than fought.** This is the
42
+ restart that has to be done two or three times before it takes. The sidecar exits when its pipe
43
+ closes, which covers an orderly stop — but not one in the middle of hashing, since libtorrent does
44
+ not hand control back until the check finishes, and not a node killed outright. What is left holds
45
+ the listen port and the resume directory, the next start fails against it, and since a failed
46
+ start leaves its own sidecar they accumulate.
47
+
48
+ Stopping now insists: the shutdown request goes first so resume data is still saved, and a sidecar
49
+ that has not gone within a few seconds is killed rather than left behind. And a start reaps what a
50
+ previous run left before spawning anything — by pid, but only where `/proc` confirms that pid is
51
+ really a sidecar, since pids are reused and killing whatever inherited one would be worse than the
52
+ problem.
53
+
54
+ ## 0.94.0
55
+ ### 🐞 Bug fixes
56
+ - **Saving settings rewrote a proxy list nobody had touched.** A trusted-proxy list may be stored as
57
+ a string — `"loopback, 10.0.0.0/8"` is what the documentation shows — and the box rendered that as
58
+ one line while reading it back as an array. The two never compared equal, so the field was sent on
59
+ every Save whether or not anybody had looked at it, and before the previous release that rewrite
60
+ was the thing that stopped the node from starting. Opening the settings page and pressing Save was
61
+ enough to do it.
62
+
63
+ The box now shows one entry per line whichever way the config wrote them, and records what a save
64
+ will read back rather than what the config holds — so an untouched field is untouched.
65
+
66
+ ## 0.93.0
67
+ ### 🐞 Bug fixes
68
+ - **A trusted-proxy list typed with commas stopped the node from starting.** The settings field
69
+ split what was typed on newlines only, so one line reading `172.16.1.2, 172.16.1.3` was saved as
70
+ an array holding both addresses in one string. Express splits a comma list when it is handed a
71
+ bare string and never inside an array, so proxy-addr was given `172.16.1.2, 172.16.1.3` as a
72
+ single address and threw -- while the app was being built, before the listener binds. The node
73
+ would not start, could not be reached, and could not be corrected from the console that had
74
+ written the value; on the node that found this it was 155 restarts.
75
+
76
+ Three things were wrong and all three are fixed. The field now splits on commas as well as
77
+ newlines. Every shape the setting can be written in -- a string, an array, commas, spaces,
78
+ newlines -- is flattened to what Express wants. And an entry that is not an address is ignored
79
+ and logged rather than thrown: this setting is not worth a node that will not boot, and trusting
80
+ nobody is the safe end of being wrong about it.
81
+
82
+ - **A failed restore took the whole node down, console included.** Handing the library back to the
83
+ engine at startup already tolerates a failure per archive; the call coming apart as a whole was
84
+ unguarded, and it happens before the listener binds — so under `Restart=always` the result is a
85
+ crash loop with no console to look at and no way to see why. It is now reported and the node
86
+ starts anyway, where every archive shows as **not loaded** until it is fixed.
87
+
88
+ - **Nothing tested that the node starts at all.** Every other test builds the pieces `src/index.js`
89
+ wires together and never runs the wiring, so an import cycle or a step that throws before the
90
+ listener binds was a failure only a real start could find. There is now a boot test that runs the
91
+ entry point the way the service does and asks it for a page.
92
+
10
93
  ## 0.92.0
11
94
  ### 🐞 Bug fixes
12
95
  - **A stack's TileJSON published the addresses its sources are read from.** A URL source is named by
@@ -82,8 +82,17 @@ Takes effect on the next request; no restart. See
82
82
  ### `trustProxy`
83
83
 
84
84
  Takes anything Express accepts: `true`, a hop count, or a subnet list such as
85
- `"loopback, 10.0.0.0/8"`. Off by default, because trusting these headers from an
86
- untrusted client lets it claim any protocol or address it likes.
85
+ `"loopback, 10.0.0.0/8"`. A list may be written as one string or as an array,
86
+ and either may separate its entries with commas, spaces or newlines — all four
87
+ shapes mean the same thing here. Off by default, because trusting these headers
88
+ from an untrusted client lets it claim any protocol or address it likes.
89
+
90
+ An entry that is not an address, a subnet, or one of `loopback`, `linklocal`
91
+ and `uniquelocal` is **ignored and logged**, and a value that cannot be read at
92
+ all leaves the node trusting nobody. Deliberately not fatal: Express compiles
93
+ this the moment it is set, which is before the listener binds, so a value it
94
+ refuses used to be a node that would not start, could not be reached, and could
95
+ not be corrected from the console that wrote it.
87
96
 
88
97
  Set it when a proxy terminates TLS, or the TileJSON will advertise `http://` tile
89
98
  URLs that browsers block as mixed content. Setting `publicUrl` instead sidesteps
@@ -715,21 +724,43 @@ through the swarm, pulling only the pieces a requested tile lives in — which i
715
724
  what lets a machine with 10 GiB free serve a 700 GiB planet. See
716
725
  [serving-tiles.md](serving-tiles.md).
717
726
 
718
- | setting | default | |
719
- | ----------------------------- | -------- | ---------------------------------------------------------------- |
720
- | `tiles.maxOpenArchives` | `16` | each holds a descriptor or a torrent reader plus its piece cache |
721
- | `tiles.directoryCacheEntries` | `200` | header and directory cache, shared across archives |
722
- | `tiles.pieceCacheBytes` | unset | sized from the torrent's piece length when unset |
723
- | `tiles.hydrateIdleMs` | unset | idle time before background hydration resumes |
724
- | `tiles.pieceTimeoutMs` | `120000` | how long to wait for one piece |
725
- | `tiles.readyTimeoutMs` | `60000` | how long to wait for torrent metadata |
726
- | `tiles.metadataTimeoutMs` | `120000` | how long a background metadata read may take |
727
- | `tiles.headerTimeoutMs` | `12000` | how long a TileJSON request waits for a header |
728
- | `tiles.sparse` | unset | `true` for 404 on a missing tile, `false` for 204 |
727
+ | setting | default | |
728
+ | ----------------------------- | -------- | ------------------------------------------------------------ |
729
+ | `tiles.maxOpenArchives` | `128` | complete archives kept open; each is a file descriptor |
730
+ | `tiles.maxOpenSwarmArchives` | `16` | cache-mode readers, counted apart: each holds a piece cache |
731
+ | `tiles.maxOpenRemoteArchives` | `64` | readers for a URL or a bucket; cheap to hold, dear to reopen |
732
+ | `tiles.directoryCacheEntries` | `2000` | header and directory cache, shared across archives |
733
+ | `tiles.pieceCacheBytes` | unset | sized from the torrent's piece length when unset |
734
+ | `tiles.hydrateIdleMs` | unset | idle time before background hydration resumes |
735
+ | `tiles.pieceTimeoutMs` | `120000` | how long to wait for one piece |
736
+ | `tiles.readyTimeoutMs` | `60000` | how long to wait for torrent metadata |
737
+ | `tiles.metadataTimeoutMs` | `120000` | how long a background metadata read may take |
738
+ | `tiles.headerTimeoutMs` | `12000` | how long a TileJSON request waits for a header |
739
+ | `tiles.sparse` | unset | `true` for 404 on a missing tile, `false` for 204 |
729
740
 
730
741
  Leave `pieceCacheBytes` unset unless you have a reason. A fixed budget is a trap
731
742
  with 16 MiB pieces, since 64 MiB holds only four.
732
743
 
744
+ ### Three limits, because the handles cost different things
745
+
746
+ They were one limit, set at sixteen for the most expensive kind and applied to
747
+ every kind. That is wrong for the node this is increasingly used to build: a
748
+ stack assembled from a provider's file index names several hundred sources, and
749
+ a bake walks every one of them. At sixteen such a run spends most of its time
750
+ reopening archives it has just closed, and each reopen re-reads a header and a
751
+ directory.
752
+
753
+ - **A complete archive** is a file descriptor and its share of the directory
754
+ cache. The unit allows 65535 descriptors, so hundreds of these are nothing.
755
+ - **A cache-mode archive** carries a piece cache sized from the torrent's piece
756
+ length. At 16 MiB pieces a hundred of them is gigabytes, which is why this
757
+ one keeps the old ceiling and is counted separately.
758
+ - **A URL or bucket archive** holds an HTTP reader and the summary it has
759
+ already read. Cheap to keep, and expensive to reopen: a reopen costs a header
760
+ and a directory fetch over the network rather than off a disk.
761
+
762
+ A node serving nothing but cache-mode archives behaves exactly as it did.
763
+
733
764
  `headerTimeoutMs` is shorter than the others on purpose: somebody is waiting on
734
765
  it. A cache-mode archive with no web seed and no peers has nothing to read a
735
766
  header from, and taking a full minute to say so looks like a hang rather than
package/docs/internals.md CHANGED
@@ -25,6 +25,7 @@ Operator-facing documentation is elsewhere — see [publishing](publishing.md),
25
25
  - [Scheduled sources](#scheduled-sources)
26
26
  - [Running two engines at once](#running-two-engines-at-once)
27
27
  - [Why libtorrent runs as a sidecar](#why-libtorrent-runs-as-a-sidecar)
28
+ - [A sidecar that outlives the node](#a-sidecar-that-outlives-the-node)
28
29
  - [Making a torrent out of a map](#making-a-torrent-out-of-a-map)
29
30
  - [Resuming a partial download](#resuming-a-partial-download)
30
31
  - [Retiring and pruning a subscription](#retiring-and-pruning-a-subscription)
@@ -579,6 +580,40 @@ under its final name until it finishes. Do not point a web server at a libtorren
579
580
  save path that is also serving web seeds. Completion is still recorded correctly
580
581
  — the watcher finds no marked file and notes that the archive is whole.
581
582
 
583
+ ### A sidecar that outlives the node
584
+
585
+ The one failure mode of running libtorrent in another process, and the reason a
586
+ service can need starting two or three times before it takes.
587
+
588
+ The sidecar exits when its pipe closes, which covers an orderly stop and most
589
+ crashes. It does not cover a sidecar in the middle of **hashing**: libtorrent
590
+ does not hand control back until a check finishes, so the process notices
591
+ neither the closed pipe nor a signal until it does — minutes on a large
592
+ archive, hours on a planet. Nor does it cover a node killed outright, which
593
+ takes no part in stopping anything.
594
+
595
+ What is left behind still holds the listen port and the resume directory. The
596
+ next start fails against it, and since a failed start leaves its own sidecar,
597
+ they accumulate. In the journal it reads as:
598
+
599
+ ```
600
+ pmtiles-swarm.service: Unit process 102573 (python) remains running after unit stopped.
601
+ pmtiles-swarm.service: Failed to kill control group ...: Invalid argument
602
+ ```
603
+
604
+ Two things prevent it. Stopping **insists**: the shutdown request goes first,
605
+ so resume data is saved either way, and a sidecar that has not gone within a
606
+ few seconds is killed rather than left. And a start **reaps** what a previous
607
+ run left, before it spawns anything — the sidecar's pid is recorded beside its
608
+ resume data, and a recorded pid is killed only when `/proc` says that process
609
+ is really a sidecar. Pids are reused, and killing whatever inherited one would
610
+ be worse than the problem.
611
+
612
+ Neither is a substitute for `KillMode=mixed` in the unit. That is what stops
613
+ systemd signalling the sidecar at the same moment as the node, which kills it
614
+ before it can write anything down — see
615
+ [running-as-a-service.md](running-as-a-service.md).
616
+
582
617
  ## Making a torrent out of a map
583
618
 
584
619
  Two choices here are specific to distributing maps rather than generic files.
@@ -189,6 +189,50 @@ The unit below is what it generates, with your paths in it. Worth reading
189
189
  either way — a generated file you do not understand is a hand-written one you
190
190
  have not written yet.
191
191
 
192
+ ## Regenerating the unit on a node that already runs
193
+
194
+ The unit is derived from two things that move: the configuration, and how many
195
+ archives the library holds. A watched folder added six months ago belongs in
196
+ `ReadWritePaths`, and a library that has grown needs longer to write its resume
197
+ data than the unit allows. So running this again is ordinary:
198
+
199
+ ```
200
+ sudo pmtiles-swarm init --systemd --config /etc/pmtiles-swarm/swarm.config.json
201
+ ```
202
+
203
+ **It reads the configuration and writes only the unit**, next to that
204
+ configuration — never into `/etc/systemd/system`, and never over
205
+ `swarm.config.json`. Installing it is your step, and worth a look first:
206
+
207
+ ```
208
+ diff /etc/systemd/system/pmtiles-swarm.service \
209
+ /etc/pmtiles-swarm/pmtiles-swarm.service
210
+ sudo cp /etc/pmtiles-swarm/pmtiles-swarm.service /etc/systemd/system/
211
+ sudo systemctl daemon-reload
212
+ sudo systemctl restart pmtiles-swarm
213
+ ```
214
+
215
+ **`--force` is a different command.** It replaces `swarm.config.json` with a
216
+ fresh one — tokens, stacks, feeds and all — and it is for starting over, not
217
+ for regenerating a unit. Nothing about writing the unit needs it.
218
+
219
+ **Run it as root, not as the service account.** It writes into `/etc`, which
220
+ the service account cannot do and should not be able to do. What decides the
221
+ `User=` line is `--user`, which defaults to `pmtiles-swarm`; pass it if the
222
+ account is called something else:
223
+
224
+ ```
225
+ sudo pmtiles-swarm init --systemd \
226
+ --config /etc/pmtiles-swarm/swarm.config.json \
227
+ --user maps
228
+ ```
229
+
230
+ `ReadWritePaths` is derived from the configuration, so **a path added to the
231
+ installed unit by hand is not in the generated one**. That is what the diff is
232
+ for. The durable fix is to move such a path into the configuration — as a save
233
+ location, a watched folder, or a cache directory — after which it is derived
234
+ like the rest.
235
+
192
236
  ## The unit
193
237
 
194
238
  Annotated, because every directive here has cost somebody a diagnosis.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.92.0",
3
+ "version": "0.95.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/api.js CHANGED
@@ -22,7 +22,12 @@ import {
22
22
  import { mutableMagnet, trackersFromMagnet } from './mutable.js';
23
23
  import { guessKind } from './library.js';
24
24
  import { QBittorrentEngine } from './engines/qbittorrent.js';
25
- import { RESTART_REQUIRED, redactConfig, saveConfig } from './config.js';
25
+ import {
26
+ RESTART_REQUIRED,
27
+ redactConfig,
28
+ saveConfig,
29
+ trustProxyFor,
30
+ } from './config.js';
26
31
  import { freeSpace, listLocations } from './locations.js';
27
32
  import { restart, restartMode } from './restart.js';
28
33
  import { parseFeed, renderFeed } from './feed.js';
@@ -260,7 +265,26 @@ export function createApp({
260
265
  // the TileJSON advertises http:// tile URLs. A browser that loaded the map
261
266
  // over https then blocks every one of them as mixed content, which looks
262
267
  // like an empty map rather than like a configuration mistake.
263
- if (config.trustProxy) app.set('trust proxy', config.trustProxy);
268
+ //
269
+ // Normalised first, and then guarded anyway. Express compiles this value
270
+ // the moment it is set, which is before the listener binds -- so a value it
271
+ // cannot compile is not a setting that fails to apply, it is a node that
272
+ // will not start, cannot be reached, and cannot be corrected from the
273
+ // console that wrote it. Nothing about which addresses to trust is worth
274
+ // that: an unparseable one is reported and the node comes up trusting
275
+ // nobody, which is the safe end of being wrong.
276
+ const trustProxy = trustProxyFor(config.trustProxy);
277
+ if (trustProxy !== false) {
278
+ try {
279
+ app.set('trust proxy', trustProxy);
280
+ } catch (error) {
281
+ console.error(
282
+ `[config] trustProxy could not be applied (${error.message}). ` +
283
+ 'X-Forwarded-* headers are being ignored; the node is starting ' +
284
+ 'anyway so this can be corrected from Settings.',
285
+ );
286
+ }
287
+ }
264
288
  app.use(express.json({ limit: '1mb' }));
265
289
 
266
290
  // Tiles, TileJSON and the feed stay public — serving them is the point.
package/src/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import net from 'node:net';
2
3
  import { hashPassword } from './auth.js';
3
4
  import path from 'node:path';
4
5
 
@@ -10,6 +11,79 @@ import path from 'node:path';
10
11
  * Every setting is documented in docs/configuration.md. Comments here say only
11
12
  * what a value is; why it is what it is belongs in the document.
12
13
  */
14
+ /** What proxy-addr accepts as a name for a group of addresses. */
15
+ const TRUST_KEYWORDS = new Set(['loopback', 'linklocal', 'uniquelocal']);
16
+
17
+ /**
18
+ * Whether one entry of a trust list is something proxy-addr can compile.
19
+ * @param {string} entry - One address, subnet or keyword.
20
+ * @returns {boolean} - True if it is usable.
21
+ */
22
+ function isTrustEntry(entry) {
23
+ if (TRUST_KEYWORDS.has(entry)) return true;
24
+ const [address, mask, ...rest] = entry.split('/');
25
+ if (rest.length > 0) return false;
26
+ if (!net.isIP(address)) return false;
27
+ if (mask === undefined) return true;
28
+ if (net.isIP(mask)) return true;
29
+ const bits = Number(mask);
30
+ if (!Number.isInteger(bits) || bits < 0) return false;
31
+ return bits <= (net.isIP(address) === 6 ? 128 : 32);
32
+ }
33
+
34
+ /**
35
+ * What to hand Express as `trust proxy`, from what the config says.
36
+ *
37
+ * Express takes four different things here and means something different by
38
+ * each, and it splits a comma-separated list only when it is handed a bare
39
+ * string -- never inside an array. So `["10.0.0.1, 10.0.0.2"]`, which is what
40
+ * a settings field split on newlines alone produces from one line with a
41
+ * comma in it, reaches proxy-addr as a single address, and proxy-addr throws.
42
+ *
43
+ * That throw happened while the app was being built, before the listener
44
+ * bound: a node that could not start, could not be reached, and could not be
45
+ * corrected from the console that wrote the value. So every shape is
46
+ * flattened here, and anything left that is not an address is dropped with a
47
+ * warning rather than carried into Express.
48
+ * @param {boolean|number|string|string[]} value - `config.trustProxy`.
49
+ * @returns {boolean|number|string[]} - Something Express can compile.
50
+ */
51
+ export function trustProxyFor(value) {
52
+ if (
53
+ value === true ||
54
+ value === false ||
55
+ value === undefined ||
56
+ value === null
57
+ ) {
58
+ return value === true;
59
+ }
60
+ if (typeof value === 'number') return Number.isFinite(value) ? value : false;
61
+
62
+ const entries = (Array.isArray(value) ? value : [value])
63
+ .flatMap((one) => String(one).split(/[\s,]+/))
64
+ .map((one) => one.trim())
65
+ .filter(Boolean);
66
+ if (!entries.length) return false;
67
+
68
+ // A lone number is a hop count, and a lone `true` trusts everybody. Both
69
+ // are things somebody may type into a field that mostly takes addresses.
70
+ if (entries.length === 1) {
71
+ if (entries[0] === 'true') return true;
72
+ if (entries[0] === 'false') return false;
73
+ if (!Number.isNaN(Number(entries[0]))) return Number(entries[0]);
74
+ }
75
+
76
+ const usable = entries.filter((entry) => isTrustEntry(entry));
77
+ for (const entry of entries) {
78
+ if (usable.includes(entry)) continue;
79
+ console.warn(
80
+ `[config] trustProxy: ignoring "${entry}", which is not an address, ` +
81
+ 'a subnet, or one of loopback, linklocal, uniquelocal.',
82
+ );
83
+ }
84
+ return usable.length ? usable : false;
85
+ }
86
+
13
87
  const DEFAULTS = {
14
88
  port: 8090,
15
89
  host: '0.0.0.0',
@@ -290,12 +364,47 @@ const DEFAULTS = {
290
364
  */
291
365
  tiles: {
292
366
  /**
293
- * Open archives kept alive at once. Each holds a file descriptor or a
294
- * torrent reader plus its piece cache, so this bounds both.
367
+ * Open archives kept alive at once.
368
+ *
369
+ * Sized for a library of hundreds, because that is what a node that
370
+ * merges layers holds: a stack built from a provider's file index names
371
+ * four hundred sources and a bake walks every one of them. At sixteen,
372
+ * such a run spent most of its time reopening archives it had just
373
+ * closed, and each reopen re-reads a header and a directory.
374
+ *
375
+ * A complete archive open is a file descriptor and its share of the
376
+ * directory cache, which is why this can be large -- the unit allows
377
+ * 65535 of them. An archive read through the swarm is not: it carries a
378
+ * piece cache, and those are bounded separately below.
379
+ */
380
+ maxOpenArchives: 128,
381
+ /**
382
+ * Open archives read through the swarm, which is the expensive kind.
383
+ *
384
+ * Counted apart from the limit above and kept where that limit used to
385
+ * be. A cache-mode reader holds a piece cache sized from the torrent's
386
+ * piece length, so with 16 MiB pieces a hundred of them is gigabytes --
387
+ * the reason the single old limit could not simply be raised.
388
+ */
389
+ maxOpenSwarmArchives: 16,
390
+ /**
391
+ * Open archives read straight from a URL or a bucket.
392
+ *
393
+ * Cheap in memory -- an HTTP reader and the summary it has already read
394
+ * -- and expensive to reopen, since a reopen costs a header and a
395
+ * directory fetch over the network. So this sits well above the swarm
396
+ * limit and below the local one.
397
+ */
398
+ maxOpenRemoteArchives: 64,
399
+ /**
400
+ * Header and directory cache entries, shared across every archive.
401
+ *
402
+ * One archive contributes several: a header, a root directory, and a leaf
403
+ * per region being read. Two hundred was a handful of archives' worth, so
404
+ * a stack over hundreds of sources evicted its own directories between
405
+ * one tile and the next.
295
406
  */
296
- maxOpenArchives: 16,
297
- /** Header and directory cache entries, shared across every archive. */
298
- directoryCacheEntries: 200,
407
+ directoryCacheEntries: 2000,
299
408
  /**
300
409
  * Byte budget for the piece cache of one swarm-read archive. Unset sizes
301
410
  * it from the torrent's piece length, which is safer than a fixed budget.
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
2
3
  import { createRequire } from 'node:module';
3
4
  import path from 'node:path';
4
5
 
@@ -29,6 +30,122 @@ function resolveSidecar() {
29
30
  }
30
31
  }
31
32
 
33
+ /**
34
+ * Ends a child process, politely and then not.
35
+ * @param {object} child - The process.
36
+ * @param {number} graceMs - How long the polite request gets.
37
+ * @returns {Promise<void>} - When it has gone.
38
+ */
39
+ export async function stopChild(child, graceMs) {
40
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
41
+ const gone = new Promise((resolve) => child.once('exit', resolve));
42
+ child.kill();
43
+ const settled = await Promise.race([
44
+ gone.then(() => true),
45
+ new Promise((resolve) => {
46
+ const timer = setTimeout(() => resolve(false), graceMs);
47
+ timer.unref?.();
48
+ }),
49
+ ]);
50
+ if (settled) return;
51
+ console.warn(
52
+ '[libtorrent] the sidecar did not stop when asked -- it is usually ' +
53
+ 'hashing, which cannot be interrupted -- so it is being killed. Its ' +
54
+ 'resume data was already saved.',
55
+ );
56
+ child.kill('SIGKILL');
57
+ await gone;
58
+ }
59
+
60
+ /**
61
+ * Where the pid of the running sidecar is written.
62
+ * @param {string} [resumeDir] - Where resume data is kept.
63
+ * @returns {string|null} - The path, or null when there is nowhere to put it.
64
+ */
65
+ function sidecarPidPath(resumeDir) {
66
+ return resumeDir ? path.join(resumeDir, 'sidecar.pid') : null;
67
+ }
68
+
69
+ /**
70
+ * Records which process is the sidecar, so a later start can recognise it.
71
+ * @param {string} [resumeDir] - Where resume data is kept.
72
+ * @param {number} pid - The sidecar.
73
+ * @returns {Promise<void>} - When written.
74
+ */
75
+ async function rememberSidecarPid(resumeDir, pid) {
76
+ const file = sidecarPidPath(resumeDir);
77
+ if (!file) return;
78
+ await fs.writeFile(file, String(pid)).catch(() => {});
79
+ }
80
+
81
+ /**
82
+ * Forgets the recorded pid.
83
+ * @param {string} [resumeDir] - Where resume data is kept.
84
+ * @returns {Promise<void>} - When removed.
85
+ */
86
+ async function forgetSidecarPid(resumeDir) {
87
+ const file = sidecarPidPath(resumeDir);
88
+ if (!file) return;
89
+ await fs.rm(file, { force: true }).catch(() => {});
90
+ }
91
+
92
+ /**
93
+ * Kills a sidecar left behind by a previous run.
94
+ *
95
+ * A node killed outright -- SIGKILL, an OOM, a power cut -- takes no part in
96
+ * stopping its sidecar, and a sidecar mid-hash does not notice its pipe close.
97
+ * It goes on holding the listen port and the resume directory, and the next
98
+ * start fails against it. Reaped here rather than lived with, because the
99
+ * alternative is the operator restarting the service until it takes.
100
+ *
101
+ * Identified by its command line, not by the pid alone: pids are reused, and
102
+ * killing whatever inherited one would be far worse than the problem.
103
+ * @param {string} [resumeDir] - Where resume data is kept.
104
+ * @returns {Promise<boolean>} - Whether one was killed.
105
+ */
106
+ export async function reapStaleSidecar(resumeDir) {
107
+ const file = sidecarPidPath(resumeDir);
108
+ if (!file) return false;
109
+ let pid;
110
+ try {
111
+ pid = Number(await fs.readFile(file, 'utf8'));
112
+ } catch {
113
+ return false;
114
+ }
115
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
116
+ await forgetSidecarPid(resumeDir);
117
+ return false;
118
+ }
119
+
120
+ // Only where the process table can be read as files, which is where this
121
+ // runs as a service. Elsewhere a stale pid is left alone: guessing is worse.
122
+ let cmdline;
123
+ try {
124
+ cmdline = await fs.readFile(`/proc/${pid}/cmdline`, 'utf8');
125
+ } catch {
126
+ await forgetSidecarPid(resumeDir);
127
+ return false;
128
+ }
129
+ if (!cmdline.includes('libtorrent_sidecar')) {
130
+ await forgetSidecarPid(resumeDir);
131
+ return false;
132
+ }
133
+
134
+ console.warn(
135
+ `[libtorrent] a sidecar from a previous run is still running (pid ${pid}). ` +
136
+ 'It holds the listen port and the resume directory this one needs, so ' +
137
+ 'it is being killed. This is what a start that has to be repeated ' +
138
+ 'two or three times looks like.',
139
+ );
140
+ try {
141
+ process.kill(pid, 'SIGKILL');
142
+ } catch {
143
+ // Gone between reading and killing, which is the good outcome.
144
+ }
145
+ await forgetSidecarPid(resumeDir);
146
+ return true;
147
+ }
148
+
32
149
  /**
33
150
  * A SeedEngine backed by libtorrent, through a sidecar process.
34
151
  *
@@ -117,6 +234,9 @@ export class LibtorrentEngine {
117
234
 
118
235
  this.#ready = new Promise((resolve, reject) => {
119
236
  const script = this.#options.script ?? resolveSidecar();
237
+ // Before the spawn, not after: a sidecar from a previous run holds the
238
+ // port this one is about to ask for.
239
+ const reaped = reapStaleSidecar(this.#options.resumeDir);
120
240
 
121
241
  const child = spawn(this.#options.python, [script], {
122
242
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -140,6 +260,7 @@ export class LibtorrentEngine {
140
260
  },
141
261
  });
142
262
  this.#child = child;
263
+ reaped.then(() => rememberSidecarPid(this.#options.resumeDir, child.pid));
143
264
 
144
265
  // Nothing of the last sidecar's is carried into this one. Being killed
145
266
  // does not wait for a newline, so a sidecar that died partway through a
@@ -802,9 +923,19 @@ export class LibtorrentEngine {
802
923
  await this.#call('shutdown', {}, options.timeoutMs ?? 15000).catch(
803
924
  () => {},
804
925
  );
805
- this.#child?.kill();
926
+
927
+ // Asked, then insisted. A sidecar in the middle of hashing is not reading
928
+ // its pipe and does not act on a signal until libtorrent hands control
929
+ // back, which on a large archive is minutes -- so a plain SIGTERM left it
930
+ // running after the node had gone. systemd then reports a unit process
931
+ // that remains after the unit stopped, the next start finds the old one
932
+ // still holding the listen port, and the library comes back holding
933
+ // nothing. That is the restart that has to be done two or three times.
934
+ const child = this.#child;
806
935
  this.#child = null;
807
936
  this.#ready = null;
937
+ await stopChild(child, options.killGraceMs ?? 5000);
938
+ await forgetSidecarPid(this.#options.resumeDir);
808
939
  }
809
940
 
810
941
  /**
package/src/index.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  installSignalHandlers,
29
29
  runStoppers,
30
30
  } from './shutdown.js';
31
+ import { TIMEOUT_STOP_SECONDS } from './systemd.js';
31
32
  import { ScheduledSourceManager } from './sources.js';
32
33
  import { StackExportScheduler } from './stack-exports.js';
33
34
  import { StackFeedSubscriber } from './stack-feed.js';
@@ -338,13 +339,47 @@ PMTILES_SWARM_PUBLIC_URL
338
339
  );
339
340
 
340
341
  const catalogued = catalog.list().length;
341
- if (catalogued > 0) {
342
- const { restored, failed } = await library.restore();
343
- console.log(
344
- `[restore] ${restored} of ${catalogued} archives handed back to the engine` +
345
- (failed > 0 ? ` (${failed} could not be)` : ''),
342
+ // Said once at startup, because the way this fails is silent. Stopping
343
+ // writes resume data for every archive and the engine is given two seconds
344
+ // apiece; a unit that allows less than that is killed partway through, and
345
+ // what comes back re-hashes every archive whose resume data never landed.
346
+ // There is no error at that moment -- only a library at 0% and hours of
347
+ // disk. The generated unit derives its allowance from the library it was
348
+ // written for, so this is really "your library has grown since then".
349
+ const stopNeeds = Math.ceil(engineStopMs(catalogued) / 1000);
350
+ if (stopNeeds > TIMEOUT_STOP_SECONDS) {
351
+ console.warn(
352
+ `[shutdown] stopping this library needs about ${stopNeeds}s to write ` +
353
+ `resume data, which is more than the ${TIMEOUT_STOP_SECONDS}s ` +
354
+ 'a default systemd unit allows. Re-run `pmtiles-swarm init --systemd` ' +
355
+ 'to regenerate the unit, or raise TimeoutStopSec by hand -- a stop cut ' +
356
+ 'short re-hashes every archive it had not saved.',
346
357
  );
347
358
  }
359
+ if (catalogued > 0) {
360
+ // Reported, never fatal. Restore already tolerates a failure per archive;
361
+ // what this catches is the whole call coming apart -- and the node it
362
+ // takes down with it is the one that could have said so. Under
363
+ // `Restart=always` that is a crash loop with no console to look at, which
364
+ // is a worse failure than a library that is not being seeded: the console
365
+ // marks an archive the engine has no record of as `not loaded`, so a node
366
+ // that comes up says exactly what went wrong here.
367
+ try {
368
+ const { restored, failed } = await library.restore();
369
+ console.log(
370
+ `[restore] ${restored} of ${catalogued} archives handed back to the engine` +
371
+ (failed > 0 ? ` (${failed} could not be)` : ''),
372
+ );
373
+ } catch (error) {
374
+ console.error(
375
+ `[restore] could not hand the library back to the engine: ` +
376
+ `${error.stack ?? error.message}
377
+ ` +
378
+ '[restore] the node is starting anyway; every archive will show as ' +
379
+ 'not loaded until this is fixed and it is restarted.',
380
+ );
381
+ }
382
+ }
348
383
 
349
384
  // And again if the engine loses its backing process and starts another. A
350
385
  // replacement holds nothing, so without this the node would come back
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { hashPassword } from './auth.js';
6
- import { writablePaths } from './config.js';
6
+ import { loadConfig, writablePaths } from './config.js';
7
7
  import { directoryCommands, unitFor } from './systemd.js';
8
8
 
9
9
  /**
@@ -78,6 +78,25 @@ function firstConfig({ dataDir, savePath, passwordHash }) {
78
78
  };
79
79
  }
80
80
 
81
+ /**
82
+ * How many archives the catalog holds, if there is one to read.
83
+ *
84
+ * Best effort by design: this runs before the node does, on a machine that
85
+ * may have no data directory at all, and a missing or unreadable catalog is
86
+ * an install rather than an error.
87
+ * @param {object} config - The resolved configuration.
88
+ * @returns {Promise<number>} - The count, or zero.
89
+ */
90
+ async function countCatalog(config) {
91
+ try {
92
+ const file = path.join(config.dataDir, 'catalog.json');
93
+ const held = JSON.parse(await fs.readFile(file, 'utf8'));
94
+ return Array.isArray(held.entries) ? held.entries.length : 0;
95
+ } catch {
96
+ return 0;
97
+ }
98
+ }
99
+
81
100
  /**
82
101
  * Writes a first configuration file.
83
102
  *
@@ -128,10 +147,49 @@ export async function runInit(options = {}, write = console.log) {
128
147
  .access(configPath)
129
148
  .then(() => true)
130
149
  .catch(() => false);
150
+
151
+ // A node that already has a configuration and wants the unit is the
152
+ // ordinary reason to run this a second time: the unit is derived from the
153
+ // configuration and from how many archives the library holds, and both move.
154
+ // Refusing sent people to --force, which replaces the configuration --
155
+ // tokens, stacks, feeds and all -- to regenerate a file beside it.
156
+ if (exists && options.systemd && !options.force) {
157
+ const held = await loadConfig(configPath);
158
+ const unitPath = path.join(
159
+ path.dirname(configPath),
160
+ 'pmtiles-swarm.service',
161
+ );
162
+ await fs.writeFile(
163
+ unitPath,
164
+ unitFor({
165
+ config: held,
166
+ configPath,
167
+ user: options.user,
168
+ execStart: options.execStart,
169
+ archives: await countCatalog(held),
170
+ }),
171
+ );
172
+ write(`Wrote ${unitPath}`);
173
+ write('');
174
+ write(` ${configPath} was read, not written.`);
175
+ write('');
176
+ write(' Compare it with the one that is installed before replacing it:');
177
+ write('');
178
+ write(` diff /etc/systemd/system/pmtiles-swarm.service ${unitPath}`);
179
+ write('');
180
+ write(' ReadWritePaths is derived from the configuration, so a path');
181
+ write(' added to the installed unit by hand is not in this one. Move it');
182
+ write(' into the configuration — as a save location, a watched folder or');
183
+ write(' a cache path — and it will be derived from now on.');
184
+ return 0;
185
+ }
186
+
131
187
  if (exists && !options.force) {
132
188
  write(
133
189
  `${configPath} already exists. Pass --force to replace it — and take a ` +
134
- 'copy first, since the tokens in it are not recoverable.',
190
+ 'copy first, since the tokens in it are not recoverable. To write ' +
191
+ 'just the unit from the configuration that is there, pass --systemd ' +
192
+ 'without --force.',
135
193
  );
136
194
  return 1;
137
195
  }
@@ -164,6 +222,10 @@ export async function runInit(options = {}, write = console.log) {
164
222
  configPath,
165
223
  user: options.user,
166
224
  execStart: options.execStart,
225
+ // So the stop timeout fits the library this node already holds. On a
226
+ // fresh install there is none and the default stands; re-running this
227
+ // after the library has grown is what keeps the two together.
228
+ archives: await countCatalog(config),
167
229
  }),
168
230
  );
169
231
  write(`Wrote ${unitPath}`);
package/src/systemd.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
+ import { engineStopMs } from './shutdown.js';
3
4
  import { writablePaths } from './config.js';
4
5
 
5
6
  /**
@@ -26,7 +27,29 @@ import { writablePaths } from './config.js';
26
27
  */
27
28
 
28
29
  /** What the resume save can want, before the library has grown into it. */
29
- const TIMEOUT_STOP_SECONDS = 300;
30
+ export const TIMEOUT_STOP_SECONDS = 300;
31
+
32
+ /**
33
+ * How long systemd should allow a stop, for a library of this size.
34
+ *
35
+ * The node gives its engine two seconds an archive to write resume data, and
36
+ * systemd's allowance has to outlast that or the save is cut off partway --
37
+ * which is exactly the library that returns at 0% and re-hashes for hours.
38
+ * A fixed five minutes covered a library of 145 and no more, silently, so
39
+ * this is derived the same way `ReadWritePaths` and the thread pool are.
40
+ *
41
+ * Rounded up to whole minutes, with a wide margin over the node's own budget:
42
+ * being generous here costs nothing, since systemd stops waiting the moment
43
+ * the process is gone.
44
+ * @param {object} config - The resolved configuration.
45
+ * @param {number} [archives] - How many the catalog holds.
46
+ * @returns {number} - Seconds for `TimeoutStopSec`.
47
+ */
48
+ export function stopTimeoutFor(config, archives = 0) {
49
+ const budget = engineStopMs(archives) / 1000;
50
+ const wanted = Math.ceil((budget * 1.5 + 60) / 60) * 60;
51
+ return Math.max(TIMEOUT_STOP_SECONDS, wanted);
52
+ }
30
53
 
31
54
  /** libuv's own, which is what a unit that says nothing gets. */
32
55
  const DEFAULT_THREADPOOL = 4;
@@ -68,11 +91,13 @@ export function unitFor({
68
91
  user = 'pmtiles-swarm',
69
92
  execStart,
70
93
  workingDirectory,
94
+ archives = 0,
71
95
  }) {
72
96
  const home = workingDirectory ?? `/var/lib/${user}`;
73
97
  const binary = execStart ?? `${home}/node_modules/.bin/pmtiles-swarm`;
74
98
  const paths = writablePaths(config, configPath);
75
99
  const threadPool = threadPoolFor(config);
100
+ const stopSeconds = stopTimeoutFor(config, archives);
76
101
 
77
102
  // Wrapped the way systemd's own examples are, because this list grows with
78
103
  // every watched folder and a single line of them is unreadable in a diff.
@@ -128,9 +153,14 @@ RestartSec=5
128
153
  # Stopping writes resume data for every archive, and the node allows its engine
129
154
  # two seconds per torrent to do it. This has to outlast that: killed mid-save,
130
155
  # every archive that had not been written re-hashes its whole store on the way
131
- # back up, which for a 700 GiB archive is hours. Raise it as the library grows
132
- # — tools/resume-doctor.py prints the arithmetic for yours.
133
- TimeoutStopSec=${TIMEOUT_STOP_SECONDS}
156
+ # back up, which for a 700 GiB archive is hours.
157
+ #
158
+ # Derived from the ${archives} archive(s) this node holds, with room to grow.
159
+ # A fixed five minutes covered a library of 145 and no more, and covered it
160
+ # silently — the symptom of outgrowing it is not an error but a library that
161
+ # comes back at 0%. Re-running init --systemd recomputes this;
162
+ # tools/resume-doctor.py prints the arithmetic for yours.
163
+ TimeoutStopSec=${stopSeconds}
134
164
 
135
165
  # The node stops the sidecar itself, and needs it alive to do so. The default,
136
166
  # control-group, signals both at once and the sidecar dies before it can write
package/src/tiles.js CHANGED
@@ -76,7 +76,7 @@ export class TileStore {
76
76
  this.#engine = engine;
77
77
  this.#config = config;
78
78
  this.#directoryCache = new SharedPromiseCache(
79
- config.tiles?.directoryCacheEntries ?? 200,
79
+ config.tiles?.directoryCacheEntries ?? 2000,
80
80
  );
81
81
  }
82
82
 
@@ -240,7 +240,7 @@ export class TileStore {
240
240
  };
241
241
  this.#remote.set(url, handle);
242
242
 
243
- const limit = this.#config.tiles?.maxOpenRemoteArchives ?? 16;
243
+ const limit = this.#config.tiles?.maxOpenRemoteArchives ?? 64;
244
244
  while (this.#remote.size > limit) {
245
245
  const [oldest] = this.#remote.keys();
246
246
  this.#remote.delete(oldest);
@@ -431,13 +431,38 @@ export class TileStore {
431
431
  const handle = await this.#openArchive(entry);
432
432
  this.#open.set(entry.infoHash, handle);
433
433
 
434
- const limit = this.#config.tiles?.maxOpenArchives ?? 16;
435
- while (this.#open.size > limit) {
436
- const [oldest, victim] = this.#open.entries().next().value;
437
- this.#open.delete(oldest);
434
+ // Two budgets, because the two kinds of handle cost quite different
435
+ // things. A complete archive is a file descriptor; one read through the
436
+ // swarm carries a piece cache sized from the torrent's piece length,
437
+ // which with 16 MiB pieces is tens of megabytes each. A single limit had
438
+ // to be set for the expensive kind, which left a library of hundreds of
439
+ // local archives reopening files it had just closed.
440
+ const tiles = this.#config.tiles ?? {};
441
+ await this.#evict(() => true, tiles.maxOpenArchives ?? 128);
442
+ await this.#evict(
443
+ (one) => one.mode === 'swarm',
444
+ tiles.maxOpenSwarmArchives ?? 16,
445
+ );
446
+ return handle;
447
+ }
448
+
449
+ /**
450
+ * Closes the least recently used handles until a budget is met.
451
+ *
452
+ * Least recently used first, which a Map gives for nothing: reading one
453
+ * moves it to the end, so iteration order is oldest first.
454
+ * @param {Function} counts - Whether a handle counts against this budget.
455
+ * @param {number} limit - How many of them to keep.
456
+ * @returns {Promise<void>} - When the closing is done.
457
+ */
458
+ async #evict(counts, limit) {
459
+ if (!Number.isFinite(limit) || limit < 1) return;
460
+ const held = [...this.#open].filter(([, one]) => counts(one));
461
+ while (held.length > limit) {
462
+ const [key, victim] = held.shift();
463
+ this.#open.delete(key);
438
464
  await this.#release(victim);
439
465
  }
440
- return handle;
441
466
  }
442
467
 
443
468
  /**
@@ -5279,17 +5279,42 @@ Every piece is hashed against the ` +
5279
5279
  key: 'tiles.maxOpenArchives',
5280
5280
  label: 'Archives kept open',
5281
5281
  type: 'number',
5282
+ placeholder: '128',
5283
+ help:
5284
+ 'A complete archive open is a file descriptor and its share ' +
5285
+ 'of the directory cache, so this can be large — the unit ' +
5286
+ 'allows 65535 of them. Sized for a node that merges layers, ' +
5287
+ 'where one stack names hundreds of sources and a bake walks ' +
5288
+ 'every one. Past the number of archives this node serves it ' +
5289
+ 'stops doing anything.',
5290
+ },
5291
+ {
5292
+ key: 'tiles.maxOpenSwarmArchives',
5293
+ label: 'Cache-mode archives kept open',
5294
+ type: 'number',
5282
5295
  placeholder: '16',
5283
5296
  help:
5284
- 'Each holds a file descriptor, and in cache mode a piece ' +
5285
- 'cache as well. Past the number of archives this node serves ' +
5286
- 'it stops doing anything.',
5297
+ 'Counted apart from the limit above, because this is the ' +
5298
+ 'expensive kind: a cache-mode reader holds a piece cache ' +
5299
+ 'sized from the torrent’s piece length, so with 16 MiB pieces ' +
5300
+ 'a hundred of them is gigabytes. That is why the one limit ' +
5301
+ 'above could not simply be raised.',
5302
+ },
5303
+ {
5304
+ key: 'tiles.maxOpenRemoteArchives',
5305
+ label: 'URL and bucket archives kept open',
5306
+ type: 'number',
5307
+ placeholder: '64',
5308
+ help:
5309
+ 'Cheap to hold — an HTTP reader and the summary it has ' +
5310
+ 'already read — and expensive to reopen, since a reopen costs ' +
5311
+ 'a header and a directory fetch over the network.',
5287
5312
  },
5288
5313
  {
5289
5314
  key: 'tiles.directoryCacheEntries',
5290
5315
  label: 'Directory cache entries',
5291
5316
  type: 'number',
5292
- placeholder: '200',
5317
+ placeholder: '2000',
5293
5318
  restart: true,
5294
5319
  help:
5295
5320
  'PMTiles finds a tile through a root directory and then a ' +
@@ -5579,11 +5604,15 @@ Every piece is hashed against the ` +
5579
5604
  type: 'addresses',
5580
5605
  placeholder: 'off — or one address or subnet per line',
5581
5606
  help:
5582
- 'One per line: 172.16.1.49, or 172.16.1.0/24, or several. A ' +
5583
- 'lone number is read as a hop count instead, and a lone `true` ' +
5584
- 'trusts any caller at all — worth avoiding, since this header ' +
5585
- 'is what decides which address the node believes a request ' +
5586
- 'came from.',
5607
+ 'An address or a subnet — <code>172.16.1.49</code>, ' +
5608
+ '<code>172.16.1.0/24</code>, <code>loopback</code> — one ' +
5609
+ 'per line, or separated by commas. A lone number is read ' +
5610
+ 'as a hop count instead, and a lone <code>true</code> ' +
5611
+ 'trusts any caller at all, which is worth avoiding: this ' +
5612
+ 'header is what decides which address the node believes a ' +
5613
+ 'request came from. Anything that is not an address is ' +
5614
+ 'ignored and said so in the log, rather than stopping the ' +
5615
+ 'node from starting.',
5587
5616
  },
5588
5617
  {
5589
5618
  key: 'publicIndex',
@@ -6307,7 +6336,14 @@ Every piece is hashed against the ` +
6307
6336
  field.type === 'secret'
6308
6337
  ? `<input type="password" autocomplete="new-password" data-setting="${field.key}"${locked} data-type="secret" data-initial="&quot;&quot;" value="" placeholder="${escapeHtml(field.placeholder ?? 'unchanged')}" />`
6309
6338
  : field.type === 'addresses'
6310
- ? `<textarea data-setting="${field.key}"${locked} data-type="addresses"${initial} rows="3" placeholder="${escapeHtml(field.placeholder ?? '')}">${escapeHtml(Array.isArray(value) ? value.join('\n') : value === undefined || value === null ? '' : String(value))}</textarea>`
6339
+ ? // Its own initial, because this is the one field whose
6340
+ // box holds a different shape from the config. A
6341
+ // stored string never compares equal to the array
6342
+ // the box parses to, so sharing `initial` meant
6343
+ // every Save rewrote a setting nobody had touched
6344
+ // -- which is how a value that worked became one
6345
+ // the node would not start with.
6346
+ `<textarea data-setting="${field.key}"${locked} data-type="addresses" data-initial="${escapeHtml(JSON.stringify(parseAddresses(addressText(value))))}" rows="3" placeholder="${escapeHtml(field.placeholder ?? '')}">${escapeHtml(addressText(value))}</textarea>`
6311
6347
  : field.type === 'list'
6312
6348
  ? `<textarea data-setting="${field.key}"${locked} data-type="list"${initial} rows="${Math.min(8, Math.max(3, (Array.isArray(value) ? value.length : 0) + 1))}" placeholder="${escapeHtml(field.placeholder ?? '')}">${escapeHtml((Array.isArray(value) ? value : []).join('\n'))}</textarea>`
6313
6349
  : field.type === 'boolean'
@@ -6337,6 +6373,56 @@ Every piece is hashed against the ` +
6337
6373
  return panel;
6338
6374
  }
6339
6375
 
6376
+ /**
6377
+ * The entries an address field holds, however the config wrote them.
6378
+ *
6379
+ * A list may be stored as an array or as one comma-separated string --
6380
+ * Express accepts both, and the documented example is a string. Shown
6381
+ * one per line either way, so that what a save reads back is the list
6382
+ * that was rendered rather than a different shape of the same thing.
6383
+ * @param {null|boolean|number|string|string[]} value - What the config holds.
6384
+ * @returns {string} - The textarea's contents.
6385
+ */
6386
+ const addressText = (value) => {
6387
+ if (value === undefined || value === null) return '';
6388
+ const entries = Array.isArray(value) ? value : [String(value)];
6389
+ return entries
6390
+ .flatMap((entry) => String(entry).split(/[\n,]/))
6391
+ .map((entry) => entry.trim())
6392
+ .filter(Boolean)
6393
+ .join('\n');
6394
+ };
6395
+
6396
+ /**
6397
+ * What an address field means by what it holds.
6398
+ *
6399
+ * Express takes four things here and means something different by each:
6400
+ * `true` trusts any caller, a number is a hop count, and a string or an
6401
+ * array of them names the proxies. What was typed decides which.
6402
+ *
6403
+ * Commas separate as well as newlines. Express splits a comma list only
6404
+ * when it is handed a bare string and never inside an array, so one line
6405
+ * reading `10.0.0.1, 10.0.0.2` was saved as a one-element array holding
6406
+ * both -- which is not an address, and which Express rejects while the
6407
+ * app is being built, before the listener binds. The node then would not
6408
+ * start, and could not be corrected from the console that wrote it.
6409
+ * @param {string} text - What the box holds.
6410
+ * @returns {null|boolean|number|string[]} - What to save.
6411
+ */
6412
+ const parseAddresses = (text) => {
6413
+ const lines = String(text)
6414
+ .split(/[\n,]/)
6415
+ .map((line) => line.trim())
6416
+ .filter(Boolean);
6417
+ if (!lines.length) return null;
6418
+ if (lines.length === 1) {
6419
+ if (lines[0] === 'true') return true;
6420
+ if (lines[0] === 'false') return false;
6421
+ if (!Number.isNaN(Number(lines[0]))) return Number(lines[0]);
6422
+ }
6423
+ return lines;
6424
+ };
6425
+
6340
6426
  /**
6341
6427
  * Collects every schema control into an update, grouped by top-level key.
6342
6428
  *
@@ -6368,22 +6454,8 @@ Every piece is hashed against the ` +
6368
6454
  continue;
6369
6455
  }
6370
6456
  if (type === 'boolean') value = element.checked;
6371
- else if (type === 'addresses') {
6372
- // Express takes four things here and means something different by
6373
- // each: `true` trusts any caller, a number is a hop count, and a
6374
- // string or an array of them names the proxies. One per line, and
6375
- // what was typed decides which of the four it is.
6376
- const lines = String(element.value)
6377
- .split('\n')
6378
- .map((line) => line.trim())
6379
- .filter(Boolean);
6380
- if (!lines.length) value = null;
6381
- else if (lines.length === 1 && (lines[0] === 'true' || lines[0] === 'false')) {
6382
- value = lines[0] === 'true';
6383
- } else if (lines.length === 1 && !Number.isNaN(Number(lines[0]))) {
6384
- value = Number(lines[0]);
6385
- } else value = lines;
6386
- } else if (type === 'list') {
6457
+ else if (type === 'addresses') value = parseAddresses(element.value);
6458
+ else if (type === 'list') {
6387
6459
  // One per line, which is how a person reads a list of trackers —
6388
6460
  // and empty means an empty list rather than "unset", because a
6389
6461
  // node with no trackers is a real thing to want and JSON would