oxmutate 1.2.4 → 1.3.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/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/coordinator.d.ts +9 -0
- package/dist/coordinator.js +29 -9
- package/dist/quiescence.js +189 -0
- package/dist/reports.js +1 -0
- package/dist/runner.d.ts +6 -0
- package/dist/runner.js +89 -24
- package/package.json +1 -1
|
Binary file
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface RunStats {
|
|
|
62
62
|
flakyMutants: number;
|
|
63
63
|
units: number;
|
|
64
64
|
serversSpawned: number;
|
|
65
|
+
/** Mutants whose Timeout their worker called itself, the wait able to end by nothing (quiescence.ts): no watchdog wait, no respawn. */
|
|
66
|
+
deadlocks: number;
|
|
65
67
|
}
|
|
66
68
|
export interface RunResult {
|
|
67
69
|
root: string;
|
|
@@ -86,4 +88,11 @@ export interface RunResult {
|
|
|
86
88
|
}
|
|
87
89
|
export declare class InstrumentFault extends Error {
|
|
88
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* No single test execution is waited on past this (owner, 2026-11-04: "we should not wait longer than
|
|
93
|
+
* that"). Every watchdog budget is capped at it per test the run executes, the 3x re-run of a busy stop
|
|
94
|
+
* included. A hang is mostly called by its worker in about half a second (quiescence.ts); the cap
|
|
95
|
+
* bounds the waits it cannot judge.
|
|
96
|
+
*/
|
|
97
|
+
export declare const MAX_TEST_MS = 5000;
|
|
89
98
|
export declare function run(options: Options): Promise<RunResult>;
|
package/dist/coordinator.js
CHANGED
|
@@ -27,7 +27,16 @@ function readRecords(file) {
|
|
|
27
27
|
}
|
|
28
28
|
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
29
29
|
const UNIT_PAIRS = 400;
|
|
30
|
+
const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
|
|
31
|
+
function timeoutReason(v) {
|
|
32
|
+
if (v.deadlock)
|
|
33
|
+
return DEADLOCK_REASON;
|
|
34
|
+
if (v.idle)
|
|
35
|
+
return 'its test waited past the budget with the worker idle: a hang, not a slow test';
|
|
36
|
+
return 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
37
|
+
}
|
|
30
38
|
const RERUN_SLACK = 3;
|
|
39
|
+
export const MAX_TEST_MS = 5000;
|
|
31
40
|
const MS_PER_MINUTE = 60_000;
|
|
32
41
|
const PERCENT = 100;
|
|
33
42
|
const allCovering = (m) => [...m.coveredBy, ...(m.specCoveredBy ?? [])];
|
|
@@ -44,7 +53,7 @@ export async function run(options) {
|
|
|
44
53
|
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
45
54
|
const stats = {
|
|
46
55
|
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,
|
|
56
|
+
watchdogKills: 0, idleStalls: 0, deadlocks: 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,
|
|
48
57
|
};
|
|
49
58
|
let unitSeq = 0;
|
|
50
59
|
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
@@ -272,7 +281,7 @@ export async function run(options) {
|
|
|
272
281
|
const hitLimitOf = (id) => Math.max(1000, (hits.get(id) ?? 1) * 100);
|
|
273
282
|
const hitLimits = Object.fromEntries(toRun.map((m) => [m.id, hitLimitOf(m.id)]));
|
|
274
283
|
const maxTestMs = (id) => { const m = byId.get(id); return Math.max(0, ...(m ? allCovering(m) : []).map((t) => testInfo.get(t)?.ms ?? 0)); };
|
|
275
|
-
const warmBudget = (id) => options.timeoutMS + Math.max(options.timeoutFactor, 3) * maxTestMs(id) + 250;
|
|
284
|
+
const warmBudget = (id) => Math.min(MAX_TEST_MS, options.timeoutMS + Math.max(options.timeoutFactor, 3) * maxTestMs(id) + 250);
|
|
276
285
|
const fileOrder = new Map();
|
|
277
286
|
for (const m of toRun) {
|
|
278
287
|
const cost = new Map();
|
|
@@ -283,11 +292,12 @@ export async function run(options) {
|
|
|
283
292
|
}
|
|
284
293
|
fileOrder.set(m.id, [...cost].sort((a, b) => a[1] - b[1]).map(([f]) => f));
|
|
285
294
|
}
|
|
295
|
+
const testMsOf = (plan) => Object.fromEntries(Object.keys(plan).map((t) => [t, testInfo.get(t)?.ms ?? 0]));
|
|
286
296
|
const planUnits = new Map();
|
|
287
297
|
const unfinished = new Set();
|
|
288
298
|
const makeUnit = (file, plan, depth = 0) => {
|
|
289
299
|
const unit = { mode: 'mutant', files: [file], results: fileFor('unit', 'jsonl'), progress: fileFor('unit', 'progress'), plan: fileFor('plan', 'json') };
|
|
290
|
-
writeFileSync(unit.plan, JSON.stringify({ tests: plan, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce }));
|
|
300
|
+
writeFileSync(unit.plan, JSON.stringify({ tests: plan, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce, testMs: testMsOf(plan) }));
|
|
291
301
|
planUnits.set(unit.results, { file, plan, depth });
|
|
292
302
|
stats.units++;
|
|
293
303
|
return unit;
|
|
@@ -384,7 +394,7 @@ export async function run(options) {
|
|
|
384
394
|
if (Object.keys(left).length === 0)
|
|
385
395
|
return false;
|
|
386
396
|
entry.plan = left;
|
|
387
|
-
writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce }));
|
|
397
|
+
writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce, testMs: testMsOf(left) }));
|
|
388
398
|
return true;
|
|
389
399
|
};
|
|
390
400
|
await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit, beforeUnit);
|
|
@@ -414,6 +424,8 @@ export async function run(options) {
|
|
|
414
424
|
}
|
|
415
425
|
else if (killers.length) {
|
|
416
426
|
m.status = killers.some((r) => r.status === 'Killed') ? 'Killed' : 'Timeout';
|
|
427
|
+
if (m.status === 'Timeout' && killers.some((r) => r.deadlock))
|
|
428
|
+
m.statusReason = DEADLOCK_REASON;
|
|
417
429
|
m.killedBy = [...new Set(killers.map((r) => r.test ?? ''))];
|
|
418
430
|
m.testsCompleted = recs.length;
|
|
419
431
|
}
|
|
@@ -452,13 +464,20 @@ export async function run(options) {
|
|
|
452
464
|
const covering = byCost([...new Set(tests)]);
|
|
453
465
|
return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
|
|
454
466
|
};
|
|
467
|
+
const testsRun = (id) => {
|
|
468
|
+
const m = byId.get(id);
|
|
469
|
+
const fs = m ? filesFor(m) : allTestFiles;
|
|
470
|
+
const covering = new Set(m && !m.static ? m.coveredBy : []);
|
|
471
|
+
return Math.max(1, dry.tests.filter((t) => fs.includes(t.file) && (covering.size === 0 || covering.has(t.id))).length);
|
|
472
|
+
};
|
|
455
473
|
const isolatedBudget = (id) => {
|
|
456
474
|
const m = byId.get(id);
|
|
457
475
|
const fs = m ? filesFor(m) : allTestFiles;
|
|
458
476
|
const covering = new Set(m && !m.static ? m.coveredBy : []);
|
|
459
477
|
const ms = dry.tests.filter((t) => fs.includes(t.file) && (covering.size === 0 || covering.has(t.id))).reduce((sum, t) => sum + t.ms, 0);
|
|
460
|
-
return 1500 + options.timeoutMS + options.timeoutFactor * ms * 2;
|
|
478
|
+
return Math.min(MAX_TEST_MS * testsRun(id), 1500 + options.timeoutMS + options.timeoutFactor * ms * 2);
|
|
461
479
|
};
|
|
480
|
+
const isolatedCapped = (id, slack) => Math.min(slack * isolatedBudget(id), MAX_TEST_MS * testsRun(id));
|
|
462
481
|
const decideStaticLive = async (items) => {
|
|
463
482
|
if (items.length === 0)
|
|
464
483
|
return [];
|
|
@@ -512,7 +531,7 @@ export async function run(options) {
|
|
|
512
531
|
const open = (m) => !killed.has(m.id) && !back.has(m.id);
|
|
513
532
|
const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
|
|
514
533
|
for (const round of rounds)
|
|
515
|
-
await staticPool.drain(unitsFor(round), (_, id) =>
|
|
534
|
+
await staticPool.drain(unitsFor(round), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
|
|
516
535
|
const undecided = [];
|
|
517
536
|
for (const m of items) {
|
|
518
537
|
const k = killed.get(m.id);
|
|
@@ -544,14 +563,14 @@ export async function run(options) {
|
|
|
544
563
|
}
|
|
545
564
|
const units = bins.sort((x, y) => y.load - x.load).map((b) => mk(b.batch));
|
|
546
565
|
stats.isolatedRuns += items.length;
|
|
547
|
-
await pool.drain(units, (_, id) =>
|
|
566
|
+
await pool.drain(units, (_, id) => isolatedCapped(id, slack), (unit, outcome) => {
|
|
548
567
|
for (const rec of readRecords(unit.results)) {
|
|
549
568
|
if (rec.type !== 'isolated' || !rec.id)
|
|
550
569
|
continue;
|
|
551
570
|
const prev = verdicts.get(rec.id);
|
|
552
571
|
const testsCompleted = (prev?.testsCompleted ?? 0) + (rec.ran ?? 0);
|
|
553
572
|
if (rec.timeout)
|
|
554
|
-
verdicts.set(rec.id, { status: 'Timeout', testsCompleted });
|
|
573
|
+
verdicts.set(rec.id, { status: 'Timeout', testsCompleted, ...(rec.deadlock && { idle: true, deadlock: true }) });
|
|
555
574
|
else if (rec.killed)
|
|
556
575
|
verdicts.set(rec.id, { status: 'Killed', killedBy: [...(prev?.killedBy ?? []), ...(rec.failed ?? [])], testsCompleted });
|
|
557
576
|
else
|
|
@@ -595,7 +614,7 @@ export async function run(options) {
|
|
|
595
614
|
m.killedBy = v.killedBy;
|
|
596
615
|
m.testsCompleted = v.testsCompleted;
|
|
597
616
|
if (v.status === 'Timeout')
|
|
598
|
-
m.statusReason = v
|
|
617
|
+
m.statusReason = timeoutReason(v);
|
|
599
618
|
}
|
|
600
619
|
}
|
|
601
620
|
const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
|
|
@@ -651,6 +670,7 @@ export async function run(options) {
|
|
|
651
670
|
stats.isolatedMs = performance.now() - i0;
|
|
652
671
|
stats.watchdogKills = pool.stats.killed;
|
|
653
672
|
stats.idleStalls = pool.stats.idle;
|
|
673
|
+
stats.deadlocks = mutants.filter((m) => m.statusReason === DEADLOCK_REASON).length;
|
|
654
674
|
stats.crashes = pool.stats.crashed;
|
|
655
675
|
if (options.incremental) {
|
|
656
676
|
mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import timers from 'node:timers';
|
|
4
|
+
import promiseTimers from 'node:timers/promises';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { env, stderr } from './runtime.js';
|
|
7
|
+
export const SAMPLE_MS = 100;
|
|
8
|
+
export const QUIET_SELF_SHARE = 0.05;
|
|
9
|
+
export const QUIET_OTHER_SHARE = 0.25;
|
|
10
|
+
export const MIN_CONFIRM_MS = 500;
|
|
11
|
+
export class DeadlockError extends Error {
|
|
12
|
+
quietMs;
|
|
13
|
+
constructor(quietMs) {
|
|
14
|
+
super(`oxmutate: deadlocked for ${String(Math.round(quietMs))} ms: nothing left in this worker could resume the test`);
|
|
15
|
+
this.quietMs = quietMs;
|
|
16
|
+
this.name = 'DeadlockError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export const isDeadlock = (e) => e instanceof DeadlockError;
|
|
20
|
+
const { threadCpuUsage: threadCpu } = process;
|
|
21
|
+
const threadCpuUsage = threadCpu?.bind(process);
|
|
22
|
+
const { setInterval: realSetInterval, clearInterval: realClearInterval } = globalThis;
|
|
23
|
+
const realNow = performance.now.bind(performance);
|
|
24
|
+
const US_PER_MS = 1000;
|
|
25
|
+
function resources() {
|
|
26
|
+
const counts = new Map();
|
|
27
|
+
for (const r of process.getActiveResourcesInfo())
|
|
28
|
+
counts.set(r, (counts.get(r) ?? 0) + 1);
|
|
29
|
+
return counts;
|
|
30
|
+
}
|
|
31
|
+
export const withinFloor = (now, floor) => [...now].every(([kind, n]) => n <= (floor.get(kind) ?? 0));
|
|
32
|
+
export const quiet = (r) => r.selfShare < QUIET_SELF_SHARE && r.otherShare < QUIET_OTHER_SHARE && r.rpcInFlight === 0 && r.pendingTimers === 0 && r.withinFloor;
|
|
33
|
+
let rpcInFlight = 0;
|
|
34
|
+
let pendingTimers = 0;
|
|
35
|
+
const live = new Map();
|
|
36
|
+
const done = (id) => {
|
|
37
|
+
if (live.delete(id))
|
|
38
|
+
pendingTimers--;
|
|
39
|
+
};
|
|
40
|
+
const WRAPPED = Symbol.for('oxmutate.timers');
|
|
41
|
+
const isWrapped = (fn) => typeof fn === 'function' && WRAPPED in fn;
|
|
42
|
+
const mark = (fn) => Object.assign(fn, { [WRAPPED]: true });
|
|
43
|
+
const clearing = (clear) => (handle) => {
|
|
44
|
+
if (handle !== undefined && handle !== null)
|
|
45
|
+
done(Number(handle));
|
|
46
|
+
clear(handle);
|
|
47
|
+
};
|
|
48
|
+
function pending(handle) {
|
|
49
|
+
live.set(Number(handle), true);
|
|
50
|
+
pendingTimers++;
|
|
51
|
+
return handle;
|
|
52
|
+
}
|
|
53
|
+
function oneShot(start, cb, args) {
|
|
54
|
+
const handle = start(() => {
|
|
55
|
+
done(Number(handle));
|
|
56
|
+
cb(...args);
|
|
57
|
+
});
|
|
58
|
+
return pending(handle);
|
|
59
|
+
}
|
|
60
|
+
function counted(p) {
|
|
61
|
+
pendingTimers++;
|
|
62
|
+
return p.finally(() => pendingTimers--);
|
|
63
|
+
}
|
|
64
|
+
function standIn(wrapper, original) {
|
|
65
|
+
const custom = Reflect.get(original, promisify.custom);
|
|
66
|
+
if (custom !== undefined)
|
|
67
|
+
Reflect.defineProperty(wrapper, promisify.custom, { value: custom });
|
|
68
|
+
return mark(wrapper);
|
|
69
|
+
}
|
|
70
|
+
export function wrapTimers(api) {
|
|
71
|
+
if (isWrapped(api.setTimeout))
|
|
72
|
+
return;
|
|
73
|
+
const { setTimeout: later, setImmediate: soon, setInterval: every, clearTimeout: unlater, clearImmediate: unsoon, clearInterval: unevery } = api;
|
|
74
|
+
const timeout = (cb, ms, ...args) => oneShot((fire) => later(fire, ms), cb, args);
|
|
75
|
+
const immediate = (cb, ...args) => oneShot((fire) => soon(fire), cb, args);
|
|
76
|
+
const interval = (cb, ms, ...args) => pending(every(cb, ms, ...args));
|
|
77
|
+
api.setTimeout = standIn(timeout, later);
|
|
78
|
+
api.setImmediate = standIn(immediate, soon);
|
|
79
|
+
api.setInterval = standIn(interval, every);
|
|
80
|
+
api.clearTimeout = clearing(unlater);
|
|
81
|
+
api.clearImmediate = clearing(unsoon);
|
|
82
|
+
api.clearInterval = clearing(unevery);
|
|
83
|
+
}
|
|
84
|
+
export function wrapPromiseTimers(api) {
|
|
85
|
+
if (isWrapped(api.setTimeout))
|
|
86
|
+
return;
|
|
87
|
+
const { setTimeout: later, setImmediate: soon } = api;
|
|
88
|
+
api.setTimeout = mark((...a) => counted(later(...a)));
|
|
89
|
+
api.setImmediate = mark((...a) => counted(soon(...a)));
|
|
90
|
+
}
|
|
91
|
+
export function trackTimers() {
|
|
92
|
+
wrapTimers(globalThis);
|
|
93
|
+
wrapTimers(timers);
|
|
94
|
+
wrapPromiseTimers(promiseTimers);
|
|
95
|
+
syncBuiltinESMExports();
|
|
96
|
+
}
|
|
97
|
+
export const pendingTimerCount = () => pendingTimers;
|
|
98
|
+
export function trackRpc(state) {
|
|
99
|
+
const rpc = state?.rpc;
|
|
100
|
+
if (!rpc || rpc.__oxmutateTracked)
|
|
101
|
+
return;
|
|
102
|
+
state.rpc = new Proxy(rpc, {
|
|
103
|
+
get(target, key, receiver) {
|
|
104
|
+
if (key === '__oxmutateTracked')
|
|
105
|
+
return true;
|
|
106
|
+
const value = Reflect.get(target, key, receiver);
|
|
107
|
+
if (typeof value !== 'function' || typeof key !== 'string' || key.startsWith('$'))
|
|
108
|
+
return value;
|
|
109
|
+
const counted = (...args) => {
|
|
110
|
+
const result = Reflect.apply(value, target, args);
|
|
111
|
+
if (!(result instanceof Promise))
|
|
112
|
+
return result;
|
|
113
|
+
rpcInFlight++;
|
|
114
|
+
return result.finally(() => rpcInFlight--);
|
|
115
|
+
};
|
|
116
|
+
return Object.defineProperties(counted, Object.getOwnPropertyDescriptors(value));
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
let floor;
|
|
121
|
+
export const floorTaken = () => floor;
|
|
122
|
+
export const rpcInFlightCount = () => rpcInFlight;
|
|
123
|
+
export function takeFloor() {
|
|
124
|
+
floor ??= resources();
|
|
125
|
+
}
|
|
126
|
+
export function confirmStep(o) {
|
|
127
|
+
if (!o.quiet)
|
|
128
|
+
return { quietSince: undefined };
|
|
129
|
+
const since = o.quietSince ?? o.now - o.span;
|
|
130
|
+
const open = o.now - since;
|
|
131
|
+
return open >= o.confirmMs ? { quietSince: since, deadlockMs: open } : { quietSince: since };
|
|
132
|
+
}
|
|
133
|
+
export function watch() {
|
|
134
|
+
let armed;
|
|
135
|
+
let quietSince;
|
|
136
|
+
let self = threadCpuUsage?.();
|
|
137
|
+
let all = process.cpuUsage();
|
|
138
|
+
let at = realNow();
|
|
139
|
+
const timer = realSetInterval(() => {
|
|
140
|
+
const now = realNow();
|
|
141
|
+
const span = Math.max(1, now - at);
|
|
142
|
+
const selfDelta = threadCpuUsage?.(self);
|
|
143
|
+
const allDelta = process.cpuUsage(all);
|
|
144
|
+
self = threadCpuUsage?.();
|
|
145
|
+
all = process.cpuUsage();
|
|
146
|
+
at = now;
|
|
147
|
+
if (!armed || !selfDelta || !floor)
|
|
148
|
+
return;
|
|
149
|
+
const selfUs = selfDelta.user + selfDelta.system;
|
|
150
|
+
const reading = {
|
|
151
|
+
selfShare: selfUs / US_PER_MS / span,
|
|
152
|
+
otherShare: Math.max(0, allDelta.user + allDelta.system - selfUs) / US_PER_MS / span,
|
|
153
|
+
rpcInFlight,
|
|
154
|
+
pendingTimers,
|
|
155
|
+
withinFloor: withinFloor(resources(), floor),
|
|
156
|
+
};
|
|
157
|
+
if (env['OXMUTATE_DEBUG_QUIET'])
|
|
158
|
+
stderr.write(`quiet? ${JSON.stringify(reading)} res=${JSON.stringify([...resources()])} floor=${JSON.stringify([...floor])}\n`);
|
|
159
|
+
const next = confirmStep({ quietSince, quiet: quiet(reading), now, span, confirmMs: armed.confirmMs });
|
|
160
|
+
quietSince = next.quietSince;
|
|
161
|
+
if (next.deadlockMs !== undefined) {
|
|
162
|
+
const { reject } = armed;
|
|
163
|
+
armed = undefined;
|
|
164
|
+
reject(new DeadlockError(next.deadlockMs));
|
|
165
|
+
}
|
|
166
|
+
}, SAMPLE_MS);
|
|
167
|
+
timer.unref();
|
|
168
|
+
return {
|
|
169
|
+
async guard(fn, confirmMs) {
|
|
170
|
+
const pending = Promise.resolve().then(fn);
|
|
171
|
+
if (!threadCpuUsage)
|
|
172
|
+
return pending;
|
|
173
|
+
const deadlock = new Promise((_, reject) => {
|
|
174
|
+
quietSince = undefined;
|
|
175
|
+
armed = { reject, confirmMs: Math.max(MIN_CONFIRM_MS, confirmMs) };
|
|
176
|
+
});
|
|
177
|
+
try {
|
|
178
|
+
return await Promise.race([pending, deadlock]);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
armed = undefined;
|
|
182
|
+
pending.catch(() => undefined);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
stop() {
|
|
186
|
+
realClearInterval(timer);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/dist/reports.js
CHANGED
|
@@ -95,6 +95,7 @@ export function describe(m) {
|
|
|
95
95
|
function corrections(s) {
|
|
96
96
|
return [
|
|
97
97
|
s.watchdogKills && `${String(s.watchdogKills)} runaway(s) stopped`,
|
|
98
|
+
s.deadlocks && `${String(s.deadlocks)} deadlock(s) called by their worker`,
|
|
98
99
|
s.pollutionRestarts && `${String(s.pollutionRestarts)} polluted context(s) restarted (in ${String(s.canaryFiles)} file(s))`,
|
|
99
100
|
s.flakyMutants && `${String(s.flakyMutants)} flaky verdict(s)`,
|
|
100
101
|
s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
|
package/dist/runner.d.ts
CHANGED
|
@@ -50,6 +50,12 @@ export default class OxmutateRunner extends OxmutateRunner_base {
|
|
|
50
50
|
* mutant is later re-run against the files that load its module, not the whole suite (Stryker
|
|
51
51
|
* re-runs everything for each one).
|
|
52
52
|
*/
|
|
53
|
+
/**
|
|
54
|
+
* Before any test file is imported: count the timers it and the code under test will create, count
|
|
55
|
+
* Vitest's calls to its main thread, and take the resource floor a deadlock is measured against
|
|
56
|
+
* (quiescence.ts).
|
|
57
|
+
*/
|
|
58
|
+
onBeforeCollect(paths: string[]): unknown;
|
|
53
59
|
onCollectStart(file: AnyTask): unknown;
|
|
54
60
|
onBeforeTryTask(test: AnyTask, options: {
|
|
55
61
|
retry: number;
|
package/dist/runner.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { availableParallelism, loadavg } from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { BEAT_ENV, markTest, realNow, startHeartbeat } from './heartbeat.js';
|
|
6
|
+
import { isDeadlock, takeFloor, trackRpc, trackTimers, watch } from './quiescence.js';
|
|
5
7
|
import { resolveEsm } from './resolve.js';
|
|
6
8
|
import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
|
|
7
9
|
const fromProject = async (specifier, dir = cwd()) => import(resolveEsm(specifier, dir));
|
|
@@ -51,6 +53,11 @@ if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
|
|
|
51
53
|
ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
|
|
52
54
|
}
|
|
53
55
|
const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
|
|
56
|
+
const deadlocks = mode === 'dry' ? undefined : watch();
|
|
57
|
+
let deadlocked = false;
|
|
58
|
+
let tornDown = false;
|
|
59
|
+
const confirmFor = (testId) => 2 * (plan.testMs?.[testId ?? ''] ?? 0) * Math.max(1, (loadavg()[0] ?? 0) / availableParallelism());
|
|
60
|
+
const guarded = (testId, fn) => (deadlocks ? deadlocks.guard(fn, confirmFor(testId)) : Promise.resolve().then(fn));
|
|
54
61
|
const PROTOTYPES = {
|
|
55
62
|
Object: Object.prototype, Array: Array.prototype, String: String.prototype, Number: Number.prototype,
|
|
56
63
|
Boolean: Boolean.prototype, Function: Function.prototype, Promise: Promise.prototype, Map: Map.prototype,
|
|
@@ -116,9 +123,10 @@ function takeCopy(test) {
|
|
|
116
123
|
const PROBE_ID = '__oxmutate_probe__';
|
|
117
124
|
async function probe(test, fn) {
|
|
118
125
|
const file = test.file?.filepath;
|
|
119
|
-
|
|
126
|
+
const scope = test.suite?.id ?? file;
|
|
127
|
+
if (!file || !scope || probed.has(scope) || runOnce.has(file))
|
|
120
128
|
return;
|
|
121
|
-
probed.add(
|
|
129
|
+
probed.add(scope);
|
|
122
130
|
await afterEach(test, test.context, []);
|
|
123
131
|
const cleanups = await beforeEach(test, test.context);
|
|
124
132
|
ns.currentTestId = PROBE_ID;
|
|
@@ -196,14 +204,19 @@ async function runCollected(file, runnable) {
|
|
|
196
204
|
const cleanups = await beforeEach(t, t.context);
|
|
197
205
|
ran++;
|
|
198
206
|
try {
|
|
199
|
-
await fn
|
|
207
|
+
await guarded(t.id, fn);
|
|
200
208
|
}
|
|
201
209
|
catch (e) {
|
|
210
|
+
if (isDeadlock(e))
|
|
211
|
+
deadlocked = true;
|
|
202
212
|
failed.push(t.id);
|
|
203
213
|
note(t, e);
|
|
214
|
+
if (isDeadlock(e))
|
|
215
|
+
return;
|
|
204
216
|
}
|
|
205
217
|
finally {
|
|
206
|
-
|
|
218
|
+
if (!deadlocked)
|
|
219
|
+
await afterEach(t, t.context, cleanups);
|
|
207
220
|
}
|
|
208
221
|
}
|
|
209
222
|
}
|
|
@@ -228,6 +241,14 @@ function fullName(t) {
|
|
|
228
241
|
return parts.join(' > ');
|
|
229
242
|
}
|
|
230
243
|
export default class OxmutateRunner extends Base {
|
|
244
|
+
onBeforeCollect(paths) {
|
|
245
|
+
if (deadlocks) {
|
|
246
|
+
trackTimers();
|
|
247
|
+
trackRpc(this.workerState);
|
|
248
|
+
takeFloor();
|
|
249
|
+
}
|
|
250
|
+
return super.onBeforeCollect?.(paths);
|
|
251
|
+
}
|
|
231
252
|
onCollectStart(file) {
|
|
232
253
|
if (mode === 'dry')
|
|
233
254
|
ns.currentTestId = `${FILE_KEY}${file.filepath ?? file.file?.filepath ?? ''}`;
|
|
@@ -247,15 +268,22 @@ export default class OxmutateRunner extends Base {
|
|
|
247
268
|
return;
|
|
248
269
|
markTest(true);
|
|
249
270
|
try {
|
|
250
|
-
await fn
|
|
271
|
+
await guarded(test.id, fn);
|
|
251
272
|
if (mode === 'dry')
|
|
252
273
|
await probe(test, fn);
|
|
253
274
|
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
if (isDeadlock(e))
|
|
277
|
+
deadlocked = true;
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
254
280
|
finally {
|
|
255
281
|
markTest(false);
|
|
256
282
|
}
|
|
257
283
|
return;
|
|
258
284
|
}
|
|
285
|
+
if (tornDown)
|
|
286
|
+
return;
|
|
259
287
|
let current = test;
|
|
260
288
|
let run = fn;
|
|
261
289
|
let context = test.context;
|
|
@@ -266,7 +294,7 @@ export default class OxmutateRunner extends Base {
|
|
|
266
294
|
let cleanups = [];
|
|
267
295
|
let attempt = 0;
|
|
268
296
|
const teardown = async () => {
|
|
269
|
-
await afterEach(current, context, cleanups);
|
|
297
|
+
await guarded(current.id, () => afterEach(current, context, cleanups));
|
|
270
298
|
cleanups = [];
|
|
271
299
|
};
|
|
272
300
|
const setup = async () => {
|
|
@@ -279,7 +307,7 @@ export default class OxmutateRunner extends Base {
|
|
|
279
307
|
context = copy.context;
|
|
280
308
|
}
|
|
281
309
|
invoked = true;
|
|
282
|
-
cleanups = await beforeEach(current, context);
|
|
310
|
+
cleanups = await guarded(current.id, () => beforeEach(current, context));
|
|
283
311
|
await this.retryReset(test, ++attempt);
|
|
284
312
|
};
|
|
285
313
|
const fresh = async () => {
|
|
@@ -287,62 +315,96 @@ export default class OxmutateRunner extends Base {
|
|
|
287
315
|
await setup();
|
|
288
316
|
};
|
|
289
317
|
let unchecked = [];
|
|
318
|
+
const pollutedBy = (e) => {
|
|
319
|
+
polluted = true;
|
|
320
|
+
canaryEvery = 1;
|
|
321
|
+
emit({ type: 'polluted', id: unchecked[0], suspects: unchecked.slice(1), test: test.id, file: test.file?.filepath, error: e instanceof Error ? e.message.slice(0, POLLUTION_ERROR_CHARS) : String(e) });
|
|
322
|
+
return false;
|
|
323
|
+
};
|
|
290
324
|
const canary = async () => {
|
|
291
325
|
if (!unchecked.length)
|
|
292
326
|
return true;
|
|
293
|
-
await fresh();
|
|
294
327
|
try {
|
|
295
|
-
await
|
|
328
|
+
await fresh();
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
if (!isDeadlock(e))
|
|
332
|
+
throw e;
|
|
333
|
+
return pollutedBy(e);
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
await guarded(test.id, run);
|
|
296
337
|
unchecked = [];
|
|
297
338
|
canaryEvery = Math.min(MAX_CANARY_EVERY, canaryEvery * 2);
|
|
298
339
|
return true;
|
|
299
340
|
}
|
|
300
341
|
catch (e) {
|
|
301
|
-
|
|
302
|
-
canaryEvery = 1;
|
|
303
|
-
emit({ type: 'polluted', id: unchecked[0], suspects: unchecked.slice(1), test: test.id, file: test.file?.filepath, error: e instanceof Error ? e.message.slice(0, POLLUTION_ERROR_CHARS) : String(e) });
|
|
304
|
-
return false;
|
|
342
|
+
return pollutedBy(e);
|
|
305
343
|
}
|
|
306
344
|
};
|
|
307
345
|
const runArmed = async (id) => {
|
|
308
|
-
|
|
346
|
+
try {
|
|
347
|
+
await teardown();
|
|
348
|
+
}
|
|
349
|
+
catch (e) {
|
|
350
|
+
if (!isDeadlock(e))
|
|
351
|
+
throw e;
|
|
352
|
+
pollutedBy(e);
|
|
353
|
+
return { status: 'Polluted', ms: 0 };
|
|
354
|
+
}
|
|
309
355
|
const t0 = performance.now();
|
|
310
356
|
ns.activeMutant = id;
|
|
311
357
|
ns.hitCount = 0;
|
|
312
358
|
ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
|
|
313
359
|
let status = 'Survived';
|
|
360
|
+
let deadlock = false;
|
|
314
361
|
try {
|
|
315
362
|
await setup();
|
|
316
|
-
await run
|
|
363
|
+
await guarded(current.id, run);
|
|
317
364
|
}
|
|
318
365
|
catch (e) {
|
|
319
|
-
|
|
366
|
+
deadlock = isDeadlock(e);
|
|
367
|
+
status = isHitLimit(e) || deadlock ? 'Timeout' : 'Killed';
|
|
320
368
|
}
|
|
321
369
|
finally {
|
|
322
370
|
ns.activeMutant = undefined;
|
|
323
371
|
ns.hitCount = undefined;
|
|
324
372
|
ns.hitLimit = undefined;
|
|
325
373
|
}
|
|
326
|
-
return { status, ms: performance.now() - t0 };
|
|
374
|
+
return { status, ms: performance.now() - t0, ...(deadlock && { deadlock: true }) };
|
|
327
375
|
};
|
|
328
376
|
for (const id of ids) {
|
|
329
377
|
if (done.has(id))
|
|
330
378
|
continue;
|
|
331
379
|
if (progressPath)
|
|
332
380
|
writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
|
|
333
|
-
let { status, ms } = await runArmed(id);
|
|
381
|
+
let { status, ms, deadlock } = await runArmed(id);
|
|
382
|
+
if (status === 'Polluted')
|
|
383
|
+
break;
|
|
334
384
|
if (status !== 'Survived' && unchecked.length > 0) {
|
|
335
385
|
if (!(await canary()))
|
|
336
386
|
break;
|
|
337
|
-
({ status, ms } = await runArmed(id));
|
|
387
|
+
({ status, ms, deadlock } = await runArmed(id));
|
|
388
|
+
if (status === 'Polluted')
|
|
389
|
+
break;
|
|
338
390
|
}
|
|
339
391
|
if (status !== 'Survived')
|
|
340
392
|
done.add(id);
|
|
341
|
-
emit({ type: 'mutant', id, test: test.id, status, ms, ...(spec ? { spec: true } : {}) });
|
|
393
|
+
emit({ type: 'mutant', id, test: test.id, status, ms, ...(spec ? { spec: true } : {}), ...(deadlock && { deadlock: true }) });
|
|
342
394
|
unchecked.push(id);
|
|
395
|
+
if (deadlock) {
|
|
396
|
+
pollutedBy(new Error('torn down: a deadlocked run is still pending in this worker'));
|
|
397
|
+
tornDown = true;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
343
400
|
if (unchecked.length >= canaryEvery && !(await canary()))
|
|
344
401
|
break;
|
|
345
402
|
}
|
|
403
|
+
if (tornDown) {
|
|
404
|
+
if (progressPath)
|
|
405
|
+
writeFileSync(progressPath, '');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
346
408
|
if (!polluted)
|
|
347
409
|
await canary();
|
|
348
410
|
if (progressPath && ids.length)
|
|
@@ -406,11 +468,12 @@ export default class OxmutateRunner extends Base {
|
|
|
406
468
|
ns.activeMutant = armed;
|
|
407
469
|
ns.hitCount = armed ? 0 : undefined;
|
|
408
470
|
ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
|
|
471
|
+
deadlocked = false;
|
|
409
472
|
try {
|
|
410
473
|
vi.resetModules();
|
|
411
474
|
const [file] = await collectTests([filepath], this);
|
|
412
475
|
const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
|
|
413
|
-
return { ...result, timeout: ranAway() };
|
|
476
|
+
return { ...result, timeout: ranAway() || deadlocked, ...(deadlocked ? { deadlock: true } : {}) };
|
|
414
477
|
}
|
|
415
478
|
finally {
|
|
416
479
|
ns.activeMutant = undefined;
|
|
@@ -430,6 +493,8 @@ export default class OxmutateRunner extends Base {
|
|
|
430
493
|
if (progressPath)
|
|
431
494
|
writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
|
|
432
495
|
held.push({ id, verdict: await evaluate(id) });
|
|
496
|
+
if (held.at(-1)?.verdict.deadlock)
|
|
497
|
+
return back('torn down: a deadlocked run is still pending in this worker');
|
|
433
498
|
const left = snapshotDiff(before, snapshot());
|
|
434
499
|
if (left.length)
|
|
435
500
|
return back('the mutant left a trace', left);
|
|
@@ -443,7 +508,7 @@ export default class OxmutateRunner extends Base {
|
|
|
443
508
|
if (after.length)
|
|
444
509
|
return back('the unarmed re-collection left a trace', after);
|
|
445
510
|
for (const { id: done, verdict } of held)
|
|
446
|
-
emit({ type: 'isolated', id: done, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }) });
|
|
511
|
+
emit({ type: 'isolated', id: done, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }), ...(verdict.deadlock && { deadlock: true }) });
|
|
447
512
|
held = [];
|
|
448
513
|
every = Math.min(STATIC_CANARY_MAX, every * 2);
|
|
449
514
|
}
|
|
@@ -527,8 +592,8 @@ export default class OxmutateRunner extends Base {
|
|
|
527
592
|
if (f.result?.state === 'fail' && !failed.length)
|
|
528
593
|
failed.push(f.id);
|
|
529
594
|
}
|
|
530
|
-
const timeout = ranAway();
|
|
531
|
-
emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }) });
|
|
595
|
+
const timeout = ranAway() || deadlocked;
|
|
596
|
+
emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }), ...(deadlocked && { deadlock: true }) });
|
|
532
597
|
}
|
|
533
598
|
return super.onAfterRunFiles?.(files);
|
|
534
599
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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": {
|