pmtiles-swarm 0.71.0 → 0.72.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 CHANGED
@@ -7,6 +7,45 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.72.0
11
+ ### ✨ Features and improvements
12
+ - **An unfinished export is picked up when the node starts.** The checkpoint was always there;
13
+ finding it again meant somebody remembering to press the button. That is fine for an export
14
+ stopped on purpose and wrong for one a crash took, which is the case that costs the most and
15
+ gives the least warning.
16
+
17
+ The checkpoint now records what the job was - what the archive is called, what the file is
18
+ called, where it was going, what it is filed under - because none of that can be worked out from
19
+ the tiles on disk, and a resumed export has to be the one somebody asked for rather than a new
20
+ one with today's date on it.
21
+
22
+ Only where the recipe still resolves to what it did. `bakeRevision` covers what each source
23
+ became, so a rebuilt source means the checkpoint holds half of a map that no longer exists;
24
+ that is left alone and reported, to be discarded when somebody exports again deliberately.
25
+ `stacks.resumeExports` turns it off for a node where hours of merging should never begin
26
+ without being asked for.
27
+
28
+ ### 🐞 Bug fixes
29
+ - **The node was exiting when a peer wire outlived its torrent.** Twice in one evening on a real
30
+ node, both times killing an export that was hours in:
31
+
32
+ torrent.js:2092 this.client._debugId -> reading '_debugId' of null
33
+ peer.js:201 this.swarm.client.dht -> reading 'dht' of null
34
+
35
+ WebTorrent nulls `torrent.client` when a torrent is destroyed and does not always tear down that
36
+ torrent's peer wires with it. One fired a keep-alive timeout afterwards and the other finished a
37
+ handshake, and both reached through the dead reference. They happen inside a timer or a socket
38
+ callback, so there is no promise to reject and no call of ours to wrap - it is an uncaught
39
+ exception, and Node's answer to that is to exit. Under `Restart=always` that reads as a
40
+ mysterious restart rather than as a crash.
41
+
42
+ `src/crash-guard.js` survives exactly that shape and nothing else: a TypeError, about one of a
43
+ named list of properties, raised from a frame inside the torrent libraries. Every other uncaught
44
+ exception still stops the process, because one that survives everything is one that lies about
45
+ its own state. The two stacks from the journal are in the tests verbatim, alongside the near
46
+ misses that must still be fatal - the same error from our own code, a different error from
47
+ theirs.
48
+
10
49
  ## 0.71.0
11
50
  ### ✨ Features and improvements
12
51
  - **The preview comes back to where it was opened from.** The link out of a preview said
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/bake-jobs.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  import {
3
4
  assertBakeable,
@@ -108,6 +109,77 @@ export class BakeManager {
108
109
  return true;
109
110
  }
110
111
 
112
+ /**
113
+ * Picks up exports a previous run did not finish.
114
+ *
115
+ * A checkpoint is the hours already spent, and finding one again used to
116
+ * mean somebody remembering to press the button. That is fine for an export
117
+ * stopped on purpose and wrong for one a crash took -- which is the case
118
+ * that costs the most and gives the least warning.
119
+ *
120
+ * Only where the recipe still resolves to what it did. `bakeRevision` covers
121
+ * what each source became, so a rebuilt source means the checkpoint holds
122
+ * half of a map that no longer exists; that is left alone rather than
123
+ * continued, and discarded when somebody exports again deliberately.
124
+ * @param {Function} resolve - `(stackId) => resolved stack | null`.
125
+ * @returns {Promise<object[]>} - The jobs started.
126
+ */
127
+ async resumeAll(resolve) {
128
+ const started = [];
129
+
130
+ for (const root of this.#workRoots()) {
131
+ const directory = path.join(root, WORK_DIR);
132
+ const found = await fs.readdir(directory).catch(() => []);
133
+
134
+ for (const stackId of found) {
135
+ if (this.#jobs.has(stackId)) continue;
136
+ const state = await fs
137
+ .readFile(path.join(directory, stackId, 'bake-state.json'), 'utf8')
138
+ .then((raw) => JSON.parse(raw))
139
+ .catch(() => null);
140
+ // Written by a version that did not record what the job was. There is
141
+ // nothing to reproduce it from, so it waits for a person.
142
+ if (!state?.describe) continue;
143
+
144
+ const resolved = resolve(stackId);
145
+ if (!resolved || bakeRevision(resolved) !== state.revision) continue;
146
+
147
+ try {
148
+ const job = await this.start({ resolved, ...state.describe });
149
+ started.push(job);
150
+ console.log(
151
+ `[bake] picking up ${stackId} where it stopped: ` +
152
+ `${state.written ?? 0} tiles already merged`,
153
+ );
154
+ } catch (error) {
155
+ // A stack that cannot be baked now -- no codec, sources gone -- is
156
+ // said out loud and left. Its checkpoint is still there.
157
+ console.warn(`[bake] could not resume ${stackId}: ${error.message}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ return started;
163
+ }
164
+
165
+ /**
166
+ * Everywhere an export might have left work.
167
+ *
168
+ * The working directory follows the destination, and a destination is
169
+ * whatever was chosen at the time -- so this is every place one could have
170
+ * been: the named locations, the default save path, and the data directory,
171
+ * where exports worked before the working directory moved.
172
+ * @returns {string[]} - Roots to look under, without repeats.
173
+ */
174
+ #workRoots() {
175
+ const roots = [
176
+ ...(this.#config.locations ?? []).map((one) => one?.path),
177
+ this.#config.savePath,
178
+ this.#config.dataDir,
179
+ ].filter(Boolean);
180
+ return [...new Set(roots.map((one) => path.resolve(one)))];
181
+ }
182
+
111
183
  /**
112
184
  * Stops every running bake and waits for each to write its checkpoint.
113
185
  *
@@ -341,6 +413,17 @@ export class BakeManager {
341
413
  header: { format },
342
414
  pauseMs: this.#config.stacks?.bakePauseMs ?? 0,
343
415
  concurrency: this.#concurrency(),
416
+ // Written into the checkpoint in the shape `start` takes, so picking one
417
+ // up is handing it back rather than reconstructing it. Nothing here can
418
+ // be worked out from the tiles on disk: what the archive is called, what
419
+ // the file is called, where it was going, what it is filed under.
420
+ describe: {
421
+ name: job.archiveName,
422
+ filename: job.name,
423
+ publishDir: job.publishDir ?? null,
424
+ description: options.description ?? null,
425
+ categories: options.categories ?? null,
426
+ },
344
427
  metadata: {
345
428
  name: job.archiveName,
346
429
  // Only what was asked for. Falling back to the recipe's own
package/src/bake.js CHANGED
@@ -468,6 +468,11 @@ export async function bakeStack(options) {
468
468
  checkpointSeconds = DEFAULT_CHECKPOINT_SECONDS,
469
469
  pauseMs = 0,
470
470
  concurrency = DEFAULT_CONCURRENCY,
471
+ // Written into the checkpoint and handed back by `readCheckpoint`. A
472
+ // resumed export has to reproduce the one somebody asked for -- its name,
473
+ // where it was going, what it was filed under -- and none of that can be
474
+ // worked out from the tiles on disk.
475
+ describe,
471
476
  } = options;
472
477
  const batchSize = Math.max(1, Math.floor(concurrency));
473
478
 
@@ -516,6 +521,7 @@ export async function bakeStack(options) {
516
521
  dataBytes: writer.dataBytes,
517
522
  addressed: writer.addressedTiles,
518
523
  clustered: writer.clustered,
524
+ ...(describe ? { describe } : {}),
519
525
  },
520
526
  writer.entries,
521
527
  persisted,
package/src/config.js CHANGED
@@ -388,6 +388,19 @@ const DEFAULTS = {
388
388
  * not clustered is bad at the one thing PMTiles is for.
389
389
  */
390
390
  bakeConcurrency: 4,
391
+ /**
392
+ * Whether an unfinished export is picked up when the node starts.
393
+ *
394
+ * A checkpoint is the hours already spent, and the case that costs most is
395
+ * the one nobody chose: a crash, or a restart in the middle of a run. On
396
+ * by default for that reason. Off for a node where hours of merging should
397
+ * never begin without somebody asking for it.
398
+ *
399
+ * Only where the recipe still resolves to what it did. A rebuilt source
400
+ * means the checkpoint holds half of a map that no longer exists, and that
401
+ * is left alone either way.
402
+ */
403
+ resumeExports: true,
391
404
  },
392
405
  /**
393
406
  * Folders scanned for new archives. Each entry is `{ path, categories,
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Surviving the one crash a torrent client raises from a timer.
3
+ *
4
+ * WebTorrent nulls `torrent.client` when a torrent is destroyed, and does not
5
+ * always tear down that torrent's peer wires with it. A wire that outlives its
6
+ * torrent then fires — a keep-alive timeout, or a handshake finishing — and
7
+ * reaches through the dead reference:
8
+ *
9
+ * torrent.js:2092 this.client._debugId → reading '_debugId' of null
10
+ * peer.js:201 this.swarm.client.dht → reading 'dht' of null
11
+ *
12
+ * Both happen inside a `setTimeout` or a socket callback, so there is no
13
+ * promise to reject and no call of ours to wrap: it is an uncaught exception,
14
+ * and Node's answer to that is to exit. Under `Restart=always` the service
15
+ * comes straight back, which is why this reads as a mysterious restart rather
16
+ * than as a crash — and anything the node was in the middle of, an export
17
+ * above all, is simply gone.
18
+ *
19
+ * So this is deliberately narrow. It survives exactly the shape above and
20
+ * nothing else: a TypeError, about a null property this list names, raised
21
+ * from inside the torrent libraries. Every other uncaught exception keeps
22
+ * Node's behaviour, because a process that survives everything is a process
23
+ * that lies about its own state.
24
+ */
25
+
26
+ /** Packages whose internals may raise this. Ours is not among them. */
27
+ const LIBRARIES = [
28
+ '/webtorrent/',
29
+ '/bittorrent-protocol/',
30
+ '/torrent-discovery/',
31
+ ];
32
+
33
+ /** The properties a wire reaches for on a client that has gone. */
34
+ const REACHING_FOR = ['_debugId', 'dht', 'client', 'swarm', 'torrent', 'wires'];
35
+
36
+ /**
37
+ * Whether an uncaught exception is a peer wire touching a destroyed torrent.
38
+ * @param {unknown} error - What was thrown.
39
+ * @returns {boolean} - True when it is safe to carry on.
40
+ */
41
+ export function isDeadTorrentWire(error) {
42
+ if (!(error instanceof TypeError)) return false;
43
+
44
+ const message = String(error.message ?? '');
45
+ const reading =
46
+ /Cannot read propert(?:y|ies) of (?:null|undefined) \(reading '([^']+)'\)/.exec(
47
+ message,
48
+ );
49
+ // Older phrasings say it the other way round.
50
+ const older = /Cannot read property '([^']+)' of (?:null|undefined)/.exec(
51
+ message,
52
+ );
53
+ const property = reading?.[1] ?? older?.[1];
54
+ if (!property || !REACHING_FOR.includes(property)) return false;
55
+
56
+ // The frame that threw has to be theirs. An error of ours that happens to
57
+ // mention the same property is a bug worth dying on.
58
+ const stack = String(error.stack ?? '').replaceAll('\\', '/');
59
+ return LIBRARIES.some((library) => stack.includes(library));
60
+ }
61
+
62
+ /**
63
+ * Installs the guard.
64
+ *
65
+ * Returns what it registered so a test can take it off again, and so nothing
66
+ * has to reach into `process` to find out what is installed.
67
+ * @param {object} [options] - `onSurvived`, and `exit` for testing.
68
+ * @returns {Function} - The handler, for `process.off`.
69
+ */
70
+ export function installCrashGuard(options = {}) {
71
+ const survived =
72
+ options.onSurvived ??
73
+ ((error) => {
74
+ console.warn(
75
+ `[torrent] a peer wire outlived its torrent and threw: ${error.message}. ` +
76
+ 'Carried on — this is a fault inside webtorrent, not a state this ' +
77
+ 'node cannot continue from.',
78
+ );
79
+ });
80
+ const exit = options.exit ?? ((code) => process.exit(code));
81
+
82
+ const handler = (error) => {
83
+ if (isDeadTorrentWire(error)) {
84
+ survived(error);
85
+ return;
86
+ }
87
+ // Node's own behaviour for everything else: say what happened, and stop.
88
+ console.error(error);
89
+ exit(1);
90
+ };
91
+
92
+ process.on('uncaughtException', handler);
93
+ return handler;
94
+ }
package/src/index.js CHANGED
@@ -5,8 +5,9 @@ import { parseArgs } from 'node:util';
5
5
  import { createApp } from './api.js';
6
6
  import { assertSafeToListen, createAuth } from './auth.js';
7
7
  import { Catalog } from './catalog.js';
8
- import { StackStore } from './stacks.js';
8
+ import { StackStore, resolveStack } from './stacks.js';
9
9
  import { StackCache } from './stack-cache.js';
10
+ import { installCrashGuard } from './crash-guard.js';
10
11
  import { CutlineStore } from './cutlines.js';
11
12
  import { BakeManager } from './bake-jobs.js';
12
13
  import { loadCodec } from './codec.js';
@@ -212,6 +213,11 @@ PMTILES_SWARM_PUBLIC_URL
212
213
  /** @type {Array<{label: string, stop: () => unknown, ms?: number}>} */
213
214
  const stoppers = [];
214
215
  installSignalHandlers(stoppers);
216
+ // Before anything opens a socket. A peer wire that outlives its torrent
217
+ // throws from a timer, which is an uncaught exception, which is an exit --
218
+ // and under Restart=always that reads as a mysterious restart rather than a
219
+ // crash, taking whatever was running with it.
220
+ installCrashGuard();
215
221
 
216
222
  // Before anything is created or any port is bound: an unauthenticated node
217
223
  // on a reachable address fails silently, working perfectly right up until
@@ -474,6 +480,25 @@ PMTILES_SWARM_PUBLIC_URL
474
480
  cutlines,
475
481
  });
476
482
 
483
+ // Anything a previous run did not finish. Deliberately after the stacks and
484
+ // the catalog are loaded, since a checkpoint is only worth picking up where
485
+ // the recipe still resolves to what it did when the work was done.
486
+ //
487
+ // Not awaited: an export is hours, and the node should be answering requests
488
+ // while it runs rather than after it.
489
+ if (config.stacks?.resumeExports !== false) {
490
+ bakes
491
+ .resumeAll((stackId) => {
492
+ const stack = stacks?.list().find((one) => one.id === stackId);
493
+ if (!stack) return null;
494
+ return resolveStack(stack, {
495
+ archive: (hash) => catalog.get(hash),
496
+ category: (name) => catalog.byCategory(name)[0] ?? null,
497
+ });
498
+ })
499
+ .catch((error) => console.warn(`[bake] resume failed: ${error.message}`));
500
+ }
501
+
477
502
  // Early in the sequence, so an export is told to stop before the pieces it
478
503
  // reads through are taken away. Its checkpoint is the hours already spent.
479
504
  stoppers.unshift({
@@ -5228,6 +5228,16 @@ Every piece is hashed against the ` +
5228
5228
  'restores the old behaviour. They are written in order ' +
5229
5229
  'whatever it is set to.',
5230
5230
  },
5231
+ {
5232
+ key: 'stacks.resumeExports',
5233
+ label: 'Pick up an unfinished export when the node starts',
5234
+ type: 'boolean',
5235
+ restart: true,
5236
+ help:
5237
+ 'A checkpoint is the hours already spent, and the case that ' +
5238
+ 'costs most is the one nobody chose - a crash, or a restart ' +
5239
+ 'mid-run. Only where the recipe still resolves to what it did.',
5240
+ },
5231
5241
  {
5232
5242
  key: 'stacks.bakePauseMs',
5233
5243
  label: 'Pause between batches while exporting',