pmtiles-swarm 0.35.0 β 0.35.2
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 +51 -0
- package/package.json +1 -1
- package/src/engines/composite.js +16 -0
- package/src/engines/libtorrent.js +87 -2
- package/src/identify.js +47 -4
- package/src/index.js +14 -0
- package/src/library.js +16 -4
- package/src/torrent-create.js +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@
|
|
|
7
7
|
### π Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.35.2
|
|
11
|
+
### β¨ Features and improvements
|
|
12
|
+
|
|
13
|
+
### π Bug fixes
|
|
14
|
+
- **A killed sidecar is reported with the signal that killed it.** `child.on('exit')` gives a null
|
|
15
|
+
code when a process dies by signal and puts the name in the *second* argument, which 0.35.1 did
|
|
16
|
+
not read β so the one case where the code carries no information printed `exited (code null)`
|
|
17
|
+
and withheld the word that does. It now says `killed by SIGKILL` or `killed by SIGTERM`, and
|
|
18
|
+
only mentions the OOM killer for `SIGKILL`, that being the one it cannot ask for politely. The
|
|
19
|
+
0.35.1 wording guessed at memory whatever had happened, which on a box with 122 GiB free sent
|
|
20
|
+
the reader somewhere there was nothing to find.
|
|
21
|
+
- **The in-process hashing fallback says what it costs.** When libtorrent cannot build a torrent,
|
|
22
|
+
creation falls back to hashing in the node process β correct, since a torrent matters more than
|
|
23
|
+
its format, but not a smaller version of the same thing: it reads the whole archive in the
|
|
24
|
+
process that also serves tiles and the console. For a 698 GiB archive that is a console which
|
|
25
|
+
has apparently locked up, with nothing in the log joining it to the sidecar that died some time
|
|
26
|
+
earlier. The warning now names the size, says the hash is happening here, and says that fixing
|
|
27
|
+
libtorrent is worth more than waiting for it.
|
|
28
|
+
|
|
29
|
+
## 0.35.1
|
|
30
|
+
### β¨ Features and improvements
|
|
31
|
+
|
|
32
|
+
### π Bug fixes
|
|
33
|
+
- **A sidecar that dies is started again, instead of taking the node down with it until somebody
|
|
34
|
+
notices.** It was given up on for good: the readiness promise stayed resolved and the process
|
|
35
|
+
handle stayed null, so every call from then on threw `libtorrent sidecar is not running` β once a
|
|
36
|
+
second, indefinitely. One crash and the node stopped seeding its whole library while whatever
|
|
37
|
+
download was in front of it carried on reporting progress, which is what made it look fine.
|
|
38
|
+
|
|
39
|
+
A replacement holds nothing, so the catalogue is handed back to it as well. Coming back empty
|
|
40
|
+
would be the worse failure of the two: `list` answers, so the node reads as healthy while seeding
|
|
41
|
+
none of its archives. Only a sidecar that reached ready at least once is restarted β one that has
|
|
42
|
+
never started is a missing python or a missing binding, and retrying that per call is a spawn
|
|
43
|
+
storm against a fault no amount of retrying fixes.
|
|
44
|
+
- **The sidecar's death is now in the log.** The exit code went only into the error handed to calls
|
|
45
|
+
that happened to be in flight, so a sidecar that died with nothing pending died silently. With no
|
|
46
|
+
stderr behind it β which is what being killed rather than failing looks like, the OOM killer being
|
|
47
|
+
the usual reason on a node hashing or downloading something large β there was nothing in the log
|
|
48
|
+
to say it had happened at all, only the consequences.
|
|
49
|
+
- **A source that could not be read is no longer called the wrong format.** Every transport fault
|
|
50
|
+
β a refused connection, a dropped body, a 404 β came back from `identifyUrl` as `unknown`, and so
|
|
51
|
+
was reported as βthis does not look like a map archiveβ. Seen in the field on a scheduled source
|
|
52
|
+
that had answered a HEAD seconds earlier, with `fetch failed` on the line below it in the same
|
|
53
|
+
log, which was the truth for both. Worse on a node with `allowUnknownArchives` set: an
|
|
54
|
+
unreachable URL passed the format check and the add went ahead on sixteen bytes nothing had
|
|
55
|
+
managed to read. It now says `could not read <url>: <why>`, and `allowUnknown` no longer applies
|
|
56
|
+
to it, being about format.
|
|
57
|
+
- **A failed add no longer haunts `/api/adds`.** An add is registered as running before its source
|
|
58
|
+
is read, and nothing removed it if identification failed β so the console drew a download that
|
|
59
|
+
was not happening, with a cancel button that cancelled nothing, until the process restarted.
|
|
60
|
+
|
|
10
61
|
## 0.35.0
|
|
11
62
|
### β¨ Features and improvements
|
|
12
63
|
- **Requires pmtiles-torrent 0.7.0, which stops the libtorrent sidecar going deaf while it works.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.2",
|
|
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/engines/composite.js
CHANGED
|
@@ -63,6 +63,22 @@ export class CompositeEngine {
|
|
|
63
63
|
return this.#secondaries;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Passes a reconnect handler to whichever engines can lose their backing
|
|
68
|
+
* process and start another.
|
|
69
|
+
*
|
|
70
|
+
* Only libtorrent has one today. Offered to every engine that will take it
|
|
71
|
+
* rather than reached for by name, so a second one gains this by
|
|
72
|
+
* implementing the method.
|
|
73
|
+
* @param {Function} handler - Called once a replacement is ready.
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
onReconnect(handler) {
|
|
77
|
+
for (const engine of [this.#primary, ...this.#secondaries]) {
|
|
78
|
+
engine.onReconnect?.(handler);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
/**
|
|
67
83
|
* The underlying WebTorrent client, where one of the engines is WebTorrent.
|
|
68
84
|
*
|
|
@@ -44,6 +44,12 @@ function resolveSidecar() {
|
|
|
44
44
|
export class LibtorrentEngine {
|
|
45
45
|
#options;
|
|
46
46
|
#child = null;
|
|
47
|
+
/** Whether the sidecar has ever announced itself, which decides if a death is worth retrying. */
|
|
48
|
+
#everReady = false;
|
|
49
|
+
/** Set when a working sidecar died, so the next one knows it is a replacement. */
|
|
50
|
+
#lost = false;
|
|
51
|
+
/** Called once a replacement sidecar is ready, to give it the library back. */
|
|
52
|
+
#onReconnect = null;
|
|
47
53
|
#pending = new Map();
|
|
48
54
|
#nextId = 1;
|
|
49
55
|
#buffer = '';
|
|
@@ -90,6 +96,18 @@ export class LibtorrentEngine {
|
|
|
90
96
|
return this.#version;
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Registers what to do once a replacement sidecar is ready.
|
|
101
|
+
*
|
|
102
|
+
* The engine can restart the process but cannot repopulate it β it does not
|
|
103
|
+
* know the catalogue. Whoever does hands this a way to put it back.
|
|
104
|
+
* @param {Function} handler - Called with no arguments; may return a promise.
|
|
105
|
+
* @returns {void}
|
|
106
|
+
*/
|
|
107
|
+
onReconnect(handler) {
|
|
108
|
+
this.#onReconnect = handler;
|
|
109
|
+
}
|
|
110
|
+
|
|
93
111
|
/**
|
|
94
112
|
* Starts the sidecar and waits for it to report readiness.
|
|
95
113
|
* @returns {Promise<void>} - Resolves once usable.
|
|
@@ -170,7 +188,33 @@ export class LibtorrentEngine {
|
|
|
170
188
|
if (message.event === 'ready') {
|
|
171
189
|
clearTimeout(timer);
|
|
172
190
|
this.#version = message.libtorrent;
|
|
191
|
+
// Remembered so a later death can be told from a start that never
|
|
192
|
+
// worked. One is worth trying again, the other is a broken install
|
|
193
|
+
// and retrying it per call is a spawn storm. See the exit handler.
|
|
194
|
+
this.#everReady = true;
|
|
173
195
|
resolve();
|
|
196
|
+
|
|
197
|
+
// A replacement sidecar holds nothing. Coming back empty is worse
|
|
198
|
+
// than staying down was: `list` answers, so the node looks healthy
|
|
199
|
+
// while seeding none of its library. Whoever owns the catalogue is
|
|
200
|
+
// told to hand it back.
|
|
201
|
+
//
|
|
202
|
+
// Not awaited, and deferred out of this turn: the respawn happens
|
|
203
|
+
// inside somebody's call, and restoring re-enters the engine.
|
|
204
|
+
if (this.#lost) {
|
|
205
|
+
this.#lost = false;
|
|
206
|
+
const restore = this.#onReconnect;
|
|
207
|
+
if (restore) {
|
|
208
|
+
setImmediate(() => {
|
|
209
|
+
Promise.resolve(restore()).catch((error) =>
|
|
210
|
+
console.error(
|
|
211
|
+
`[libtorrent] could not hand the library back to the ` +
|
|
212
|
+
`replacement sidecar: ${error.message}`,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
174
218
|
continue;
|
|
175
219
|
}
|
|
176
220
|
this.#settle(message);
|
|
@@ -203,7 +247,7 @@ export class LibtorrentEngine {
|
|
|
203
247
|
);
|
|
204
248
|
});
|
|
205
249
|
|
|
206
|
-
child.on('exit', (code) => {
|
|
250
|
+
child.on('exit', (code, signal) => {
|
|
207
251
|
clearTimeout(timer);
|
|
208
252
|
this.#child = null;
|
|
209
253
|
|
|
@@ -219,9 +263,50 @@ export class LibtorrentEngine {
|
|
|
219
263
|
return;
|
|
220
264
|
}
|
|
221
265
|
|
|
222
|
-
|
|
266
|
+
// Said out loud, always. The exit code used to go only into the error
|
|
267
|
+
// handed to whatever calls happened to be in flight, so a sidecar that
|
|
268
|
+
// died with nothing pending died silently β and a death with no stderr
|
|
269
|
+
// behind it, which is what being killed by the OOM killer looks like,
|
|
270
|
+
// left nothing in the log at all. What the operator saw instead was
|
|
271
|
+
// "libtorrent sidecar is not running" once a second, for ever, with no
|
|
272
|
+
// line anywhere saying when it stopped or why.
|
|
273
|
+
// The signal, not just the code. A process killed by one exits with a
|
|
274
|
+
// null code, so reporting the code alone said "exited (code null)" β
|
|
275
|
+
// which names the one case where the code carries no information and
|
|
276
|
+
// withholds the word that does. SIGKILL is the OOM killer or someone
|
|
277
|
+
// with a big hammer; SIGTERM is something asking politely, which for a
|
|
278
|
+
// sidecar nobody meant to stop is usually the service manager taking
|
|
279
|
+
// the whole cgroup down.
|
|
280
|
+
const how = signal ? `killed by ${signal}` : `exited with code ${code}`;
|
|
281
|
+
const why = signal
|
|
282
|
+
? signal === 'SIGKILL'
|
|
283
|
+
? ' Nothing asks for SIGKILL politely: on a node hashing or ' +
|
|
284
|
+
'downloading a large archive this is the OOM killer, and ' +
|
|
285
|
+
'`dmesg -T | grep -i oom` will say so outright.'
|
|
286
|
+
: ''
|
|
287
|
+
: ' No stderr above this means it failed without saying why.';
|
|
288
|
+
console.error(`[libtorrent] sidecar ${how}.${why}`);
|
|
289
|
+
|
|
290
|
+
const error = new Error(`libtorrent sidecar ${how}`);
|
|
223
291
|
for (const { reject: fail } of this.#pending.values()) fail(error);
|
|
224
292
|
this.#pending.clear();
|
|
293
|
+
|
|
294
|
+
// A sidecar that worked and then died is worth starting again. It was
|
|
295
|
+
// not: #ready stayed resolved and #child stayed null, so every call
|
|
296
|
+
// from then on threw "libtorrent sidecar is not running" until the
|
|
297
|
+
// whole service was restarted by hand β one crash and the node stopped
|
|
298
|
+
// seeding its entire library, with the download in front of it still
|
|
299
|
+
// running and reporting progress as though nothing had happened.
|
|
300
|
+
//
|
|
301
|
+
// Only when it had reached ready at least once. A sidecar that has
|
|
302
|
+
// never started is a missing python or a missing binding, and
|
|
303
|
+
// respawning that on every call is a spawn storm against a fault no
|
|
304
|
+
// amount of retrying fixes.
|
|
305
|
+
if (this.#everReady) {
|
|
306
|
+
this.#ready = null;
|
|
307
|
+
this.#lost = true;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
225
310
|
reject(error);
|
|
226
311
|
});
|
|
227
312
|
});
|
package/src/identify.js
CHANGED
|
@@ -92,12 +92,33 @@ export async function identifyFile(filePath) {
|
|
|
92
92
|
* @returns {Promise<ArchiveKind>} - What it is.
|
|
93
93
|
*/
|
|
94
94
|
export async function identifyUrl(url, options = {}) {
|
|
95
|
+
// "I could not read this" and "this is not an archive" are different
|
|
96
|
+
// answers, and returning UNKNOWN for both made every network fault report
|
|
97
|
+
// itself as a format one. Seen in the field as a source that had answered a
|
|
98
|
+
// HEAD seconds earlier being refused with "this does not look like a map
|
|
99
|
+
// archive", while the line under it in the same log said "fetch failed" β
|
|
100
|
+
// which was the truth for both of them.
|
|
101
|
+
//
|
|
102
|
+
// Worse than a confusing message on a node with allowUnknownArchives set:
|
|
103
|
+
// an unreachable URL passed assertPublishable as an unknown format and the
|
|
104
|
+
// add went ahead, on sixteen bytes nothing had managed to read.
|
|
105
|
+
let response;
|
|
95
106
|
try {
|
|
96
|
-
|
|
107
|
+
response = await fetch(url, {
|
|
97
108
|
headers: { range: 'bytes=0-15' },
|
|
98
109
|
signal: options.signal,
|
|
99
110
|
});
|
|
100
|
-
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// An abort is the caller's own doing and already means something to it.
|
|
113
|
+
if (error?.name === 'AbortError') throw error;
|
|
114
|
+
throw unreachable(url, error?.message ?? String(error), error);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!response.ok && response.status !== 206) {
|
|
118
|
+
throw unreachable(url, `${response.status} ${response.statusText}`.trim());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
101
122
|
if (!response.body) return UNKNOWN;
|
|
102
123
|
|
|
103
124
|
// Read the first bytes off the stream and then stop, rather than
|
|
@@ -121,11 +142,33 @@ export async function identifyUrl(url, options = {}) {
|
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
return identifyBytes(head.subarray(0, filled));
|
|
124
|
-
} catch {
|
|
125
|
-
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// The body died partway through the sixteen bytes. Still a transport
|
|
147
|
+
// failure rather than a verdict on the format.
|
|
148
|
+
if (error?.name === 'AbortError') throw error;
|
|
149
|
+
throw unreachable(url, error?.message ?? String(error), error);
|
|
126
150
|
}
|
|
127
151
|
}
|
|
128
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The error for a source that could not be read at all.
|
|
155
|
+
* @param {string} url - What was being read.
|
|
156
|
+
* @param {string} why - The transport's own account of it.
|
|
157
|
+
* @param {Error} [cause] - The original failure, kept for a stack.
|
|
158
|
+
* @returns {Error} - Ready to throw.
|
|
159
|
+
*/
|
|
160
|
+
function unreachable(url, why, cause) {
|
|
161
|
+
const error = new Error(
|
|
162
|
+
`could not read ${url}: ${why}. Nothing was published β this says the ` +
|
|
163
|
+
'source could not be reached, not that it is the wrong format, so ' +
|
|
164
|
+
'allowUnknown does not apply.',
|
|
165
|
+
cause ? { cause } : undefined,
|
|
166
|
+
);
|
|
167
|
+
// A source this node could not reach is not the caller's bad request.
|
|
168
|
+
error.status = 502;
|
|
169
|
+
return error;
|
|
170
|
+
}
|
|
171
|
+
|
|
129
172
|
/**
|
|
130
173
|
* Checks a file is something this node is willing to publish.
|
|
131
174
|
* @param {ArchiveKind} identified - The result of identifying it.
|
package/src/index.js
CHANGED
|
@@ -272,6 +272,20 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
272
272
|
);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// And again if the engine loses its backing process and starts another. A
|
|
276
|
+
// replacement holds nothing, so without this the node would come back
|
|
277
|
+
// answering every call and seeding none of its library β which reads as
|
|
278
|
+
// healthy, and is the worse of the two failures.
|
|
279
|
+
engine.onReconnect?.(async () => {
|
|
280
|
+
const held = catalog.list().length;
|
|
281
|
+
if (held === 0) return;
|
|
282
|
+
const { restored, failed } = await library.restore();
|
|
283
|
+
console.log(
|
|
284
|
+
`[restore] ${restored} of ${held} archives handed back to the replacement` +
|
|
285
|
+
(failed > 0 ? ` (${failed} could not be)` : ''),
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
275
289
|
// What this node has served. In memory and bounded, so it costs the same
|
|
276
290
|
// after a billion tiles as after ten; `tileStats.recent: 0` keeps the
|
|
277
291
|
// counters and drops the per-request ring, and `false` turns it off.
|
package/src/library.js
CHANGED
|
@@ -749,10 +749,22 @@ export class Library {
|
|
|
749
749
|
// Probing reads only the header and directory, so this is cheap even
|
|
750
750
|
// against a multi-gigabyte archive β worth doing before committing to a
|
|
751
751
|
// download that may take hours.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
752
|
+
//
|
|
753
|
+
// Cleaned up on the way out, because this is the one stretch that can fail
|
|
754
|
+
// with the add already listed as running. It left an entry behind that
|
|
755
|
+
// nothing would ever remove: /api/adds reported a download that was not
|
|
756
|
+
// happening, the console drew it under "being added" with a cancel button
|
|
757
|
+
// that cancelled nothing, and it stayed until the process restarted.
|
|
758
|
+
let identified;
|
|
759
|
+
try {
|
|
760
|
+
identified = await identifyUrl(url, { signal: controller.signal });
|
|
761
|
+
assertPublishable(identified, {
|
|
762
|
+
allowUnknown: options.allowUnknown ?? this.#config.allowUnknownArchives,
|
|
763
|
+
});
|
|
764
|
+
} catch (error) {
|
|
765
|
+
this.#running.delete(url);
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
756
768
|
|
|
757
769
|
// Everything a caller can do something about has now been checked: the URL
|
|
758
770
|
// answers, it is an archive of a kind this will publish, and it is not a
|
package/src/torrent-create.js
CHANGED
|
@@ -671,7 +671,21 @@ async function buildTorrent(input, name, size, options) {
|
|
|
671
671
|
};
|
|
672
672
|
} catch (error) {
|
|
673
673
|
// A torrent is more important than the format of a torrent.
|
|
674
|
-
|
|
674
|
+
//
|
|
675
|
+
// Said with what it costs, though, because the fallback is not a smaller
|
|
676
|
+
// version of the same thing. libtorrent hashes in its own process; this
|
|
677
|
+
// hashes in *this* one, so an archive large enough to be worth handing
|
|
678
|
+
// to libtorrent is now being read end to end by the process also serving
|
|
679
|
+
// tiles and the console β which is how a sidecar dying mid-create turns
|
|
680
|
+
// into a console that has apparently locked up, with nothing in the log
|
|
681
|
+
// connecting the two.
|
|
682
|
+
const gib = size ? ` (${(size / 1024 ** 3).toFixed(1)} GiB)` : '';
|
|
683
|
+
console.warn(
|
|
684
|
+
`[create] ${error.message}; hashing${gib} in this process instead, ` +
|
|
685
|
+
'which is slower, holds no hybrid v2 layers, and competes with ' +
|
|
686
|
+
'everything else this node is doing. Fixing whatever stopped ' +
|
|
687
|
+
'libtorrent is worth more than waiting for this.',
|
|
688
|
+
);
|
|
675
689
|
}
|
|
676
690
|
}
|
|
677
691
|
|