oxmutate 1.1.0 → 1.2.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/README.md +3 -2
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/child.js +12 -1
- package/dist/coordinator.d.ts +6 -0
- package/dist/coordinator.js +25 -11
- package/dist/directives.js +55 -0
- package/dist/heartbeat.js +111 -0
- package/dist/reports.js +1 -1
- package/dist/runner.js +37 -8
- package/dist/runtime.js +1 -0
- package/dist/scheduler.js +42 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -189,7 +189,7 @@ setting a value inside the worker that already has the tests loaded.
|
|
|
189
189
|
| Re-parse | Every instrumented file is parsed again before it runs; one that does not parse stops the run (exit 3) instead of scoring a broken file |
|
|
190
190
|
| Canary | The unmutated test must still pass after mutants run in a context, with its `beforeEach`/`afterEach` hooks re-run around every run, file-level ones included; if a mutant left a cache, a registry or a fixture behind, the context restarts and the suspect verdicts run again. A kill that follows unchecked runs is checked at once and decided again from clean state, so no kill rests on another mutant's leftovers |
|
|
191
191
|
| Survivor confirmation | A survivor is re-run with the mutant armed across its covering tests in one file, as Stryker arms it, so a mutant that only fails across tests is killed |
|
|
192
|
-
| Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout |
|
|
192
|
+
| Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout. A stop whose worker was idle inside a test body (its event loop still turning, by a heartbeat, and its server under 25% of a core) is a hang, not a slow test: it is a Timeout as it stands, the way Stryker scores it, with no 3x re-run |
|
|
193
193
|
| Static mutants | Code that runs at import is decided in a live worker: the mutant is armed, the module registry reset and the test file collected again, against the files that load it. A file whose collection changes globals, `process.env` or a built-in prototype, or a mutant that leaves a trace, goes back to a fresh worker per mutant (`OXMUTATE_ISOLATE_STATIC=1` sends all of them there) |
|
|
194
194
|
| Flaky tests | Tests that behave differently across two dry runs have their mutants re-run; a verdict that changes is reported `flaky` |
|
|
195
195
|
| No orphans | Every Vitest server exits when its run does, killed or not |
|
|
@@ -261,7 +261,8 @@ head-to-head runs (`bench/burgee-pair.mts`, concurrency 4, one tool after the ot
|
|
|
261
261
|
|
|
262
262
|
The ceiling depends on the suite as much as the tool: controlroom's own `vitest run` takes ~8 s and
|
|
263
263
|
every fresh worker imports React and Ink, so even zero overhead would make it ~6x. The figures to
|
|
264
|
-
publish
|
|
264
|
+
publish come from `bench/burgee-pair.mts`, run locally on a quiet machine: no mutation run happens
|
|
265
|
+
in GitHub Actions (`D-20261101-no-mutation-in-gha`).
|
|
265
266
|
|
|
266
267
|
## Benchmarks
|
|
267
268
|
|
|
Binary file
|
package/dist/child.js
CHANGED
|
@@ -14,11 +14,14 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { ExitCode } from 'burgee';
|
|
17
|
+
import { BEAT_ENV, beatFile, CPU_SAMPLE_MS } from './heartbeat.js';
|
|
17
18
|
import { resolveEsm } from './resolve.js';
|
|
18
|
-
import { ACTIVE_MUTANT_ENV, argv, env, exit } from './runtime.js';
|
|
19
|
+
import { ACTIVE_MUTANT_ENV, argv, env, exit, pid } from './runtime.js';
|
|
19
20
|
import { instrumentedSources } from './vite-plugin.js';
|
|
20
21
|
const config = JSON.parse(readFileSync(argv[2] ?? '', 'utf8'));
|
|
21
22
|
env['NODE_ENV'] ??= 'test';
|
|
23
|
+
// Before Vitest starts any worker, so each one inherits it (heartbeat.ts).
|
|
24
|
+
env[BEAT_ENV] = beatFile(path.dirname(config.instrumentedMap), pid);
|
|
22
25
|
const vitestNode = (await import(resolveEsm('vitest/node', config.root)));
|
|
23
26
|
const runner = fileURLToPath(new URL('./runner.js', import.meta.url));
|
|
24
27
|
const silent = { onInit() { }, onFinished() { } };
|
|
@@ -142,5 +145,13 @@ process.on('message', (msg) => {
|
|
|
142
145
|
// and a worker thread may still be looping in a mutant. Two such servers were found spinning at
|
|
143
146
|
// 100% CPU 11 hours after their run ended, and slowed the next run 50x (2026-10-27).
|
|
144
147
|
process.on('disconnect', () => exit(ExitCode.OK));
|
|
148
|
+
// The server's CPU time, every thread (the test worker included), for the watchdog to tell an idle
|
|
149
|
+
// stall from a loop (heartbeat.ts). This thread stays free while a worker loops, so the samples come.
|
|
150
|
+
const sendCpu = () => {
|
|
151
|
+
const { user, system } = process.cpuUsage();
|
|
152
|
+
process.send?.({ type: 'cpu', at: Date.now(), us: user + system });
|
|
153
|
+
};
|
|
154
|
+
sendCpu();
|
|
155
|
+
setInterval(sendCpu, CPU_SAMPLE_MS).unref();
|
|
145
156
|
process.send?.({ type: 'ready' });
|
|
146
157
|
//# sourceMappingURL=child.js.map
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -36,10 +36,16 @@ export interface RunStats {
|
|
|
36
36
|
instrumentMs: number;
|
|
37
37
|
dryRunMs: number;
|
|
38
38
|
mutationMs: number;
|
|
39
|
+
/** Static mutants decided in live workers (section 5b), between the warm and the isolated phases. */
|
|
40
|
+
staticMs: number;
|
|
41
|
+
/** Unarmed re-collections in that phase, the guard each live static verdict waits on (runner.ts). */
|
|
42
|
+
staticRecollections: number;
|
|
39
43
|
isolatedMs: number;
|
|
40
44
|
wallMs: number;
|
|
41
45
|
testExecutions: number;
|
|
42
46
|
watchdogKills: number;
|
|
47
|
+
/** Of `watchdogKills`, the stops whose worker was idle, waiting (heartbeat.ts). In an isolated run each is a Timeout as it stands. */
|
|
48
|
+
idleStalls: number;
|
|
43
49
|
crashes: number;
|
|
44
50
|
canaryFiles: number;
|
|
45
51
|
/** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
|
package/dist/coordinator.js
CHANGED
|
@@ -17,6 +17,7 @@ import { ambientRuntime, run as spawn } from 'bellpull';
|
|
|
17
17
|
import { checkSurvivors } from './checker.js';
|
|
18
18
|
import { runCommandMutants } from './command-runner.js';
|
|
19
19
|
import { disables } from './config.js';
|
|
20
|
+
import { rangeDisables } from './directives.js';
|
|
20
21
|
import { globToRegExp, matchFiles } from './files.js';
|
|
21
22
|
import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
|
|
22
23
|
import { native } from './native.js';
|
|
@@ -44,6 +45,8 @@ const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
|
44
45
|
* (2026-10-30). Smaller stays better for a pollution restart, which re-runs the rest of a unit.
|
|
45
46
|
*/
|
|
46
47
|
const UNIT_PAIRS = 400;
|
|
48
|
+
/** How many times its isolated budget a busy stop gets on its re-run. */
|
|
49
|
+
const RERUN_SLACK = 3;
|
|
47
50
|
const MS_PER_MINUTE = 60_000;
|
|
48
51
|
const PERCENT = 100;
|
|
49
52
|
/** Every test that reaches a mutant: the score's, then a spec oracle's replay tests. */
|
|
@@ -61,8 +64,8 @@ export async function run(options) {
|
|
|
61
64
|
const { root } = options;
|
|
62
65
|
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
63
66
|
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,
|
|
67
|
+
files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
|
|
68
|
+
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
69
|
};
|
|
67
70
|
let unitSeq = 0;
|
|
68
71
|
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
@@ -93,8 +96,9 @@ export async function run(options) {
|
|
|
93
96
|
const directiveErrors = [];
|
|
94
97
|
for (const f of instrumented) {
|
|
95
98
|
const { disables: offs, errors } = disables(sources[f.path] ?? '', f.path);
|
|
99
|
+
const ranged = rangeDisables(sources[f.path] ?? '', f.path);
|
|
96
100
|
const ranges = rangesFor(f.path);
|
|
97
|
-
directiveErrors.push(...errors);
|
|
101
|
+
directiveErrors.push(...errors, ...ranged.errors);
|
|
98
102
|
let any = false;
|
|
99
103
|
for (const m of f.mutants) {
|
|
100
104
|
if (changed && !touches(changed, f.path, m.start.line, m.end.line))
|
|
@@ -104,13 +108,14 @@ export async function run(options) {
|
|
|
104
108
|
if (options.shard && shardOf(m.id, options.shard.count) !== options.shard.index)
|
|
105
109
|
continue;
|
|
106
110
|
const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
|
|
111
|
+
const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
|
|
107
112
|
const excluded = options.excludedMutations.includes(m.mutatorName);
|
|
108
113
|
mutants.push({
|
|
109
114
|
...m,
|
|
110
115
|
file: f.path,
|
|
111
116
|
coveredBy: [],
|
|
112
|
-
status: off || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
|
|
113
|
-
statusReason: off?.reason ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
|
|
117
|
+
status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
|
|
118
|
+
statusReason: off?.reason ?? inRange ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
|
|
114
119
|
});
|
|
115
120
|
any = true;
|
|
116
121
|
}
|
|
@@ -563,6 +568,8 @@ export async function run(options) {
|
|
|
563
568
|
const file = unit.files[0] ?? '';
|
|
564
569
|
const decided = new Set();
|
|
565
570
|
for (const r of readRecords(unit.results)) {
|
|
571
|
+
if (r.type === 'recollection')
|
|
572
|
+
stats.staticRecollections++;
|
|
566
573
|
if (r.type === 'static-fallback')
|
|
567
574
|
for (const id of r.ids ?? [])
|
|
568
575
|
back.add(id);
|
|
@@ -646,7 +653,7 @@ export async function run(options) {
|
|
|
646
653
|
verdicts.set(rec.id, { status: prev?.status ?? 'Survived', killedBy: prev?.killedBy, testsCompleted });
|
|
647
654
|
}
|
|
648
655
|
if (outcome.killedFor)
|
|
649
|
-
verdicts.set(outcome.killedFor, { status: 'Timeout' });
|
|
656
|
+
verdicts.set(outcome.killedFor, { status: 'Timeout', idle: outcome.stall === 'idle' });
|
|
650
657
|
if (outcome.crashed && outcome.crashedFor)
|
|
651
658
|
verdicts.set(outcome.crashedFor, { status: 'RuntimeError' });
|
|
652
659
|
if (!outcome.killedFor && !outcome.crashed)
|
|
@@ -665,7 +672,9 @@ export async function run(options) {
|
|
|
665
672
|
// ms: 29 of 35 s on burgee/paratext (2026-10-30). A live worker re-evaluates the project's modules
|
|
666
673
|
// per mutant instead; whatever it cannot vouch for comes back here for the fresh-worker path.
|
|
667
674
|
// OXMUTATE_ISOLATE_STATIC=1 sends every static mutant down the fresh-worker path, as before.
|
|
675
|
+
const s0 = performance.now();
|
|
668
676
|
const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
|
|
677
|
+
stats.staticMs = performance.now() - s0;
|
|
669
678
|
isolatedQueue.push(...staticBack);
|
|
670
679
|
progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
|
|
671
680
|
const i0 = performance.now();
|
|
@@ -673,8 +682,12 @@ export async function run(options) {
|
|
|
673
682
|
// A watchdog stop on a loaded machine is a slow test, not a runaway: 31 of them on one
|
|
674
683
|
// burgee/paratext run while the load average sat at 40, all Survived for Stryker (2026-10-27).
|
|
675
684
|
// Each stop gets one more run with 3x the budget; only one that is stopped again is a Timeout.
|
|
676
|
-
|
|
677
|
-
|
|
685
|
+
// An idle stop is not re-run: its worker was waiting with its event loop turning and no CPU spent,
|
|
686
|
+
// on something the mutant never lets happen, which no budget cures; it is a Timeout as Stryker
|
|
687
|
+
// scores it. Re-run at 3x, 375 of them on caique's 10-31 run "finished" only because Vitest's 30 s
|
|
688
|
+
// test timeout failed the test (D-20261101-idle-stall-is-timeout, stall.qnt).
|
|
689
|
+
const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
|
|
690
|
+
const again = await runIsolated(stopped, RERUN_SLACK);
|
|
678
691
|
for (const m of stopped) {
|
|
679
692
|
const v = again.get(m.id);
|
|
680
693
|
if (v && v.status !== 'Timeout' && v.status !== 'RuntimeError') {
|
|
@@ -689,7 +702,7 @@ export async function run(options) {
|
|
|
689
702
|
m.killedBy = v.killedBy;
|
|
690
703
|
m.testsCompleted = v.testsCompleted;
|
|
691
704
|
if (v.status === 'Timeout')
|
|
692
|
-
m.statusReason = 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
705
|
+
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
706
|
}
|
|
694
707
|
}
|
|
695
708
|
// ── 6b. Confirm survivors under whole-file semantics ──────────────────────────────────────
|
|
@@ -706,8 +719,8 @@ export async function run(options) {
|
|
|
706
719
|
// A timeout here is a watchdog stop like any other, and a loaded machine makes them: two
|
|
707
720
|
// closeout survivors were reported killed this way while a docs build shared the machine
|
|
708
721
|
// (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,
|
|
722
|
+
const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
|
|
723
|
+
const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
|
|
711
724
|
for (const m of stoppedHere) {
|
|
712
725
|
const v = recheck.get(m.id);
|
|
713
726
|
if (v)
|
|
@@ -759,6 +772,7 @@ export async function run(options) {
|
|
|
759
772
|
}
|
|
760
773
|
stats.isolatedMs = performance.now() - i0;
|
|
761
774
|
stats.watchdogKills = pool.stats.killed;
|
|
775
|
+
stats.idleStalls = pool.stats.idle;
|
|
762
776
|
stats.crashes = pool.stats.crashed;
|
|
763
777
|
// ── 8. Score, cache, result ───────────────────────────────────────────────────────────────
|
|
764
778
|
if (options.incremental) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stryker's range directives: a `disable` comment naming mutators (or `all`) and a reason turns them
|
|
3
|
+
* off from the next line on, until a `restore` comment naming them (either spelled with `Stryker` or
|
|
4
|
+
* `oxmutate`). The `next-line` form is config.ts's `disables`. Before this, oxmutate read only the
|
|
5
|
+
* `next-line` form and graded every mutant inside a range as if no directive were there: 116 such
|
|
6
|
+
* mutants in compat-oracle's `hosts.ts` alone (.sdlc/intents/oxmutate/sc8-profile.md).
|
|
7
|
+
*
|
|
8
|
+
* Read by line, as written. Stryker reads a directive only as the leading comment of the next AST
|
|
9
|
+
* node, so a `restore` that closes an object (`// Stryker restore all` above its `}`) is invisible to
|
|
10
|
+
* it, and its `disable` then runs to the end of the file. oxmutate does not reproduce that: the
|
|
11
|
+
* directive means what it says.
|
|
12
|
+
*/
|
|
13
|
+
const HEAD = /\/\/\s*(?:Stryker|oxmutate)\s+(disable|restore)\s+(\S.*)/i;
|
|
14
|
+
/** `all`, or a comma list of mutator names. */
|
|
15
|
+
const LIST = /^(?:all|\w+(?:\s*,\s*\w+)*)$/i;
|
|
16
|
+
const covers = (r, mutator) => r.mutators === 'all' || r.mutators.includes(mutator);
|
|
17
|
+
/** The range directives of a file. A `disable` without a reason is an error, as `next-line`'s is. */
|
|
18
|
+
export function rangeDisables(source, file) {
|
|
19
|
+
const rules = [];
|
|
20
|
+
const errors = [];
|
|
21
|
+
source.split('\n').forEach((text, i) => {
|
|
22
|
+
const m = HEAD.exec(text);
|
|
23
|
+
if (!m)
|
|
24
|
+
return;
|
|
25
|
+
// `next-line` is not a list (the hyphen), so that form is left to config.ts here too.
|
|
26
|
+
const body = m[2];
|
|
27
|
+
const colon = body.indexOf(':');
|
|
28
|
+
const list = (colon === -1 ? body : body.slice(0, colon)).trim();
|
|
29
|
+
if (!LIST.test(list))
|
|
30
|
+
return;
|
|
31
|
+
const kind = m[1].toLowerCase();
|
|
32
|
+
const reason = colon === -1 ? '' : body.slice(colon + 1).trim();
|
|
33
|
+
if (kind === 'disable' && !reason) {
|
|
34
|
+
errors.push(`${file}:${String(i + 1)}: disable comment without a reason. fix: add ": <why these mutants cannot be killed>"`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const mutators = list.toLowerCase() === 'all' ? 'all' : list.split(',').map((s) => s.trim().toLowerCase());
|
|
38
|
+
rules.push({ line: i + 1, kind, mutators, reason });
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
errors,
|
|
42
|
+
reasonFor(line, mutator) {
|
|
43
|
+
const name = mutator.toLowerCase();
|
|
44
|
+
// The latest directive before the line that names this mutator decides it.
|
|
45
|
+
for (let k = rules.length - 1; k >= 0; k--) {
|
|
46
|
+
const r = rules[k];
|
|
47
|
+
if (r.line >= line || !covers(r, name))
|
|
48
|
+
continue;
|
|
49
|
+
return r.kind === 'disable' ? r.reason : undefined;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=directives.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watchdog's evidence for the difference between a mutant that loops and one that hangs
|
|
3
|
+
* (.sdlc/intents/oxmutate/stall.qnt, D-20261101-idle-stall-is-timeout).
|
|
4
|
+
*
|
|
5
|
+
* Two readings, both taken when a run passes its budget:
|
|
6
|
+
*
|
|
7
|
+
* - **The beat.** The test worker writes the time to its server's beat file every HEARTBEAT_MS
|
|
8
|
+
* from a real interval timer. The beat keeps coming only while the worker's event loop turns. A
|
|
9
|
+
* synchronous loop stops it, and so does a microtask loop.
|
|
10
|
+
* - **The CPU.** The server reports its process's CPU time (every thread, the worker included)
|
|
11
|
+
* every CPU_SAMPLE_MS. Over the last CPU_WINDOW_MS, a loop burns about a core.
|
|
12
|
+
*
|
|
13
|
+
* A run whose worker is inside a test body, with a fresh beat and under IDLE_CPU of a core, is
|
|
14
|
+
* **idle**: the test waits on something the mutant never lets happen (a prompt awaiting a
|
|
15
|
+
* keypress). Anything else is **busy**, including a worker still collecting its file: on a loaded
|
|
16
|
+
* machine a short budget can run out while modules load, at low CPU, and that is a slow machine. On a caique
|
|
17
|
+
* subset 18 of 19 stops read 0–2% CPU with a live beat, and the one loop read 72%
|
|
18
|
+
* (sc8-profile.md). An idle stop is a Timeout as it stands, the way Stryker scores it. A busy stop
|
|
19
|
+
* may be a slow test on a loaded machine, so it keeps its re-run at 3x.
|
|
20
|
+
*
|
|
21
|
+
* The beat file is the server's, named by its pid and set in its environment when it starts, so
|
|
22
|
+
* every worker it ever runs inherits it: a path set per unit could reach a worker that started
|
|
23
|
+
* under an earlier unit, and on caique two stops read a beat that never came.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/** How often the worker writes its beat. */
|
|
28
|
+
export const HEARTBEAT_MS = 250;
|
|
29
|
+
/** A beat older than this means the worker's event loop is not turning: a loop, not a wait. */
|
|
30
|
+
export const STALE_MS = 1000;
|
|
31
|
+
/** How often a server reports its CPU time. */
|
|
32
|
+
export const CPU_SAMPLE_MS = 250;
|
|
33
|
+
/** The window a stop's CPU share is measured over. */
|
|
34
|
+
export const CPU_WINDOW_MS = 1000;
|
|
35
|
+
/** The shortest span of samples a CPU share is read from; less, and the stop counts as busy. */
|
|
36
|
+
export const MIN_CPU_SPAN_MS = 500;
|
|
37
|
+
/** Under this share of one core, with a fresh beat, a stopped run is idle. Measured: idle 0–2%, a loop 72–108%. */
|
|
38
|
+
export const IDLE_CPU = 0.25;
|
|
39
|
+
/** The environment variable that hands a server's beat file to its test workers. */
|
|
40
|
+
export const BEAT_ENV = 'OXMUTATE_BEAT';
|
|
41
|
+
/** A server's beat file, in the run's work directory. */
|
|
42
|
+
export const beatFile = (workDir, pid) => path.join(workDir, `beat-${String(pid)}`);
|
|
43
|
+
// Captured when this module is evaluated, before any test file can install fake timers: a faked
|
|
44
|
+
// clock would write a frozen time, and the watchdog would read a live worker as a stale one.
|
|
45
|
+
const { setInterval: realSetInterval, clearInterval: realClearInterval } = globalThis;
|
|
46
|
+
const realNow = Date.now.bind(Date);
|
|
47
|
+
/** The beat this thread runs: one at a time, whatever re-evaluates this module. */
|
|
48
|
+
// Stryker disable next-line StringLiteral: any key names one registry symbol on every evaluation of this module, which is all the key is for
|
|
49
|
+
const BEAT_KEY = Symbol.for('oxmutate.heartbeat');
|
|
50
|
+
/** Whether a test body is running in this worker (the runner marks it); the beat carries it. */
|
|
51
|
+
let inTest = false;
|
|
52
|
+
/** Mark a test body as running, or done. Only a wait inside a test body can be an idle stall. */
|
|
53
|
+
export function markTest(running) {
|
|
54
|
+
inTest = running;
|
|
55
|
+
}
|
|
56
|
+
/** Start this worker's beat into `file`. Does nothing when no beat file is set. */
|
|
57
|
+
export function startHeartbeat(file) {
|
|
58
|
+
if (!file)
|
|
59
|
+
return;
|
|
60
|
+
const holder = globalThis;
|
|
61
|
+
realClearInterval(holder[BEAT_KEY]);
|
|
62
|
+
/** One beat; false once the run's directory is gone: the run is over, and nothing reads the beat. */
|
|
63
|
+
const beat = () => {
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(file, inTest ? `${String(realNow())} test` : String(realNow()));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
beat();
|
|
73
|
+
holder[BEAT_KEY] = realSetInterval(() => {
|
|
74
|
+
if (!beat())
|
|
75
|
+
realClearInterval(holder[BEAT_KEY]);
|
|
76
|
+
}, HEARTBEAT_MS);
|
|
77
|
+
// Stryker disable next-line CallExpression: unref only lets a process exit while its beat runs; Vitest ends its workers regardless, so no verdict depends on it
|
|
78
|
+
holder[BEAT_KEY].unref();
|
|
79
|
+
}
|
|
80
|
+
/** The last beat of this server's workers. */
|
|
81
|
+
export function readBeat(file, now = Date.now()) {
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = String(readFileSync(file));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { age: Number.POSITIVE_INFINITY, inTest: false };
|
|
88
|
+
}
|
|
89
|
+
const [stamp, where] = text.split(' ');
|
|
90
|
+
const at = Number(stamp);
|
|
91
|
+
return { age: Number.isFinite(at) && at > 0 ? now - at : Number.POSITIVE_INFINITY, inTest: where === 'test' };
|
|
92
|
+
}
|
|
93
|
+
const US_PER_MS = 1000;
|
|
94
|
+
/**
|
|
95
|
+
* The share of one core a server used over the last CPU_WINDOW_MS before `now`, from its samples
|
|
96
|
+
* (oldest first). Undefined when the samples span less than MIN_CPU_SPAN_MS of that window.
|
|
97
|
+
*/
|
|
98
|
+
export function cpuShare(samples, now) {
|
|
99
|
+
const recent = samples.filter((s) => s.at >= now - CPU_WINDOW_MS && s.at <= now);
|
|
100
|
+
const first = recent[0];
|
|
101
|
+
const last = recent.at(-1);
|
|
102
|
+
// Stryker disable next-line LogicalOperator: the first and the last sample are both there or both missing, so `&&` reads the same
|
|
103
|
+
if (!first || !last || last.at - first.at < MIN_CPU_SPAN_MS)
|
|
104
|
+
return undefined;
|
|
105
|
+
return (last.us - first.us) / US_PER_MS / (last.at - first.at);
|
|
106
|
+
}
|
|
107
|
+
/** A stopped run is idle when its worker waits inside a test body, its event loop turning, and its server burns almost no CPU. */
|
|
108
|
+
export function classifyStall(o) {
|
|
109
|
+
return o.beat.inTest && o.beat.age < STALE_MS && (o.cpuShare ?? IDLE_CPU) < IDLE_CPU ? 'idle' : 'busy';
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=heartbeat.js.map
|
package/dist/reports.js
CHANGED
|
@@ -142,7 +142,7 @@ export function textSummary(r, opts = {}) {
|
|
|
142
142
|
`oxmutate ${VERSION}: ${String(r.mutants.length)} mutants in ${String(s.files)} files, ${(s.wallMs / MS_PER_S).toFixed(1)} s`,
|
|
143
143
|
` mutation score ${r.score.mutationScore.toFixed(2)}% (covered ${r.score.covered.toFixed(2)}%)`,
|
|
144
144
|
` killed ${String(count('Killed'))} · timeout ${String(count('Timeout'))} · survived ${String(count('Survived'))} · no coverage ${String(count('NoCoverage'))} · ignored ${String(count('Ignored'))} · errors ${String(count('RuntimeError') + count('CompileError'))}`,
|
|
145
|
-
` instrument ${s.instrumentMs.toFixed(0)} ms · dry run ${(s.dryRunMs / MS_PER_S).toFixed(1)} s · mutants ${(s.mutationMs / MS_PER_S).toFixed(1)} s · isolated ${(s.isolatedMs / MS_PER_S).toFixed(1)} s`,
|
|
145
|
+
` instrument ${s.instrumentMs.toFixed(0)} ms · dry run ${(s.dryRunMs / MS_PER_S).toFixed(1)} s · mutants ${(s.mutationMs / MS_PER_S).toFixed(1)} s · static ${(s.staticMs / MS_PER_S).toFixed(1)} s · isolated ${(s.isolatedMs / MS_PER_S).toFixed(1)} s`,
|
|
146
146
|
];
|
|
147
147
|
const corrected = corrections(s);
|
|
148
148
|
if (corrected.length)
|
package/dist/runner.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { createRequire } from 'node:module';
|
|
27
27
|
import path from 'node:path';
|
|
28
|
+
import { BEAT_ENV, markTest, startHeartbeat } from './heartbeat.js';
|
|
28
29
|
import { resolveEsm } from './resolve.js';
|
|
29
30
|
import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
|
|
30
31
|
/**
|
|
@@ -65,6 +66,7 @@ const { Base, statics } = await loadBase();
|
|
|
65
66
|
const mode = env['OXMUTATE_MODE'] ?? 'dry';
|
|
66
67
|
const resultsPath = env['OXMUTATE_RESULTS'];
|
|
67
68
|
const progressPath = env['OXMUTATE_PROGRESS'];
|
|
69
|
+
startHeartbeat(env[BEAT_ENV]); // the watchdog tells a wait from a loop by it (heartbeat.ts)
|
|
68
70
|
const ns = (globalThis[NAMESPACE] ??= {});
|
|
69
71
|
ns.mutantCoverage ??= { static: {}, perTest: {} };
|
|
70
72
|
const plan = mode === 'mutant' && env['OXMUTATE_PLAN'] ? JSON.parse(readFileSync(env['OXMUTATE_PLAN'], 'utf8')) : { tests: {}, hitLimits: {}, skip: [] };
|
|
@@ -123,6 +125,8 @@ const sameSnapshot = (a, b) => snapshotDiff(a, b).length === 0;
|
|
|
123
125
|
let pristine;
|
|
124
126
|
/** Set when a mutant left state behind; the rest of this context runs no mutants. */
|
|
125
127
|
let polluted = false;
|
|
128
|
+
/** `static` mode: the most mutants decided between two unarmed re-collections (staticwarm.qnt `grouped`). */
|
|
129
|
+
const STATIC_CANARY_MAX = 4;
|
|
126
130
|
/** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
|
|
127
131
|
let canaryEvery = 1;
|
|
128
132
|
const MAX_CANARY_EVERY = 8;
|
|
@@ -249,7 +253,14 @@ export default class OxmutateRunner extends Base {
|
|
|
249
253
|
if (mode !== 'mutant') {
|
|
250
254
|
if (only && !only.has(test.id))
|
|
251
255
|
return;
|
|
252
|
-
|
|
256
|
+
// Only a wait inside a test body can be an idle stall; collection at low CPU is a slow machine.
|
|
257
|
+
markTest(true);
|
|
258
|
+
try {
|
|
259
|
+
await fn();
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
markTest(false);
|
|
263
|
+
}
|
|
253
264
|
return;
|
|
254
265
|
}
|
|
255
266
|
const context = test.context;
|
|
@@ -428,22 +439,40 @@ export default class OxmutateRunner extends Base {
|
|
|
428
439
|
ns.hitLimit = undefined;
|
|
429
440
|
}
|
|
430
441
|
};
|
|
442
|
+
// Verdicts since the last clean unarmed re-collection are held, not emitted: the re-collection runs
|
|
443
|
+
// after the first mutant, then after every 2, then every STATIC_CANARY_MAX while it stays clean, and
|
|
444
|
+
// one that fails sends every held mutant back with the rest. Each mutant's own snapshot diff still
|
|
445
|
+
// runs at once. The re-collection runs the file's tests unarmed, and after every mutant it was half
|
|
446
|
+
// of the live static phase, 6,713 s of compat-oracle's 11,193 s weekly run (sc8-profile.md,
|
|
447
|
+
// staticwarm.qnt `grouped`).
|
|
448
|
+
let held = [];
|
|
449
|
+
let every = 1;
|
|
450
|
+
const back = (reason, keys = []) => {
|
|
451
|
+
rest.unshift(...held.map((h) => h.id));
|
|
452
|
+
held = [];
|
|
453
|
+
fallback(reason, keys);
|
|
454
|
+
};
|
|
431
455
|
while (rest.length) {
|
|
432
|
-
const id = rest
|
|
456
|
+
const id = rest.shift();
|
|
433
457
|
if (progressPath)
|
|
434
458
|
writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
|
|
435
|
-
|
|
459
|
+
held.push({ id, verdict: await evaluate(id) });
|
|
436
460
|
const left = snapshotDiff(before, snapshot());
|
|
437
461
|
if (left.length)
|
|
438
|
-
return
|
|
462
|
+
return back('the mutant left a trace', left);
|
|
463
|
+
if (held.length < every && rest.length)
|
|
464
|
+
continue;
|
|
439
465
|
const unarmed = await evaluate(undefined);
|
|
466
|
+
emit({ type: 'recollection' });
|
|
440
467
|
if (unarmed.failed.length > 0)
|
|
441
|
-
return
|
|
468
|
+
return back('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
|
|
442
469
|
const after = snapshotDiff(before, snapshot());
|
|
443
470
|
if (after.length)
|
|
444
|
-
return
|
|
445
|
-
|
|
446
|
-
|
|
471
|
+
return back('the unarmed re-collection left a trace', after);
|
|
472
|
+
for (const { id: done, verdict } of held)
|
|
473
|
+
emit({ type: 'isolated', id: done, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }) });
|
|
474
|
+
held = [];
|
|
475
|
+
every = Math.min(STATIC_CANARY_MAX, every * 2);
|
|
447
476
|
}
|
|
448
477
|
if (progressPath)
|
|
449
478
|
writeFileSync(progressPath, '');
|
package/dist/runtime.js
CHANGED
|
@@ -18,6 +18,7 @@ export const ACTIVE_MUTANT_ENV = '__OXMUTATE_ACTIVE_MUTANT__';
|
|
|
18
18
|
// place the package touches it. `env` is the live object: a write here is a write to the process.
|
|
19
19
|
export const env = process.env;
|
|
20
20
|
export const argv = process.argv;
|
|
21
|
+
export const pid = process.pid;
|
|
21
22
|
export const cwd = () => process.cwd();
|
|
22
23
|
export const stderr = process.stderr;
|
|
23
24
|
export const exit = (code) => process.exit(code);
|
package/dist/scheduler.js
CHANGED
|
@@ -12,6 +12,8 @@ import { fork } from 'node:child_process';
|
|
|
12
12
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { beatFile, classifyStall, CPU_WINDOW_MS, cpuShare, readBeat } from './heartbeat.js';
|
|
16
|
+
import { env, stderr } from './runtime.js';
|
|
15
17
|
const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
|
|
16
18
|
/** The tail of a server's stderr kept for an error message. */
|
|
17
19
|
const STDERR_TAIL = 4000;
|
|
@@ -28,15 +30,23 @@ function inFlight(progress) {
|
|
|
28
30
|
return undefined;
|
|
29
31
|
}
|
|
30
32
|
}
|
|
33
|
+
const PERCENT = 100;
|
|
34
|
+
/** How many CPU samples a server keeps: a little over the window. */
|
|
35
|
+
const CPU_SAMPLES_KEPT = 8;
|
|
31
36
|
class Server {
|
|
32
37
|
child;
|
|
33
38
|
ready;
|
|
34
39
|
stderr = '';
|
|
40
|
+
/** Where this server's test workers beat (heartbeat.ts). */
|
|
41
|
+
beat;
|
|
42
|
+
/** The server's latest CPU readings, oldest first (child.ts sends them). */
|
|
43
|
+
cpu = [];
|
|
35
44
|
onMessage;
|
|
36
45
|
onExit;
|
|
37
46
|
exited = false;
|
|
38
|
-
constructor(configFile, cwd, execArgv) {
|
|
47
|
+
constructor(configFile, cwd, execArgv, workDir) {
|
|
39
48
|
this.child = fork(childJs, [configFile], { cwd, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], execArgv });
|
|
49
|
+
this.beat = beatFile(workDir, this.child.pid);
|
|
40
50
|
this.child.stderr?.on('data', (d) => (this.stderr = (this.stderr + d.toString()).slice(-STDERR_TAIL)));
|
|
41
51
|
this.ready = new Promise((resolve) => {
|
|
42
52
|
const first = (m) => {
|
|
@@ -48,7 +58,15 @@ class Server {
|
|
|
48
58
|
this.child.on('message', first);
|
|
49
59
|
this.child.once('exit', () => resolve(false));
|
|
50
60
|
});
|
|
51
|
-
this.child.on('message', (m) =>
|
|
61
|
+
this.child.on('message', (m) => {
|
|
62
|
+
if (m.type === 'cpu') {
|
|
63
|
+
this.cpu.push({ at: m.at ?? 0, us: m.us ?? 0 });
|
|
64
|
+
if (this.cpu.length > CPU_SAMPLES_KEPT)
|
|
65
|
+
this.cpu.shift();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.onMessage?.(m);
|
|
69
|
+
});
|
|
52
70
|
this.child.once('exit', (code) => {
|
|
53
71
|
this.exited = true;
|
|
54
72
|
this.onExit?.(code);
|
|
@@ -78,9 +96,18 @@ class Server {
|
|
|
78
96
|
if (!line)
|
|
79
97
|
return;
|
|
80
98
|
const [id = '', since = '0'] = line.split(' ');
|
|
81
|
-
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
if (now - Number(since) > budget(id)) {
|
|
101
|
+
// Read before the kill: an idle stop is a Timeout as it stands, a busy one is re-run (heartbeat.ts).
|
|
102
|
+
const beat = readBeat(this.beat, now);
|
|
103
|
+
const share = cpuShare(this.cpu, now);
|
|
104
|
+
const stall = classifyStall({ beat, cpuShare: share });
|
|
105
|
+
if (env['OXMUTATE_DEBUG_WATCHDOG']) {
|
|
106
|
+
const cpu = share === undefined ? '?' : `${String(Math.round(share * PERCENT))}%`;
|
|
107
|
+
stderr.write(`oxmutate watchdog: stop ${id} after ${String(now - Number(since))} ms; ${stall}; beat ${Number.isFinite(beat.age) ? `${String(beat.age)} ms ago` : 'never'}${beat.inTest ? ' in a test' : ''}; cpu ${cpu} over ${String(CPU_WINDOW_MS)} ms\n`);
|
|
108
|
+
}
|
|
82
109
|
this.child.kill('SIGKILL');
|
|
83
|
-
finish({ killedFor: id, stderr: this.stderr });
|
|
110
|
+
finish({ killedFor: id, stall, stderr: this.stderr });
|
|
84
111
|
}
|
|
85
112
|
}, WATCHDOG_POLL_MS);
|
|
86
113
|
this.onMessage = (m) => {
|
|
@@ -104,13 +131,21 @@ class Server {
|
|
|
104
131
|
export function createPool(config, size, workDir, tag) {
|
|
105
132
|
const configFile = path.join(workDir, `server-${tag}.json`);
|
|
106
133
|
writeFileSync(configFile, JSON.stringify(config));
|
|
107
|
-
const stats = { spawned: 0, killed: 0, crashed: 0 };
|
|
134
|
+
const stats = { spawned: 0, killed: 0, idle: 0, crashed: 0 };
|
|
108
135
|
const spawn = () => {
|
|
109
136
|
stats.spawned++;
|
|
110
|
-
return new Server(configFile, config.root, config.nodeArgs ?? []);
|
|
137
|
+
return new Server(configFile, config.root, config.nodeArgs ?? [], workDir);
|
|
111
138
|
};
|
|
112
139
|
const servers = Array.from({ length: size }, spawn);
|
|
113
140
|
let spare = spawn();
|
|
141
|
+
const tally = (outcome) => {
|
|
142
|
+
if (outcome.killedFor)
|
|
143
|
+
stats.killed++;
|
|
144
|
+
if (outcome.stall === 'idle')
|
|
145
|
+
stats.idle++;
|
|
146
|
+
if (outcome.crashed)
|
|
147
|
+
stats.crashed++;
|
|
148
|
+
};
|
|
114
149
|
const replacement = () => {
|
|
115
150
|
const next = spare ?? spawn();
|
|
116
151
|
spare = spawn();
|
|
@@ -152,10 +187,7 @@ export function createPool(config, size, workDir, tag) {
|
|
|
152
187
|
await server.ready;
|
|
153
188
|
}
|
|
154
189
|
const outcome = await server.run(unit, (id) => budget(unit, id));
|
|
155
|
-
|
|
156
|
-
stats.killed++;
|
|
157
|
-
if (outcome.crashed)
|
|
158
|
-
stats.crashed++;
|
|
190
|
+
tally(outcome);
|
|
159
191
|
if (outcome.killedFor || outcome.crashed) {
|
|
160
192
|
servers[slot] = replacement();
|
|
161
193
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|