oxmutate 1.1.0 → 1.2.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/README.md +3 -2
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/bin.js +0 -1
- package/dist/checker.js +1 -32
- package/dist/child.js +10 -43
- package/dist/cli.js +0 -10
- package/dist/command-runner.js +0 -27
- package/dist/config.js +0 -29
- package/dist/coordinator.d.ts +6 -0
- package/dist/coordinator.js +25 -135
- package/dist/directives.js +38 -0
- package/dist/files.js +0 -10
- package/dist/frozen.js +0 -15
- package/dist/heartbeat.js +62 -0
- package/dist/index.js +0 -4
- package/dist/lanes.js +1 -30
- package/dist/native.js +0 -13
- package/dist/platform.js +0 -14
- package/dist/progress.js +0 -9
- package/dist/reports.js +1 -29
- package/dist/resolve.js +0 -10
- package/dist/runner.js +33 -113
- package/dist/runtime.js +1 -16
- package/dist/scheduler.js +38 -26
- package/dist/spec-oracle.js +0 -23
- package/dist/spec.js +0 -28
- package/dist/vite-plugin.js +0 -5
- package/dist/vite.js +0 -2
- package/package.json +2 -2
package/dist/coordinator.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One mutation run, end to end:
|
|
3
|
-
*
|
|
4
|
-
* instrument (native, parallel) → dry run ×2 (coverage, timings, nondeterministic tests)
|
|
5
|
-
* → incremental reuse → plan → warm units on a work-stealing server pool, under a watchdog
|
|
6
|
-
* → isolated re-runs (static mutants, canary-flagged files, unfinished work, flaky verdicts)
|
|
7
|
-
* → verdicts
|
|
8
|
-
*
|
|
9
|
-
* Every decision that can make a verdict wrong is guarded and counted in `stats`, so a run says
|
|
10
|
-
* what it had to correct, not just what it concluded.
|
|
11
|
-
*/
|
|
12
1
|
import { createHash } from 'node:crypto';
|
|
13
2
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
3
|
import { tmpdir } from 'node:os';
|
|
@@ -17,6 +6,7 @@ import { ambientRuntime, run as spawn } from 'bellpull';
|
|
|
17
6
|
import { checkSurvivors } from './checker.js';
|
|
18
7
|
import { runCommandMutants } from './command-runner.js';
|
|
19
8
|
import { disables } from './config.js';
|
|
9
|
+
import { rangeDisables } from './directives.js';
|
|
20
10
|
import { globToRegExp, matchFiles } from './files.js';
|
|
21
11
|
import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
|
|
22
12
|
import { native } from './native.js';
|
|
@@ -36,20 +26,12 @@ function readRecords(file) {
|
|
|
36
26
|
.map((l) => JSON.parse(l));
|
|
37
27
|
}
|
|
38
28
|
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
39
|
-
/**
|
|
40
|
-
* Work units of at most this many (mutant, test) executions: small enough to steal, large enough
|
|
41
|
-
* that each unit's fresh worker, which imports the test file's whole graph, is paid for few units.
|
|
42
|
-
* At 120, module loading was most of the CPU on burgee/controlroom (React/Ink in every worker); 400
|
|
43
|
-
* cut units 172 -> 93 and wall 44 -> 39 s (paratext 39 -> 35 s), and 1000 gained nothing more
|
|
44
|
-
* (2026-10-30). Smaller stays better for a pollution restart, which re-runs the rest of a unit.
|
|
45
|
-
*/
|
|
46
29
|
const UNIT_PAIRS = 400;
|
|
30
|
+
const RERUN_SLACK = 3;
|
|
47
31
|
const MS_PER_MINUTE = 60_000;
|
|
48
32
|
const PERCENT = 100;
|
|
49
|
-
/** Every test that reaches a mutant: the score's, then a spec oracle's replay tests. */
|
|
50
33
|
const allCovering = (m) => [...m.coveredBy, ...(m.specCoveredBy ?? [])];
|
|
51
34
|
const percent = (n, of) => (of ? (PERCENT * n) / of : PERCENT);
|
|
52
|
-
/** Stryker's score: detected over everything valid and not ignored; `covered` leaves out no-coverage. */
|
|
53
35
|
function scoreOf(mutants) {
|
|
54
36
|
const scored = mutants.filter((m) => !['Ignored', 'CompileError', 'RuntimeError'].includes(m.status));
|
|
55
37
|
const detected = scored.filter((m) => m.status === 'Killed' || m.status === 'Timeout').length;
|
|
@@ -61,8 +43,8 @@ export async function run(options) {
|
|
|
61
43
|
const { root } = options;
|
|
62
44
|
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
63
45
|
const stats = {
|
|
64
|
-
files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
|
|
65
|
-
watchdogKills: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
|
|
46
|
+
files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
|
|
47
|
+
watchdogKills: 0, idleStalls: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
|
|
66
48
|
};
|
|
67
49
|
let unitSeq = 0;
|
|
68
50
|
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
@@ -70,8 +52,6 @@ export async function run(options) {
|
|
|
70
52
|
const progress = startProgress(options.reporters, stderr);
|
|
71
53
|
try {
|
|
72
54
|
progress.phase('instrumenting');
|
|
73
|
-
// ── 1. Instrument ─────────────────────────────────────────────────────────────────────────
|
|
74
|
-
// Stryker's `file:line-line` entries select files by their glob and mutants by their range.
|
|
75
55
|
const specs = options.mutate.map(parseMutateSpec);
|
|
76
56
|
const files = matchFiles(root, specs.map((x) => x.glob));
|
|
77
57
|
const rangesFor = (file) => {
|
|
@@ -93,8 +73,9 @@ export async function run(options) {
|
|
|
93
73
|
const directiveErrors = [];
|
|
94
74
|
for (const f of instrumented) {
|
|
95
75
|
const { disables: offs, errors } = disables(sources[f.path] ?? '', f.path);
|
|
76
|
+
const ranged = rangeDisables(sources[f.path] ?? '', f.path);
|
|
96
77
|
const ranges = rangesFor(f.path);
|
|
97
|
-
directiveErrors.push(...errors);
|
|
78
|
+
directiveErrors.push(...errors, ...ranged.errors);
|
|
98
79
|
let any = false;
|
|
99
80
|
for (const m of f.mutants) {
|
|
100
81
|
if (changed && !touches(changed, f.path, m.start.line, m.end.line))
|
|
@@ -104,13 +85,14 @@ export async function run(options) {
|
|
|
104
85
|
if (options.shard && shardOf(m.id, options.shard.count) !== options.shard.index)
|
|
105
86
|
continue;
|
|
106
87
|
const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
|
|
88
|
+
const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
|
|
107
89
|
const excluded = options.excludedMutations.includes(m.mutatorName);
|
|
108
90
|
mutants.push({
|
|
109
91
|
...m,
|
|
110
92
|
file: f.path,
|
|
111
93
|
coveredBy: [],
|
|
112
|
-
status: off || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
|
|
113
|
-
statusReason: off?.reason ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
|
|
94
|
+
status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
|
|
95
|
+
statusReason: off?.reason ?? inRange ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
|
|
114
96
|
});
|
|
115
97
|
any = true;
|
|
116
98
|
}
|
|
@@ -122,16 +104,13 @@ export async function run(options) {
|
|
|
122
104
|
stats.files = files.length;
|
|
123
105
|
stats.mutants = mutants.length;
|
|
124
106
|
const mapFile = path.join(work, 'instrumented.json');
|
|
125
|
-
// The frozen oracle's base-ref test files ride along in the same map (frozen.ts).
|
|
126
107
|
writeFileSync(mapFile, JSON.stringify({ ...codeMap, ...options.testOverrides }));
|
|
127
|
-
// ── 1b. The build, once, before anything runs (Stryker's `buildCommand`) ─────────────────
|
|
128
108
|
if (options.buildCommand) {
|
|
129
109
|
const [command = '', ...args] = options.buildCommand.trim().split(/\s+/);
|
|
130
110
|
const built = await spawn(command, args, { cwd: root, runtime: ambientRuntime() });
|
|
131
111
|
if (!built.ok)
|
|
132
112
|
throw new InstrumentFault(`buildCommand \`${options.buildCommand}\` exited ${String(built.code)}: ${(built.stderr || built.stdout).trim().slice(-1500)}. fix: make the build pass on its own first`);
|
|
133
113
|
}
|
|
134
|
-
// ── 1c. The command runner: any test command, one process per mutant (command-runner.ts) ──
|
|
135
114
|
if (options.commandRunner) {
|
|
136
115
|
const m0 = performance.now();
|
|
137
116
|
try {
|
|
@@ -146,8 +125,6 @@ export async function run(options) {
|
|
|
146
125
|
stats.wallMs = performance.now() - wall0;
|
|
147
126
|
return { root, options, sources, mutants, tests: [], stats, score: scoreOf(mutants) };
|
|
148
127
|
}
|
|
149
|
-
// ── 2. Dry run, twice (the second only to find nondeterministic tests) ────────────────────
|
|
150
|
-
// Spec oracles' traces, generated once; servers inherit where they are (spec.ts reads it).
|
|
151
128
|
const traceDirs = {};
|
|
152
129
|
for (const oracle of options.specOracles)
|
|
153
130
|
traceDirs[oracle.name] = await tracesFor(oracle, root, work);
|
|
@@ -193,8 +170,6 @@ export async function run(options) {
|
|
|
193
170
|
throw new InstrumentFault(`${String(failing.length)} test(s) fail with no mutant active, so no verdict can be trusted: ${failing.slice(0, 5).map((t) => t.name).join('; ')}. fix: make the suite pass first`);
|
|
194
171
|
if (dry.tests.length === 0 && !options.allowEmpty)
|
|
195
172
|
throw new InstrumentFault('the dry run ran no tests. fix: check the Vitest config the run used, or set allowEmpty');
|
|
196
|
-
// The second dry run only finds nondeterministic tests, which matter at step 7: it runs beside
|
|
197
|
-
// the warm phase instead of before it (6 s of a 107 s burgee/controlroom run, 2026-10-27).
|
|
198
173
|
const findFlakyTests = async () => {
|
|
199
174
|
const flaky = new Set();
|
|
200
175
|
if (options.flakyReruns > 0) {
|
|
@@ -211,11 +186,10 @@ export async function run(options) {
|
|
|
211
186
|
return flaky;
|
|
212
187
|
};
|
|
213
188
|
const flakyCheck = findFlakyTests();
|
|
214
|
-
flakyCheck.catch(() => undefined);
|
|
189
|
+
flakyCheck.catch(() => undefined);
|
|
215
190
|
const testInfo = new Map(dry.tests.map((t) => [t.id, t]));
|
|
216
191
|
const coveringTests = new Map();
|
|
217
192
|
const hits = new Map();
|
|
218
|
-
/** Static mutant → the test files whose collection (imports, beforeAll) reached it. */
|
|
219
193
|
const staticFiles = new Map();
|
|
220
194
|
for (const [testId, perMutant] of Object.entries(dry.perTest)) {
|
|
221
195
|
if (testId.startsWith(FILE_KEY)) {
|
|
@@ -234,8 +208,6 @@ export async function run(options) {
|
|
|
234
208
|
}
|
|
235
209
|
}
|
|
236
210
|
const staticIds = new Set([...Object.keys(dry.static), ...staticFiles.keys()]);
|
|
237
|
-
// A spec oracle's replay tests, by test id → oracle name. They are not tests of the score:
|
|
238
|
-
// their kills are reported as the spec's, on a track of their own.
|
|
239
211
|
const specOf = new Map();
|
|
240
212
|
for (const oracle of options.specOracles) {
|
|
241
213
|
const replay = new Set(matchFiles(root, oracle.replay).map((f) => path.join(root, f)));
|
|
@@ -272,7 +244,6 @@ export async function run(options) {
|
|
|
272
244
|
const tests = dry.tests.map(({ id, file, name, ms }) => ({ id, file, name, ms }));
|
|
273
245
|
return { root, options, sources, mutants, tests, stats, score: { detected: 0, undetected: 0, total: 0, mutationScore: 0, covered: 0 }, dryRunOnly: true };
|
|
274
246
|
}
|
|
275
|
-
// ── 3. Incremental reuse ──────────────────────────────────────────────────────────────────
|
|
276
247
|
const fileHashes = new Map();
|
|
277
248
|
const fileHash = (abs) => {
|
|
278
249
|
let h = fileHashes.get(abs);
|
|
@@ -296,21 +267,12 @@ export async function run(options) {
|
|
|
296
267
|
stats.reused++;
|
|
297
268
|
}
|
|
298
269
|
}
|
|
299
|
-
// ── 4. Warm units on the server pool ──────────────────────────────────────────────────────
|
|
300
|
-
// Static mutants, hybrids included, never run warm: a module evaluated without the mutant
|
|
301
|
-
// cannot stand in for one evaluated with it. Tried 2026-10-27: warm kills of hybrids gave 3
|
|
302
|
-
// false kills on burgee/paratext (an import-time registration the tests then read).
|
|
303
270
|
const toRun = mutants.filter((m) => !m.reused && m.status !== 'Ignored' && !m.static && allCovering(m).length > 0);
|
|
304
271
|
const byId = new Map(mutants.map((m) => [m.id, m]));
|
|
305
|
-
/** The switch-taken count past which a run is a runaway: 100x what the dry run saw, at least 1000. */
|
|
306
272
|
const hitLimitOf = (id) => Math.max(1000, (hits.get(id) ?? 1) * 100);
|
|
307
273
|
const hitLimits = Object.fromEntries(toRun.map((m) => [m.id, hitLimitOf(m.id)]));
|
|
308
274
|
const maxTestMs = (id) => { const m = byId.get(id); return Math.max(0, ...(m ? allCovering(m) : []).map((t) => testInfo.get(t)?.ms ?? 0)); };
|
|
309
|
-
// The watchdog is for runaways, which loop in fast code; a slow test under a loaded machine is
|
|
310
|
-
// not one. 3x the covering test's dry-run time, not Stryker's 1.5x: at 1.5x, subprocess-heavy
|
|
311
|
-
// burgee/paratext tests were stopped as false timeouts (2026-10-27).
|
|
312
275
|
const warmBudget = (id) => options.timeoutMS + Math.max(options.timeoutFactor, 3) * maxTestMs(id) + 250;
|
|
313
|
-
// Files a mutant's covering tests live in, cheapest first (by the covering tests' time there).
|
|
314
276
|
const fileOrder = new Map();
|
|
315
277
|
for (const m of toRun) {
|
|
316
278
|
const cost = new Map();
|
|
@@ -330,7 +292,6 @@ export async function run(options) {
|
|
|
330
292
|
stats.units++;
|
|
331
293
|
return unit;
|
|
332
294
|
};
|
|
333
|
-
/** Split a file's plan into units of about UNIT_PAIRS executions, by mutant. */
|
|
334
295
|
const unitsFor = (file, plan) => {
|
|
335
296
|
const ids = [...new Set(Object.values(plan).flat())];
|
|
336
297
|
const pairs = Object.values(plan).reduce((sum, l) => sum + l.length, 0);
|
|
@@ -340,7 +301,6 @@ export async function run(options) {
|
|
|
340
301
|
return makeUnit(file, Object.fromEntries(Object.entries(plan).map(([t, l]) => [t, l.filter((id) => mine.has(id))]).filter(([, l]) => l.length > 0)));
|
|
341
302
|
});
|
|
342
303
|
};
|
|
343
|
-
/** Units for these mutants, each planned only in the files `pick` selects for it. */
|
|
344
304
|
const unitsForRound = (mutantsHere, pick) => {
|
|
345
305
|
const perFile = new Map();
|
|
346
306
|
for (const m of mutantsHere) {
|
|
@@ -358,22 +318,16 @@ export async function run(options) {
|
|
|
358
318
|
}
|
|
359
319
|
}
|
|
360
320
|
const units = [...perFile].flatMap(([file, tests]) => unitsFor(file, Object.fromEntries(tests)));
|
|
361
|
-
// Sizing and ordering units by dry-run time instead of pair count (longest first) was tried:
|
|
362
|
-
// 30% more units and ~10% more CPU on burgee/controlroom, and no wall-clock gain that this
|
|
363
|
-
// machine's load (average 21 to 76 across the A/B) could show (2026-10-27).
|
|
364
321
|
const weight = (u) => Object.values(planUnits.get(u.results)?.plan ?? {}).reduce((sum, l) => sum + l.length, 0);
|
|
365
322
|
return units.sort((a, b) => weight(b) - weight(a));
|
|
366
323
|
};
|
|
367
324
|
const records = [];
|
|
368
325
|
const runaways = new Set();
|
|
369
326
|
const crashedIds = new Set();
|
|
370
|
-
// Dependencies inlined here too, so the static phase (5b) reuses these servers instead of starting its own.
|
|
371
327
|
const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs, inlineDeps: true }, options.concurrency, work, 'mutant');
|
|
372
328
|
pools.push(pool);
|
|
373
|
-
/** Mutants decided on one verdict track: the tests', or a spec oracle's replay tests'. */
|
|
374
329
|
const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
|
|
375
330
|
const afterUnit = (unit, outcome) => {
|
|
376
|
-
// Verdicts a failed canary cannot vouch for are dropped; their pairs are re-queued below.
|
|
377
331
|
const raw = readRecords(unit.results);
|
|
378
332
|
const suspect = new Set(raw.filter((r) => r.type === 'polluted').flatMap((r) => (r.suspects ?? []).map((id) => `${id}|${r.test ?? ''}`)));
|
|
379
333
|
const recs = raw.filter((r) => !(r.type === 'mutant' && suspect.has(`${r.id ?? ''}|${r.test ?? ''}`)));
|
|
@@ -388,7 +342,6 @@ export async function run(options) {
|
|
|
388
342
|
runaways.add(outcome.killedFor);
|
|
389
343
|
else if (bad)
|
|
390
344
|
crashedIds.add(bad);
|
|
391
|
-
// The rest of the unit: pairs not yet run, mutants not yet decided, into a fresh context.
|
|
392
345
|
const ran = new Set(recs.filter((r) => r.type === 'mutant').map((r) => `${r.id ?? ''}|${r.test ?? ''}`));
|
|
393
346
|
const decided = { tests: killedSoFar(false), spec: killedSoFar(true) };
|
|
394
347
|
const { file, plan, depth } = planUnits.get(unit.results);
|
|
@@ -401,8 +354,6 @@ export async function run(options) {
|
|
|
401
354
|
}
|
|
402
355
|
if (Object.keys(rest).length === 0)
|
|
403
356
|
return [];
|
|
404
|
-
// A unit that made no progress, or that keeps being polluted, is left to the isolated
|
|
405
|
-
// re-run rather than retried for ever.
|
|
406
357
|
if (ran.size === 0 || depth >= 40) {
|
|
407
358
|
for (const id of new Set(Object.values(rest).flat()))
|
|
408
359
|
unfinished.add(id);
|
|
@@ -418,13 +369,6 @@ export async function run(options) {
|
|
|
418
369
|
const killedCount = () => `${String(killedSoFar(false).size)} of ${String(toRun.length)} mutants killed so far`;
|
|
419
370
|
progress.phase('warm runs', killedCount);
|
|
420
371
|
const m0 = performance.now();
|
|
421
|
-
// One round, every covering file at once. A two-round plan (cheapest file first, survivors
|
|
422
|
-
// to the rest) cut executions 23% on burgee/paratext but took 2.9x longer: the second round
|
|
423
|
-
// waits for the first to drain, and the expensive files then run as a tail (2026-10-27).
|
|
424
|
-
// Kills are filtered out at dispatch, not when a unit is built: most units wait in the queue while
|
|
425
|
-
// others run, and a mutant another unit has killed (vouched for by its canary) needs no more of its
|
|
426
|
-
// covering tests. 1,022 of 6,550 warm executions on burgee/paratext ran after the mutant's kill
|
|
427
|
-
// (2026-10-30). Verdicts are unchanged: a Killed mutant needs no other pair (dispatch.qnt).
|
|
428
372
|
const beforeUnit = (unit) => {
|
|
429
373
|
const entry = planUnits.get(unit.results);
|
|
430
374
|
if (!entry)
|
|
@@ -445,7 +389,6 @@ export async function run(options) {
|
|
|
445
389
|
};
|
|
446
390
|
await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit, beforeUnit);
|
|
447
391
|
stats.mutationMs = performance.now() - m0;
|
|
448
|
-
// ── 5. Verdicts from the warm run ─────────────────────────────────────────────────────────
|
|
449
392
|
const byMutant = new Map();
|
|
450
393
|
for (const r of records)
|
|
451
394
|
if (r.type === 'mutant' && r.id)
|
|
@@ -460,10 +403,10 @@ export async function run(options) {
|
|
|
460
403
|
if (m.specCoveredBy)
|
|
461
404
|
m.spec = specVerdict(m, all.filter((r) => r.spec));
|
|
462
405
|
if (m.coveredBy.length === 0 && !runaways.has(m.id) && !crashedIds.has(m.id)) {
|
|
463
|
-
continue;
|
|
406
|
+
continue;
|
|
464
407
|
}
|
|
465
408
|
else if (runaways.has(m.id)) {
|
|
466
|
-
isolatedQueue.push(m);
|
|
409
|
+
isolatedQueue.push(m);
|
|
467
410
|
}
|
|
468
411
|
else if (crashedIds.has(m.id)) {
|
|
469
412
|
m.status = 'RuntimeError';
|
|
@@ -479,22 +422,15 @@ export async function run(options) {
|
|
|
479
422
|
m.testsCompleted = recs.length;
|
|
480
423
|
}
|
|
481
424
|
else {
|
|
482
|
-
isolatedQueue.push(m);
|
|
425
|
+
isolatedQueue.push(m);
|
|
483
426
|
}
|
|
484
427
|
}
|
|
485
428
|
const staticMutants = mutants.filter((m) => m.static && m.status !== 'Ignored' && !m.reused);
|
|
486
|
-
// ── 6. Isolated re-runs: the switch armed before import, Vitest run normally ──────────────
|
|
487
429
|
const allTestFiles = [...new Set(dry.tests.map((t) => t.file))];
|
|
488
|
-
// A static mutant needs the files that load it (plus any whose tests reach it); only a hit
|
|
489
|
-
// with no file to attribute it to (a setup file outside collection) falls back to all files.
|
|
490
430
|
const fileCost = new Map();
|
|
491
431
|
for (const t of dry.tests)
|
|
492
432
|
fileCost.set(t.file, (fileCost.get(t.file) ?? 0) + t.ms);
|
|
493
433
|
const byCost = (files) => [...files].sort((a, b) => (fileCost.get(a) ?? 0) - (fileCost.get(b) ?? 0));
|
|
494
|
-
// Test file -> source file -> how many of that source's mutants its tests reach. A static mutant
|
|
495
|
-
// rarely has covering tests of its own, so its loaders were tried cheapest first, and the cheapest
|
|
496
|
-
// seldom tests the module: on burgee/controlroom each tabs.ts static mutant ran 75 tests in two
|
|
497
|
-
// files where Stryker needed 37 (2026-10-30). The file that exercises the module goes first.
|
|
498
434
|
const affinity = new Map();
|
|
499
435
|
for (const mm of mutants) {
|
|
500
436
|
for (const t of mm.coveredBy) {
|
|
@@ -513,35 +449,20 @@ export async function run(options) {
|
|
|
513
449
|
const loaders = staticFiles.get(m.id);
|
|
514
450
|
if (!loaders || m.id in dry.static)
|
|
515
451
|
return byCost(allTestFiles);
|
|
516
|
-
// Files whose tests reach the mutant first: they kill it. Ordering all loaders by cost alone
|
|
517
|
-
// ran a file that only imports it first, and 288 of 349 isolated kills on burgee/paratext
|
|
518
|
-
// came from a later file (2026-10-27).
|
|
519
452
|
const covering = byCost([...new Set(tests)]);
|
|
520
453
|
return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
|
|
521
454
|
};
|
|
522
455
|
const isolatedBudget = (id) => {
|
|
523
456
|
const m = byId.get(id);
|
|
524
457
|
const fs = m ? filesFor(m) : allTestFiles;
|
|
525
|
-
// A non-static mutant's isolated run executes only its covering tests (`tests` below), so they
|
|
526
|
-
// are its budget; every test of its files made a hang wait 30-40 s on oxmutate's own suite,
|
|
527
|
-
// where its covering tests take a fraction of that (2026-10-30). A static mutant runs them all.
|
|
528
458
|
const covering = new Set(m && !m.static ? m.coveredBy : []);
|
|
529
459
|
const ms = dry.tests.filter((t) => fs.includes(t.file) && (covering.size === 0 || covering.has(t.id))).reduce((sum, t) => sum + t.ms, 0);
|
|
530
460
|
return 1500 + options.timeoutMS + options.timeoutFactor * ms * 2;
|
|
531
461
|
};
|
|
532
|
-
// A non-static mutant runs only its covering tests, armed across all of them in file order:
|
|
533
|
-
// Stryker's semantics, so cross-test effects still show (a no-op reset() lets state pile up
|
|
534
|
-
// for the next covering test). A static mutant runs every test of its files, because an
|
|
535
|
-
// import-time change reaches tests that never execute the mutated line.
|
|
536
|
-
/**
|
|
537
|
-
* Static mutants decided in live workers, one unit per (test file, batch of its static mutants),
|
|
538
|
-
* on a pool that inlines dependencies. Returns the mutants this path could not decide.
|
|
539
|
-
*/
|
|
540
462
|
const decideStaticLive = async (items) => {
|
|
541
463
|
if (items.length === 0)
|
|
542
464
|
return [];
|
|
543
465
|
const STATIC_BATCH = 25;
|
|
544
|
-
/** Units of up to STATIC_BATCH mutants per test file, for the files each mutant gets this round. */
|
|
545
466
|
const unitsFor = (filesOf) => {
|
|
546
467
|
const byFile = new Map();
|
|
547
468
|
for (const m of items)
|
|
@@ -553,9 +474,8 @@ export async function run(options) {
|
|
|
553
474
|
})));
|
|
554
475
|
};
|
|
555
476
|
const killed = new Map();
|
|
556
|
-
/** Decided by the hit-count limit: a runaway, Timeout like a warm one. */
|
|
557
477
|
const timedOut = new Set();
|
|
558
|
-
const passed = new Map();
|
|
478
|
+
const passed = new Map();
|
|
559
479
|
const back = new Set();
|
|
560
480
|
const ran = new Map();
|
|
561
481
|
const staticPool = pool;
|
|
@@ -563,6 +483,8 @@ export async function run(options) {
|
|
|
563
483
|
const file = unit.files[0] ?? '';
|
|
564
484
|
const decided = new Set();
|
|
565
485
|
for (const r of readRecords(unit.results)) {
|
|
486
|
+
if (r.type === 'recollection')
|
|
487
|
+
stats.staticRecollections++;
|
|
566
488
|
if (r.type === 'static-fallback')
|
|
567
489
|
for (const id of r.ids ?? [])
|
|
568
490
|
back.add(id);
|
|
@@ -577,7 +499,6 @@ export async function run(options) {
|
|
|
577
499
|
else
|
|
578
500
|
(passed.get(r.id) ?? passed.set(r.id, new Set()).get(r.id))?.add(file);
|
|
579
501
|
}
|
|
580
|
-
// A stop or a crash leaves the rest of the batch undecided: the fresh-worker path decides it.
|
|
581
502
|
if (outcome.killedFor || outcome.crashed)
|
|
582
503
|
for (const id of unit.staticIds ?? [])
|
|
583
504
|
if (!decided.has(id))
|
|
@@ -588,13 +509,8 @@ export async function run(options) {
|
|
|
588
509
|
unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
|
|
589
510
|
return unit.staticIds.length > 0;
|
|
590
511
|
};
|
|
591
|
-
// Two rounds: each mutant first in the file most likely to kill it (filesFor puts the files whose
|
|
592
|
-
// tests reach it first, cheapest first), then its other files only if it survived. In one round
|
|
593
|
-
// every loading file ran a mutant at once, and a kill in one did not spare the others: 41 tests a
|
|
594
|
-
// static kill on burgee/controlroom against Stryker's 33.6 (2026-10-30).
|
|
595
512
|
const open = (m) => !killed.has(m.id) && !back.has(m.id);
|
|
596
513
|
const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
|
|
597
|
-
// the mutant's run and the unarmed canary after it
|
|
598
514
|
for (const round of rounds)
|
|
599
515
|
await staticPool.drain(unitsFor(round), (_, id) => 2 * isolatedBudget(id), afterStatic, keepUndecided);
|
|
600
516
|
const undecided = [];
|
|
@@ -619,9 +535,6 @@ export async function run(options) {
|
|
|
619
535
|
if (items.length === 0)
|
|
620
536
|
return verdicts;
|
|
621
537
|
const mk = (batch) => ({ mode: 'isolated', files: [], results: fileFor('iso', 'jsonl'), progress: fileFor('iso', 'progress'), batch: batch.map((m) => ({ id: m.id, files: filesFor(m), tests: m.static ? undefined : m.coveredBy, hitLimit: hitLimitOf(m.id) })) });
|
|
622
|
-
// Packed by estimated cost, heaviest first into the lightest bin, and the heaviest bins run
|
|
623
|
-
// first. Equal mutant counts left one unit at 8.4 s while the others finished in under 3 s on
|
|
624
|
-
// burgee/controlroom (2026-10-30): a mutant's cost is its files' dry-run time, not one.
|
|
625
538
|
const cost = (m) => isolatedBudget(m.id);
|
|
626
539
|
const bins = Array.from({ length: Math.min(items.length, options.concurrency * 2) }, () => ({ load: 0, batch: [] }));
|
|
627
540
|
for (const m of [...items].sort((x, y) => cost(y) - cost(x))) {
|
|
@@ -632,7 +545,6 @@ export async function run(options) {
|
|
|
632
545
|
const units = bins.sort((x, y) => y.load - x.load).map((b) => mk(b.batch));
|
|
633
546
|
stats.isolatedRuns += items.length;
|
|
634
547
|
await pool.drain(units, (_, id) => slack * isolatedBudget(id), (unit, outcome) => {
|
|
635
|
-
// Vitest reports per test file: a mutant is killed if any of its files failed.
|
|
636
548
|
for (const rec of readRecords(unit.results)) {
|
|
637
549
|
if (rec.type !== 'isolated' || !rec.id)
|
|
638
550
|
continue;
|
|
@@ -646,7 +558,7 @@ export async function run(options) {
|
|
|
646
558
|
verdicts.set(rec.id, { status: prev?.status ?? 'Survived', killedBy: prev?.killedBy, testsCompleted });
|
|
647
559
|
}
|
|
648
560
|
if (outcome.killedFor)
|
|
649
|
-
verdicts.set(outcome.killedFor, { status: 'Timeout' });
|
|
561
|
+
verdicts.set(outcome.killedFor, { status: 'Timeout', idle: outcome.stall === 'idle' });
|
|
650
562
|
if (outcome.crashed && outcome.crashedFor)
|
|
651
563
|
verdicts.set(outcome.crashedFor, { status: 'RuntimeError' });
|
|
652
564
|
if (!outcome.killedFor && !outcome.crashed)
|
|
@@ -660,21 +572,15 @@ export async function run(options) {
|
|
|
660
572
|
verdicts.set(m.id, { status: 'RuntimeError' });
|
|
661
573
|
return verdicts;
|
|
662
574
|
};
|
|
663
|
-
|
|
664
|
-
// A fresh worker per (static mutant, file) cost ~80 ms of start-up around tests that take a few
|
|
665
|
-
// ms: 29 of 35 s on burgee/paratext (2026-10-30). A live worker re-evaluates the project's modules
|
|
666
|
-
// per mutant instead; whatever it cannot vouch for comes back here for the fresh-worker path.
|
|
667
|
-
// OXMUTATE_ISOLATE_STATIC=1 sends every static mutant down the fresh-worker path, as before.
|
|
575
|
+
const s0 = performance.now();
|
|
668
576
|
const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
|
|
577
|
+
stats.staticMs = performance.now() - s0;
|
|
669
578
|
isolatedQueue.push(...staticBack);
|
|
670
579
|
progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
|
|
671
580
|
const i0 = performance.now();
|
|
672
581
|
const first = await runIsolated(isolatedQueue);
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// Each stop gets one more run with 3x the budget; only one that is stopped again is a Timeout.
|
|
676
|
-
const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout');
|
|
677
|
-
const again = await runIsolated(stopped, 3);
|
|
582
|
+
const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
|
|
583
|
+
const again = await runIsolated(stopped, RERUN_SLACK);
|
|
678
584
|
for (const m of stopped) {
|
|
679
585
|
const v = again.get(m.id);
|
|
680
586
|
if (v && v.status !== 'Timeout' && v.status !== 'RuntimeError') {
|
|
@@ -689,25 +595,13 @@ export async function run(options) {
|
|
|
689
595
|
m.killedBy = v.killedBy;
|
|
690
596
|
m.testsCompleted = v.testsCompleted;
|
|
691
597
|
if (v.status === 'Timeout')
|
|
692
|
-
m.statusReason = 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
598
|
+
m.statusReason = v.idle ? 'its test waited past the budget with the worker idle: a hang, not a slow test' : 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
693
599
|
}
|
|
694
600
|
}
|
|
695
|
-
// ── 6b. Confirm survivors under whole-file semantics ──────────────────────────────────────
|
|
696
|
-
// The warm runner arms a mutant for one test at a time, so a mutant whose effect only shows
|
|
697
|
-
// across tests (a `reset()` in beforeEach made a no-op, so state piles up for the next test)
|
|
698
|
-
// survives warm and is killed when armed for the whole file, the way Stryker runs it (burgee/
|
|
699
|
-
// paratext plugin.ts, 2026-10-27). Survivors are what a person acts on, so each one is
|
|
700
|
-
// re-run once with the mutant armed across its covering files before it is reported.
|
|
701
|
-
// A survivor with one covering test is not re-run: confirmation runs only covering tests, so there
|
|
702
|
-
// is no later test for its leftover state to reach, and the warm run already armed that test
|
|
703
|
-
// through its hooks from clean state (.sdlc/intents/oxmutate/confirm.qnt).
|
|
704
601
|
const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
|
|
705
602
|
const confirmed = await runIsolated(survivors);
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
// (2026-10-27). The same rule as section 6: one more run at 3x the budget before believing it.
|
|
709
|
-
const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout');
|
|
710
|
-
const recheck = await runIsolated(stoppedHere, 3);
|
|
603
|
+
const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
|
|
604
|
+
const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
|
|
711
605
|
for (const m of stoppedHere) {
|
|
712
606
|
const v = recheck.get(m.id);
|
|
713
607
|
if (v)
|
|
@@ -722,10 +616,8 @@ export async function run(options) {
|
|
|
722
616
|
stats.survivorsOverturned++;
|
|
723
617
|
}
|
|
724
618
|
}
|
|
725
|
-
// ── 6c. Survivors that do not type-check are CompileErrors (checker.ts) ────────────────────
|
|
726
619
|
if (options.typeCheck) {
|
|
727
620
|
progress.phase('type-checking survivors');
|
|
728
|
-
// Every undetected mutant is one somebody would act on: survivors and the uncovered.
|
|
729
621
|
const survived = mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
|
|
730
622
|
const refused = await checkSurvivors({ root, tsconfig: options.tsconfigFile, command: options.typeChecker, concurrency: options.concurrency }, survived).catch((error) => {
|
|
731
623
|
throw new InstrumentFault(error instanceof Error ? error.message : String(error));
|
|
@@ -736,7 +628,6 @@ export async function run(options) {
|
|
|
736
628
|
stats.compileErrors++;
|
|
737
629
|
}
|
|
738
630
|
}
|
|
739
|
-
// ── 7. Flaky verdicts: re-run mutants whose covering tests are nondeterministic ───────────
|
|
740
631
|
const flakyTests = await flakyCheck;
|
|
741
632
|
stats.flakyTests = flakyTests.size;
|
|
742
633
|
const flakyCandidates = toRun.filter((m) => m.coveredBy.some((t) => flakyTests.has(t)));
|
|
@@ -759,8 +650,8 @@ export async function run(options) {
|
|
|
759
650
|
}
|
|
760
651
|
stats.isolatedMs = performance.now() - i0;
|
|
761
652
|
stats.watchdogKills = pool.stats.killed;
|
|
653
|
+
stats.idleStalls = pool.stats.idle;
|
|
762
654
|
stats.crashes = pool.stats.crashed;
|
|
763
|
-
// ── 8. Score, cache, result ───────────────────────────────────────────────────────────────
|
|
764
655
|
if (options.incremental) {
|
|
765
656
|
mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
|
|
766
657
|
const next = { ...cache };
|
|
@@ -800,4 +691,3 @@ export async function run(options) {
|
|
|
800
691
|
rmSync(work, { recursive: true, force: true });
|
|
801
692
|
}
|
|
802
693
|
}
|
|
803
|
-
//# sourceMappingURL=coordinator.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const HEAD = /\/\/\s*(?:Stryker|oxmutate)\s+(disable|restore)\s+(\S.*)/i;
|
|
2
|
+
const LIST = /^(?:all|\w+(?:\s*,\s*\w+)*)$/i;
|
|
3
|
+
const covers = (r, mutator) => r.mutators === 'all' || r.mutators.includes(mutator);
|
|
4
|
+
export function rangeDisables(source, file) {
|
|
5
|
+
const rules = [];
|
|
6
|
+
const errors = [];
|
|
7
|
+
source.split('\n').forEach((text, i) => {
|
|
8
|
+
const m = HEAD.exec(text);
|
|
9
|
+
if (!m)
|
|
10
|
+
return;
|
|
11
|
+
const body = m[2];
|
|
12
|
+
const colon = body.indexOf(':');
|
|
13
|
+
const list = (colon === -1 ? body : body.slice(0, colon)).trim();
|
|
14
|
+
if (!LIST.test(list))
|
|
15
|
+
return;
|
|
16
|
+
const kind = m[1].toLowerCase();
|
|
17
|
+
const reason = colon === -1 ? '' : body.slice(colon + 1).trim();
|
|
18
|
+
if (kind === 'disable' && !reason) {
|
|
19
|
+
errors.push(`${file}:${String(i + 1)}: disable comment without a reason. fix: add ": <why these mutants cannot be killed>"`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const mutators = list.toLowerCase() === 'all' ? 'all' : list.split(',').map((s) => s.trim().toLowerCase());
|
|
23
|
+
rules.push({ line: i + 1, kind, mutators, reason });
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
errors,
|
|
27
|
+
reasonFor(line, mutator) {
|
|
28
|
+
const name = mutator.toLowerCase();
|
|
29
|
+
for (let k = rules.length - 1; k >= 0; k--) {
|
|
30
|
+
const r = rules[k];
|
|
31
|
+
if (r.line >= line || !covers(r, name))
|
|
32
|
+
continue;
|
|
33
|
+
return r.kind === 'disable' ? r.reason : undefined;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The files to mutate, from Stryker-style globs (`src/**\/*.ts`, `!src/**\/*.test.ts`, `{a,b}`).
|
|
3
|
-
* No dependency: a glob is translated to one regular expression, and the walk skips the
|
|
4
|
-
* directories a project never mutates.
|
|
5
|
-
*/
|
|
6
1
|
import { readdirSync } from 'node:fs';
|
|
7
2
|
import path from 'node:path';
|
|
8
3
|
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.turbo', '.oxmutate', '.stryker-tmp', 'reports']);
|
|
9
4
|
const escapeRegExp = (s) => s.replace(/[.+^$()|[\]\\]/g, '\\$&');
|
|
10
|
-
/** Translate one glob (without a leading `!`) into an anchored regular expression. */
|
|
11
5
|
export function globToRegExp(glob) {
|
|
12
6
|
let re = '';
|
|
13
|
-
// Stryker disable next-line EqualityOperator: one step past the end reads charAt(length), an empty string, which appends nothing
|
|
14
7
|
for (let i = 0; i < glob.length; i++) {
|
|
15
8
|
const c = glob.charAt(i);
|
|
16
9
|
if (c === '*') {
|
|
17
10
|
if (glob[i + 1] === '*') {
|
|
18
|
-
// `**/` matches zero or more directories; a bare `**` matches anything.
|
|
19
11
|
if (glob[i + 2] === '/') {
|
|
20
12
|
re += '(?:.*/)?';
|
|
21
13
|
i += 2;
|
|
@@ -40,7 +32,6 @@ export function globToRegExp(glob) {
|
|
|
40
32
|
}
|
|
41
33
|
return new RegExp(`^${re}$`);
|
|
42
34
|
}
|
|
43
|
-
/** Repository-relative paths (forward slashes) of files matching the include and exclude globs. */
|
|
44
35
|
export function matchFiles(root, patterns) {
|
|
45
36
|
const include = patterns.filter((p) => !p.startsWith('!')).map(globToRegExp);
|
|
46
37
|
const exclude = patterns.filter((p) => p.startsWith('!')).map((p) => globToRegExp(p.slice(1)));
|
|
@@ -59,4 +50,3 @@ export function matchFiles(root, patterns) {
|
|
|
59
50
|
walk('');
|
|
60
51
|
return out.sort();
|
|
61
52
|
}
|
|
62
|
-
//# sourceMappingURL=files.js.map
|
package/dist/frozen.js
CHANGED
|
@@ -1,22 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The frozen oracle (spec R14): a change that edits tests is also scored against the base ref's
|
|
3
|
-
* tests. The code under test is the same in both runs, so the mutants and their ids are the same;
|
|
4
|
-
* only the test files differ. A mutant the base tests detect and the changed tests do not is a test
|
|
5
|
-
* the change weakened, which is how a patch that "makes the check pass" by editing the check shows
|
|
6
|
-
* up (the reward-hacking concern in the intent, written as a CI result).
|
|
7
|
-
*
|
|
8
|
-
* The base tests are served to Vitest from memory, the way instrumented sources are: nothing in
|
|
9
|
-
* the working tree is touched.
|
|
10
|
-
*/
|
|
11
1
|
import path from 'node:path';
|
|
12
2
|
import { ambientRuntime, run as spawn } from 'bellpull';
|
|
13
3
|
import { run } from './coordinator.js';
|
|
14
4
|
const detected = (m) => m?.status === 'Killed' || m?.status === 'Timeout';
|
|
15
5
|
const undetected = (m) => m?.status === 'Survived' || m?.status === 'NoCoverage';
|
|
16
|
-
/**
|
|
17
|
-
* A test file the change added has no base version. It is served as one skipped test, so the base
|
|
18
|
-
* run has the file (Vitest finds it on disk) and none of its tests.
|
|
19
|
-
*/
|
|
20
6
|
const ABSENT_AT_BASE = "import { it } from 'vitest';\nit.skip('absent at the base ref (oxmutate frozen oracle)', () => {});\n";
|
|
21
7
|
async function git(root, args) {
|
|
22
8
|
const r = await spawn('git', args, { cwd: root, runtime: ambientRuntime() });
|
|
@@ -44,4 +30,3 @@ export async function frozenOracle(head, base) {
|
|
|
44
30
|
.map((m) => ({ id: m.id, file: m.file, line: m.start.line, mutatorName: m.mutatorName, replacement: m.replacement }));
|
|
45
31
|
return result;
|
|
46
32
|
}
|
|
47
|
-
//# sourceMappingURL=frozen.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const HEARTBEAT_MS = 250;
|
|
4
|
+
export const STALE_MS = 1000;
|
|
5
|
+
export const CPU_SAMPLE_MS = 250;
|
|
6
|
+
export const CPU_WINDOW_MS = 1000;
|
|
7
|
+
export const MIN_CPU_SPAN_MS = 500;
|
|
8
|
+
export const IDLE_CPU = 0.25;
|
|
9
|
+
export const BEAT_ENV = 'OXMUTATE_BEAT';
|
|
10
|
+
export const beatFile = (workDir, pid) => path.join(workDir, `beat-${String(pid)}`);
|
|
11
|
+
const { setInterval: realSetInterval, clearInterval: realClearInterval } = globalThis;
|
|
12
|
+
export const realNow = Date.now.bind(Date);
|
|
13
|
+
const BEAT_KEY = Symbol.for('oxmutate.heartbeat');
|
|
14
|
+
let inTest = false;
|
|
15
|
+
export function markTest(running) {
|
|
16
|
+
inTest = running;
|
|
17
|
+
}
|
|
18
|
+
export function startHeartbeat(file) {
|
|
19
|
+
if (!file)
|
|
20
|
+
return;
|
|
21
|
+
const holder = globalThis;
|
|
22
|
+
realClearInterval(holder[BEAT_KEY]);
|
|
23
|
+
const beat = () => {
|
|
24
|
+
try {
|
|
25
|
+
writeFileSync(file, inTest ? `${String(realNow())} test` : String(realNow()));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
beat();
|
|
33
|
+
holder[BEAT_KEY] = realSetInterval(() => {
|
|
34
|
+
if (!beat())
|
|
35
|
+
realClearInterval(holder[BEAT_KEY]);
|
|
36
|
+
}, HEARTBEAT_MS);
|
|
37
|
+
holder[BEAT_KEY].unref();
|
|
38
|
+
}
|
|
39
|
+
export function readBeat(file, now = Date.now()) {
|
|
40
|
+
let text;
|
|
41
|
+
try {
|
|
42
|
+
text = String(readFileSync(file));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { age: Number.POSITIVE_INFINITY, inTest: false };
|
|
46
|
+
}
|
|
47
|
+
const [stamp, where] = text.split(' ');
|
|
48
|
+
const at = Number(stamp);
|
|
49
|
+
return { age: Number.isFinite(at) && at > 0 ? now - at : Number.POSITIVE_INFINITY, inTest: where === 'test' };
|
|
50
|
+
}
|
|
51
|
+
const US_PER_MS = 1000;
|
|
52
|
+
export function cpuShare(samples, now) {
|
|
53
|
+
const recent = samples.filter((s) => s.at >= now - CPU_WINDOW_MS && s.at <= now);
|
|
54
|
+
const first = recent[0];
|
|
55
|
+
const last = recent.at(-1);
|
|
56
|
+
if (!first || !last || last.at - first.at < MIN_CPU_SPAN_MS)
|
|
57
|
+
return undefined;
|
|
58
|
+
return (last.us - first.us) / US_PER_MS / (last.at - first.at);
|
|
59
|
+
}
|
|
60
|
+
export function classifyStall(o) {
|
|
61
|
+
return o.beat.inTest && o.beat.age < STALE_MS && (o.cpuShare ?? IDLE_CPU) < IDLE_CPU ? 'idle' : 'busy';
|
|
62
|
+
}
|