mikser-io 9.89.0 → 9.95.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/docs/diagnostics.md +57 -0
- package/package.json +1 -1
- package/src/engine.js +81 -2
- package/src/instance.js +29 -3
- package/src/logger.js +20 -1
- package/src/plugins/assets.js +15 -5
- package/src/references.js +15 -2
- package/src/report.js +54 -0
- package/src/runtime.js +34 -0
package/docs/diagnostics.md
CHANGED
|
@@ -133,8 +133,65 @@ anything.
|
|
|
133
133
|
Add `--json` for the whole report as a machine-readable object. Exits `3`
|
|
134
134
|
when the entity cannot be found.
|
|
135
135
|
|
|
136
|
+
Every coded warning and fault prints its code on the console, in brackets
|
|
137
|
+
before the sentence:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
🟡 [output-drift] Output changed with unchanged inputs: /documents/index.html → /index.html
|
|
141
|
+
🟡 [reference-wrong-base] Points at the wrong place: ...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The code is added, not substituted: the sentence is what a person reads and the
|
|
145
|
+
code is what a script matches. Both come from the same field on the same
|
|
146
|
+
record, so the console and the report cannot drift apart — which they had. The
|
|
147
|
+
report called it `output-drift` and the console said "produced different bytes
|
|
148
|
+
from the same inputs", so anything watching the build had to match prose, the
|
|
149
|
+
half that is free to be reworded, while the stable identifier appeared only in
|
|
150
|
+
this document.
|
|
151
|
+
|
|
152
|
+
`--audit-output --json` writes its verdict as a document — `verdict`,
|
|
153
|
+
`snapshots`, a `summary` of the five counts, and the entries behind each. It
|
|
154
|
+
reported through the log and wrote nothing to stdout before, which made the
|
|
155
|
+
check a deploy script most wants the one it could not read. Exit codes are
|
|
156
|
+
unchanged: 2 for FAIL.
|
|
157
|
+
|
|
136
158
|
### `--json`
|
|
137
159
|
|
|
160
|
+
The report carries `timings`: what each phase COST, in milliseconds, for the
|
|
161
|
+
cycle being reported.
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
"timings": {
|
|
165
|
+
"total": 114, // the phases below, summed — this cycle
|
|
166
|
+
"processUptime": 415.5, // how long THIS PROCESS has been alive
|
|
167
|
+
"phases": [
|
|
168
|
+
{ "phase": "load", "ms": 80.5, "calls": 1 },
|
|
169
|
+
{ "phase": "import", "ms": 17.5, "calls": 1 }
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Ordered slowest first, so a diff between two versions leads with what moved.
|
|
175
|
+
This exists because the report said what was *done* and never what it *took*: a
|
|
176
|
+
preset fan-out that scanned the whole catalog every cycle shipped and ran for
|
|
177
|
+
four releases, with byte-identical output and every check passing, while the
|
|
178
|
+
build was twice as slow. An upgrade check could prove no bytes moved and could
|
|
179
|
+
not prove the speed had not halved.
|
|
180
|
+
|
|
181
|
+
`finishedAt - startedAt` is not this — it spans the processing cycle only,
|
|
182
|
+
measured at 12ms of a 443ms run, leaving boot, the config graph, the plugin
|
|
183
|
+
load and the import scan with no number anywhere.
|
|
184
|
+
|
|
185
|
+
`processUptime` is deliberately not called "elapsed". For a one-shot it is the
|
|
186
|
+
whole run, and subtracting `total` gives what the phases do not cover — module
|
|
187
|
+
loading and engine construction, which happen before any hook. For a build
|
|
188
|
+
forwarded to a watcher it is the *instance's* age, which says nothing about the
|
|
189
|
+
build.
|
|
190
|
+
|
|
191
|
+
Milliseconds because the console's progress lines round to whole seconds, so a
|
|
192
|
+
phase that doubled from 400ms to 800ms prints `0s` both times — and they are
|
|
193
|
+
suppressed off a TTY, which is every CI run and every `--json` invocation.
|
|
194
|
+
|
|
138
195
|
A build report on stdout, as JSON. Logs and the banner move to stderr
|
|
139
196
|
under this flag, so stdout parses whole.
|
|
140
197
|
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -125,7 +125,7 @@ async function reportBrokenReferences(logger) {
|
|
|
125
125
|
// because the preset does not cover that file. Confidently naming the
|
|
126
126
|
// wrong cause is worse than naming none, so a real answer wins over the
|
|
127
127
|
// heuristic wherever there is one.
|
|
128
|
-
const explain = runtime.
|
|
128
|
+
const explain = runtime.engine?.assets?.explainMissing
|
|
129
129
|
const reasons = new Map()
|
|
130
130
|
if (explain) {
|
|
131
131
|
// Every broken entry, not the ones that happen to sort first. The cap
|
|
@@ -261,7 +261,7 @@ async function reportMissingAssets(logger, alreadyReported = new Set()) {
|
|
|
261
261
|
// every page on the site, and a thousand lines of it buries whatever else
|
|
262
262
|
// the build said.
|
|
263
263
|
const SHOWN = 10
|
|
264
|
-
const explain = runtime.
|
|
264
|
+
const explain = runtime.engine?.assets?.explainMissing
|
|
265
265
|
for (const [destination, ids] of missing.slice(0, SHOWN)) {
|
|
266
266
|
// Same question, same answer, wherever the symptom surfaces. This path
|
|
267
267
|
// sees urls that never reach an html file at all — a sitemap, a feed —
|
|
@@ -381,6 +381,33 @@ export async function runReportOnly(request = {}) {
|
|
|
381
381
|
await runtime.manifest.auditOutput()
|
|
382
382
|
const total = runtime.manifest.size()
|
|
383
383
|
|
|
384
|
+
// The document, under --json.
|
|
385
|
+
//
|
|
386
|
+
// This is the check a deploy script wants and it was the one that
|
|
387
|
+
// could not be read programmatically: it reported through the log and
|
|
388
|
+
// wrote nothing to stdout, so `--audit-output --json` returned zero
|
|
389
|
+
// bytes and exit 0 or 2 — a caller had to parse prose, or trust the
|
|
390
|
+
// exit code and lose every detail behind it.
|
|
391
|
+
//
|
|
392
|
+
// Emitted here and returned immediately, because the log lines below
|
|
393
|
+
// are the human rendering of exactly this and printing both would put
|
|
394
|
+
// the prose on stderr for no one.
|
|
395
|
+
if (json) {
|
|
396
|
+
process.stdout.write(JSON.stringify({
|
|
397
|
+
verdict,
|
|
398
|
+
snapshots: total,
|
|
399
|
+
summary: {
|
|
400
|
+
missing: missing.length,
|
|
401
|
+
mismatched: mismatched.length,
|
|
402
|
+
unverifiable: unverifiable.length,
|
|
403
|
+
orphaned: orphaned.length,
|
|
404
|
+
collisions: collisions.length,
|
|
405
|
+
},
|
|
406
|
+
missing, mismatched, unverifiable, orphaned, collisions,
|
|
407
|
+
}, null, 2) + '\n')
|
|
408
|
+
return verdict === 'FAIL' ? 2 : 0
|
|
409
|
+
}
|
|
410
|
+
|
|
384
411
|
for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
|
|
385
412
|
for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
|
|
386
413
|
e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
|
|
@@ -495,6 +522,58 @@ export async function setup(options) {
|
|
|
495
522
|
.option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
|
|
496
523
|
attachServerCliOptions(runtime.engine.commander)
|
|
497
524
|
|
|
525
|
+
// Which check answers which question.
|
|
526
|
+
//
|
|
527
|
+
// The boundaries between these are real and none of them is redundant,
|
|
528
|
+
// but that knowledge lived only in commit messages — which are the
|
|
529
|
+
// wrong place for it, because nobody reads them before they need the
|
|
530
|
+
// answer. A caller choosing how to verify a build was left to learn it
|
|
531
|
+
// by experiment, one release at a time.
|
|
532
|
+
//
|
|
533
|
+
// On --help rather than in a doc alone: this is read at the moment the
|
|
534
|
+
// question is being asked.
|
|
535
|
+
runtime.engine.commander.addHelpText('after', `
|
|
536
|
+
Which check answers which question:
|
|
537
|
+
|
|
538
|
+
Did this build write what it thought it wrote?
|
|
539
|
+
--audit-output compares the output on disk against the manifest.
|
|
540
|
+
Catches tampering, truncation and a file deleted
|
|
541
|
+
behind the build's back. Structurally CANNOT catch
|
|
542
|
+
a render that changed, because a render rewrites
|
|
543
|
+
its own snapshot — afterwards the new bytes agree
|
|
544
|
+
with themselves.
|
|
545
|
+
|
|
546
|
+
Did an upgrade change what a render produces?
|
|
547
|
+
--force re-renders everything and reports output-drift:
|
|
548
|
+
same inputs, different bytes. This is the one that
|
|
549
|
+
catches a renderer, helper or dependency moving
|
|
550
|
+
under the build. It also reconciles deletions.
|
|
551
|
+
|
|
552
|
+
Do the URLs in the output point at anything?
|
|
553
|
+
(runs every build) reads the emitted html and css and resolves each
|
|
554
|
+
reference the way a browser would. Reports what
|
|
555
|
+
resolves to nothing, what a preset never produced,
|
|
556
|
+
and what loads only because a browser discarded a
|
|
557
|
+
climb above the site root.
|
|
558
|
+
|
|
559
|
+
Are the derivatives current?
|
|
560
|
+
--render-presets [n] re-derives every preset, or one by name, without
|
|
561
|
+
touching anything else. For a preset edited without
|
|
562
|
+
bumping its revision.
|
|
563
|
+
|
|
564
|
+
Start over.
|
|
565
|
+
--clear removes the output folder and reopens the cache.
|
|
566
|
+
A boot operation: it is refused while an instance
|
|
567
|
+
is running in the same folder.
|
|
568
|
+
|
|
569
|
+
What did this build do, and cost?
|
|
570
|
+
--json the whole report as one document on stdout, with
|
|
571
|
+
every warning carrying a stable code, and per-phase
|
|
572
|
+
timings in milliseconds so two releases can be
|
|
573
|
+
compared.
|
|
574
|
+
|
|
575
|
+
The full version, with what each code means: docs/diagnostics.md`)
|
|
576
|
+
|
|
498
577
|
Object.assign(runtime.options, options || runtime.engine.commander.parse(process.argv).opts())
|
|
499
578
|
// runtime.options.info gates the progress bar — gauge stays
|
|
500
579
|
// silent in --debug/--trace modes because logs are voluminous
|
package/src/instance.js
CHANGED
|
@@ -37,6 +37,7 @@ import runtime from './runtime.js'
|
|
|
37
37
|
import { onLoaded } from './lifecycle.js'
|
|
38
38
|
import { renderErrorCount } from './report.js'
|
|
39
39
|
import { runReportOnly } from './engine.js'
|
|
40
|
+
import { emitReport } from './report.js'
|
|
40
41
|
|
|
41
42
|
// Where the endpoint lives.
|
|
42
43
|
//
|
|
@@ -359,12 +360,24 @@ function refuseClear(socket, request) {
|
|
|
359
360
|
return true
|
|
360
361
|
}
|
|
361
362
|
|
|
363
|
+
// Which process to restart.
|
|
364
|
+
//
|
|
365
|
+
// "Restart it" is only actionable if you know which `it` — and the machine
|
|
366
|
+
// that hits this is the machine running several instances from several
|
|
367
|
+
// projects, because that is what makes a stale config likely in the first
|
|
368
|
+
// place. Without the pid, finding it meant walking /proc by cwd.
|
|
369
|
+
//
|
|
370
|
+
// The instance answers with its OWN pid, which is the one fact a client cannot
|
|
371
|
+
// work out: it knows the folder it asked about, not who is holding it. The
|
|
372
|
+
// wrong-config refusal beside this one already names both sides; this one
|
|
373
|
+
// named neither.
|
|
362
374
|
function refuseStale(socket, movedFile) {
|
|
363
375
|
frame(socket, {
|
|
364
376
|
type: 'refused',
|
|
365
377
|
reason: `this instance's config changed on disk since it started (${movedFile}).`,
|
|
366
|
-
detail:
|
|
367
|
-
+
|
|
378
|
+
detail: `It is still running the old one — pid ${process.pid}, started in `
|
|
379
|
+
+ `${runtime.options.workingFolder}. Restart that process and this command will reach an `
|
|
380
|
+
+ 'instance that matches what you edited.',
|
|
368
381
|
})
|
|
369
382
|
}
|
|
370
383
|
|
|
@@ -429,7 +442,20 @@ async function serveBuild(socket, request, logger) {
|
|
|
429
442
|
// rebuild() — the same call a one-shot makes. Nothing here
|
|
430
443
|
// re-implements it: the contract is what decides whether it
|
|
431
444
|
// writes, so setting the contract is the whole fix.
|
|
432
|
-
await withRequestOutput(request, () =>
|
|
445
|
+
await withRequestOutput(request, async () => {
|
|
446
|
+
// Suppressed for the duration of the cycle and emitted once
|
|
447
|
+
// after it: any cycle already in flight when this request
|
|
448
|
+
// arrived would otherwise write a second document into a
|
|
449
|
+
// stream that promises one.
|
|
450
|
+
runtime.state ??= {}
|
|
451
|
+
runtime.state.suppressReport = true
|
|
452
|
+
try {
|
|
453
|
+
await runtime.rebuild()
|
|
454
|
+
} finally {
|
|
455
|
+
runtime.state.suppressReport = false
|
|
456
|
+
}
|
|
457
|
+
emitReport()
|
|
458
|
+
})
|
|
433
459
|
} finally {
|
|
434
460
|
runtime.options.renderPresets = priorRenderPresets
|
|
435
461
|
}
|
package/src/logger.js
CHANGED
|
@@ -178,9 +178,28 @@ export function createMikserLogger(level = 'info') {
|
|
|
178
178
|
customPrettifiers: {
|
|
179
179
|
level: () => '',
|
|
180
180
|
},
|
|
181
|
+
// The CODE, on the line a person is reading.
|
|
182
|
+
//
|
|
183
|
+
// A finding had two names: the report called it `output-drift` and the
|
|
184
|
+
// console said "produced different bytes from the same inputs", and
|
|
185
|
+
// nothing on the console contained the string someone reading
|
|
186
|
+
// docs/diagnostics.md would grep for. So a script watching the build
|
|
187
|
+
// matched prose — the half that is free to be reworded — while the
|
|
188
|
+
// stable identifier existed only in a document that script was not
|
|
189
|
+
// reading.
|
|
190
|
+
//
|
|
191
|
+
// Printed here because this is the one function every line passes
|
|
192
|
+
// through, so the code cannot be attached to the record and missing
|
|
193
|
+
// from the terminal: they come from the same field. `hideObject` still
|
|
194
|
+
// suppresses the rest of the structured fields, which belong in the
|
|
195
|
+
// report and would turn a one-line warning into a block.
|
|
196
|
+
//
|
|
197
|
+
// Only where there is a code. An ordinary info line has no identity to
|
|
198
|
+
// print and gains nothing from a bracket.
|
|
181
199
|
messageFormat: (log, key) => {
|
|
182
200
|
const icon = ICONS[LEVEL_LABELS[log.level]] ?? ''
|
|
183
|
-
|
|
201
|
+
const code = typeof log.code === 'string' && log.code ? `[${log.code}] ` : ''
|
|
202
|
+
return icon + code + (log[key] ?? '')
|
|
184
203
|
},
|
|
185
204
|
})
|
|
186
205
|
|
package/src/plugins/assets.js
CHANGED
|
@@ -405,16 +405,26 @@ export function assets(options = {}) {
|
|
|
405
405
|
runtime.state.assets = {
|
|
406
406
|
presets: {},
|
|
407
407
|
assetsMap: {},
|
|
408
|
-
// The engine asks this when a linked derivative is not on disk.
|
|
409
|
-
// Published on state rather than imported, so the engine keeps
|
|
410
|
-
// knowing nothing about presets and says nothing when this plugin
|
|
411
|
-
// is not loaded.
|
|
412
|
-
explainMissing,
|
|
413
408
|
assetsFolder: options.outputFolder
|
|
414
409
|
? path.join(options.outputFolder, assetsName)
|
|
415
410
|
: assetsName,
|
|
416
411
|
}
|
|
417
412
|
|
|
413
|
+
// The engine asks this when a linked derivative is not on disk.
|
|
414
|
+
//
|
|
415
|
+
// On runtime.engine, NOT on runtime.state. State is structured-cloned
|
|
416
|
+
// into render and postprocess workers, and a function cannot be
|
|
417
|
+
// cloned: putting it there made every worker-dispatched render fail
|
|
418
|
+
// with DataCloneError on any build that loads this plugin, reported as
|
|
419
|
+
// a render error whose message was this function's own source. Options
|
|
420
|
+
// already had workerSafeOptions for exactly this hazard; state had no
|
|
421
|
+
// equivalent, so state is the wrong place to publish anything callable.
|
|
422
|
+
//
|
|
423
|
+
// Still published rather than imported, so the engine keeps knowing
|
|
424
|
+
// nothing about presets and says nothing when this plugin is absent.
|
|
425
|
+
runtime.engine ??= {}
|
|
426
|
+
runtime.engine.assets = { explainMissing }
|
|
427
|
+
|
|
418
428
|
runtime.options.presets = options.presetsFolder || collection
|
|
419
429
|
runtime.options.presetsFolder = path.join(runtime.options.workingFolder, runtime.options.presets)
|
|
420
430
|
logger.debug('Presets folder: %s', runtime.options.presetsFolder)
|
package/src/references.js
CHANGED
|
@@ -120,9 +120,22 @@ export function extractReferences(rawSource) {
|
|
|
120
120
|
export function resolveUrl(pageDir, url, { root = '' } = {}) {
|
|
121
121
|
const clean = url.split('#')[0].split('?')[0]
|
|
122
122
|
const absolute = clean.startsWith('/')
|
|
123
|
-
|
|
123
|
+
// `.` is not a directory to climb out of, in either half of this.
|
|
124
|
+
//
|
|
125
|
+
// The url segments have always dropped it and the page's directory did
|
|
126
|
+
// not, which mattered for exactly one page on a site: the one at its root,
|
|
127
|
+
// where path.dirname gives '.'. That lone '.' counted as a real segment,
|
|
128
|
+
// so a `..` popped it instead of flooring, and the reference resolved to
|
|
129
|
+
// the same file a browser reaches while reporting floored: 0.
|
|
130
|
+
//
|
|
131
|
+
// The target was right and the verdict was wrong, which is why nothing
|
|
132
|
+
// noticed: a root page's over-deep url was silently exempt on a
|
|
133
|
+
// single-root build, and reported on a multi-site one where the site root
|
|
134
|
+
// makes pageDir genuinely empty. Same markup, two answers.
|
|
135
|
+
const segment = (s) => s !== '' && s !== '.'
|
|
136
|
+
const segments = clean.split('/').filter(segment)
|
|
124
137
|
|
|
125
|
-
const parts = absolute ? [] : pageDir.split('/').filter(
|
|
138
|
+
const parts = absolute ? [] : pageDir.split('/').filter(segment)
|
|
126
139
|
// How FAR above the root it climbed, not merely that it did. When every
|
|
127
140
|
// over-deep url on a site climbs the same distance, that is one base
|
|
128
141
|
// mismatch reported once — not N findings, which is how a real signal gets
|
package/src/report.js
CHANGED
|
@@ -119,6 +119,13 @@ export function resetReport() {
|
|
|
119
119
|
// time was not re-checked, and claiming otherwise would be the kind of
|
|
120
120
|
// completeness this codebase keeps having to walk back.
|
|
121
121
|
runtime.state.assetUse = new Map()
|
|
122
|
+
// Per cycle, like everything else here. Accumulating for the life of the
|
|
123
|
+
// process would make a watcher's second build report what the instance has
|
|
124
|
+
// spent since boot — a number that only grows, and never answers "what did
|
|
125
|
+
// this cycle cost", which is the question someone comparing builds is
|
|
126
|
+
// asking. The boot phases therefore appear in the first cycle's report and
|
|
127
|
+
// not in later ones, which is what actually happened.
|
|
128
|
+
runtime.state.timings = {}
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
// Published on the runtime so runtime.js can start a fresh cycle for a
|
|
@@ -433,6 +440,32 @@ function invalidation() {
|
|
|
433
440
|
}
|
|
434
441
|
}
|
|
435
442
|
|
|
443
|
+
// Phase durations, rounded to a tenth of a millisecond and ordered slowest
|
|
444
|
+
// first — so a diff between two versions leads with what moved.
|
|
445
|
+
function phaseTimings() {
|
|
446
|
+
const timings = runtime.state?.timings ?? {}
|
|
447
|
+
const entries = Object.entries(timings)
|
|
448
|
+
.map(([phase, { ms, calls }]) => ({ phase, ms: Math.round(ms * 10) / 10, calls }))
|
|
449
|
+
.sort((a, b) => b.ms - a.ms)
|
|
450
|
+
return {
|
|
451
|
+
// This cycle, summed. The phases below add up to it.
|
|
452
|
+
total: Math.round(entries.reduce((sum, e) => sum + e.ms, 0) * 10) / 10,
|
|
453
|
+
// How long THIS PROCESS has been alive, which is not the same thing
|
|
454
|
+
// and is named so nobody reads it as one.
|
|
455
|
+
//
|
|
456
|
+
// For a one-shot it is the whole run, and subtracting `total` gives
|
|
457
|
+
// what the phases do not cover: module loading and engine
|
|
458
|
+
// construction, which happen before any hook and on a small site are
|
|
459
|
+
// most of the elapsed time. For a build forwarded to a watcher it is
|
|
460
|
+
// the INSTANCE's age — minutes or days — and says nothing about the
|
|
461
|
+
// build. Reported either way, because a caller that knows which case
|
|
462
|
+
// it is in can use it, and a caller that does not would have been
|
|
463
|
+
// misled by a name like "elapsed".
|
|
464
|
+
processUptime: Math.round(process.uptime() * 1000 * 10) / 10,
|
|
465
|
+
phases: entries,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
436
469
|
export function buildReport() {
|
|
437
470
|
const report = store()
|
|
438
471
|
const cycle = runtime.state?.cycle
|
|
@@ -442,6 +475,17 @@ export function buildReport() {
|
|
|
442
475
|
cycleId: cycle?.id ?? null,
|
|
443
476
|
startedAt: cycle?.startedAt ?? null,
|
|
444
477
|
finishedAt: cycle?.finishedAt ?? null,
|
|
478
|
+
// What the run COST, per phase, in milliseconds.
|
|
479
|
+
//
|
|
480
|
+
// `finishedAt - startedAt` is not this. It spans the processing cycle
|
|
481
|
+
// only — measured on a small site, 12ms of a 443ms run — so boot, the
|
|
482
|
+
// config graph, the plugin load and the import scan, which is most of
|
|
483
|
+
// where a regression lands, had no number anywhere. A caller could
|
|
484
|
+
// prove an upgrade moved no bytes and not that it halved the speed.
|
|
485
|
+
//
|
|
486
|
+
// Milliseconds, because the console's whole-second rounding reports a
|
|
487
|
+
// phase that doubled from 400ms to 800ms as "0s" both times.
|
|
488
|
+
...(runtime.state?.timings ? { timings: phaseTimings() } : {}),
|
|
445
489
|
rendered: report.rendered,
|
|
446
490
|
skipped: report.skipped,
|
|
447
491
|
unchanged: report.unchanged,
|
|
@@ -494,5 +538,15 @@ export function emitReport() {
|
|
|
494
538
|
// questions: a server with the mcp plugin records so a tool can read it,
|
|
495
539
|
// and must not write a document to stdout.
|
|
496
540
|
if (!runtime.options?.json) return
|
|
541
|
+
// Not while a forwarded request owns the stream.
|
|
542
|
+
//
|
|
543
|
+
// The control socket opens in onLoaded, before the instance's own first
|
|
544
|
+
// build has finished, so a client can connect into that window — turn on
|
|
545
|
+
// the json contract, and have the INSTANCE's startup cycle write its
|
|
546
|
+
// report into the client's stdout, followed by the requested build's.
|
|
547
|
+
// Two documents in a stream promising one, which is the same broken
|
|
548
|
+
// contract as emitting none. The request emits exactly once, after the
|
|
549
|
+
// cycle it asked for.
|
|
550
|
+
if (runtime.state?.suppressReport) return
|
|
497
551
|
process.stdout.write(JSON.stringify(buildReport(), null, 2) + '\n')
|
|
498
552
|
}
|
package/src/runtime.js
CHANGED
|
@@ -62,18 +62,52 @@ const runtime = {
|
|
|
62
62
|
completed: [],
|
|
63
63
|
},
|
|
64
64
|
|
|
65
|
+
// What each phase COST, not only what it did.
|
|
66
|
+
//
|
|
67
|
+
// A build report says what was done and never what it took, so a
|
|
68
|
+
// regression is invisible to the one caller that would catch it. The
|
|
69
|
+
// preset fan-out shipped scanning the whole catalog every cycle and ran
|
|
70
|
+
// for four releases before anyone happened to time a rebuild by hand:
|
|
71
|
+
// output was byte-identical, every check passed, and the build was twice
|
|
72
|
+
// as slow.
|
|
73
|
+
//
|
|
74
|
+
// Recorded here because this is the one place every phase passes through,
|
|
75
|
+
// so nothing has to be instrumented plugin by plugin and no phase can be
|
|
76
|
+
// added later without being counted. The console's progress lines are not
|
|
77
|
+
// this: they are per-collection, rounded to whole seconds — so a phase
|
|
78
|
+
// that doubled from 400ms to 800ms prints "0s" either way — and they are
|
|
79
|
+
// suppressed entirely off a TTY, which is every CI run and every --json
|
|
80
|
+
// invocation, meaning the numbers did not exist where a script could read
|
|
81
|
+
// them.
|
|
82
|
+
//
|
|
83
|
+
// Accumulated per phase rather than assigned, because a phase runs more
|
|
84
|
+
// than once in a watch process and a cycle can re-enter one.
|
|
85
|
+
recordPhase(phaseName, ms) {
|
|
86
|
+
if (!phaseName) return
|
|
87
|
+
this.state ??= {}
|
|
88
|
+
const timings = (this.state.timings ??= {})
|
|
89
|
+
const entry = (timings[phaseName] ??= { ms: 0, calls: 0 })
|
|
90
|
+
entry.ms += ms
|
|
91
|
+
entry.calls++
|
|
92
|
+
},
|
|
93
|
+
|
|
65
94
|
async callHooks(hooks, signal, phaseName) {
|
|
66
95
|
// Lifecycle methods below pass `phaseName` so introspection
|
|
67
96
|
// tools (mikser-io-mcp's mikser://lifecycle resource, debuggers)
|
|
68
97
|
// can see what's running. Direct callers (tests, plugins driving
|
|
69
98
|
// sub-flows) can omit it.
|
|
70
99
|
if (phaseName) this.phase = phaseName
|
|
100
|
+
const started = performance.now()
|
|
71
101
|
try {
|
|
72
102
|
for (let hook of hooks) {
|
|
73
103
|
if (signal?.aborted) throw new AbortError()
|
|
74
104
|
await hook(signal)
|
|
75
105
|
}
|
|
76
106
|
} finally {
|
|
107
|
+
// In `finally`, so a phase that threw still reports what it spent
|
|
108
|
+
// before throwing — which is exactly the phase someone is about to
|
|
109
|
+
// go looking at.
|
|
110
|
+
this.recordPhase(phaseName, performance.now() - started)
|
|
77
111
|
if (phaseName) this.phase = null
|
|
78
112
|
}
|
|
79
113
|
},
|