pmtiles-swarm 0.35.0 β†’ 0.35.1

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,38 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.35.1
11
+ ### ✨ Features and improvements
12
+
13
+ ### 🐞 Bug fixes
14
+ - **A sidecar that dies is started again, instead of taking the node down with it until somebody
15
+ notices.** It was given up on for good: the readiness promise stayed resolved and the process
16
+ handle stayed null, so every call from then on threw `libtorrent sidecar is not running` β€” once a
17
+ second, indefinitely. One crash and the node stopped seeding its whole library while whatever
18
+ download was in front of it carried on reporting progress, which is what made it look fine.
19
+
20
+ A replacement holds nothing, so the catalogue is handed back to it as well. Coming back empty
21
+ would be the worse failure of the two: `list` answers, so the node reads as healthy while seeding
22
+ none of its archives. Only a sidecar that reached ready at least once is restarted β€” one that has
23
+ never started is a missing python or a missing binding, and retrying that per call is a spawn
24
+ storm against a fault no amount of retrying fixes.
25
+ - **The sidecar's death is now in the log.** The exit code went only into the error handed to calls
26
+ that happened to be in flight, so a sidecar that died with nothing pending died silently. With no
27
+ stderr behind it β€” which is what being killed rather than failing looks like, the OOM killer being
28
+ the usual reason on a node hashing or downloading something large β€” there was nothing in the log
29
+ to say it had happened at all, only the consequences.
30
+ - **A source that could not be read is no longer called the wrong format.** Every transport fault
31
+ β€” a refused connection, a dropped body, a 404 β€” came back from `identifyUrl` as `unknown`, and so
32
+ was reported as β€œthis does not look like a map archive”. Seen in the field on a scheduled source
33
+ that had answered a HEAD seconds earlier, with `fetch failed` on the line below it in the same
34
+ log, which was the truth for both. Worse on a node with `allowUnknownArchives` set: an
35
+ unreachable URL passed the format check and the add went ahead on sixteen bytes nothing had
36
+ managed to read. It now says `could not read <url>: <why>`, and `allowUnknown` no longer applies
37
+ to it, being about format.
38
+ - **A failed add no longer haunts `/api/adds`.** An add is registered as running before its source
39
+ is read, and nothing removed it if identification failed β€” so the console drew a download that
40
+ was not happening, with a cancel button that cancelled nothing, until the process restarted.
41
+
10
42
  ## 0.35.0
11
43
  ### ✨ Features and improvements
12
44
  - **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.1",
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);
@@ -219,9 +263,39 @@ export class LibtorrentEngine {
219
263
  return;
220
264
  }
221
265
 
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
+ console.error(
274
+ `[libtorrent] sidecar exited (code ${code}). No stderr above this ` +
275
+ 'means it was killed rather than failing β€” the OOM killer is the ' +
276
+ 'usual reason on a node hashing or downloading a large archive.',
277
+ );
278
+
222
279
  const error = new Error(`libtorrent sidecar exited (code ${code})`);
223
280
  for (const { reject: fail } of this.#pending.values()) fail(error);
224
281
  this.#pending.clear();
282
+
283
+ // A sidecar that worked and then died is worth starting again. It was
284
+ // not: #ready stayed resolved and #child stayed null, so every call
285
+ // from then on threw "libtorrent sidecar is not running" until the
286
+ // whole service was restarted by hand β€” one crash and the node stopped
287
+ // seeding its entire library, with the download in front of it still
288
+ // running and reporting progress as though nothing had happened.
289
+ //
290
+ // Only when it had reached ready at least once. A sidecar that has
291
+ // never started is a missing python or a missing binding, and
292
+ // respawning that on every call is a spawn storm against a fault no
293
+ // amount of retrying fixes.
294
+ if (this.#everReady) {
295
+ this.#ready = null;
296
+ this.#lost = true;
297
+ return;
298
+ }
225
299
  reject(error);
226
300
  });
227
301
  });
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