mikser-io 11.13.0 → 11.14.1
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 +1 -1
- package/src/instance.js +41 -8
- package/src/manager.js +68 -1
- package/src/runtime.js +18 -0
package/package.json
CHANGED
package/src/instance.js
CHANGED
|
@@ -31,7 +31,8 @@ import { createHash } from 'node:crypto'
|
|
|
31
31
|
import { tmpdir } from 'node:os'
|
|
32
32
|
import path from 'node:path'
|
|
33
33
|
import { existsSync, unlinkSync } from 'node:fs'
|
|
34
|
-
import { chmod } from 'node:fs/promises'
|
|
34
|
+
import { chmod, stat } from 'node:fs/promises'
|
|
35
|
+
import { checksum } from './utils/index.js'
|
|
35
36
|
|
|
36
37
|
import runtime from './runtime.js'
|
|
37
38
|
import { onLoaded } from './lifecycle.js'
|
|
@@ -288,23 +289,55 @@ function configMismatch(theirs) {
|
|
|
288
289
|
// configCoverage lists every local module the config graph pulled in, so a
|
|
289
290
|
// stat over that list catches an edit to an imported module — which a
|
|
290
291
|
// client-side checksum of the entry file would miss entirely.
|
|
292
|
+
// Has a config file CHANGED since this instance started?
|
|
293
|
+
//
|
|
294
|
+
// Content, not mtime. A `git rebase`, `git checkout` or `rsync` rewrites a
|
|
295
|
+
// file with byte-identical content and a new mtime, and the instance then
|
|
296
|
+
// refused every forwarded command — "this instance's config changed on disk
|
|
297
|
+
// since it started" — while `git diff HEAD` reported nothing. Twice in one
|
|
298
|
+
// afternoon on one machine.
|
|
299
|
+
//
|
|
300
|
+
// On production the cost is not a restart. The config checksum is what the
|
|
301
|
+
// cache is stamped with, so a config the engine believes has moved buys a
|
|
302
|
+
// full cold re-derive of every asset, which is how a no-op rebase turns into
|
|
303
|
+
// twenty minutes of ffmpeg.
|
|
304
|
+
//
|
|
305
|
+
// mtime is kept as a PRE-FILTER, not as the answer: it is a stat against a
|
|
306
|
+
// read, and in the ordinary case nothing has moved and nothing is read. Only
|
|
307
|
+
// a file whose mtime shifted gets hashed, and only a file whose CONTENT
|
|
308
|
+
// shifted is reported. A file rewritten identically has its stamp refreshed
|
|
309
|
+
// so the next check is cheap again.
|
|
310
|
+
async function configFingerprint(file) {
|
|
311
|
+
try {
|
|
312
|
+
return { mtime: (await stat(file)).mtimeMs, hash: await checksum(file) }
|
|
313
|
+
} catch {
|
|
314
|
+
// Deleted, or unreadable. A stable sentinel, so a file that is still
|
|
315
|
+
// missing on the next check does not read as having changed again.
|
|
316
|
+
return { mtime: 0, hash: null }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
291
320
|
export async function configStale() {
|
|
292
321
|
const covered = runtime.options.configCoverage?.files ?? []
|
|
293
322
|
if (!covered.length) return null
|
|
294
|
-
const { stat } = await import('node:fs/promises')
|
|
295
323
|
const stamps = runtime.options.configStamps
|
|
296
324
|
if (!stamps) {
|
|
297
325
|
// First call: record, do not judge. Nothing to compare against yet.
|
|
298
326
|
runtime.options.configStamps = Object.fromEntries(
|
|
299
|
-
await Promise.all(covered.map(async (file) =>
|
|
300
|
-
try { return [file, (await stat(file)).mtimeMs] } catch { return [file, 0] }
|
|
301
|
-
})))
|
|
327
|
+
await Promise.all(covered.map(async (file) => [file, await configFingerprint(file)])))
|
|
302
328
|
return null
|
|
303
329
|
}
|
|
304
330
|
for (const file of covered) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (
|
|
331
|
+
const was = stamps[file]
|
|
332
|
+
const now = await configFingerprint(file)
|
|
333
|
+
if (was?.mtime === now.mtime) continue // nothing touched it
|
|
334
|
+
if (was?.hash === now.hash) {
|
|
335
|
+
// Touched and identical — a checkout, a rebase, a sync. Not a
|
|
336
|
+
// change, and re-stamped so this file is a stat again next time.
|
|
337
|
+
stamps[file] = now
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
return file
|
|
308
341
|
}
|
|
309
342
|
return null
|
|
310
343
|
}
|
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) {
|