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 +84 -1
- package/docs/configuration.md +44 -13
- package/docs/internals.md +35 -0
- package/docs/running-as-a-service.md +44 -0
- package/package.json +1 -1
- package/src/api.js +26 -2
- package/src/config.js +114 -5
- package/src/engines/libtorrent.js +132 -1
- package/src/index.js +40 -5
- package/src/init-command.js +64 -2
- package/src/systemd.js +34 -4
- package/src/tiles.js +32 -7
- package/src/web/index.html +98 -26
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,94 @@
|
|
|
2
2
|
|
|
3
3
|
## master
|
|
4
4
|
### ✨ Features and improvements
|
|
5
|
-
-
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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"`.
|
|
86
|
-
|
|
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` | `
|
|
721
|
-
| `tiles.
|
|
722
|
-
| `tiles.
|
|
723
|
-
| `tiles.
|
|
724
|
-
| `tiles.
|
|
725
|
-
| `tiles.
|
|
726
|
-
| `tiles.
|
|
727
|
-
| `tiles.
|
|
728
|
-
| `tiles.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
294
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
package/src/init-command.js
CHANGED
|
@@ -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.
|
|
132
|
-
#
|
|
133
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
/**
|
package/src/web/index.html
CHANGED
|
@@ -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
|
-
'
|
|
5285
|
-
'
|
|
5286
|
-
'
|
|
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: '
|
|
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
|
-
'
|
|
5583
|
-
'
|
|
5584
|
-
'
|
|
5585
|
-
'
|
|
5586
|
-
'
|
|
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="""" value="" placeholder="${escapeHtml(field.placeholder ?? 'unchanged')}" />`
|
|
6309
6338
|
: field.type === 'addresses'
|
|
6310
|
-
?
|
|
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
|
-
|
|
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
|