mikser-io 11.11.1 → 11.12.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/app.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import path from 'node:path'
3
3
 
4
4
  import { setup } from './index.js'
5
+ import runtime from './src/runtime.js'
5
6
  import { forward, isInstanceLive } from './src/instance.js'
6
7
 
7
8
  // Before anything is imported or read.
@@ -132,6 +133,43 @@ async function main() {
132
133
  }
133
134
 
134
135
  const mikser = await setup()
136
+ guardAgainstSilentDeath()
135
137
  await mikser.start()
136
138
  }
139
+
140
+ // A long-running process must not die of an asynchronous error nobody caught.
141
+ //
142
+ // Node's default for an unhandled rejection is to print it and exit 1. For a
143
+ // one-shot build that is right — the build failed, and the exit code is how a
144
+ // deploy finds out. For a WATCHER it is the worst available behaviour: the
145
+ // process is gone, the only evidence is an exit code, and whoever is using it
146
+ // finds out because the site stopped updating. Measured on a live site: four
147
+ // deaths in one afternoon, each from a file that moved between the event and
148
+ // the read, and each time the entire signal was "the server is not there any
149
+ // more".
150
+ //
151
+ // So the handler is honest about the difference. It always says what happened,
152
+ // with the stack, because a swallowed fault is the same silence in a different
153
+ // costume. It only keeps the process alive where staying alive is the correct
154
+ // answer.
155
+ //
156
+ // The specific rejections that caused those deaths are now caught where they
157
+ // happen (src/manager.js), which is the better fix. This is the floor under
158
+ // the ones nobody has found yet — a timer callback that rejects, a plugin's
159
+ // detached promise — and there is no shortage of those in an engine this size.
160
+ function guardAgainstSilentDeath() {
161
+ const longRunning = Boolean(runtime.options?.watch || runtime.options?.server)
162
+ process.on('unhandledRejection', (reason) => {
163
+ const logger = runtime.engine?.logger
164
+ const detail = reason?.stack ?? reason?.message ?? String(reason)
165
+ if (!longRunning) {
166
+ // Same outcome as the default, with a line saying so first.
167
+ logger?.fatal?.('Unhandled rejection: %s', detail)
168
+ process.exitCode = 1
169
+ throw reason
170
+ }
171
+ logger?.error('Unhandled rejection (the watcher is still running): %s', detail)
172
+ })
173
+ }
174
+
137
175
  main()
@@ -1072,6 +1072,7 @@ distinction is the whole of [Faults](#faults) above.
1072
1072
  | --- | --- | --- |
1073
1073
  | `source-content-not-text` | warn | A `sources()` collection with `content: true` loaded a file whose bytes are not text. They are decoded as UTF-8 and stored mangled, and every consumer inherits that. Set `content: false` to catalogue the files by path instead — `entity.uri` still points at them. Once per collection. |
1074
1074
  | `front-matter-unreadable` | warn | A file's front-matter block is present but not valid YAML. Its attributes are not available and the text is left as written. Previously this threw and ended the whole run with a parser stack that never named the file. |
1075
+ | `source-vanished` | warn | A watched file disappeared between the event and the read. Nothing was imported for it. This is what an upload that writes a temporary file and renames it looks like, and it used to kill the process. |
1075
1076
  | `observer-bad-uri` | warn | An observer's `uri` is not an absolute URL, so no webhook can be routed to it. |
1076
1077
  | `untracked-file-read` | warn | A template read a file outside every folder mikser takes entities from, so it has no entity and changing it invalidates nothing. |
1077
1078
  | `progress` | info | A long phase reporting where it has got to. See [Progress](#progress). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.11.1",
3
+ "version": "11.12.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/manager.js CHANGED
@@ -131,6 +131,44 @@ export async function deletedHook(name, context) {
131
131
  // `ignored` and a function is the one form that has stayed stable.
132
132
  const ignoreJunk = (filePath) => /[/\\]\./.test(filePath) || junkFilter()(filePath)
133
133
 
134
+ // Wait for the bytes to stop moving before saying a file arrived.
135
+ //
136
+ // Without this an event fires the moment a file appears, and the tools people
137
+ // actually upload with do not write in one go. A WebDAV client, Finder and
138
+ // `rsync` without --inplace all write a temporary file into the watched folder
139
+ // and rename it afterwards, so the sequence is: `add` for a name that is about
140
+ // to stop existing, a checksum read of a file that is still growing, and a
141
+ // rename out from under both.
142
+ //
143
+ // Measured on a live site during a video upload. Four server deaths in one
144
+ // afternoon from the vanishing half, and — worse, because nothing stopped —
145
+ // one derivative built from a file read at ZERO BYTES: its checksum note held
146
+ // d41d8cd98f00b204e9800998ecf8427e, the md5 of nothing, where a file that size
147
+ // should carry `size:head:tail`. The build called that current and the next
148
+ // one agreed with it.
149
+ //
150
+ // 500ms is the compromise the two failures point at from opposite sides. It is
151
+ // long enough to swallow a temp-file rename and a local tear, short enough
152
+ // that an incremental rebuild of a saved document still feels immediate — the
153
+ // engine's own budget for one is about 100ms. A big upload over a slow link
154
+ // needs more, and the files plugin asks for more; anything can, through
155
+ // `options`.
156
+ const AWAIT_WRITE_FINISH = { stabilityThreshold: 500, pollInterval: 100 }
157
+
158
+ // `interval` and `binaryInterval` are NOT here, and their absence is the fix.
159
+ //
160
+ // They were set to 1000 and 3000, which reads as a debounce and is not one:
161
+ // chokidar consumes both only inside `if (opts.usePolling)` (handler.js), and
162
+ // polling is off. They described an intention that never ran, which is the
163
+ // most expensive kind of setting to leave lying around — somebody reads them
164
+ // and stops looking for the debounce that is actually missing. Polling can be
165
+ // asked for explicitly through `options`, and then they would apply.
166
+ const WATCH_DEFAULTS = {
167
+ ignored: ignoreJunk,
168
+ ignoreInitial: true,
169
+ awaitWriteFinish: AWAIT_WRITE_FINISH,
170
+ }
171
+
134
172
  // Watch a folder, with mikser's own settings and none of its lifecycle.
135
173
  //
136
174
  // `watch()` below turns file events into SYNC events — it is how a source
@@ -148,35 +186,65 @@ const ignoreJunk = (filePath) => /[/\\]\./.test(filePath) || junkFilter()(filePa
148
186
  export function watchFolder(folder, handler, options = {}) {
149
187
  return chokidar
150
188
  .watch(folder, {
151
- interval: 1000,
152
- binaryInterval: 3000,
153
- ignored: ignoreJunk,
154
- ignoreInitial: true,
189
+ ...WATCH_DEFAULTS,
155
190
  followSymlinks: true,
156
191
  ...options,
157
192
  })
158
193
  .on('all', (event, fullPath) => handler(event, fullPath))
159
194
  }
160
195
 
161
- export function watch(name, folder, options = { interval: 1000, binaryInterval: 3000, ignored: ignoreJunk, ignoreInitial: true }) {
196
+ export function watch(name, folder, options = {}) {
162
197
  if (runtime.options.watch !== true) return
163
198
 
164
- chokidar.watch(folder, options)
199
+ // Every hook is AWAITED and its failure caught here.
200
+ //
201
+ // They used to be called and dropped: `createdHook(name, …)` with no
202
+ // await inside an async listener, which makes a rejection nobody is
203
+ // holding. There is no unhandledRejection handler in a plugin's reach, so
204
+ // the default applied — Node printed the error and exited 1. A watcher
205
+ // that dies of an asynchronous error from a file that moved is the worst
206
+ // available failure mode, because the only evidence is an exit code: it
207
+ // happened four times in one afternoon on a live site and each time the
208
+ // symptom was "the server is gone".
209
+ //
210
+ // Caught per event, not per watcher, so one bad file costs that file and
211
+ // not the process.
212
+ const deliver = async (hook, fullPath) => {
213
+ const relativePath = fullPath.replace(`${folder}/`, '')
214
+ try {
215
+ await hook(name, { relativePath })
216
+ } catch (err) {
217
+ reportWatchFailure(err, fullPath)
218
+ }
219
+ }
220
+
221
+ chokidar.watch(folder, { ...WATCH_DEFAULTS, ...options })
165
222
  .on('all', () => {
166
223
  clearTimeout(runtime.engine.processTimeout)
167
224
  })
168
- .on('add', async fullPath => {
169
- const relativePath = fullPath.replace(`${folder}/`, '')
170
- createdHook(name, { relativePath })
171
- })
172
- .on('change', async fullPath => {
173
- const relativePath = fullPath.replace(`${folder}/`, '')
174
- updatedHook(name, { relativePath })
175
- })
176
- .on('unlink', async fullPath => {
177
- const relativePath = fullPath.replace(`${folder}/`, '')
178
- deletedHook(name, { relativePath })
179
- })
225
+ .on('add', fullPath => deliver(createdHook, fullPath))
226
+ .on('change', fullPath => deliver(updatedHook, fullPath))
227
+ .on('unlink', fullPath => deliver(deletedHook, fullPath))
228
+ .on('error', err => reportWatchFailure(err))
229
+ }
230
+
231
+ // A file that is gone is not an error; anything else is.
232
+ //
233
+ // The vanishing case is ordinary for a watcher — the event describes a moment
234
+ // that has already passed — so it is a warning naming the path, and the build
235
+ // carries on. Everything else keeps its stack, because a watcher that
236
+ // swallows real faults is the same silence in a different costume.
237
+ function reportWatchFailure(err, fullPath) {
238
+ const logger = useLogger()
239
+ if (err?.code === 'ENOENT') {
240
+ logger?.warn(
241
+ { code: 'source-vanished', path: err.path ?? fullPath },
242
+ 'Watched file disappeared before it could be read: %s. Nothing was imported for it — '
243
+ + 'this is what an upload that writes a temporary file and renames it looks like.',
244
+ err.path ?? fullPath ?? 'unknown')
245
+ return
246
+ }
247
+ logger?.error('Watcher failed for %s: %s', fullPath ?? 'the watched folder', err?.stack ?? err?.message ?? err)
180
248
  }
181
249
 
182
250
  export function schedule(name, expression, context) {
@@ -103,7 +103,21 @@ export function files(options = {}) {
103
103
 
104
104
  let synced = true
105
105
  switch (action) {
106
- case ACTION.CREATE:
106
+ case ACTION.CREATE: {
107
+ // The checksum FIRST, and the link only once it answered.
108
+ //
109
+ // Linking first left a symlink pointing at nothing when
110
+ // the source moved between the event and the read: the
111
+ // link was made, `checksum` threw ENOENT, the entity was
112
+ // never created, and out/ kept a dangling link that no
113
+ // catalog row admits to. It then broke whatever walked
114
+ // the output with stat — a consumer's own scope check,
115
+ // twice.
116
+ //
117
+ // Reading first makes the vanishing case leave nothing
118
+ // behind: the throw happens before anything is written,
119
+ // and the watcher logs it as the ordinary event it is.
120
+ const sourceChecksum = await checksum(source)
107
121
  await ensureLink(relativePath)
108
122
  await createEntity({
109
123
  id,
@@ -118,10 +132,11 @@ export function files(options = {}) {
118
132
  // $-ref to this entity expands to it; consumers read
119
133
  // meta.url for the served location (ADR-0011).
120
134
  meta: { url: '/' + name },
121
- checksum: await checksum(source),
135
+ checksum: sourceChecksum,
122
136
  link: await link(source)
123
137
  })
124
138
  break
139
+ }
125
140
  case ACTION.UPDATE: {
126
141
  const current = await findEntity({ id })
127
142
  // `checksum` is the source-checksum FUNCTION from the
@@ -177,7 +192,19 @@ export function files(options = {}) {
177
192
  logger.debug('Files folder: %s', runtime.options.filesFolder)
178
193
  await mkdir(runtime.options.filesFolder, { recursive: true })
179
194
 
180
- watch(collection, runtime.options.filesFolder)
195
+ // A longer settle than the engine's default, because this is the
196
+ // collection large binaries land in and they are the case that
197
+ // bit: a video uploaded over WebDAV grows for as long as the link
198
+ // takes, and an event fired mid-upload gets a checksum of a
199
+ // partial file — once, of a file read at zero bytes, which was
200
+ // then published as current.
201
+ //
202
+ // Three seconds of stability costs nothing here (nobody edits a
203
+ // 300 MB video and waits for the page) and buys the margin a slow
204
+ // or stalling link needs.
205
+ watch(collection, runtime.options.filesFolder, {
206
+ awaitWriteFinish: { stabilityThreshold: 3000, pollInterval: 200 },
207
+ })
181
208
  })
182
209
 
183
210
  onImport(async () => {