pmtiles-swarm 0.98.2 โ†’ 0.99.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,51 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.99.0
11
+ ### โœจ Features and improvements
12
+ - **A sidecar crash now names the frame it happened in.** `pmtiles-torrent` 0.11.0 arms
13
+ `faulthandler` in the sidecar, so a segfault writes a Python traceback to stderr on its way out
14
+ and this node forwards it into the log line by line, beside the `sidecar killed by SIGSEGV` that
15
+ used to be the whole story. Nothing to configure; it costs nothing until the process faults.
16
+
17
+ Worth having because that signal is otherwise the only evidence. On the node this came from it
18
+ appeared several times per start for eleven days, and each crash takes the archives handed over
19
+ before it โ€” which is what leaves a library reading `not loaded` after a restart.
20
+
21
+ ### ๐Ÿž Bug fixes
22
+ - **An archive the engine took and then did not keep is handed back, rather than only reported.**
23
+ The seeding check has been able to spot this for a while and its own message admitted the rest:
24
+ "nothing will start it before the next restart". It was right, which is why the cure was always
25
+ another restart. The usual cause is a sidecar that dies partway through a restore โ€” the
26
+ replacement holds nothing, so the archives handed over before it died are absent while the ones
27
+ after it are fine, and `add` resolving is no evidence that anything was kept.
28
+
29
+ Those are now handed back once and re-checked, and the log says whether it took. Once, not in a
30
+ loop: an engine that refuses twice will not be talked round by a third try. And only when the
31
+ engine is holding *some* of the library โ€” one holding none of it, or one that could not be listed
32
+ at all, is not suffering a per-archive fault, and re-adding everything on the strength of that
33
+ answer is how a node spends its start hashing what it already had.
34
+ - **A pinned archive in a stack was listed by its infohash.** Forty characters of hex, where the
35
+ picker that offered it had shown a filename. The row leads with the archive's name now and shows
36
+ the infohash as what it resolves to, which for a pinned source is exactly what it means: this
37
+ build and no later one.
38
+
39
+ ## 0.98.3
40
+ ### โœจ Features and improvements
41
+
42
+ ### ๐Ÿž Bug fixes
43
+ - **A stack could not be pointed at a completed MBTiles archive.** The source picker offered only
44
+ PMTiles, so an MBTiles the node was perfectly able to serve tiles from was simply absent from the
45
+ menu. It offers a complete one now, by the same rule the tile route enforces โ€” and still hides one
46
+ that is arriving, since a stack naming that would answer 503 for every tile until the download
47
+ finished.
48
+ - **A planet dump was offered as a stack source.** The same filter read an archive with no recorded
49
+ kind as PMTiles, which is not a harmless default: a mirrored `.osm.pbf` appeared in the menu, and
50
+ naming it would have produced a recipe that failed at its first tile. The kind now falls back to
51
+ the filename before anything is assumed, which is how `prewarm.js` resolves it after the identical
52
+ mistake sent every mirrored dump round the header-read backoff for ever. Such an archive is also
53
+ labelled `unknown ยท not servable` in the list, where it previously said nothing at all.
54
+
10
55
  ## 0.98.2
11
56
  ### โœจ Features and improvements
12
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.98.2",
3
+ "version": "0.99.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",
@@ -49,7 +49,7 @@
49
49
  "maplibre-gl": "^6.2.0",
50
50
  "parse-torrent": "^11.0.24",
51
51
  "pmtiles": "^4.4.1",
52
- "pmtiles-torrent": "^0.10.2",
52
+ "pmtiles-torrent": "^0.11.0",
53
53
  "webtorrent": "^3.0.21"
54
54
  },
55
55
  "engines": {
package/src/library.js CHANGED
@@ -2154,16 +2154,46 @@ export class Library {
2154
2154
  * @param {object[]} entries - The entries restore worked through.
2155
2155
  * @returns {Promise<void>} - Resolves once every claim has been checked.
2156
2156
  */
2157
+ /**
2158
+ * Whether the engine is holding this archive right now.
2159
+ *
2160
+ * Asked of one archive rather than by re-listing the library: this runs only
2161
+ * for an archive that has just been handed back, which on a healthy node is
2162
+ * never.
2163
+ * @param {string} infoHash - The archive.
2164
+ * @returns {Promise<boolean>} - Whether the engine has it.
2165
+ */
2166
+ async #engineHolds(infoHash) {
2167
+ const status = await this.#engine.get?.(infoHash).catch(() => null);
2168
+ return Boolean(status);
2169
+ }
2170
+
2157
2171
  async #verifySeeding(entries) {
2158
2172
  if (entries.length === 0) return;
2159
2173
 
2160
2174
  // One listing rather than a status call each: this runs over the whole
2161
2175
  // library on every start, and a round trip per archive is a cost paid by
2162
2176
  // every node to catch a fault most of them do not have.
2163
- const held = new Map();
2164
- for (const status of await this.#engine.list().catch(() => [])) {
2165
- held.set(status.infoHash, status);
2166
- }
2177
+ // Whether the listing worked is a separate fact from what it contained,
2178
+ // and collapsing the two into an empty map is what made this only ever
2179
+ // able to report. A repair has to know the difference: an engine that
2180
+ // could not be asked is not an engine holding nothing.
2181
+ const listing = await this.#engine.list().then(
2182
+ (all) => ({ answered: true, all }),
2183
+ () => ({ answered: false, all: [] }),
2184
+ );
2185
+ const held = new Map(
2186
+ listing.all.map((status) => [status.infoHash, status]),
2187
+ );
2188
+
2189
+ // Handing archives back one at a time only makes sense against an engine
2190
+ // that is holding some of them. One holding none of what it was just given
2191
+ // is not suffering a per-archive fault -- it is a replacement that came up
2192
+ // empty, or a listing that cannot be trusted -- and re-adding the whole
2193
+ // library on the strength of a bad answer is how a node spends its start
2194
+ // hashing everything it already had. That case is the reconnect handler's,
2195
+ // and it is reported here rather than acted on.
2196
+ const repairable = listing.answered && held.size > 0;
2167
2197
 
2168
2198
  let wrong = 0;
2169
2199
  for (const entry of entries) {
@@ -2178,11 +2208,50 @@ export class Library {
2178
2208
  // it absent from the engine and unreported by the very check meant to
2179
2209
  // notice. Absent is absent โ€” it is neither seeding nor downloading.
2180
2210
  if (!status) {
2211
+ // Handed back rather than only reported. This is the one fault here
2212
+ // with an obvious remedy -- the archive was restorable a moment ago,
2213
+ // since restore did it without complaint -- and the usual cause is a
2214
+ // sidecar that died partway through and was replaced by one holding
2215
+ // nothing. The replacement is up by the time this runs, so the second
2216
+ // attempt is against a working engine.
2217
+ //
2218
+ // Once, not in a loop. An engine that refuses twice is not going to be
2219
+ // talked round by a third try, and a restore that retried for ever
2220
+ // would keep a node busy instead of letting it say what is wrong.
2221
+ if (!repairable) {
2222
+ wrong += 1;
2223
+ console.error(
2224
+ `${label}: restore handed this to the engine and the engine is ` +
2225
+ 'not holding it. It is neither seeding nor downloading, and ' +
2226
+ 'nothing will start it before the next restart.',
2227
+ );
2228
+ continue;
2229
+ }
2230
+
2231
+ const again = await this.#readd(entry).then(
2232
+ () => true,
2233
+ (error) => {
2234
+ console.error(
2235
+ `${label}: could not be handed back: ${error.message}`,
2236
+ );
2237
+ return false;
2238
+ },
2239
+ );
2240
+ const recovered = again && (await this.#engineHolds(entry.infoHash));
2241
+ if (recovered) {
2242
+ console.warn(
2243
+ `${label}: the engine was not holding this after restore, so it ` +
2244
+ 'was handed back. It is loaded now.',
2245
+ );
2246
+ continue;
2247
+ }
2248
+
2181
2249
  wrong += 1;
2182
2250
  console.error(
2183
- `${label}: restore handed this to the engine and the engine is not ` +
2184
- 'holding it. It is neither seeding nor downloading, and nothing ' +
2185
- 'will start it before the next restart.',
2251
+ `${label}: restore handed this to the engine, the engine is not ` +
2252
+ 'holding it, and handing it back did not take either. It is ' +
2253
+ 'neither seeding nor downloading. The log above this says what ' +
2254
+ 'the engine has been doing; a restart is the next thing to try.',
2186
2255
  );
2187
2256
  continue;
2188
2257
  }
@@ -1836,11 +1836,31 @@
1836
1836
  * @returns {boolean} - Whether to offer the tile endpoints.
1837
1837
  */
1838
1838
  const isServable = (entry) => {
1839
- const kind = entry.kind ?? 'pmtiles';
1839
+ const kind = kindOf(entry);
1840
1840
  if (kind === 'pmtiles') return true;
1841
1841
  return kind === 'mbtiles' && entry.complete === true;
1842
1842
  };
1843
1843
 
1844
+ /**
1845
+ * What kind of archive this is, from the record or failing that the name.
1846
+ *
1847
+ * An entry from before the kind was recorded carries none, and reading
1848
+ * that as PMTiles is not a harmless default: it is how a planet dump ends
1849
+ * up labelled servable and offered as a stack source, where naming it
1850
+ * would produce a recipe that fails at its first tile. `prewarm.js`
1851
+ * resolves it exactly this way, after the same mistake sent every
1852
+ * mirrored .osm.pbf round the header-read backoff for ever.
1853
+ * @param {object} entry - A catalog entry.
1854
+ * @returns {string} - 'pmtiles', 'mbtiles', or 'unknown'.
1855
+ */
1856
+ const kindOf = (entry) => {
1857
+ if (entry.kind) return entry.kind;
1858
+ const name = entry.name ?? '';
1859
+ if (/\.pmtiles$/i.test(name)) return 'pmtiles';
1860
+ if (/\.mbtiles$/i.test(name)) return 'mbtiles';
1861
+ return 'unknown';
1862
+ };
1863
+
1844
1864
  /**
1845
1865
  * What to say under the size about an archive that is not PMTiles.
1846
1866
  *
@@ -1852,7 +1872,7 @@
1852
1872
  * @returns {string} - The note, or empty for a PMTiles archive.
1853
1873
  */
1854
1874
  const kindNote = (entry) => {
1855
- const kind = entry.kind ?? 'pmtiles';
1875
+ const kind = kindOf(entry);
1856
1876
  if (kind === 'pmtiles') return '';
1857
1877
  if (kind !== 'mbtiles') return `${escapeHtml(kind)} ยท not servable`;
1858
1878
  return entry.complete === true
@@ -2563,7 +2583,7 @@
2563
2583
  // anything for an archive read out of the swarm. An MBTiles is read
2564
2584
  // whole or not at all โ€” once it is here there is nothing left to
2565
2585
  // fetch, and before that there is no tile to warm.
2566
- const warmable = (entry.kind ?? 'pmtiles') === 'pmtiles';
2586
+ const warmable = kindOf(entry) === 'pmtiles';
2567
2587
  const torrentUrl = `${base}/archives/${entry.infoHash}/archive.torrent`;
2568
2588
  const summary = entry.pmtiles ?? {};
2569
2589
  const mode = entry.mode ?? 'mirror';
@@ -2648,7 +2668,7 @@
2648
2668
  ${
2649
2669
  servable
2650
2670
  ? `<div class="sub" style="margin-bottom:1rem"><code>${escapeHtml(tileJson)}</code></div>`
2651
- : (entry.kind ?? 'pmtiles') === 'mbtiles'
2671
+ : kindOf(entry) === 'mbtiles'
2652
2672
  ? `<div class="sub" style="margin-bottom:1rem">
2653
2673
  This MBTiles archive is still arriving, so it has no tile endpoint yet. It is
2654
2674
  SQLite, which cannot be read a byte range at a time out of the swarm the way
@@ -7943,6 +7963,17 @@ Every piece is hashed against the ` +
7943
7963
  ? source.bounds.map((n) => Number(n).toFixed(2)).join(', ')
7944
7964
  : null;
7945
7965
 
7966
+ // A pinned archive is named in the recipe by its infohash, which is
7967
+ // forty characters of hex and tells nobody which archive it is. The
7968
+ // filename is what the picker offered and what an operator recognises,
7969
+ // so lead with that and let the infohash be what it resolves to --
7970
+ // which for a pinned source is exactly what it means: this build, and
7971
+ // no later one. Falls back to the hash for an archive that has been
7972
+ // removed, where the name is all that is gone.
7973
+ const pinnedArchive = kind === 'archive';
7974
+ const label =
7975
+ pinnedArchive && source.archiveName ? source.archiveName : source.name;
7976
+
7946
7977
  const notes = [];
7947
7978
  if (index === all.length - 1 && all.length > 1) {
7948
7979
  notes.push('<span class="pill">wins</span>');
@@ -7955,7 +7986,7 @@ Every piece is hashed against the ` +
7955
7986
  return `
7956
7987
  <tr${source.resolved ? '' : ' class="bad"'}>
7957
7988
  <td class="muted">${index}</td>
7958
- <td><code>${escapeHtml(source.name)}</code>
7989
+ <td><code>${escapeHtml(label)}</code>
7959
7990
  <span class="muted">${kind}</span></td>
7960
7991
  <td>${
7961
7992
  source.resolved
@@ -7965,7 +7996,13 @@ Every piece is hashed against the ` +
7965
7996
  ? `<span class="muted" title="Another recipe, evaluated for this tile and merged as one layer. It follows every later change to that stack.">recipe of ${
7966
7997
  source.nested ?? 0
7967
7998
  } source${source.nested === 1 ? '' : 's'}</span>`
7968
- : `<code class="muted">${escapeHtml(source.archiveName ?? '')}</code>`
7999
+ : pinnedArchive
8000
+ ? `<code class="muted" title="${escapeHtml(
8001
+ source.infohash ?? source.name,
8002
+ )}">${escapeHtml(
8003
+ (source.infohash ?? source.name).slice(0, 12),
8004
+ )}โ€ฆ</code>`
8005
+ : `<code class="muted">${escapeHtml(source.archiveName ?? '')}</code>`
7969
8006
  : '<span class="bad">does not resolve</span>'
7970
8007
  }</td>
7971
8008
  <td>${zooms}${
@@ -8175,10 +8212,12 @@ Every piece is hashed against the ` +
8175
8212
  // missing from a menu the operator is looking at. This is the same
8176
8213
  // endpoint the Archives view reads, and it answers a bare array.
8177
8214
  const held = await api('/api/torrents');
8215
+ // The same rule the tile route enforces, because a stack source is
8216
+ // read through exactly that path: a complete MBTiles belongs here,
8217
+ // and one still arriving does not -- a stack naming it would answer
8218
+ // 503 for every tile until the download finished.
8178
8219
  const free = (held ?? []).filter(
8179
- (entry) =>
8180
- !taken.has(entry.infoHash) &&
8181
- (entry.kind ?? 'pmtiles') === 'pmtiles',
8220
+ (entry) => !taken.has(entry.infoHash) && isServable(entry),
8182
8221
  );
8183
8222
  if (free.length) {
8184
8223
  groups.push(