pmtiles-swarm 0.58.1 → 0.59.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 +69 -0
- package/docs/running-as-a-service.md +167 -2
- package/package.json +2 -2
- package/src/config.js +42 -2
- package/src/engines/composite.js +7 -5
- package/src/engines/libtorrent.js +19 -4
- package/src/index.js +53 -3
- package/src/init-command.js +231 -0
- package/src/library.js +67 -6
- package/src/shutdown.js +53 -5
- package/src/systemd.js +158 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,75 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.59.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **Requires pmtiles-torrent 0.10.2**, which is what actually ends the re-checking: a
|
|
13
|
+
`seedOnly` add now discards resume data that would cancel the claim, and the periodic save
|
|
14
|
+
leaves a hashing torrent alone. Everything below only stops the node making more of it.
|
|
15
|
+
- **`pmtiles-swarm init` writes a first configuration, and optionally the unit to run it.**
|
|
16
|
+
Every path it writes is absolute, which is the one mistake it exists to make impossible: a
|
|
17
|
+
relative path resolves against the config file, the documented layout puts that file in
|
|
18
|
+
`/etc`, and so `./data` there means a catalog, a resume directory and potentially a 700 GiB
|
|
19
|
+
archive on the configuration partition. State under `/etc` is refused rather than warned
|
|
20
|
+
about — at the moment a config is written there is nothing to migrate, and the same mistake
|
|
21
|
+
found later costs a stopped service and a careful move.
|
|
22
|
+
|
|
23
|
+
`--systemd` also writes `pmtiles-swarm.service` beside it, with `ReadWritePaths` **derived
|
|
24
|
+
from the configuration it just wrote**. That derivation is the point. A unit and a config
|
|
25
|
+
that disagree is every systemd failure this project has diagnosed, and none of them look
|
|
26
|
+
like what they are: a `savePath` missing from that line is refused inside the unit's
|
|
27
|
+
namespace before any permission bit is read, so the directory's owner and mode are both
|
|
28
|
+
perfect and the write still fails. Two files generated from one source cannot drift.
|
|
29
|
+
|
|
30
|
+
It installs nothing. The unit is written next to the configuration and the `cp` into
|
|
31
|
+
`/etc/systemd/system` is one of the commands it prints, alongside an `install -d` for every
|
|
32
|
+
directory involved with the right owner and mode.
|
|
33
|
+
|
|
34
|
+
`--password` is hashed before it is written, and left out entirely when none is given.
|
|
35
|
+
There is deliberately no placeholder: `auth.password` accepts plaintext, so `REPLACE-ME` in
|
|
36
|
+
that field is a working password until somebody notices — a credential that looks set and
|
|
37
|
+
is not.
|
|
38
|
+
|
|
39
|
+
- **Two scripts for diagnosing a library that re-checks on every start**, in `tools/`.
|
|
40
|
+
`resume-doctor.py` reads a node's real configuration, catalog, stored `.torrent` files and
|
|
41
|
+
resume directory and says, for each archive, what libtorrent will do on the next start and
|
|
42
|
+
why — applying libtorrent's own rules and citing the file and line each came from. It also
|
|
43
|
+
reads the unit through `systemctl show`, does the arithmetic on every deadline that can cut
|
|
44
|
+
a resume save short, and hashes pieces rather than trusting the catalog. `resume-experiment.py`
|
|
45
|
+
proves the five behaviours involved on whatever libtorrent is actually installed, because
|
|
46
|
+
1.2, 2.0 and 2.1 differ enough that a claim verified on one is not a claim about the other.
|
|
47
|
+
|
|
48
|
+
### 🐞 Bug fixes
|
|
49
|
+
- **A stop no longer abandons the sidecar mid-write, which is where resume data was going.**
|
|
50
|
+
Three separate bounds decided how long the engine step had, and the smallest won: eight
|
|
51
|
+
seconds for the step, fifteen for the whole shutdown, fifteen for the shutdown RPC. The
|
|
52
|
+
sidecar allows each torrent two seconds of its resume-save budget, so past four archives the
|
|
53
|
+
node gave up first and every torrent it had not persisted re-hashed its whole store on the
|
|
54
|
+
way back up. That is the state a library gets stuck in: checking, on every start, for hours.
|
|
55
|
+
|
|
56
|
+
The engine step is now worked out from the catalog — two seconds a torrent, over a floor —
|
|
57
|
+
and the shutdown watchdog is derived from the steps it is meant to contain rather than being
|
|
58
|
+
a second deadline kept in agreement with them by hand. It was not in agreement.
|
|
59
|
+
|
|
60
|
+
The same fixed 60s applied to the periodic save, so past thirty archives the call gave up
|
|
61
|
+
before the sidecar finished, and the written/asked counts never came back — which is why the
|
|
62
|
+
shortfall this reports could not be seen from outside. **`TimeoutStopSec` in the documented
|
|
63
|
+
unit rises from 45 to 300 seconds**, and existing installs need it raised by hand.
|
|
64
|
+
|
|
65
|
+
- **A `.torrent` that moved with `dataDir` is found again instead of silently becoming a
|
|
66
|
+
magnet.** `torrentPath` is recorded absolute, so moving state out of `/etc` — which
|
|
67
|
+
`docs/running-as-a-service.md` tells you to do — left every catalog entry naming a directory
|
|
68
|
+
that no longer existed. Nothing repointed them and nothing complained, because an unreadable
|
|
69
|
+
`.torrent` was treated as "use the magnet instead".
|
|
70
|
+
|
|
71
|
+
That fallback is the damage rather than a graceful degradation. A magnet carries no metadata
|
|
72
|
+
and neither does resume data, so the archive waits on BEP 9 for a file list that only a peer
|
|
73
|
+
can supply — and for an archive this node originated there is nobody to ask. Seen in the
|
|
74
|
+
field: twenty archives at 0% in `downloading_metadata`, indefinitely, after one documented
|
|
75
|
+
migration. The current `dataDir` is now tried second, the recorded path is corrected in
|
|
76
|
+
place so the warning is printed once rather than for ever, and falling back to a magnet at
|
|
77
|
+
all now says so.
|
|
78
|
+
|
|
10
79
|
## 0.58.1
|
|
11
80
|
### 🐞 Bug fixes
|
|
12
81
|
- **The sample configuration put every piece of state under the config file.** `"dataDir": "./data"`,
|
|
@@ -151,8 +151,50 @@ sudo apt-get install -y python3-libtorrent
|
|
|
151
151
|
sudo -u pmtiles-swarm python3 -c "import libtorrent; print(libtorrent.__version__)"
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
+
## The configuration, and a unit for it
|
|
155
|
+
|
|
156
|
+
`init` writes both, and the unit's `ReadWritePaths` is derived from the
|
|
157
|
+
configuration it just wrote. That derivation is the reason to use it: a unit and
|
|
158
|
+
a configuration that disagree is the failure this whole document is arranged
|
|
159
|
+
around, and two files generated from one source cannot.
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
sudo -u pmtiles-swarm -H /var/lib/pmtiles-swarm/node_modules/.bin/pmtiles-swarm init --config /etc/pmtiles-swarm/swarm.config.json --data-dir /var/lib/pmtiles-swarm/data --save-path /mnt/store/torrent-data --systemd --password 'the console password'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
That writes the configuration, generates `pmtiles-swarm.service` beside it, and
|
|
166
|
+
prints a command for every directory involved — each with the right owner and
|
|
167
|
+
mode. Nothing privileged happens and nothing is installed: the unit is written
|
|
168
|
+
next to the configuration, and copying it into `/etc/systemd/system` is one of
|
|
169
|
+
the lines it hands you.
|
|
170
|
+
|
|
171
|
+
Two refusals worth knowing before you type it. State under `/etc` is rejected
|
|
172
|
+
rather than warned about, because at the moment a configuration is written there
|
|
173
|
+
is nothing to migrate — the same mistake found six months later costs a stopped
|
|
174
|
+
service and a careful move. And a configuration that already exists is left
|
|
175
|
+
alone unless `--force` says otherwise, since the credentials in it are not
|
|
176
|
+
recoverable.
|
|
177
|
+
|
|
178
|
+
`--password` is hashed before it is written. Leave it off and there is no
|
|
179
|
+
console password at all: the generated API key is the way in until you set one.
|
|
180
|
+
There is deliberately no placeholder, because `auth.password` accepts plaintext
|
|
181
|
+
and a placeholder in that field is a working password until somebody notices.
|
|
182
|
+
|
|
183
|
+
**Re-run it after adding a watched folder**, or a subscription with a save path
|
|
184
|
+
of its own. Both are directories the node writes to, and neither reaches
|
|
185
|
+
`ReadWritePaths` by itself. `tools/resume-doctor.py` checks a running node's
|
|
186
|
+
unit against its live configuration and says which directories are missing.
|
|
187
|
+
|
|
188
|
+
The unit below is what it generates, with your paths in it. Worth reading
|
|
189
|
+
either way — a generated file you do not understand is a hand-written one you
|
|
190
|
+
have not written yet.
|
|
191
|
+
|
|
154
192
|
## The unit
|
|
155
193
|
|
|
194
|
+
Annotated, because every directive here has cost somebody a diagnosis.
|
|
195
|
+
`init --systemd` writes this file with your paths already in it; what
|
|
196
|
+
follows is what those lines are for.
|
|
197
|
+
|
|
156
198
|
```ini
|
|
157
199
|
[Unit]
|
|
158
200
|
Description=pmtiles-swarm
|
|
@@ -177,8 +219,12 @@ Restart=always
|
|
|
177
219
|
RestartSec=5
|
|
178
220
|
|
|
179
221
|
# Stopping announces "stopped" to every tracker, releases the data directory
|
|
180
|
-
# lock
|
|
181
|
-
|
|
222
|
+
# lock, cancels downloads in flight and writes resume data for every archive.
|
|
223
|
+
# That last part is what scales: the node allows its engine two seconds per
|
|
224
|
+
# torrent, so a library of fifty wants well over a minute and systemd has to
|
|
225
|
+
# outlast it. Too low here and the process is killed mid-save, which costs a
|
|
226
|
+
# re-hash of everything unwritten on the way back up.
|
|
227
|
+
TimeoutStopSec=300
|
|
182
228
|
|
|
183
229
|
# The node stops the sidecar itself, and needs it alive to do so. The default
|
|
184
230
|
# signals both at once, which kills the sidecar before it can write its resume
|
|
@@ -233,6 +279,17 @@ itself, in order, and waits for the resume data to be written. Nothing is left
|
|
|
233
279
|
running: anything still alive when `TimeoutStopSec` expires is killed, sidecar
|
|
234
280
|
included.
|
|
235
281
|
|
|
282
|
+
**`TimeoutStopSec`, and why it is not a round number you pick once.** Stopping
|
|
283
|
+
writes resume data for every archive, and the node allows its engine two seconds
|
|
284
|
+
per torrent to do it — so the figure that matters grows with the library. Three
|
|
285
|
+
hundred seconds covers a hundred and forty archives; a larger node needs more.
|
|
286
|
+
Set it too low and systemd kills the process mid-save, and every archive that
|
|
287
|
+
had not been written re-hashes its whole store on the way back up, which for a
|
|
288
|
+
700 GiB archive is hours.
|
|
289
|
+
|
|
290
|
+
`tools/resume-doctor.py` prints the arithmetic for your library and says whether
|
|
291
|
+
the unit's value covers it.
|
|
292
|
+
|
|
236
293
|
**No `ExecStop=`.** systemd already sends `SIGTERM`, and the node handles it
|
|
237
294
|
from the moment it starts. `ExecStop=/bin/kill -15 $MAINPID` is redundant, and
|
|
238
295
|
becomes wrong under `KillMode=process` — that one leaves the rest of the unit
|
|
@@ -779,3 +836,111 @@ once rather than diagnosing later:
|
|
|
779
836
|
`journalctl -u pmtiles-swarm | grep -i onComplete` — and a hook redirecting its
|
|
780
837
|
own output to a file will have nothing for the journal to show, which is not
|
|
781
838
|
the same as not having run.
|
|
839
|
+
|
|
840
|
+
## When archives re-check on every start
|
|
841
|
+
|
|
842
|
+
The symptom is always the same shape — a restart, and then a library sitting at
|
|
843
|
+
0% or grinding through a re-hash of archives that are whole on the disk. The
|
|
844
|
+
causes are not the same shape at all, so guessing between them is expensive:
|
|
845
|
+
re-hashing a 700 GiB archive is half an hour during which it serves nobody.
|
|
846
|
+
|
|
847
|
+
Two scripts in `tools/` answer it without guessing. Neither starts anything,
|
|
848
|
+
neither writes anything, and both are safe against a running node.
|
|
849
|
+
|
|
850
|
+
```sh
|
|
851
|
+
sudo -u pmtiles-swarm python3 tools/resume-doctor.py \
|
|
852
|
+
-c /etc/pmtiles-swarm/swarm.config.json
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
**`resume-doctor.py`** reads your configuration, catalog, stored `.torrent`
|
|
856
|
+
files and resume directory, and says for each archive what libtorrent will do on
|
|
857
|
+
the next start and why — seed at once, stat its files, or re-hash the store. It
|
|
858
|
+
applies libtorrent's own rules rather than a guess at them, and cites the file
|
|
859
|
+
and line each one came from. It also reads the unit through `systemctl show`,
|
|
860
|
+
does the arithmetic on every deadline that can cut a resume save short, and
|
|
861
|
+
scans the journal for the failures that leave a trace.
|
|
862
|
+
|
|
863
|
+
```sh
|
|
864
|
+
sudo -u pmtiles-swarm python3 tools/resume-experiment.py
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
**`resume-experiment.py`** proves the behaviour on the libtorrent you actually
|
|
868
|
+
have, rather than the one the documentation was written against. It builds a
|
|
869
|
+
real torrent over a temporary file, saves real resume data, adds it again, and
|
|
870
|
+
reports what happened. 2.0 and 2.1 differ enough to be worth the thirty seconds.
|
|
871
|
+
|
|
872
|
+
Three things they exist to tell apart, because the first two look identical from
|
|
873
|
+
outside and want opposite fixes:
|
|
874
|
+
|
|
875
|
+
- **No resume data is not the problem.** An archive recorded complete is added
|
|
876
|
+
with `seed_mode`, and that claim stands on its own: libtorrent stats the files
|
|
877
|
+
and seeds, without hashing a byte. A missing resume file costs nothing.
|
|
878
|
+
- **Stale resume data is the problem, and it is worse than none.** A resume file
|
|
879
|
+
holding a _partial_ bitfield cancels the `seed_mode` claim — libtorrent drops
|
|
880
|
+
it if a single piece is unset. The archive then comes back as a downloader
|
|
881
|
+
with every byte already on disk, and fetches again what it has. Deleting that
|
|
882
|
+
one file is the repair; with none, it seeds at once.
|
|
883
|
+
- **A short or missing file is a genuine re-check.** `mismatching_file_size` is
|
|
884
|
+
the only thing here that really does mean re-hashing, and it is about the data
|
|
885
|
+
on disk rather than about resume data at all. libtorrent 2.x records no
|
|
886
|
+
mtimes, so nothing in this depends on a timestamp.
|
|
887
|
+
|
|
888
|
+
Which archives carry stale resume data is not random, and the pattern is worth
|
|
889
|
+
knowing before it looks like a second bug. An archive from a watched folder was
|
|
890
|
+
read end to end before it was ever registered: it is complete from its first
|
|
891
|
+
moment and no partial bitfield is ever written for it. An archive joined from a
|
|
892
|
+
feed, a magnet or a peer is genuinely partial for hours, with partial resume
|
|
893
|
+
data written for it every `resumeSaveIntervalSeconds` throughout. Only the
|
|
894
|
+
second kind has anything to leave behind, so trouble concentrating there is this
|
|
895
|
+
failure's ordinary shape rather than something the import path introduced.
|
|
896
|
+
|
|
897
|
+
Two more things the doctor looks at, because they are the ones a snapshot alone
|
|
898
|
+
gets wrong.
|
|
899
|
+
|
|
900
|
+
**Whether the catalog still points at its own `.torrent` files.** Each entry
|
|
901
|
+
records `torrentPath` as an absolute path, and restore reads that rather than
|
|
902
|
+
recomputing it — so moving `dataDir` out of `/etc`, which this guide tells you
|
|
903
|
+
to do, leaves every recorded path behind. Nothing warns, because a missing
|
|
904
|
+
`.torrent` is not treated as an error: restore quietly falls back to the magnet.
|
|
905
|
+
That fallback is the damage. A magnet carries no metadata, resume data does not
|
|
906
|
+
carry it either, and so the archive waits on BEP 9 for a file list that only a
|
|
907
|
+
peer can supply — from a swarm where this node is the origin. It sits at 0% in
|
|
908
|
+
`downloading_metadata` indefinitely, which no amount of re-checking will fix.
|
|
909
|
+
|
|
910
|
+
**Whether the periodic save gets through the whole library.** Every torrent is
|
|
911
|
+
asked to save in the same cycle, so a healthy `resumeDir` has all its files
|
|
912
|
+
within one interval of each other. Ages spread wider than that mean some cycles
|
|
913
|
+
are finishing early, and the ages alone cannot say whether the same archives
|
|
914
|
+
lose every time. `--watch` measures it rather than inferring it:
|
|
915
|
+
|
|
916
|
+
```sh
|
|
917
|
+
sudo -u pmtiles-swarm python3 tools/resume-doctor.py \
|
|
918
|
+
-c /etc/pmtiles-swarm/swarm.config.json --watch 660
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
It reports each save cycle as it happens, how many of them covered every
|
|
922
|
+
archive, and which archives were never written at all.
|
|
923
|
+
|
|
924
|
+
**A poisoned archive may heal itself, so run this twice.** The commonest way a
|
|
925
|
+
whole archive acquires a partial bitfield is a save taken while it was
|
|
926
|
+
re-checking: `write_resume_data` truncates `have_pieces` to the pieces checked
|
|
927
|
+
so far, and the five-minute timer lands inside that window every time a large
|
|
928
|
+
archive checks. That shortened bitfield is what cancels `seed_mode` on the next
|
|
929
|
+
start — so the archive comes back partial, checks again, and writes another one.
|
|
930
|
+
The loop needs no restart to keep itself going.
|
|
931
|
+
|
|
932
|
+
The doctor tells the two apart by length: a bitfield shorter than the torrent
|
|
933
|
+
was written mid-check, and one at full length is what a finished check
|
|
934
|
+
concluded. A check that completes rewrites its own resume file correctly, so an
|
|
935
|
+
archive poisoned in one run can read `seeds at once` in the next with nothing
|
|
936
|
+
done to it. **Only the archives that stay poisoned across two runs, several
|
|
937
|
+
minutes apart, are worth deleting a file for.**
|
|
938
|
+
|
|
939
|
+
Two things it deliberately does not treat as faults. An archive whose data sits
|
|
940
|
+
outside the `savePathLayout` shape is normal — one adopted from a file this node
|
|
941
|
+
already holds keeps that file, and a node may keep archives beside whatever
|
|
942
|
+
produced them. What matters is whether the data is where the entry says, which
|
|
943
|
+
is checked directly. And every directory an archive actually lives in has to
|
|
944
|
+
appear in `ReadWritePaths`, not just the `savePath` in the configuration; the
|
|
945
|
+
doctor checks each one in use, because a deliberately-placed archive is exactly
|
|
946
|
+
the case that gets left out of the unit.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"maplibre-gl": "^6.2.0",
|
|
47
47
|
"parse-torrent": "^11.0.24",
|
|
48
48
|
"pmtiles": "^4.4.1",
|
|
49
|
-
"pmtiles-torrent": "^0.
|
|
49
|
+
"pmtiles-torrent": "^0.10.2",
|
|
50
50
|
"webtorrent": "^3.0.21"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
package/src/config.js
CHANGED
|
@@ -507,12 +507,52 @@ function clone(value) {
|
|
|
507
507
|
return value;
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Every directory this configuration means to write to.
|
|
512
|
+
*
|
|
513
|
+
* Which is exactly what `ReadWritePaths` has to name. Under
|
|
514
|
+
* `ProtectSystem=strict` a directory missing from that line is refused inside
|
|
515
|
+
* the unit's namespace, before any permission bit is consulted — so it fails
|
|
516
|
+
* with ownership and mode both perfect, which is a hard thing to go looking
|
|
517
|
+
* for. Deriving the list from the config rather than writing it out by hand is
|
|
518
|
+
* the only way the two cannot drift.
|
|
519
|
+
*
|
|
520
|
+
* See docs/running-as-a-service.md — "Where it writes".
|
|
521
|
+
* @param {object} config - A config whose paths have been resolved.
|
|
522
|
+
* @param {string} [configPath] - The config file, whose directory is written to.
|
|
523
|
+
* @returns {string[]} - Absolute directories, deduplicated and shortest-first.
|
|
524
|
+
*/
|
|
525
|
+
export function writablePaths(config, configPath) {
|
|
526
|
+
const found = [
|
|
527
|
+
config?.dataDir,
|
|
528
|
+
config?.savePath,
|
|
529
|
+
config?.cacheSavePath,
|
|
530
|
+
config?.libtorrent?.resumeDir,
|
|
531
|
+
config?.torrentDropDir,
|
|
532
|
+
// The console rewrites the configuration when a token is minted, so the
|
|
533
|
+
// directory holding it is written to as surely as any of the above.
|
|
534
|
+
configPath ? path.dirname(path.resolve(configPath)) : undefined,
|
|
535
|
+
...(config?.watch ?? []).map((entry) => entry?.path),
|
|
536
|
+
...(config?.locations ?? []).map((entry) => entry?.path),
|
|
537
|
+
...(config?.subscriptions ?? []).map((entry) => entry?.savePath),
|
|
538
|
+
].filter((value) => typeof value === 'string' && value);
|
|
539
|
+
|
|
540
|
+
// Deduplicated but deliberately not collapsed into common ancestors. A
|
|
541
|
+
// shorter list would grant the same access — `ReadWritePaths` covers a
|
|
542
|
+
// directory and everything under it — but the two callers want different
|
|
543
|
+
// things from it, and only one of them would be served. Creating the
|
|
544
|
+
// directories needs each of them named; and collapsing quietly widens the
|
|
545
|
+
// grant to whatever ancestor happens to be shared, which for a config beside
|
|
546
|
+
// its data is the whole tree above both.
|
|
547
|
+
return [...new Set(found.map((value) => path.resolve(value)))].sort();
|
|
548
|
+
}
|
|
549
|
+
|
|
510
550
|
/**
|
|
511
551
|
* Complains about state that has landed in /etc.
|
|
512
552
|
*
|
|
513
553
|
* Nobody chooses this. The documented service layout puts the config file in
|
|
514
|
-
* /etc, every path resolves relative to that file, and the sample
|
|
515
|
-
* "./data" — so the catalog and the resume directory
|
|
554
|
+
* /etc, every path resolves relative to that file, and the sample read
|
|
555
|
+
* "./data" — so the catalog and the resume directory ended up on the partition
|
|
516
556
|
* meant for configuration. Warned rather than corrected: it is a real path
|
|
517
557
|
* that works, and moving a running node's data would be worse than saying so.
|
|
518
558
|
*
|
package/src/engines/composite.js
CHANGED
|
@@ -433,7 +433,7 @@ export class CompositeEngine {
|
|
|
433
433
|
* @returns {Promise<{written: number, asked: number}>} - Totals across every
|
|
434
434
|
* engine that keeps resume data.
|
|
435
435
|
*/
|
|
436
|
-
async saveResume(infoHash) {
|
|
436
|
+
async saveResume(infoHash, options = {}) {
|
|
437
437
|
// Summed rather than dropped, so a caller can tell the difference between
|
|
438
438
|
// "every torrent wrote" and "half of them will be re-hashed on the next
|
|
439
439
|
// start". An engine that fails outright contributes nothing to either
|
|
@@ -444,7 +444,7 @@ export class CompositeEngine {
|
|
|
444
444
|
// WebTorrent keeps none, and says so by not offering the method.
|
|
445
445
|
if (!engine.saveResume) continue;
|
|
446
446
|
try {
|
|
447
|
-
const result = (await engine.saveResume(infoHash)) ?? {};
|
|
447
|
+
const result = (await engine.saveResume(infoHash, options)) ?? {};
|
|
448
448
|
written += Number(result.written) || 0;
|
|
449
449
|
asked += Number(result.asked) || 0;
|
|
450
450
|
} catch (error) {
|
|
@@ -653,19 +653,21 @@ export class CompositeEngine {
|
|
|
653
653
|
* Shuts every engine down.
|
|
654
654
|
* @returns {Promise<void>} - Resolves once all are stopped.
|
|
655
655
|
*/
|
|
656
|
-
async destroy() {
|
|
656
|
+
async destroy(options = {}) {
|
|
657
657
|
this.#stopping = true;
|
|
658
658
|
if (this.#timer) clearInterval(this.#timer);
|
|
659
659
|
this.#timer = undefined;
|
|
660
660
|
|
|
661
661
|
for (const engine of this.#secondaries) {
|
|
662
662
|
await engine
|
|
663
|
-
.destroy()
|
|
663
|
+
.destroy(options)
|
|
664
664
|
.catch((error) =>
|
|
665
665
|
console.error(`[engine] ${engine.name}: ${error.message}`),
|
|
666
666
|
);
|
|
667
667
|
}
|
|
668
|
-
|
|
668
|
+
// Forwarded, because the primary is the one holding resume data and the
|
|
669
|
+
// budget was worked out from how much of it there is to write.
|
|
670
|
+
await this.#primary.destroy(options);
|
|
669
671
|
}
|
|
670
672
|
}
|
|
671
673
|
|
|
@@ -763,25 +763,40 @@ export class LibtorrentEngine {
|
|
|
763
763
|
* were told to write resume data, and how many actually did before the
|
|
764
764
|
* deadline.
|
|
765
765
|
*/
|
|
766
|
-
async saveResume(infoHash) {
|
|
766
|
+
async saveResume(infoHash, options = {}) {
|
|
767
767
|
// Returned rather than discarded. The sidecar reports both numbers, and
|
|
768
768
|
// the gap between them is the thing worth knowing: a torrent that did not
|
|
769
769
|
// write is one that gets re-hashed on the next start, which for a 700 GiB
|
|
770
770
|
// archive is the difference between seeding in seconds and seeding in half
|
|
771
771
|
// an hour. That answer was being thrown away here.
|
|
772
|
-
|
|
772
|
+
//
|
|
773
|
+
// The default #call timeout is 60s and the sidecar's own budget is two
|
|
774
|
+
// seconds per torrent, so past thirty archives the call gave up first —
|
|
775
|
+
// and then the counts never came back, which is why the shortfall this
|
|
776
|
+
// reports could not be seen from outside.
|
|
777
|
+
return this.#call('save_resume', { infoHash }, options.timeoutMs);
|
|
773
778
|
}
|
|
774
779
|
|
|
775
780
|
/**
|
|
776
781
|
* Saves resume data and stops the sidecar.
|
|
782
|
+
*
|
|
783
|
+
* The timeout is the caller's to set, because only the caller knows how many
|
|
784
|
+
* torrents are about to be written down and the sidecar spends two seconds
|
|
785
|
+
* per torrent. Fifteen seconds was the fixed value, which meant every
|
|
786
|
+
* library past seven archives had its resume save cut off — and each torrent
|
|
787
|
+
* that missed was re-hashed in full on the way back up.
|
|
788
|
+
* @param {object} [options] - Overrides.
|
|
789
|
+
* @param {number} [options.timeoutMs] - How long the sidecar gets to finish.
|
|
777
790
|
* @returns {Promise<void>} - Resolves once stopped.
|
|
778
791
|
*/
|
|
779
|
-
async destroy() {
|
|
792
|
+
async destroy(options = {}) {
|
|
780
793
|
// Set before anything else, so the exit this is about to cause is
|
|
781
794
|
// recognised as intended by the handler that sees it.
|
|
782
795
|
this.#stopping = true;
|
|
783
796
|
if (!this.#child) return;
|
|
784
|
-
await this.#call('shutdown', {}, 15000).catch(
|
|
797
|
+
await this.#call('shutdown', {}, options.timeoutMs ?? 15000).catch(
|
|
798
|
+
() => {},
|
|
799
|
+
);
|
|
785
800
|
this.#child?.kill();
|
|
786
801
|
this.#child = null;
|
|
787
802
|
this.#ready = null;
|
package/src/index.js
CHANGED
|
@@ -16,7 +16,12 @@ import { assertPortsFree, claimDataDir } from './lock.js';
|
|
|
16
16
|
import { ProgramHooks } from './hooks.js';
|
|
17
17
|
import { SpeedLimits } from './rate-limits.js';
|
|
18
18
|
import { SeedingLimits } from './seeding.js';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
closeServer,
|
|
21
|
+
engineStopMs,
|
|
22
|
+
installSignalHandlers,
|
|
23
|
+
runStoppers,
|
|
24
|
+
} from './shutdown.js';
|
|
20
25
|
import { ScheduledSourceManager } from './sources.js';
|
|
21
26
|
import { SubscriptionManager } from './subscriptions.js';
|
|
22
27
|
import { TileStats } from './tile-stats.js';
|
|
@@ -100,6 +105,12 @@ async function main() {
|
|
|
100
105
|
port: { type: 'string', short: 'p' },
|
|
101
106
|
help: { type: 'boolean', short: 'h' },
|
|
102
107
|
json: { type: 'boolean' },
|
|
108
|
+
'data-dir': { type: 'string' },
|
|
109
|
+
'save-path': { type: 'string' },
|
|
110
|
+
force: { type: 'boolean' },
|
|
111
|
+
systemd: { type: 'boolean' },
|
|
112
|
+
user: { type: 'string' },
|
|
113
|
+
password: { type: 'string' },
|
|
103
114
|
},
|
|
104
115
|
allowPositionals: true,
|
|
105
116
|
});
|
|
@@ -109,10 +120,17 @@ async function main() {
|
|
|
109
120
|
|
|
110
121
|
Usage:
|
|
111
122
|
pmtiles-swarm [--config FILE] start the node
|
|
123
|
+
pmtiles-swarm init [--config FILE] write a first configuration
|
|
112
124
|
pmtiles-swarm status [--config FILE] ask a running node what it is doing
|
|
113
125
|
pmtiles-swarm publisher-key print a new BEP 46 signing key
|
|
114
126
|
|
|
115
127
|
--config, -c path to a JSON config file
|
|
128
|
+
--data-dir where state goes, for init. Absolute, and never under /etc
|
|
129
|
+
--save-path where archive data goes, for init
|
|
130
|
+
--systemd also write a unit file, for init
|
|
131
|
+
--user service account the unit runs as. Default pmtiles-swarm
|
|
132
|
+
--password console password, stored hashed. For init
|
|
133
|
+
--force let init replace a config that already exists
|
|
116
134
|
--port, -p override the listen port
|
|
117
135
|
--json machine-readable output, for the status command
|
|
118
136
|
--help, -h this message
|
|
@@ -124,6 +142,21 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
124
142
|
return;
|
|
125
143
|
}
|
|
126
144
|
|
|
145
|
+
// Ahead of loadConfig, which is the point: there is no config to load yet.
|
|
146
|
+
if (positionals[0] === 'init') {
|
|
147
|
+
const { runInit } = await import('./init-command.js');
|
|
148
|
+
process.exitCode = await runInit({
|
|
149
|
+
config: values.config,
|
|
150
|
+
dataDir: values['data-dir'],
|
|
151
|
+
savePath: values['save-path'],
|
|
152
|
+
systemd: values.systemd,
|
|
153
|
+
user: values.user,
|
|
154
|
+
password: values.password,
|
|
155
|
+
force: values.force,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
127
160
|
const config = await loadConfig(values.config);
|
|
128
161
|
if (values.port) config.port = Number(values.port);
|
|
129
162
|
|
|
@@ -211,7 +244,19 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
211
244
|
const engine = createEngine(config);
|
|
212
245
|
// The slowest, because it announces "stopped" to every tracker, and an
|
|
213
246
|
// unreachable one costs a timeout each. Registered first so it stops last.
|
|
214
|
-
|
|
247
|
+
//
|
|
248
|
+
// Scaled to the library, because what dominates this step is writing resume
|
|
249
|
+
// data and the sidecar gives each torrent two seconds of that budget. A flat
|
|
250
|
+
// eight seconds was under it for any node past four archives, so the node
|
|
251
|
+
// abandoned the sidecar mid-write on every stop and re-hashed on the way
|
|
252
|
+
// back up whatever had not been persisted — which is how a library ends up
|
|
253
|
+
// permanently checking rather than seeding.
|
|
254
|
+
const resumeStopMs = engineStopMs(catalog.list().length);
|
|
255
|
+
stoppers.unshift({
|
|
256
|
+
label: 'engine',
|
|
257
|
+
stop: () => engine.destroy({ timeoutMs: resumeStopMs }),
|
|
258
|
+
ms: resumeStopMs + 2000,
|
|
259
|
+
});
|
|
215
260
|
try {
|
|
216
261
|
await engine.connect();
|
|
217
262
|
console.log(`[engine] ${engine.name} ready`);
|
|
@@ -528,7 +573,12 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
528
573
|
if (resumeSeconds > 0 && engine.saveResume) {
|
|
529
574
|
resumeTimer = setInterval(() => {
|
|
530
575
|
engine
|
|
531
|
-
|
|
576
|
+
// Recomputed each tick rather than captured, because the library grows:
|
|
577
|
+
// a node that started with four archives and watched forty in would
|
|
578
|
+
// otherwise still be allowing the budget for four.
|
|
579
|
+
.saveResume(undefined, {
|
|
580
|
+
timeoutMs: engineStopMs(catalog.list().length),
|
|
581
|
+
})
|
|
532
582
|
.then((result) => {
|
|
533
583
|
// Said out loud, because the shortfall is the thing that costs.
|
|
534
584
|
// A torrent that did not write inside the deadline is one that gets
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { hashPassword } from './auth.js';
|
|
6
|
+
import { writablePaths } from './config.js';
|
|
7
|
+
import { directoryCommands, unitFor } from './systemd.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where state goes when nobody said.
|
|
11
|
+
*
|
|
12
|
+
* Never beside the config file, which is the whole point of this command: a
|
|
13
|
+
* path relative to a config in /etc puts a catalog and an archive on the
|
|
14
|
+
* partition meant for configuration. See docs/running-as-a-service.md.
|
|
15
|
+
* @returns {string} - An absolute directory.
|
|
16
|
+
*/
|
|
17
|
+
function defaultDataDir() {
|
|
18
|
+
const service =
|
|
19
|
+
process.platform !== 'win32' &&
|
|
20
|
+
typeof process.getuid === 'function' &&
|
|
21
|
+
process.getuid() === 0;
|
|
22
|
+
return service
|
|
23
|
+
? '/var/lib/pmtiles-swarm/data'
|
|
24
|
+
: path.join(process.cwd(), 'data');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The configuration a new node starts from.
|
|
29
|
+
* @param {object} paths - Resolved dataDir, savePath and any password hash.
|
|
30
|
+
* @returns {object} - The config to write.
|
|
31
|
+
*/
|
|
32
|
+
function firstConfig({ dataDir, savePath, passwordHash }) {
|
|
33
|
+
return {
|
|
34
|
+
port: 8090,
|
|
35
|
+
adminPort: 8091,
|
|
36
|
+
adminHost: '127.0.0.1',
|
|
37
|
+
|
|
38
|
+
dataDir,
|
|
39
|
+
savePath,
|
|
40
|
+
savePathLayout: 'infohash',
|
|
41
|
+
|
|
42
|
+
engine: 'libtorrent',
|
|
43
|
+
secondaryEngines: ['webtorrent'],
|
|
44
|
+
libtorrent: {
|
|
45
|
+
python: 'python3',
|
|
46
|
+
resumeDir: path.join(dataDir, 'resume'),
|
|
47
|
+
listen: '0.0.0.0:6881',
|
|
48
|
+
upnp: true,
|
|
49
|
+
natpmp: true,
|
|
50
|
+
},
|
|
51
|
+
webtorrent: { clientOptions: { torrentPort: 6882 } },
|
|
52
|
+
|
|
53
|
+
torrentFormat: 'hybrid',
|
|
54
|
+
pieceLength: 4194304,
|
|
55
|
+
maxConnections: 100,
|
|
56
|
+
trackers: [
|
|
57
|
+
'udp://tracker.opentrackr.org:1337/announce',
|
|
58
|
+
'udp://tracker.torrent.eu.org:451/announce',
|
|
59
|
+
'udp://tracker-udp.gbitt.info:80/announce',
|
|
60
|
+
'wss://tracker.openwebtorrent.com',
|
|
61
|
+
'wss://tracker.webtorrent.dev',
|
|
62
|
+
],
|
|
63
|
+
|
|
64
|
+
auth: {
|
|
65
|
+
username: 'admin',
|
|
66
|
+
// Generated rather than left as a placeholder. A sample saying
|
|
67
|
+
// REPLACE-ME is a sample that ships unreplaced.
|
|
68
|
+
apiKey: crypto.randomBytes(32).toString('hex'),
|
|
69
|
+
// Hashed here, never stored as the plaintext that was typed. A password
|
|
70
|
+
// key is written only when one was given: the same reasoning as the API
|
|
71
|
+
// key, one step further on. A placeholder would be a credential that
|
|
72
|
+
// looks set and is not, and `auth.password` accepts plaintext — so
|
|
73
|
+
// "REPLACE-ME" in that field is a working password until somebody
|
|
74
|
+
// notices.
|
|
75
|
+
...(passwordHash ? { passwordHash } : {}),
|
|
76
|
+
tokens: [],
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Writes a first configuration file.
|
|
83
|
+
*
|
|
84
|
+
* Everything it writes is absolute. A path that resolves against the config
|
|
85
|
+
* file is the one mistake this command exists to make impossible: the
|
|
86
|
+
* documented layout puts that file in /etc, so `./data` there means a catalog,
|
|
87
|
+
* a resume directory and potentially a 700 GiB archive on the configuration
|
|
88
|
+
* partition — and the person who followed both documents did nothing wrong.
|
|
89
|
+
*
|
|
90
|
+
* State under /etc is refused rather than warned about. At the moment a config
|
|
91
|
+
* is written there is nothing to preserve and nothing to migrate, so refusing
|
|
92
|
+
* costs a retyped flag; the same mistake found later costs a stopped service
|
|
93
|
+
* and a careful move.
|
|
94
|
+
* @param {object} [options] - Flags from the command line.
|
|
95
|
+
* @param {Function} [write] - Where to report, for tests.
|
|
96
|
+
* @returns {Promise<number>} - Exit code.
|
|
97
|
+
*/
|
|
98
|
+
export async function runInit(options = {}, write = console.log) {
|
|
99
|
+
const configPath = path.resolve(
|
|
100
|
+
options.config ?? path.join(process.cwd(), 'swarm.config.json'),
|
|
101
|
+
);
|
|
102
|
+
const dataDir = path.resolve(options.dataDir ?? defaultDataDir());
|
|
103
|
+
const savePath = path.resolve(
|
|
104
|
+
options.savePath ?? path.join(dataDir, 'torrents-data'),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Both what was typed and what it resolved to: on Windows the first is the
|
|
108
|
+
// only one that can be recognised, and on POSIX the second catches a
|
|
109
|
+
// relative path that lands there anyway.
|
|
110
|
+
for (const [name, ...values] of [
|
|
111
|
+
['--data-dir', options.dataDir, dataDir],
|
|
112
|
+
['--save-path', options.savePath, savePath],
|
|
113
|
+
]) {
|
|
114
|
+
const value = values.find(
|
|
115
|
+
(candidate) =>
|
|
116
|
+
typeof candidate === 'string' && candidate.startsWith('/etc/'),
|
|
117
|
+
);
|
|
118
|
+
if (value) {
|
|
119
|
+
write(
|
|
120
|
+
`${name} is ${value}. /etc is for configuration; state belongs under ` +
|
|
121
|
+
'/var/lib or a data disk. Nothing has been written.',
|
|
122
|
+
);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const exists = await fs
|
|
128
|
+
.access(configPath)
|
|
129
|
+
.then(() => true)
|
|
130
|
+
.catch(() => false);
|
|
131
|
+
if (exists && !options.force) {
|
|
132
|
+
write(
|
|
133
|
+
`${configPath} already exists. Pass --force to replace it — and take a ` +
|
|
134
|
+
'copy first, since the tokens in it are not recoverable.',
|
|
135
|
+
);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
140
|
+
const config = firstConfig({
|
|
141
|
+
dataDir,
|
|
142
|
+
savePath,
|
|
143
|
+
passwordHash: options.password ? hashPassword(options.password) : undefined,
|
|
144
|
+
});
|
|
145
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}${os.EOL}`);
|
|
146
|
+
|
|
147
|
+
write(`Wrote ${configPath}`);
|
|
148
|
+
write('');
|
|
149
|
+
write(` dataDir ${dataDir}`);
|
|
150
|
+
write(` savePath ${savePath}`);
|
|
151
|
+
write(` resumeDir ${config.libtorrent.resumeDir}`);
|
|
152
|
+
write('');
|
|
153
|
+
|
|
154
|
+
const unitPath = path.join(path.dirname(configPath), 'pmtiles-swarm.service');
|
|
155
|
+
if (options.systemd) {
|
|
156
|
+
// Written beside the configuration rather than into /etc/systemd/system.
|
|
157
|
+
// Installing a unit is a privileged, system-changing act and this command
|
|
158
|
+
// may be run by anyone; the copy is one line and it is the caller's to
|
|
159
|
+
// make.
|
|
160
|
+
await fs.writeFile(
|
|
161
|
+
unitPath,
|
|
162
|
+
unitFor({
|
|
163
|
+
config,
|
|
164
|
+
configPath,
|
|
165
|
+
user: options.user,
|
|
166
|
+
execStart: options.execStart,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
write(`Wrote ${unitPath}`);
|
|
170
|
+
write('');
|
|
171
|
+
write(' Its ReadWritePaths was derived from the configuration above, so');
|
|
172
|
+
write(' the two cannot disagree. Adding a watched folder later means');
|
|
173
|
+
write(' re-running this, or adding the folder to that line by hand.');
|
|
174
|
+
if (path.sep !== '/') {
|
|
175
|
+
// Said rather than refused: writing the unit is still the fastest way to
|
|
176
|
+
// see its shape, and somebody may be preparing a config to carry across.
|
|
177
|
+
// But every path in it is this machine's, and systemd will not read them.
|
|
178
|
+
write('');
|
|
179
|
+
write(' Written on a platform that is not the one this runs on, so the');
|
|
180
|
+
write(' paths in it are this machine’s. Re-run init on the server, or');
|
|
181
|
+
write(' rewrite every path in the unit before installing it.');
|
|
182
|
+
}
|
|
183
|
+
write('');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
write('Next:');
|
|
187
|
+
write('');
|
|
188
|
+
for (const command of directoryCommands({
|
|
189
|
+
config,
|
|
190
|
+
configPath,
|
|
191
|
+
user: options.user,
|
|
192
|
+
})) {
|
|
193
|
+
write(` ${command}`);
|
|
194
|
+
}
|
|
195
|
+
write('');
|
|
196
|
+
|
|
197
|
+
if (options.systemd) {
|
|
198
|
+
write(` sudo cp ${unitPath} /etc/systemd/system/`);
|
|
199
|
+
write(' sudo systemctl daemon-reload');
|
|
200
|
+
write(' sudo systemctl enable --now pmtiles-swarm');
|
|
201
|
+
write('');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!config.auth.passwordHash) {
|
|
205
|
+
write(' There is no console password. The API key in the file is the way');
|
|
206
|
+
write(' in until you set one: re-run with --password, or add a plaintext');
|
|
207
|
+
write(' auth.password, which is hashed the first time it is read.');
|
|
208
|
+
write('');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
write(' Point a watch folder at where your archives are built, or add one');
|
|
212
|
+
write(' from the console. A watched folder is a directory this writes to,');
|
|
213
|
+
write(
|
|
214
|
+
options.systemd
|
|
215
|
+
? ' so it has to go in ReadWritePaths as well.'
|
|
216
|
+
: ' so it has to go in the unit ReadWritePaths as well.',
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (!options.systemd) {
|
|
220
|
+
// Still printed for anyone writing the unit by hand, which is the case
|
|
221
|
+
// this was for before there was a generator. Derived from the same place
|
|
222
|
+
// the generated one is, so the advice cannot be worse than the file.
|
|
223
|
+
write('');
|
|
224
|
+
write('Under systemd every one of those has to be in ReadWritePaths:');
|
|
225
|
+
write('');
|
|
226
|
+
write(` ReadWritePaths=${writablePaths(config, configPath).join(' ')}`);
|
|
227
|
+
write('');
|
|
228
|
+
write('Re-run with --systemd to have that written for you, as a unit.');
|
|
229
|
+
}
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
package/src/library.js
CHANGED
|
@@ -667,16 +667,77 @@ export class Library {
|
|
|
667
667
|
* @param {object} entry - Catalog entry.
|
|
668
668
|
* @returns {Promise<void>} - Resolves once added.
|
|
669
669
|
*/
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
670
|
+
/**
|
|
671
|
+
* An entry's metainfo, from wherever it actually is.
|
|
672
|
+
*
|
|
673
|
+
* `torrentPath` is recorded absolute, so moving `dataDir` — which the service
|
|
674
|
+
* guide tells you to do, out of `/etc` — leaves every entry in the catalog
|
|
675
|
+
* naming a directory that no longer exists. Nothing repoints them, and
|
|
676
|
+
* nothing complained, because an unreadable `.torrent` was simply treated as
|
|
677
|
+
* "use the magnet instead".
|
|
678
|
+
*
|
|
679
|
+
* So the recorded path is tried first and the current `dataDir` second. The
|
|
680
|
+
* file is stored under the infohash either way, which is what makes the
|
|
681
|
+
* second lookup possible at all.
|
|
682
|
+
* @param {object} entry - The catalog entry.
|
|
683
|
+
* @returns {Promise<Uint8Array | null>} - The metainfo, or null.
|
|
684
|
+
*/
|
|
685
|
+
async #metainfoFor(entry) {
|
|
686
|
+
const read = (file) =>
|
|
687
|
+
file
|
|
688
|
+
? fs
|
|
689
|
+
.readFile(file)
|
|
690
|
+
.then((buffer) => new Uint8Array(buffer))
|
|
691
|
+
.catch(() => null)
|
|
692
|
+
: Promise.resolve(null);
|
|
693
|
+
|
|
694
|
+
const recorded = await read(entry.torrentPath);
|
|
695
|
+
if (recorded) return recorded;
|
|
696
|
+
|
|
697
|
+
const beside = entry.infoHash
|
|
698
|
+
? path.join(this.torrentDir, `${entry.infoHash}.torrent`)
|
|
676
699
|
: null;
|
|
700
|
+
if (!beside || beside === entry.torrentPath) return null;
|
|
701
|
+
|
|
702
|
+
const found = await read(beside);
|
|
703
|
+
if (!found) return null;
|
|
704
|
+
|
|
705
|
+
console.warn(
|
|
706
|
+
`[restore] ${entry.name}: the catalog names ${entry.torrentPath}, which ` +
|
|
707
|
+
`is not there; using ${beside} instead. Its recorded path is being ` +
|
|
708
|
+
'corrected.',
|
|
709
|
+
);
|
|
710
|
+
// Corrected in place, so the warning is printed once rather than on every
|
|
711
|
+
// start for the rest of the node's life.
|
|
712
|
+
await this.#catalog
|
|
713
|
+
.put({ infoHash: entry.infoHash, torrentPath: beside })
|
|
714
|
+
.catch(() => null);
|
|
715
|
+
return found;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async #readd(entry) {
|
|
719
|
+
const torrentFile = await this.#metainfoFor(entry);
|
|
677
720
|
const magnet = torrentFile ? undefined : this.#withTrackers(entry);
|
|
678
721
|
if (!torrentFile && !magnet) return;
|
|
679
722
|
|
|
723
|
+
// Falling back to the magnet is not a quiet equivalent, and it used to
|
|
724
|
+
// happen silently. A magnet carries no metadata and neither does resume
|
|
725
|
+
// data, so the archive waits on BEP 9 for a file list — which no peer can
|
|
726
|
+
// supply for an archive this node originated. It sits at 0% in
|
|
727
|
+
// downloading_metadata indefinitely, and no re-check will move it.
|
|
728
|
+
//
|
|
729
|
+
// Only where the entry claims a metainfo it cannot produce. An archive
|
|
730
|
+
// that never had one was joined by magnet and is waiting on
|
|
731
|
+
// captureMetadata in the ordinary way; saying this about it would be a
|
|
732
|
+
// warning on the normal path.
|
|
733
|
+
if (!torrentFile && entry.torrentPath) {
|
|
734
|
+
console.warn(
|
|
735
|
+
`[restore] ${entry.name}: no .torrent could be read, so it is being ` +
|
|
736
|
+
'added as a magnet and must fetch its metadata from a peer. If this ' +
|
|
737
|
+
'node is the only seeder, it will not finish.',
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
680
741
|
await this.#engine.add({
|
|
681
742
|
torrentFile: torrentFile ?? undefined,
|
|
682
743
|
magnet,
|
package/src/shutdown.js
CHANGED
|
@@ -18,8 +18,53 @@
|
|
|
18
18
|
* every step is bounded, and the whole sequence is bounded again behind that.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
const
|
|
21
|
+
/** Slack over the sum of the steps, for the shutdown's own bookkeeping. */
|
|
22
|
+
const WATCHDOG_SLACK_MS = 5000;
|
|
23
|
+
|
|
24
|
+
/** What a step is allowed when it does not say. */
|
|
25
|
+
const STEP_MS = 5000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long the whole shutdown may take before it gives up on itself.
|
|
29
|
+
*
|
|
30
|
+
* Derived from the steps rather than fixed, because a fixed bound is a second
|
|
31
|
+
* deadline that has to be kept in agreement with them by hand — and was not.
|
|
32
|
+
* Fifteen seconds sat under an engine step that legitimately wants two seconds
|
|
33
|
+
* per torrent, so on any library past seven archives the watchdog fired first
|
|
34
|
+
* and exited the process while the sidecar was still writing resume data. What
|
|
35
|
+
* that costs is a re-hash of everything that had not been written yet, on the
|
|
36
|
+
* way back up.
|
|
37
|
+
*
|
|
38
|
+
* Computed when the signal arrives, so a step registered later is still
|
|
39
|
+
* covered.
|
|
40
|
+
* @param {Array<{ms?: number}>} stoppers - The steps to be run.
|
|
41
|
+
* @returns {number} - The bound, in milliseconds.
|
|
42
|
+
*/
|
|
43
|
+
export function watchdogFor(stoppers) {
|
|
44
|
+
const total = stoppers.reduce(
|
|
45
|
+
(sum, stopper) => sum + (stopper.ms ?? STEP_MS),
|
|
46
|
+
0,
|
|
47
|
+
);
|
|
48
|
+
return total + WATCHDOG_SLACK_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How long to let the engine stop, given how much it has to write down.
|
|
53
|
+
*
|
|
54
|
+
* The sidecar allows each torrent two seconds of its resume-save budget, so
|
|
55
|
+
* the only bound that does not eventually cut a library short is one that
|
|
56
|
+
* counts them. The floor covers a node with nothing in it, where what remains
|
|
57
|
+
* is announcing "stopped" to trackers that may not answer.
|
|
58
|
+
*
|
|
59
|
+
* Whatever this returns has to be under the unit's `TimeoutStopSec`, or
|
|
60
|
+
* systemd kills the process while it is still working. See
|
|
61
|
+
* docs/running-as-a-service.md.
|
|
62
|
+
* @param {number} archives - How many archives the catalog holds.
|
|
63
|
+
* @returns {number} - Milliseconds.
|
|
64
|
+
*/
|
|
65
|
+
export function engineStopMs(archives) {
|
|
66
|
+
return Math.max(15000, archives * 2000 + 10000);
|
|
67
|
+
}
|
|
23
68
|
|
|
24
69
|
/**
|
|
25
70
|
* Runs one shutdown step, giving up on it rather than waiting for ever.
|
|
@@ -125,11 +170,14 @@ export function installSignalHandlers(stoppers, options = {}) {
|
|
|
125
170
|
stopping = true;
|
|
126
171
|
console.log(`\n[shutdown] ${signal}`);
|
|
127
172
|
|
|
128
|
-
// A last resort, in case a step ignores its own bound
|
|
173
|
+
// A last resort, in case a step ignores its own bound — never a bound in
|
|
174
|
+
// its own right, which is what it silently became when it was shorter than
|
|
175
|
+
// the steps it was meant to outlast.
|
|
176
|
+
const limitMs = watchdogFor(stoppers);
|
|
129
177
|
const watchdog = setTimeout(() => {
|
|
130
|
-
console.warn(
|
|
178
|
+
console.warn(`[shutdown] took longer than ${limitMs}ms; exiting anyway`);
|
|
131
179
|
exit(1);
|
|
132
|
-
},
|
|
180
|
+
}, limitMs);
|
|
133
181
|
|
|
134
182
|
await runStoppers(stoppers);
|
|
135
183
|
|
package/src/systemd.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writablePaths } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A unit file written from the configuration it will run.
|
|
6
|
+
*
|
|
7
|
+
* Every failure this project has diagnosed under systemd came from the unit
|
|
8
|
+
* and the configuration disagreeing, and none of them looked like what they
|
|
9
|
+
* were:
|
|
10
|
+
*
|
|
11
|
+
* - a `savePath` missing from `ReadWritePaths` fails inside the unit's
|
|
12
|
+
* namespace, before any permission bit is read, so the directory's owner
|
|
13
|
+
* and mode are both perfect and the write is still refused
|
|
14
|
+
* - the default `KillMode` signals the Python sidecar at the same instant as
|
|
15
|
+
* the node, so the sidecar dies before it can write resume data and every
|
|
16
|
+
* archive returns at 0% to be re-checked
|
|
17
|
+
* - a `TimeoutStopSec` shorter than the resume save kills the process
|
|
18
|
+
* mid-write, which costs the same re-check by a different route
|
|
19
|
+
*
|
|
20
|
+
* None of that is discoverable from the symptom. All of it is decidable from
|
|
21
|
+
* the configuration, which is why this is generated rather than documented.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What the resume save can want, before the library has grown into it. */
|
|
25
|
+
const TIMEOUT_STOP_SECONDS = 300;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The unit file for a configuration.
|
|
29
|
+
* @param {object} options - What to write.
|
|
30
|
+
* @param {object} options.config - The resolved configuration.
|
|
31
|
+
* @param {string} options.configPath - Where that configuration lives.
|
|
32
|
+
* @param {string} [options.user] - Service account. Default 'pmtiles-swarm'.
|
|
33
|
+
* @param {string} [options.execStart] - The binary. Default the documented install.
|
|
34
|
+
* @param {string} [options.workingDirectory] - Default the binary's install root.
|
|
35
|
+
* @returns {string} - The unit, ready to install.
|
|
36
|
+
*/
|
|
37
|
+
export function unitFor({
|
|
38
|
+
config,
|
|
39
|
+
configPath,
|
|
40
|
+
user = 'pmtiles-swarm',
|
|
41
|
+
execStart,
|
|
42
|
+
workingDirectory,
|
|
43
|
+
}) {
|
|
44
|
+
const home = workingDirectory ?? `/var/lib/${user}`;
|
|
45
|
+
const binary = execStart ?? `${home}/node_modules/.bin/pmtiles-swarm`;
|
|
46
|
+
const paths = writablePaths(config, configPath);
|
|
47
|
+
|
|
48
|
+
// Wrapped the way systemd's own examples are, because this list grows with
|
|
49
|
+
// every watched folder and a single line of them is unreadable in a diff.
|
|
50
|
+
const readWrite = paths.join(' \\\n ');
|
|
51
|
+
|
|
52
|
+
return `# pmtiles-swarm, generated by \`pmtiles-swarm init --systemd\`.
|
|
53
|
+
#
|
|
54
|
+
# Install it, then check what systemd actually merged — which is the only
|
|
55
|
+
# account of it worth trusting:
|
|
56
|
+
#
|
|
57
|
+
# sudo cp pmtiles-swarm.service /etc/systemd/system/
|
|
58
|
+
# sudo systemctl daemon-reload
|
|
59
|
+
# sudo systemctl enable --now pmtiles-swarm
|
|
60
|
+
# systemctl show -p ReadWritePaths -p KillMode -p TimeoutStopUSec pmtiles-swarm
|
|
61
|
+
#
|
|
62
|
+
# ReadWritePaths below was derived from ${path.basename(configPath)}. Adding a
|
|
63
|
+
# watched folder, a subscription with its own save path, or a cache directory
|
|
64
|
+
# to that file means adding it here too — or the write is refused with the
|
|
65
|
+
# directory's permissions perfect. Re-running init --systemd rewrites this.
|
|
66
|
+
|
|
67
|
+
[Unit]
|
|
68
|
+
Description=pmtiles-swarm
|
|
69
|
+
Documentation=https://github.com/TechIdiots-LLC/pmtiles-swarm
|
|
70
|
+
After=network-online.target
|
|
71
|
+
Wants=network-online.target
|
|
72
|
+
|
|
73
|
+
[Service]
|
|
74
|
+
Type=simple
|
|
75
|
+
User=${user}
|
|
76
|
+
Group=${user}
|
|
77
|
+
|
|
78
|
+
WorkingDirectory=${home}
|
|
79
|
+
|
|
80
|
+
# Absolute: systemd reads no shell profile.
|
|
81
|
+
ExecStart=${binary} \\
|
|
82
|
+
--config ${path.resolve(configPath)}
|
|
83
|
+
|
|
84
|
+
# Required, not a preference. The console's Save & Restart applies settings a
|
|
85
|
+
# running process cannot take, so it exits 0 and expects to be brought back;
|
|
86
|
+
# Restart=on-failure ignores an exit 0 and would leave the node stopped with
|
|
87
|
+
# the unit reporting success.
|
|
88
|
+
Restart=always
|
|
89
|
+
RestartSec=5
|
|
90
|
+
|
|
91
|
+
# Stopping writes resume data for every archive, and the node allows its engine
|
|
92
|
+
# two seconds per torrent to do it. This has to outlast that: killed mid-save,
|
|
93
|
+
# every archive that had not been written re-hashes its whole store on the way
|
|
94
|
+
# back up, which for a 700 GiB archive is hours. Raise it as the library grows
|
|
95
|
+
# — tools/resume-doctor.py prints the arithmetic for yours.
|
|
96
|
+
TimeoutStopSec=${TIMEOUT_STOP_SECONDS}
|
|
97
|
+
|
|
98
|
+
# The node stops the sidecar itself, and needs it alive to do so. The default,
|
|
99
|
+
# control-group, signals both at once and the sidecar dies before it can write
|
|
100
|
+
# anything. That is what a library returning at 0% after every restart is.
|
|
101
|
+
KillMode=mixed
|
|
102
|
+
|
|
103
|
+
# A seeding node holds a socket per peer, and the tile reader holds file
|
|
104
|
+
# descriptors of its own.
|
|
105
|
+
LimitNOFILE=65535
|
|
106
|
+
|
|
107
|
+
# Everything not named below is read-only inside this unit's namespace.
|
|
108
|
+
ProtectSystem=strict
|
|
109
|
+
ProtectHome=read-only
|
|
110
|
+
PrivateTmp=true
|
|
111
|
+
NoNewPrivileges=true
|
|
112
|
+
|
|
113
|
+
ReadWritePaths=${readWrite}
|
|
114
|
+
|
|
115
|
+
# Group-writable, so what this service creates in a folder shared with whatever
|
|
116
|
+
# builds the archives can still be modified by it.
|
|
117
|
+
UMask=0002
|
|
118
|
+
|
|
119
|
+
[Install]
|
|
120
|
+
WantedBy=multi-user.target
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The commands that make the directories this unit needs, owned correctly.
|
|
126
|
+
*
|
|
127
|
+
* Printed rather than run. init may be running as root or as a person, and a
|
|
128
|
+
* directory created by the wrong one is the same refused write as a missing
|
|
129
|
+
* `ReadWritePaths` entry — with even less to go on, because the unit is right.
|
|
130
|
+
* @param {object} options - What to write.
|
|
131
|
+
* @param {object} options.config - The resolved configuration.
|
|
132
|
+
* @param {string} options.configPath - Where that configuration lives.
|
|
133
|
+
* @param {string} [options.user] - Service account.
|
|
134
|
+
* @returns {string[]} - Shell commands, in the order they should be run.
|
|
135
|
+
*/
|
|
136
|
+
export function directoryCommands({
|
|
137
|
+
config,
|
|
138
|
+
configPath,
|
|
139
|
+
user = 'pmtiles-swarm',
|
|
140
|
+
}) {
|
|
141
|
+
// The config's own directory is excluded: it exists already, since the file
|
|
142
|
+
// was just written into it, and it is usually /etc where this would be wrong.
|
|
143
|
+
const configDir = path.dirname(path.resolve(configPath));
|
|
144
|
+
const wanted = writablePaths(config, configPath).filter(
|
|
145
|
+
(value) => value !== configDir,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return [
|
|
149
|
+
`sudo useradd --system --home-dir /var/lib/${user} --shell /usr/sbin/nologin ${user}`,
|
|
150
|
+
...wanted.map(
|
|
151
|
+
(value) => `sudo install -d -o ${user} -g ${user} -m 0775 ${value}`,
|
|
152
|
+
),
|
|
153
|
+
// The directory as well as the file. The console rewrites the
|
|
154
|
+
// configuration by writing a temp file beside it and renaming, which needs
|
|
155
|
+
// write permission on the directory rather than on the file.
|
|
156
|
+
`sudo chown ${user} ${configDir} ${path.resolve(configPath)}`,
|
|
157
|
+
];
|
|
158
|
+
}
|