mikser-io 11.13.0 → 11.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.13.0",
3
+ "version": "11.14.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/manager.js CHANGED
@@ -21,12 +21,52 @@ const tasks = []
21
21
  // Not done inside runtime.process(): the first cycle's `gated` count is
22
22
  // recorded during import, which runs BEFORE process(), so resetting there
23
23
  // would wipe it out of a one-shot build's report.
24
+ // File events that arrived while a cycle was running, waiting for it to end.
25
+ //
26
+ // Keyed by nothing and deduplicated by nothing: a file touched three times
27
+ // mid-cycle is three entries, and the source gate collapses them — it compares
28
+ // checksums and skips what has not moved. Deduplicating here would mean
29
+ // deciding that a CREATE and a DELETE for one path cancel out, which they do
30
+ // not.
31
+ const pending = []
32
+
33
+ // Replay them, now that the cycle is over.
34
+ //
35
+ // Each delivery journals and schedules on its own, exactly as it would have
36
+ // if it had arrived a moment later — which, as far as the engine is now
37
+ // concerned, it did.
38
+ async function flushPending() {
39
+ if (!pending.length || runtime.processing) return
40
+ const waiting = pending.splice(0, pending.length)
41
+ for (const { hook, name, relativePath, fullPath } of waiting) {
42
+ try {
43
+ await hook(name, { relativePath })
44
+ } catch (err) {
45
+ reportWatchFailure(err, fullPath)
46
+ }
47
+ }
48
+ }
49
+
50
+ // Every cycle ends here, whichever started it — the watcher, `start()`, or a
51
+ // forwarded rebuild — so this is the one place that catches them all.
52
+ //
53
+ // `cycled` and not `onFinalized`: a finalize hook runs INSIDE the cycle, with
54
+ // `runtime.processing` still true, so the flush would defer the events it was
55
+ // called to deliver and the queue would sit there until something unrelated
56
+ // moved. Measured — the first version of this fix did exactly that and
57
+ // imported nothing.
58
+ runtime.hooks.cycled.push(flushPending)
59
+
24
60
  function scheduleProcess() {
25
61
  clearTimeout(runtime.engine.processTimeout)
26
62
  runtime.engine.processTimeout = setTimeout(async () => {
27
63
  await warnConfigStale()
28
64
  resetReport()
29
- runtime.process()
65
+ await runtime.process()
66
+ // A cancelled cycle runs no finalize hooks, so the flush above never
67
+ // fires for it and anything that arrived mid-cycle would wait for a
68
+ // later one that may never come.
69
+ await flushPending()
30
70
  }, 1000)
31
71
  }
32
72
 
@@ -223,6 +263,33 @@ export function watch(name, folder, options = {}) {
223
263
  // not the process.
224
264
  const deliver = async (hook, fullPath) => {
225
265
  const relativePath = fullPath.replace(`${folder}/`, '')
266
+ // A file that changes WHILE a cycle is running belongs to the next
267
+ // one, so it waits.
268
+ //
269
+ // Delivered immediately, the sync journals the entity behind a cycle
270
+ // that has already passed its source gate and its dispatch. Later
271
+ // phases still walk the journal and the catalog is updated, so the
272
+ // change is half-applied — and then onFinalized clears the journal,
273
+ // so the next cycle starts with nothing to do and the entity never
274
+ // renders. No error anywhere; the build is green twice.
275
+ //
276
+ // Reported from production, and it is the shape a plugin reaches for
277
+ // on purpose: one that re-derives pages by touching its sources from
278
+ // `onProcess`. 47 PDFs touched, every mtime updated, two cycles
279
+ // completed, not one file re-imported. Worse than a missed rebuild,
280
+ // because the plugin had already recorded the work as handled — so
281
+ // nothing retried, and 46 pages served four-day-old prices with
282
+ // nothing in the log to say so.
283
+ //
284
+ // Deferring is the honest reading of what a cycle is: it processes
285
+ // the state as of its start. The alternative — keeping those journal
286
+ // entries for the next cycle — does not work, because the engine's
287
+ // own later walks legitimately consume them first; measured before
288
+ // choosing this.
289
+ if (runtime.processing) {
290
+ pending.push({ hook, name, relativePath, fullPath })
291
+ return
292
+ }
226
293
  try {
227
294
  await hook(name, { relativePath })
228
295
  } catch (err) {
package/src/runtime.js CHANGED
@@ -60,6 +60,11 @@ const runtime = {
60
60
  finalized: [],
61
61
  sync: [],
62
62
  completed: [],
63
+ // Fires once a cycle is fully over and `processing` is false — see
64
+ // process(). The watcher uses it to replay file events it held back
65
+ // while the cycle ran; anything that must act BETWEEN cycles rather
66
+ // than inside one belongs here.
67
+ cycled: [],
63
68
  },
64
69
 
65
70
  // What each phase COST, not only what it did.
@@ -190,6 +195,11 @@ const runtime = {
190
195
  await this.cancel()
191
196
  }
192
197
  await this.mutex.use(async () => {
198
+ // True for the whole cycle, including the gaps between phases —
199
+ // `phase` goes null between them, so it cannot answer "is a cycle
200
+ // running". The watcher reads this to decide whether a file event
201
+ // belongs to this cycle or the next one; see src/manager.js.
202
+ this.processing = true
193
203
  try {
194
204
  this.abortController = new AbortController()
195
205
  const { signal } = this.abortController
@@ -205,8 +215,16 @@ const runtime = {
205
215
  this.phase = 'cancelled'
206
216
  for (let hook of this.hooks.cancelled) await hook()
207
217
  this.phase = null
218
+ } finally {
219
+ this.processing = false
208
220
  }
209
221
  })
222
+
223
+ // After the mutex, so `processing` is already false and a hook that
224
+ // starts work of its own is not told a cycle is still running. Inside
225
+ // the cycle — at onFinalized, say — it would be, which is exactly the
226
+ // mistake this placement exists to avoid.
227
+ for (const hook of this.hooks.cycled) await hook()
210
228
  },
211
229
 
212
230
  async render(signal) {