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 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.0",
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",
@@ -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
- const error = new Error(`libtorrent sidecar exited (code ${code})`);
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
- const response = await fetch(url, {
107
+ response = await fetch(url, {
97
108
  headers: { range: 'bytes=0-15' },
98
109
  signal: options.signal,
99
110
  });
100
- if (!response.ok && response.status !== 206) return UNKNOWN;
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
- return UNKNOWN;
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
- const identified = await identifyUrl(url, { signal: controller.signal });
753
- assertPublishable(identified, {
754
- allowUnknown: options.allowUnknown ?? this.#config.allowUnknownArchives,
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
@@ -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
- console.warn(`[create] ${error.message}; falling back to a v1 torrent`);
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