pmtiles-swarm 0.72.0 → 0.73.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,119 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.73.0
11
+ ### ✨ Features and improvements
12
+ - **A source can fade in at the edge of its shape instead of stopping dead.** Where a
13
+ high-resolution local DEM meets a coarser global one the two disagree - by their survey and by
14
+ their vertical datum - and the merge took the upper one outright, so the step between them read
15
+ as a wall under a hillshade. `feather` on a source, in pixels, ramps its weight in from its
16
+ `cutline` or its `bounds`.
17
+
18
+ The step left is the height difference divided by the feather, which makes the number
19
+ predictable: two sources 40 m apart, faded over 16 pixels, step 2.5 m a pixel instead of 40 m at
20
+ once. It also hides a vertical datum disagreement, which otherwise wants a hand-tuned
21
+ `heightAdjustment` to correct and is the same wall by another cause.
22
+
23
+ Three things worth knowing. The ramp runs inward only, because a cutline says where a source's
24
+ data is good and spreading it outward would answer for ground the recipe just excluded. A
25
+ feathered layer over a hole stands alone, because fading into nothing would erode the source by
26
+ the width of its own feather exactly where it is the only cover. And RGBA needed no merge change
27
+ at all - alpha is already the weight that space composites with.
28
+
29
+ Not yet for a mask edge, which is where most seams actually come from. A cutline is known in
30
+ full, so the ramp beside a tile costs a few extra rasterised rows; a mask edge lives in the
31
+ source's own pixels and needs its neighbours read and decoded. docs/tile-stacks.md says what that
32
+ would take.
33
+ - **Smoothing is in the stack editor, and bounded.** `gaussianBlurSigma` was honoured, documented
34
+ and reachable only by hand-editing `stacks.json`. It now sits under the output settings beside
35
+ Resampling, where it belongs - the two describe the same operation - and is hidden for an RGBA
36
+ stack, which never enters elevation space. Zero is left out of the recipe rather than written
37
+ down.
38
+
39
+ Validated as well as offered. The sigma a recipe names is multiplied by how many zoom levels a
40
+ source was upscaled, so the cost of a typo grows with the kernel and the distance at once: `50`
41
+ at a six-level upscale is a 900-pixel kernel and eight seconds for one 512px tile, on an endpoint
42
+ anybody can ask. `8` is the most it takes now, which is already past useful - these archives were
43
+ built with `1.5`.
44
+
45
+ The export dialog says what the recipe will write - encoding, format, size, resampling, smoothing
46
+ - because an export is hours of work and every setting that decides the result lives in the stack
47
+ rather than in that dialog.
48
+ - **An export says so when it cannot use the machine it was given.** `stacks.bakeConcurrency` sizes
49
+ how many tiles are merged at once and how many threads do their arithmetic. It does not size the
50
+ one that binds: decoding and encoding a tile is sharp, sharp does that on libuv's thread pool, and
51
+ that pool holds four threads unless the environment says otherwise. Raising `bakeConcurrency` past
52
+ four moved where the merges queued rather than how many ran, so the export stayed slow and the
53
+ cores stayed idle with nothing logged and nothing failing.
54
+
55
+ Measured on sixteen cores, merging 512px tiles from three sources into lossless WebP: 26 tiles/s
56
+ at the default pool of 4, 41 at 8, 51 at 16, 54 at 32. It cannot be set from inside the process -
57
+ the pool is built before any of this code runs, and setting `process.env.UV_THREADPOOL_SIZE` at
58
+ startup measurably does nothing - so a bake now warns once when `bakeConcurrency` is larger than
59
+ the pool, naming the variable and the value to give it. The systemd unit in the guide sets it,
60
+ and docs/tile-stacks.md has the numbers under "Giving a bake the whole machine".
61
+
62
+ The batch is not the problem, which is worth writing down because it looks like it should be:
63
+ replacing it with a sliding window that retires in order measured 12% better on a large pool and
64
+ worse on the default one.
65
+
66
+ `init --systemd` writes `Environment=UV_THREADPOOL_SIZE` into the unit, set from
67
+ `stacks.bakeConcurrency`, for the same reason it derives `ReadWritePaths`: it is decidable from
68
+ the configuration and silent when wrong. An existing unit does not gain it by upgrading - re-run
69
+ `pmtiles-swarm init --systemd`, or add the line to `[Service]` by hand.
70
+
71
+ Both the unit it writes and the warning ask for `min(bakeConcurrency, cores)` rather than
72
+ `bakeConcurrency`, because past the core count a thread has nowhere to run and the measurements
73
+ flatten. A node merging 32 tiles at once on twelve cores wants twelve threads and is not
74
+ misconfigured; the first version of this would have told its operator otherwise.
75
+
76
+ ### 🐞 Bug fixes
77
+ - **The smoothing was drawing a grid at tile boundaries.** `blurHeights` reads a neighbourhood and
78
+ a tile server has one tile, so it clamped at the edge - and two adjacent tiles then computed their
79
+ shared boundary from different data. On a slope they disagreed by tens of metres, which under a
80
+ hillshade is a faint line at every tile edge.
81
+
82
+ An upscaled source turns out to have the pixels already. The blur only runs where the tile came
83
+ from a parent, and that parent was read in full, so `resampleFromParent` now takes a `margin`,
84
+ the blur runs over the larger raster, and `cropMargin` throws the border away. At a six-level
85
+ upscale a 27-pixel border is 0.42 parent pixels - already in the array. Two adjacent children of
86
+ one parent, at `gaussianBlurSigma: 1.5`, stepping beyond what the same two unblurred tiles step:
87
+
88
+ upscale 2 (sigma 3) 31.73 m -> -0.05 m
89
+ upscale 4 (sigma 6) 20.14 m -> 0.00 m
90
+ upscale 6 (sigma 9) 6.15 m -> 0.00 m
91
+ upscale 8 (sigma 12) 0.43 m -> 0.00 m
92
+
93
+ Worst at a moderate upscale rather than an extreme one, which is not the intuition: eight levels
94
+ up the sub-region is two parent pixels across and there is little for a clamped edge to get
95
+ wrong. It costs 10-15%, and the border is capped at a quarter of the tile.
96
+
97
+ Anything built with a sigma has this baked in, here and in the offline merge - `gaussian_filter`
98
+ defaults to `mode='reflect'` over one tile's array. Re-exporting is what removes it.
99
+
100
+ ## 0.72.1
101
+ ### 🐞 Bug fixes
102
+ - **A settings save could set something that was not a setting.** `saveConfig` rejected a key it
103
+ did not recognise by asking `key in DEFAULTS`, and `in` walks the prototype chain: every object
104
+ inherits `__proto__`, `constructor` and `toString`, so all three passed as known settings. The
105
+ assignment underneath then did what those names mean rather than what a setting means -
106
+ `config.__proto__ = {...}` replaces the running config's prototype - and the result was written
107
+ to the config file. `Object.hasOwn` asks the question that was meant.
108
+
109
+ Reachable only by an admin token, which is a token that can change any setting anyway, so this
110
+ is a gate that was not doing its job rather than a way in. Found by reading through what
111
+ `security/detect-object-injection` had to say, which is what it is switched on for.
112
+ - **A date placeholder that was nearly right took exponentially long to reject.** `expandTemplate`
113
+ read a `{...}` group as a date pattern by testing it against `^[YMDymd]+([-_./ ]?[YMDymd]+)*$`.
114
+ With the separator optional, a run of field letters can be divided between the group and the `+`
115
+ in front of it in every possible way, so a group that is all field letters and one character that
116
+ cannot match has to try all of them before saying no: 24 characters took 100ms, and each two
117
+ after that doubled it.
118
+
119
+ Requiring the separator inside the group accepts exactly the same set of patterns - a run can
120
+ only be matched one way now - and rejects the near miss immediately. The template is config
121
+ rather than anything a stranger sends, so this was a source of surprise rather than a way in.
122
+
10
123
  ## 0.72.0
11
124
  ### ✨ Features and improvements
12
125
  - **An unfinished export is picked up when the node starts.** The checkpoint was always there;
@@ -213,6 +213,12 @@ WorkingDirectory=/var/lib/pmtiles-swarm
213
213
  ExecStart=/var/lib/pmtiles-swarm/node_modules/.bin/pmtiles-swarm \
214
214
  --config /etc/pmtiles-swarm/swarm.config.json
215
215
 
216
+ # How many tiles a stack export can decode or encode at once. Four unless this
217
+ # says otherwise, whatever stacks.bakeConcurrency is set to. `init --systemd`
218
+ # writes this from that setting; if you keep the unit by hand, keep the two in
219
+ # step. See docs/tile-stacks.md - "Giving a bake the whole machine".
220
+ Environment=UV_THREADPOOL_SIZE=16
221
+
216
222
  # Required. The console's Save & Restart exits and expects to be brought back;
217
223
  # see below.
218
224
  Restart=always
@@ -225,7 +225,7 @@ it differs from the snake_case rio-rgbify-merge uses.
225
225
  | `output.nodata` | Height an uncovered pixel encodes as. Elevation space only. |
226
226
  | `output.tileSize` | 256 or 512, the stack's default. A URL overrides it per request; with neither, the largest source decides. |
227
227
  | `resampling` | `nearest`, `bilinear`, `cubic` or `lanczos`. |
228
- | `gaussianBlurSigma` | Multiplied by the zoom distance of an upscaled parent. `0` disables. |
228
+ | `gaussianBlurSigma` | Multiplied by the zoom distance of an upscaled parent. `0` disables, `8` is the most it takes. |
229
229
  | `boundsSource` | Index into `sources` whose bounds become the stack's. Omit for the union. |
230
230
  | `bounds` | Explicit `[w, s, e, n]`. Wins over `boundsSource`. |
231
231
  | `minzoom` / `maxzoom` | Clamps. Default to the min and **max** over the sources. |
@@ -242,6 +242,7 @@ it differs from the snake_case rio-rgbify-merge uses.
242
242
  | `baseVal` / `interval` | Mapbox decode offset and step. Default `-10000` and `0.1`. |
243
243
  | `maskValues` | Decoded heights meaning "no data here". Elevation space only. |
244
244
  | `heightAdjustment` | Metres, added **after** masking. Elevation space only. |
245
+ | `feather` | Pixels to fade in over at the edge of the source's shape. Needs a `cutline` or `bounds`. Max 64. |
245
246
  | `opacity` | `0`–`1`, scales the source alpha. RGBA space only. |
246
247
  | `blend` | `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`. RGBA space only. |
247
248
 
@@ -711,6 +712,57 @@ tiles. Zero is as fast as it can go, which is right for a node baking and doing
711
712
  nothing else. On a node that is also serving maps it is the direct trade: how
712
713
  long the bake takes against how much of the machine it takes while it runs.
713
714
 
715
+ ### Giving a bake the whole machine
716
+
717
+ `stacks.bakeConcurrency` sizes two things: how many tiles are merged at once,
718
+ and how many threads `src/pixels.js` starts to do their arithmetic on. It does
719
+ not size the third thing, and the third thing is the one that binds.
720
+
721
+ Decoding a source tile and encoding the result are sharp, and sharp does that
722
+ work on libuv's thread pool. That pool holds **four** threads unless the
723
+ environment says otherwise, and it is built before any of this code runs — so
724
+ setting `process.env.UV_THREADPOOL_SIZE` from inside the process is too late and
725
+ measurably does nothing. Raising `bakeConcurrency` past four therefore moves
726
+ where the merges queue rather than how many of them run: they queue at the
727
+ decode instead of at the arithmetic, and the machine sits mostly idle looking
728
+ like a bake that is simply slow.
729
+
730
+ On sixteen cores, merging 512px tiles from three sources into lossless WebP:
731
+
732
+ | `UV_THREADPOOL_SIZE` | tiles/s |
733
+ | -------------------- | ------- |
734
+ | 4 (the default) | 26.2 |
735
+ | 8 | 40.9 |
736
+ | 16 | 51.1 |
737
+ | 32 | 54.2 |
738
+
739
+ So set it in the environment the node starts in, alongside `bakeConcurrency`:
740
+
741
+ ```ini
742
+ Environment=UV_THREADPOOL_SIZE=16
743
+ ```
744
+
745
+ Past the core count it flattens, because at that point the cores are the limit
746
+ and that is the right place for the limit to be — so what the pool wants is
747
+ `min(bakeConcurrency, cores)`, and both `init --systemd` and the warning below
748
+ work that out rather than echoing `bakeConcurrency` back. A node merging 32
749
+ tiles at once on twelve cores wants twelve threads and is not misconfigured.
750
+
751
+ A bake warns once when the pool is smaller than that, because the failure is
752
+ otherwise invisible: nothing errors, nothing logs, the export is just several
753
+ times slower than the machine can manage.
754
+
755
+ `sharp.concurrency()` is a different knob — how many threads libvips uses
756
+ _within_ one operation — and it is left alone. A 512px tile is too small to
757
+ split usefully, and the measurements above move by under 2% whether it is 1 or 16.
758
+
759
+ The batch is not the problem, which is worth writing down because it looks like
760
+ it should be. `bakeStack` merges a batch, waits for all of it, then writes it in
761
+ order, so nothing merges while anything writes and a batch costs the slowest
762
+ tile in it. Replacing that with a sliding window that retires in order was
763
+ measured at 12% on a large pool and _slower_ on the default one, which is not
764
+ worth the second thing to get right.
765
+
714
766
  ### What identifies a bake
715
767
 
716
768
  `bakeRevision` is the recipe's revision and what each source resolved to,
@@ -962,6 +1014,179 @@ missing. Serving a source unclipped because its cutline could not be found would
962
1014
  put back exactly the data somebody asked to remove, which is the one failure a
963
1015
  clip must not have.
964
1016
 
1017
+ ## Feathering a seam
1018
+
1019
+ _Built for a cutline and for `bounds`. A mask edge is not feathered yet, and
1020
+ the last section says what that still needs._
1021
+
1022
+ Smoothing hides the terracing **inside** an upscaled area. It does nothing about
1023
+ the artefact the original discussion actually named — "artefacts at tiles and
1024
+ country borders when multiple DEMs overlap and do not have the same resolution".
1025
+ That is a different thing: a step where one dataset stops and another resumes,
1026
+ and no amount of blurring one side of it will help.
1027
+
1028
+ Today the handover is exact. `paintHeights` walks the layers in order and takes
1029
+ whatever the upper one has:
1030
+
1031
+ ```js
1032
+ if (!Number.isNaN(value)) result[i] = value;
1033
+ ```
1034
+
1035
+ A pixel is one dataset or the other. Where a high-resolution local DEM ends, the
1036
+ next pixel is a global 30 m one upscaled six levels, and the two disagree — by
1037
+ their vertical datum, by their resampling, by simply being different surveys.
1038
+ Under a hillshade that reads as a wall.
1039
+
1040
+ ### Where the seams actually are
1041
+
1042
+ Three geometries, and they are not equally hard.
1043
+
1044
+ **A mask hole.** `maskValues` and `maskColors` turn nodata into `NaN`, and this
1045
+ is where most seams come from — a dataset that covers part of the world carries
1046
+ a fill value everywhere else, and masking it is what lets the layer underneath
1047
+ show through. The edge is data-dependent: it is wherever masked pixels meet
1048
+ unmasked ones, discovered per tile, at whatever resolution the source is being
1049
+ read at.
1050
+
1051
+ **A missing tile.** A sparse archive simply has no tile outside its extent, so
1052
+ the source contributes nothing and the boundary lands exactly on a tile edge —
1053
+ a 512-pixel straight line, which is the most visible seam of the three and the
1054
+ cheapest to detect. Nothing needs decoding to find it.
1055
+
1056
+ **A cutline.** Geometric, known analytically, and already computed per pixel by
1057
+ `coverageMask`. The easiest case and, going by how these recipes are actually
1058
+ written, the rarest.
1059
+
1060
+ ### What feathering is
1061
+
1062
+ `coverage` becomes a weight rather than a switch — `Float32Array` in 0..1 rather
1063
+ than `Uint8Array` in {0,1} — and the merge blends instead of replacing:
1064
+
1065
+ ```js
1066
+ result[i] = result[i] * (1 - w) + value * w;
1067
+ ```
1068
+
1069
+ The weight ramps from 0 to 1 over `feather` pixels inward from the edge, so a
1070
+ local DEM fades into the global one across a band instead of stepping into it.
1071
+ That also buys the thing nobody asks for by name: two DEMs on different geoids
1072
+ sit metres apart, and today the only cure is a hand-tuned `heightAdjustment`.
1073
+ Feathered, a metre or two of bias becomes a ramp nobody sees.
1074
+
1075
+ `NaN` still has to mean "nothing here" — a weight of 0 and a height of `NaN` are
1076
+ the same statement, and only one of them can be written into a `Float32Array` of
1077
+ heights. So the weight travels beside the layer, not inside it.
1078
+
1079
+ ### The border underneath it, which is built
1080
+
1081
+ Any operation with a radius needs pixels beyond the tile it is filling in, and a
1082
+ tile server has one tile. `blurHeights` used to handle that by clamping at the
1083
+ edge, so two adjacent tiles computed their shared boundary from different data
1084
+ and did not agree.
1085
+
1086
+ The fix is that an upscaled source already has the pixels. The blur only runs
1087
+ where `parentZ < z`, and `resampleFromParent` is sampling a sub-region of a
1088
+ parent tile that was read in full — so it now takes a `margin` and returns
1089
+ `size + 2r` a side, the blur runs over that, and `cropMargin` throws the border
1090
+ away. At a six-level upscale a 512px output tile is 8×8 parent pixels, so a
1091
+ 27-pixel output border is **0.42 parent pixels**: already in the array.
1092
+
1093
+ Two adjacent children of one parent, `gaussianBlurSigma: 1.5`, measuring how far
1094
+ their shared edge steps beyond what the same two unblurred tiles step:
1095
+
1096
+ | upscale | sigma | border | clamped | bordered |
1097
+ | ------- | ----- | ------ | ------- | -------- |
1098
+ | 2 | 3 | 9 px | 31.73 m | −0.05 m |
1099
+ | 4 | 6 | 18 px | 20.14 m | 0.00 m |
1100
+ | 6 | 9 | 27 px | 6.15 m | 0.00 m |
1101
+ | 8 | 12 | 36 px | 0.43 m | 0.00 m |
1102
+
1103
+ Worst at a **moderate** upscale rather than an extreme one, which is not the
1104
+ intuition. At eight levels the sub-region is two parent pixels across and the
1105
+ tile is nearly flat, so there is little for a clamped edge to get wrong; at two
1106
+ levels there is real gradient at the tile edge and clamping repeats it.
1107
+
1108
+ It costs 10–15% of the resample and the blur together. The border is capped at a
1109
+ quarter of the tile — past that it buys nothing the radius has not already used,
1110
+ and a sigma wanting more is smoothing the tile into itself, where the tile
1111
+ boundary is not what is wrong with the result.
1112
+
1113
+ The offline merge still has this. `scipy.ndimage.gaussian_filter` defaults to
1114
+ `mode='reflect'` and is handed one tile's array, so an archive built with a
1115
+ sigma carries the seam.
1116
+
1117
+ ### Where the margin comes from for the feather
1118
+
1119
+ The border above solves the upscaled case, which is the one the blur needed.
1120
+ Feathering has two more.
1121
+
1122
+ **A missing tile costs nothing.** Whether a neighbour exists is a directory
1123
+ lookup, not a decode. A source that vanishes at a tile edge can ramp down to 0
1124
+ approaching that edge using only knowledge of which neighbours are absent.
1125
+
1126
+ **A mask hole at native resolution is the expensive one.** There the edge is in
1127
+ the source's own pixels at full resolution, with no parent to widen the window
1128
+ into, so the margin genuinely means reading the neighbouring tiles — up to eight
1129
+ reads and eight decodes for that source, on that tile. That is the case to
1130
+ bound, to make opt-in, and to measure before believing.
1131
+
1132
+ ### What it does
1133
+
1134
+ `feather` on a source, in pixels, `0` and absent meaning the edge is a switch as
1135
+ before. It fades that source in over that many pixels measured inward from its
1136
+ cutline or its `bounds`:
1137
+
1138
+ ```json
1139
+ { "archive": "swissalti", "cutline": "switzerland", "feather": 16 }
1140
+ ```
1141
+
1142
+ The step left at the seam is the height difference divided by the feather, which
1143
+ is what makes the number predictable: two sources 40 m apart at the border, faded
1144
+ over 16 pixels, step 2.5 m a pixel instead of 40 m at once. Sixty-four is the
1145
+ most it takes — past that the ramp does not reach full weight anywhere inside a
1146
+ 256px tile, and the source is being turned down rather than blended in.
1147
+
1148
+ Three things fall out of the implementation and are worth stating.
1149
+
1150
+ **The ramp runs inward only.** A pixel outside the shape stays at 0. A cutline is
1151
+ a statement about where a source's data is good, and spreading the ramp outward
1152
+ would answer for ground the recipe just said this source does not cover.
1153
+
1154
+ **A feathered layer over a hole stands alone.** Weight is how much of what is
1155
+ underneath shows through, so with nothing underneath there is nothing to show —
1156
+ and fading into nothing would erode the source by the width of its own feather
1157
+ exactly where it is the only thing covering that ground.
1158
+
1159
+ **RGBA needed no merge change at all.** Alpha is already the weight that space
1160
+ composites with, so the feather multiplies it and `over` does what it always did.
1161
+
1162
+ The distance is exact Euclidean, by Felzenszwalb and Huttenlocher's lower
1163
+ envelope — linear in the tile's width, and chosen over a chamfer approximation
1164
+ because a chamfer's error is largest on diagonals and a national boundary is
1165
+ mostly diagonals.
1166
+
1167
+ ### What it does not do yet
1168
+
1169
+ **A mask edge.** `maskValues` and `maskColors` are where most seams actually come
1170
+ from, and they are not feathered. The reason is the margin: a cutline is known in
1171
+ full, so the ramp beside a tile can be computed by rasterising a few extra rows,
1172
+ while a mask edge lives in the source's own pixels and the ramp needs the
1173
+ neighbouring tiles to be read and decoded — up to eight of them, for that source,
1174
+ on that tile.
1175
+
1176
+ That is the piece with a real cost attached, and the one to measure before
1177
+ building. Two things make it more tractable than it looks: in a bake the tiles
1178
+ are walked in Hilbert order, which is chosen for locality, so a cache of decoded
1179
+ source tiles would be hit far more often than missed; and the ramp is smooth by
1180
+ construction, so it can be computed from the source's _parent_ at half resolution
1181
+ and upscaled, which turns eight reads at this zoom into a handful at the one
1182
+ above, shared between four children.
1183
+
1184
+ **A source that vanishes at a tile edge.** A sparse archive simply has no tile
1185
+ outside its extent, so the seam lands exactly on a tile boundary — the most
1186
+ visible of the three and the cheapest to detect, since whether a neighbour exists
1187
+ is a directory lookup rather than a decode. It ramps down toward an absent
1188
+ neighbour, and nothing else has to happen.
1189
+
965
1190
  ## Finding a stack
966
1191
 
967
1192
  A stack has no infohash and appears in no feed, so nothing about it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.72.0",
3
+ "version": "0.73.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/api.js CHANGED
@@ -84,10 +84,11 @@ function route(handler) {
84
84
  * Progress has its own route already: `runningAdds()` feeds `/api/adds`, the
85
85
  * console polls it, and `DELETE /api/adds` cancels the ones that can be.
86
86
  * @param {object} res - The response to answer.
87
- * @param {Function} start - Called with `{onValidated}`; returns the add's promise.
88
- * @param {object} accepted - Fields describing the source, for the 202 body.
89
- * @param {string} message - What the 202 tells the caller is now happening.
90
- * @param {string} what - Prefixed log tag and source, for a failure nobody is waiting on.
87
+ * @param {object} options - The add itself, and what to say about it.
88
+ * @param {Function} options.start - Called with `{onValidated}`; returns the add's promise.
89
+ * @param {object} options.accepted - Fields describing the source, for the 202 body.
90
+ * @param {string} options.message - What the 202 tells the caller is now happening.
91
+ * @param {string} options.what - Prefixed log tag and source, for a failure nobody is waiting on.
91
92
  * @returns {Promise<void>} - Resolves once the response has been sent.
92
93
  */
93
94
  async function acceptAdd(res, { start, accepted, message, what }) {
@@ -154,7 +155,6 @@ async function acceptAdd(res, { start, accepted, message, what }) {
154
155
  * worse, because it makes the shape of the fragment depend on what happened to
155
156
  * be available, so every reader has to handle both anyway. One shape,
156
157
  * `URLSearchParams` reads it, and a magnet survives the round trip exactly.
157
- *
158
158
  * @param {string} url - The URL a style points at.
159
159
  * @param {object} handles - `{torrent, magnet}`, either of which may be absent.
160
160
  * @returns {string} - The URL, with a fragment if there is anything to put in one.
@@ -205,6 +205,11 @@ function sourceUrlFor(category, newest, base) {
205
205
  return withSwarmHandles(url, { torrent, magnet });
206
206
  }
207
207
 
208
+ /**
209
+ * Builds the HTTP surface over everything the node has already started.
210
+ * @param {object} parts - The node's live pieces.
211
+ * @returns {object} - The Express app.
212
+ */
208
213
  export function createApp({
209
214
  library,
210
215
  catalog,
package/src/auth.js CHANGED
@@ -338,7 +338,10 @@ export function createAuth(config) {
338
338
  /** Whether any credential is configured. */
339
339
  enabled,
340
340
 
341
- /** Whether a password login is possible, as opposed to only a token. */
341
+ /**
342
+ * Whether a password login is possible, as opposed to only a token.
343
+ * @returns {boolean} - True when a password or a hash is set.
344
+ */
342
345
  get passwordLoginEnabled() {
343
346
  return Boolean(settings().password || settings().passwordHash);
344
347
  },
package/src/bake-jobs.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import {
4
5
  assertBakeable,
@@ -58,6 +59,64 @@ export function workDirFor(job, config = {}) {
58
59
  /** A bake that has finished is kept this long, so the console can report it. */
59
60
  const KEEP_FINISHED_MS = 10 * 60 * 1000;
60
61
 
62
+ /** libuv's own default, which is what is in force when nothing says otherwise. */
63
+ const DEFAULT_THREADPOOL = 4;
64
+
65
+ /** Said once per process rather than once per bake. */
66
+ let saidAboutThreads = false;
67
+
68
+ /**
69
+ * What the pool has to hold for the cores to stay busy.
70
+ *
71
+ * Not `bakeConcurrency`: past the core count another thread has nowhere to run,
72
+ * and the measurements flatten there. A node told to merge 32 tiles at once on
73
+ * twelve cores wants twelve, and telling its operator to ask for 32 would be
74
+ * asking them to fix something that is not broken.
75
+ * @param {number} concurrency - What the bake was told to run.
76
+ * @param {number} cores - How many the machine has.
77
+ * @returns {number} - The pool worth having.
78
+ */
79
+ function poolWorthHaving(concurrency, cores) {
80
+ return Math.max(1, Math.min(concurrency, cores));
81
+ }
82
+
83
+ /**
84
+ * Says so when the machine cannot actually run the bake as wide as it was told
85
+ * to.
86
+ *
87
+ * Decoding and encoding a tile is sharp, and sharp does that work on libuv's
88
+ * thread pool, which holds four threads unless the environment says otherwise.
89
+ * `bakeConcurrency` sizes the batch and the pixel pool above that, so raising
90
+ * it past four buys nothing on its own: the merges queue at the decode instead
91
+ * of at the arithmetic, and the machine sits idle looking like a bake that is
92
+ * simply slow. It cannot be fixed from in here — the pool is built before any
93
+ * of this runs — so the only thing to do is say it plainly and name the fix.
94
+ * See docs/tile-stacks.md — "Giving a bake the whole machine".
95
+ * @param {number} concurrency - What the bake was told to run.
96
+ * @param {number} [cores] - How many the machine has. Injectable, so the rule
97
+ * can be tested at a size rather than at whatever the test machine happens
98
+ * to have -- on four cores or fewer there is nothing to be short of, and a
99
+ * test asserting otherwise fails on the smallest runners and passes here.
100
+ * @returns {void}
101
+ */
102
+ export function sayIfThreadStarved(
103
+ concurrency,
104
+ cores = os.availableParallelism(),
105
+ ) {
106
+ if (saidAboutThreads) return;
107
+ const pool = Number(process.env.UV_THREADPOOL_SIZE) || DEFAULT_THREADPOOL;
108
+ const wanted = poolWorthHaving(concurrency, cores);
109
+ if (pool >= wanted) return;
110
+ saidAboutThreads = true;
111
+ console.warn(
112
+ `[bake] bakeConcurrency is ${concurrency} but UV_THREADPOOL_SIZE is ` +
113
+ `${pool}, so at most ${pool} tiles can be decoded or encoded at once. ` +
114
+ `Set UV_THREADPOOL_SIZE=${wanted} in the environment the node starts ` +
115
+ 'in — it cannot be set from inside the process.',
116
+ );
117
+ }
118
+
119
+ /** The exports a node is running, and the ones it has just finished. */
61
120
  export class BakeManager {
62
121
  #library;
63
122
  #tiles;
@@ -315,6 +374,7 @@ export class BakeManager {
315
374
  const pixels = codec
316
375
  ? new PixelWorker({ size: this.#concurrency() })
317
376
  : null;
377
+ if (codec) sayIfThreadStarved(this.#concurrency());
318
378
  try {
319
379
  await this.#merge(
320
380
  job,
@@ -357,6 +417,7 @@ export class BakeManager {
357
417
  * @param {string} workDir - Where the unfinished work lives.
358
418
  * @param {string} destination - Where the archive goes.
359
419
  * @param {string} format - The output format.
420
+ * @param {object} options - `signal` to stop early, and how often to checkpoint.
360
421
  * @returns {Promise<void>} - Resolves when the file is written.
361
422
  */
362
423
  async #merge(
package/src/catalog.js CHANGED
@@ -8,7 +8,6 @@ import path from 'node:path';
8
8
  * dozens, the whole thing is human-readable and hand-editable, and it avoids a
9
9
  * native dependency in a project that already asks a lot of the install.
10
10
  * Writes go through a temp file and a rename, so a crash cannot truncate it.
11
- *
12
11
  * @typedef {object} CatalogEntry
13
12
  * @property {string} infoHash - Hex v1 infohash. The catalog's primary key.
14
13
  * @property {string} name - Archive filename.
@@ -99,6 +98,7 @@ export function newerFirst(a, b) {
99
98
  return String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''));
100
99
  }
101
100
 
101
+ /** What the node knows about every archive it holds. */
102
102
  export class Catalog {
103
103
  #file;
104
104
  #entries = new Map();
package/src/config.js CHANGED
@@ -538,16 +538,13 @@ function merge(base, override) {
538
538
  // `libtorrent`, which would otherwise alter the defaults themselves.
539
539
  const out = {};
540
540
  for (const [key, value] of Object.entries(base ?? {})) {
541
- // eslint-disable-next-line security/detect-object-injection -- keys come from DEFAULTS
542
541
  out[key] = clone(value);
543
542
  }
544
543
  for (const [key, value] of Object.entries(override ?? {})) {
545
544
  if (value === undefined) continue;
546
- // eslint-disable-next-line security/detect-object-injection -- keys come from a config file the operator controls
547
545
  out[key] =
548
546
  value && typeof value === 'object' && !Array.isArray(value)
549
- ? // eslint-disable-next-line security/detect-object-injection -- as above
550
- merge(base[key] ?? {}, value)
547
+ ? merge(base[key] ?? {}, value)
551
548
  : value;
552
549
  }
553
550
  return out;
@@ -926,7 +923,10 @@ export async function saveConfig(config, updates, configPath) {
926
923
  );
927
924
 
928
925
  for (const [key] of changing) {
929
- if (!(key in DEFAULTS)) {
926
+ // `in` would walk the prototype chain, which says yes to `__proto__` and
927
+ // to `constructor` — and the assignment below then swaps the config's
928
+ // prototype rather than setting a setting.
929
+ if (!Object.hasOwn(DEFAULTS, key)) {
930
930
  throw new Error(`unknown setting: ${key}`);
931
931
  }
932
932
  if (guarded.has(key)) {