oxmutate 1.2.5 → 1.4.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 +13 -0
- package/dist/coordinator.js +86 -11
- package/dist/diskguard.js +92 -0
- package/dist/quiescence.js +189 -0
- package/dist/reports.js +2 -0
- package/dist/runner.d.ts +6 -0
- package/dist/runner.js +86 -22
- package/dist/scheduler.js +1 -1
- package/package.json +1 -1
|
Binary file
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface MutantResult extends NativeMutant {
|
|
|
23
23
|
flaky?: boolean | undefined;
|
|
24
24
|
/** Reused from the incremental cache, not run. */
|
|
25
25
|
reused?: boolean | undefined;
|
|
26
|
+
/** Paths this mutant's run wrote outside the sandbox, found in its unit and put back (diskguard.ts). */
|
|
27
|
+
wrote?: string[] | undefined;
|
|
26
28
|
}
|
|
27
29
|
export interface TestInfo {
|
|
28
30
|
id: string;
|
|
@@ -50,6 +52,8 @@ export interface RunStats {
|
|
|
50
52
|
canaryFiles: number;
|
|
51
53
|
/** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
|
|
52
54
|
pollutionRestarts: number;
|
|
55
|
+
/** Units after which the project on disk had changed (diskguard.ts): put back, and their verdicts and those of the units running beside them re-run. */
|
|
56
|
+
diskWrites: number;
|
|
53
57
|
/** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
|
|
54
58
|
survivorsOverturned: number;
|
|
55
59
|
/** Watchdog timeouts that finished when re-run with 3x the budget: the machine was slow, not the mutant. */
|
|
@@ -62,6 +66,8 @@ export interface RunStats {
|
|
|
62
66
|
flakyMutants: number;
|
|
63
67
|
units: number;
|
|
64
68
|
serversSpawned: number;
|
|
69
|
+
/** Mutants whose Timeout their worker called itself, the wait able to end by nothing (quiescence.ts): no watchdog wait, no respawn. */
|
|
70
|
+
deadlocks: number;
|
|
65
71
|
}
|
|
66
72
|
export interface RunResult {
|
|
67
73
|
root: string;
|
|
@@ -86,4 +92,11 @@ export interface RunResult {
|
|
|
86
92
|
}
|
|
87
93
|
export declare class InstrumentFault extends Error {
|
|
88
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* No single test execution is waited on past this (owner, 2026-11-04: "we should not wait longer than
|
|
97
|
+
* that"). Every watchdog budget is capped at it per test the run executes, the 3x re-run of a busy stop
|
|
98
|
+
* included. A hang is mostly called by its worker in about half a second (quiescence.ts); the cap
|
|
99
|
+
* bounds the waits it cannot judge.
|
|
100
|
+
*/
|
|
101
|
+
export declare const MAX_TEST_MS = 5000;
|
|
89
102
|
export declare function run(options: Options): Promise<RunResult>;
|
package/dist/coordinator.js
CHANGED
|
@@ -7,6 +7,7 @@ import { checkSurvivors } from './checker.js';
|
|
|
7
7
|
import { runCommandMutants } from './command-runner.js';
|
|
8
8
|
import { disables } from './config.js';
|
|
9
9
|
import { rangeDisables } from './directives.js';
|
|
10
|
+
import { guardDisk, isClean } from './diskguard.js';
|
|
10
11
|
import { globToRegExp, matchFiles } from './files.js';
|
|
11
12
|
import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
|
|
12
13
|
import { native } from './native.js';
|
|
@@ -27,7 +28,16 @@ function readRecords(file) {
|
|
|
27
28
|
}
|
|
28
29
|
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
29
30
|
const UNIT_PAIRS = 400;
|
|
31
|
+
const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
|
|
32
|
+
function timeoutReason(v) {
|
|
33
|
+
if (v.deadlock)
|
|
34
|
+
return DEADLOCK_REASON;
|
|
35
|
+
if (v.idle)
|
|
36
|
+
return 'its test waited past the budget with the worker idle: a hang, not a slow test';
|
|
37
|
+
return 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
38
|
+
}
|
|
30
39
|
const RERUN_SLACK = 3;
|
|
40
|
+
export const MAX_TEST_MS = 5000;
|
|
31
41
|
const MS_PER_MINUTE = 60_000;
|
|
32
42
|
const PERCENT = 100;
|
|
33
43
|
const allCovering = (m) => [...m.coveredBy, ...(m.specCoveredBy ?? [])];
|
|
@@ -44,7 +54,7 @@ export async function run(options) {
|
|
|
44
54
|
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
45
55
|
const stats = {
|
|
46
56
|
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,
|
|
57
|
+
watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
|
|
48
58
|
};
|
|
49
59
|
let unitSeq = 0;
|
|
50
60
|
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
@@ -272,7 +282,7 @@ export async function run(options) {
|
|
|
272
282
|
const hitLimitOf = (id) => Math.max(1000, (hits.get(id) ?? 1) * 100);
|
|
273
283
|
const hitLimits = Object.fromEntries(toRun.map((m) => [m.id, hitLimitOf(m.id)]));
|
|
274
284
|
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;
|
|
285
|
+
const warmBudget = (id) => Math.min(MAX_TEST_MS, options.timeoutMS + Math.max(options.timeoutFactor, 3) * maxTestMs(id) + 250);
|
|
276
286
|
const fileOrder = new Map();
|
|
277
287
|
for (const m of toRun) {
|
|
278
288
|
const cost = new Map();
|
|
@@ -283,11 +293,12 @@ export async function run(options) {
|
|
|
283
293
|
}
|
|
284
294
|
fileOrder.set(m.id, [...cost].sort((a, b) => a[1] - b[1]).map(([f]) => f));
|
|
285
295
|
}
|
|
296
|
+
const testMsOf = (plan) => Object.fromEntries(Object.keys(plan).map((t) => [t, testInfo.get(t)?.ms ?? 0]));
|
|
286
297
|
const planUnits = new Map();
|
|
287
298
|
const unfinished = new Set();
|
|
288
299
|
const makeUnit = (file, plan, depth = 0) => {
|
|
289
300
|
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 }));
|
|
301
|
+
writeFileSync(unit.plan, JSON.stringify({ tests: plan, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce, testMs: testMsOf(plan) }));
|
|
291
302
|
planUnits.set(unit.results, { file, plan, depth });
|
|
292
303
|
stats.units++;
|
|
293
304
|
return unit;
|
|
@@ -322,12 +333,58 @@ export async function run(options) {
|
|
|
322
333
|
return units.sort((a, b) => weight(b) - weight(a));
|
|
323
334
|
};
|
|
324
335
|
const records = [];
|
|
336
|
+
const disk = env['OXMUTATE_NO_DISKGUARD'] ? undefined : await guardDisk(root);
|
|
337
|
+
const inFlight = new Set();
|
|
338
|
+
const tainted = new Set();
|
|
339
|
+
const writers = new Map();
|
|
340
|
+
const diskWrittenDuring = async (unit) => {
|
|
341
|
+
const change = await disk?.check();
|
|
342
|
+
const dirty = change !== undefined && !isClean(change);
|
|
343
|
+
if (dirty) {
|
|
344
|
+
stats.diskWrites++;
|
|
345
|
+
if (!disk?.restore(change)) {
|
|
346
|
+
throw new InstrumentFault(`a mutant wrote outside the sandbox and the project could not be put back: ${[...change.changed, ...change.added, ...change.removed].join(', ')}. fix: restore them (git checkout / git clean), and see which test writes into the project`);
|
|
347
|
+
}
|
|
348
|
+
for (const other of inFlight)
|
|
349
|
+
tainted.add(other);
|
|
350
|
+
}
|
|
351
|
+
if (!dirty && !tainted.delete(unit.results))
|
|
352
|
+
return undefined;
|
|
353
|
+
const entry = planUnits.get(unit.results);
|
|
354
|
+
if (!entry)
|
|
355
|
+
return [];
|
|
356
|
+
const ids = [...new Set(Object.values(entry.plan).flat())];
|
|
357
|
+
const paths = change ? [...change.changed, ...change.added, ...change.removed] : [];
|
|
358
|
+
if (dirty && ids.length === 1) {
|
|
359
|
+
const id = ids[0];
|
|
360
|
+
writers.set(id, paths);
|
|
361
|
+
if (inFlight.size === 0)
|
|
362
|
+
records.push(...readRecords(unit.results));
|
|
363
|
+
else
|
|
364
|
+
unfinished.add(id);
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
if (entry.depth >= 40) {
|
|
368
|
+
for (const id of ids)
|
|
369
|
+
unfinished.add(id);
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
if (ids.length < 2)
|
|
373
|
+
return [makeUnit(entry.file, entry.plan, entry.depth + 1)];
|
|
374
|
+
const half = new Set(ids.slice(0, Math.ceil(ids.length / 2)));
|
|
375
|
+
const pick = (inHalf) => Object.fromEntries(Object.entries(entry.plan).map(([t, l]) => [t, l.filter((id) => half.has(id) === inHalf)]).filter(([, l]) => l.length > 0));
|
|
376
|
+
return [makeUnit(entry.file, pick(true), entry.depth + 1), makeUnit(entry.file, pick(false), entry.depth + 1)];
|
|
377
|
+
};
|
|
325
378
|
const runaways = new Set();
|
|
326
379
|
const crashedIds = new Set();
|
|
327
380
|
const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs, inlineDeps: true }, options.concurrency, work, 'mutant');
|
|
328
381
|
pools.push(pool);
|
|
329
382
|
const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
|
|
330
|
-
const afterUnit = (unit, outcome) => {
|
|
383
|
+
const afterUnit = async (unit, outcome) => {
|
|
384
|
+
inFlight.delete(unit.results);
|
|
385
|
+
const wrote = await diskWrittenDuring(unit);
|
|
386
|
+
if (wrote)
|
|
387
|
+
return wrote;
|
|
331
388
|
const raw = readRecords(unit.results);
|
|
332
389
|
const suspect = new Set(raw.filter((r) => r.type === 'polluted').flatMap((r) => (r.suspects ?? []).map((id) => `${id}|${r.test ?? ''}`)));
|
|
333
390
|
const recs = raw.filter((r) => !(r.type === 'mutant' && suspect.has(`${r.id ?? ''}|${r.test ?? ''}`)));
|
|
@@ -370,6 +427,7 @@ export async function run(options) {
|
|
|
370
427
|
progress.phase('warm runs', killedCount);
|
|
371
428
|
const m0 = performance.now();
|
|
372
429
|
const beforeUnit = (unit) => {
|
|
430
|
+
inFlight.add(unit.results);
|
|
373
431
|
const entry = planUnits.get(unit.results);
|
|
374
432
|
if (!entry)
|
|
375
433
|
return true;
|
|
@@ -381,10 +439,12 @@ export async function run(options) {
|
|
|
381
439
|
if (keep.length)
|
|
382
440
|
left[t] = keep;
|
|
383
441
|
}
|
|
384
|
-
if (Object.keys(left).length === 0)
|
|
442
|
+
if (Object.keys(left).length === 0) {
|
|
443
|
+
inFlight.delete(unit.results);
|
|
385
444
|
return false;
|
|
445
|
+
}
|
|
386
446
|
entry.plan = left;
|
|
387
|
-
writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce }));
|
|
447
|
+
writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce, testMs: testMsOf(left) }));
|
|
388
448
|
return true;
|
|
389
449
|
};
|
|
390
450
|
await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit, beforeUnit);
|
|
@@ -414,6 +474,8 @@ export async function run(options) {
|
|
|
414
474
|
}
|
|
415
475
|
else if (killers.length) {
|
|
416
476
|
m.status = killers.some((r) => r.status === 'Killed') ? 'Killed' : 'Timeout';
|
|
477
|
+
if (m.status === 'Timeout' && killers.some((r) => r.deadlock))
|
|
478
|
+
m.statusReason = DEADLOCK_REASON;
|
|
417
479
|
m.killedBy = [...new Set(killers.map((r) => r.test ?? ''))];
|
|
418
480
|
m.testsCompleted = recs.length;
|
|
419
481
|
}
|
|
@@ -452,13 +514,20 @@ export async function run(options) {
|
|
|
452
514
|
const covering = byCost([...new Set(tests)]);
|
|
453
515
|
return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
|
|
454
516
|
};
|
|
517
|
+
const testsRun = (id) => {
|
|
518
|
+
const m = byId.get(id);
|
|
519
|
+
const fs = m ? filesFor(m) : allTestFiles;
|
|
520
|
+
const covering = new Set(m && !m.static ? m.coveredBy : []);
|
|
521
|
+
return Math.max(1, dry.tests.filter((t) => fs.includes(t.file) && (covering.size === 0 || covering.has(t.id))).length);
|
|
522
|
+
};
|
|
455
523
|
const isolatedBudget = (id) => {
|
|
456
524
|
const m = byId.get(id);
|
|
457
525
|
const fs = m ? filesFor(m) : allTestFiles;
|
|
458
526
|
const covering = new Set(m && !m.static ? m.coveredBy : []);
|
|
459
527
|
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;
|
|
528
|
+
return Math.min(MAX_TEST_MS * testsRun(id), 1500 + options.timeoutMS + options.timeoutFactor * ms * 2);
|
|
461
529
|
};
|
|
530
|
+
const isolatedCapped = (id, slack) => Math.min(slack * isolatedBudget(id), MAX_TEST_MS * testsRun(id));
|
|
462
531
|
const decideStaticLive = async (items) => {
|
|
463
532
|
if (items.length === 0)
|
|
464
533
|
return [];
|
|
@@ -512,7 +581,7 @@ export async function run(options) {
|
|
|
512
581
|
const open = (m) => !killed.has(m.id) && !back.has(m.id);
|
|
513
582
|
const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
|
|
514
583
|
for (const round of rounds)
|
|
515
|
-
await staticPool.drain(unitsFor(round), (_, id) =>
|
|
584
|
+
await staticPool.drain(unitsFor(round), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
|
|
516
585
|
const undecided = [];
|
|
517
586
|
for (const m of items) {
|
|
518
587
|
const k = killed.get(m.id);
|
|
@@ -544,14 +613,14 @@ export async function run(options) {
|
|
|
544
613
|
}
|
|
545
614
|
const units = bins.sort((x, y) => y.load - x.load).map((b) => mk(b.batch));
|
|
546
615
|
stats.isolatedRuns += items.length;
|
|
547
|
-
await pool.drain(units, (_, id) =>
|
|
616
|
+
await pool.drain(units, (_, id) => isolatedCapped(id, slack), (unit, outcome) => {
|
|
548
617
|
for (const rec of readRecords(unit.results)) {
|
|
549
618
|
if (rec.type !== 'isolated' || !rec.id)
|
|
550
619
|
continue;
|
|
551
620
|
const prev = verdicts.get(rec.id);
|
|
552
621
|
const testsCompleted = (prev?.testsCompleted ?? 0) + (rec.ran ?? 0);
|
|
553
622
|
if (rec.timeout)
|
|
554
|
-
verdicts.set(rec.id, { status: 'Timeout', testsCompleted });
|
|
623
|
+
verdicts.set(rec.id, { status: 'Timeout', testsCompleted, ...(rec.deadlock && { idle: true, deadlock: true }) });
|
|
555
624
|
else if (rec.killed)
|
|
556
625
|
verdicts.set(rec.id, { status: 'Killed', killedBy: [...(prev?.killedBy ?? []), ...(rec.failed ?? [])], testsCompleted });
|
|
557
626
|
else
|
|
@@ -595,7 +664,7 @@ export async function run(options) {
|
|
|
595
664
|
m.killedBy = v.killedBy;
|
|
596
665
|
m.testsCompleted = v.testsCompleted;
|
|
597
666
|
if (v.status === 'Timeout')
|
|
598
|
-
m.statusReason = v
|
|
667
|
+
m.statusReason = timeoutReason(v);
|
|
599
668
|
}
|
|
600
669
|
}
|
|
601
670
|
const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
|
|
@@ -651,6 +720,12 @@ export async function run(options) {
|
|
|
651
720
|
stats.isolatedMs = performance.now() - i0;
|
|
652
721
|
stats.watchdogKills = pool.stats.killed;
|
|
653
722
|
stats.idleStalls = pool.stats.idle;
|
|
723
|
+
for (const [id, paths] of writers) {
|
|
724
|
+
const m = byId.get(id);
|
|
725
|
+
if (m)
|
|
726
|
+
m.wrote = paths;
|
|
727
|
+
}
|
|
728
|
+
stats.deadlocks = mutants.filter((m) => m.statusReason === DEADLOCK_REASON).length;
|
|
654
729
|
stats.crashes = pool.stats.crashed;
|
|
655
730
|
if (options.incremental) {
|
|
656
731
|
mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ambientRuntime, run } from 'bellpull';
|
|
4
|
+
export const MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
export const isClean = (c) => c.changed.length + c.added.length + c.removed.length === 0;
|
|
6
|
+
async function git(cwd, args) {
|
|
7
|
+
const r = await run('git', args, { cwd, runtime: ambientRuntime() }).catch(() => undefined);
|
|
8
|
+
return r?.ok ? r.stdout : undefined;
|
|
9
|
+
}
|
|
10
|
+
export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES) {
|
|
11
|
+
const listed = await git(root, ['ls-files', '-z', '--cached', '--', '.']);
|
|
12
|
+
if (listed === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
const files = listed.split('\0').filter(Boolean);
|
|
15
|
+
const tracked = new Map();
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
for (const rel of files) {
|
|
18
|
+
const abs = path.join(root, rel);
|
|
19
|
+
if (!existsSync(abs))
|
|
20
|
+
continue;
|
|
21
|
+
const st = statSync(abs);
|
|
22
|
+
if (!st.isFile())
|
|
23
|
+
continue;
|
|
24
|
+
bytes += st.size;
|
|
25
|
+
tracked.set(rel, { size: st.size, mtimeMs: st.mtimeMs });
|
|
26
|
+
}
|
|
27
|
+
const restorable = bytes <= maxSnapshotBytes;
|
|
28
|
+
if (restorable)
|
|
29
|
+
for (const [rel, t] of tracked)
|
|
30
|
+
t.content = readFileSync(path.join(root, rel));
|
|
31
|
+
const dirs = new Map();
|
|
32
|
+
for (const rel of tracked.keys()) {
|
|
33
|
+
const dir = path.dirname(rel);
|
|
34
|
+
if (!dirs.has(dir))
|
|
35
|
+
dirs.set(dir, new Set(readdirSync(path.join(root, dir))));
|
|
36
|
+
}
|
|
37
|
+
const knownIgnored = new Map();
|
|
38
|
+
const ignored = async (rels) => {
|
|
39
|
+
const ask = rels.filter((rel) => !knownIgnored.has(rel));
|
|
40
|
+
if (ask.length) {
|
|
41
|
+
const r = await run('git', ['check-ignore', '--', ...ask], { cwd: root, runtime: ambientRuntime() }).catch(() => undefined);
|
|
42
|
+
const yes = new Set((r?.stdout ?? '').split('\n').filter(Boolean));
|
|
43
|
+
for (const rel of ask)
|
|
44
|
+
knownIgnored.set(rel, yes.has(rel));
|
|
45
|
+
}
|
|
46
|
+
return new Set(rels.filter((rel) => knownIgnored.get(rel)));
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
async check() {
|
|
50
|
+
const changed = [];
|
|
51
|
+
const removed = [];
|
|
52
|
+
for (const [rel, t] of tracked) {
|
|
53
|
+
const abs = path.join(root, rel);
|
|
54
|
+
if (!existsSync(abs)) {
|
|
55
|
+
removed.push(rel);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const st = statSync(abs);
|
|
59
|
+
if (st.size !== t.size || st.mtimeMs !== t.mtimeMs)
|
|
60
|
+
changed.push(rel);
|
|
61
|
+
}
|
|
62
|
+
const fresh = [];
|
|
63
|
+
for (const [dir, before] of dirs) {
|
|
64
|
+
const abs = path.join(root, dir);
|
|
65
|
+
if (!existsSync(abs))
|
|
66
|
+
continue;
|
|
67
|
+
for (const name of readdirSync(abs))
|
|
68
|
+
if (!before.has(name))
|
|
69
|
+
fresh.push(path.join(dir, name));
|
|
70
|
+
}
|
|
71
|
+
const skip = await ignored(fresh);
|
|
72
|
+
return { changed, added: fresh.filter((rel) => !skip.has(rel)), removed };
|
|
73
|
+
},
|
|
74
|
+
restore(change) {
|
|
75
|
+
if (!restorable)
|
|
76
|
+
return false;
|
|
77
|
+
for (const rel of [...change.changed, ...change.removed]) {
|
|
78
|
+
const t = tracked.get(rel);
|
|
79
|
+
if (!t?.content)
|
|
80
|
+
return false;
|
|
81
|
+
mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
82
|
+
writeFileSync(path.join(root, rel), t.content);
|
|
83
|
+
const st = statSync(path.join(root, rel));
|
|
84
|
+
t.mtimeMs = st.mtimeMs;
|
|
85
|
+
t.size = st.size;
|
|
86
|
+
}
|
|
87
|
+
for (const rel of change.added)
|
|
88
|
+
rmSync(path.join(root, rel), { recursive: true, force: true });
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -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,8 @@ 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`,
|
|
99
|
+
s.diskWrites && `${String(s.diskWrites)} write(s) outside the sandbox put back, their units re-run`,
|
|
98
100
|
s.pollutionRestarts && `${String(s.pollutionRestarts)} polluted context(s) restarted (in ${String(s.canaryFiles)} file(s))`,
|
|
99
101
|
s.flakyMutants && `${String(s.flakyMutants)} flaky verdict(s)`,
|
|
100
102
|
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,
|
|
@@ -197,14 +204,19 @@ async function runCollected(file, runnable) {
|
|
|
197
204
|
const cleanups = await beforeEach(t, t.context);
|
|
198
205
|
ran++;
|
|
199
206
|
try {
|
|
200
|
-
await fn
|
|
207
|
+
await guarded(t.id, fn);
|
|
201
208
|
}
|
|
202
209
|
catch (e) {
|
|
210
|
+
if (isDeadlock(e))
|
|
211
|
+
deadlocked = true;
|
|
203
212
|
failed.push(t.id);
|
|
204
213
|
note(t, e);
|
|
214
|
+
if (isDeadlock(e))
|
|
215
|
+
return;
|
|
205
216
|
}
|
|
206
217
|
finally {
|
|
207
|
-
|
|
218
|
+
if (!deadlocked)
|
|
219
|
+
await afterEach(t, t.context, cleanups);
|
|
208
220
|
}
|
|
209
221
|
}
|
|
210
222
|
}
|
|
@@ -229,6 +241,14 @@ function fullName(t) {
|
|
|
229
241
|
return parts.join(' > ');
|
|
230
242
|
}
|
|
231
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
|
+
}
|
|
232
252
|
onCollectStart(file) {
|
|
233
253
|
if (mode === 'dry')
|
|
234
254
|
ns.currentTestId = `${FILE_KEY}${file.filepath ?? file.file?.filepath ?? ''}`;
|
|
@@ -248,15 +268,22 @@ export default class OxmutateRunner extends Base {
|
|
|
248
268
|
return;
|
|
249
269
|
markTest(true);
|
|
250
270
|
try {
|
|
251
|
-
await fn
|
|
271
|
+
await guarded(test.id, fn);
|
|
252
272
|
if (mode === 'dry')
|
|
253
273
|
await probe(test, fn);
|
|
254
274
|
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
if (isDeadlock(e))
|
|
277
|
+
deadlocked = true;
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
255
280
|
finally {
|
|
256
281
|
markTest(false);
|
|
257
282
|
}
|
|
258
283
|
return;
|
|
259
284
|
}
|
|
285
|
+
if (tornDown)
|
|
286
|
+
return;
|
|
260
287
|
let current = test;
|
|
261
288
|
let run = fn;
|
|
262
289
|
let context = test.context;
|
|
@@ -267,7 +294,7 @@ export default class OxmutateRunner extends Base {
|
|
|
267
294
|
let cleanups = [];
|
|
268
295
|
let attempt = 0;
|
|
269
296
|
const teardown = async () => {
|
|
270
|
-
await afterEach(current, context, cleanups);
|
|
297
|
+
await guarded(current.id, () => afterEach(current, context, cleanups));
|
|
271
298
|
cleanups = [];
|
|
272
299
|
};
|
|
273
300
|
const setup = async () => {
|
|
@@ -280,7 +307,7 @@ export default class OxmutateRunner extends Base {
|
|
|
280
307
|
context = copy.context;
|
|
281
308
|
}
|
|
282
309
|
invoked = true;
|
|
283
|
-
cleanups = await beforeEach(current, context);
|
|
310
|
+
cleanups = await guarded(current.id, () => beforeEach(current, context));
|
|
284
311
|
await this.retryReset(test, ++attempt);
|
|
285
312
|
};
|
|
286
313
|
const fresh = async () => {
|
|
@@ -288,62 +315,96 @@ export default class OxmutateRunner extends Base {
|
|
|
288
315
|
await setup();
|
|
289
316
|
};
|
|
290
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
|
+
};
|
|
291
324
|
const canary = async () => {
|
|
292
325
|
if (!unchecked.length)
|
|
293
326
|
return true;
|
|
294
|
-
await fresh();
|
|
295
327
|
try {
|
|
296
|
-
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);
|
|
297
337
|
unchecked = [];
|
|
298
338
|
canaryEvery = Math.min(MAX_CANARY_EVERY, canaryEvery * 2);
|
|
299
339
|
return true;
|
|
300
340
|
}
|
|
301
341
|
catch (e) {
|
|
302
|
-
|
|
303
|
-
canaryEvery = 1;
|
|
304
|
-
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) });
|
|
305
|
-
return false;
|
|
342
|
+
return pollutedBy(e);
|
|
306
343
|
}
|
|
307
344
|
};
|
|
308
345
|
const runArmed = async (id) => {
|
|
309
|
-
|
|
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
|
+
}
|
|
310
355
|
const t0 = performance.now();
|
|
311
356
|
ns.activeMutant = id;
|
|
312
357
|
ns.hitCount = 0;
|
|
313
358
|
ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
|
|
314
359
|
let status = 'Survived';
|
|
360
|
+
let deadlock = false;
|
|
315
361
|
try {
|
|
316
362
|
await setup();
|
|
317
|
-
await run
|
|
363
|
+
await guarded(current.id, run);
|
|
318
364
|
}
|
|
319
365
|
catch (e) {
|
|
320
|
-
|
|
366
|
+
deadlock = isDeadlock(e);
|
|
367
|
+
status = isHitLimit(e) || deadlock ? 'Timeout' : 'Killed';
|
|
321
368
|
}
|
|
322
369
|
finally {
|
|
323
370
|
ns.activeMutant = undefined;
|
|
324
371
|
ns.hitCount = undefined;
|
|
325
372
|
ns.hitLimit = undefined;
|
|
326
373
|
}
|
|
327
|
-
return { status, ms: performance.now() - t0 };
|
|
374
|
+
return { status, ms: performance.now() - t0, ...(deadlock && { deadlock: true }) };
|
|
328
375
|
};
|
|
329
376
|
for (const id of ids) {
|
|
330
377
|
if (done.has(id))
|
|
331
378
|
continue;
|
|
332
379
|
if (progressPath)
|
|
333
380
|
writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
|
|
334
|
-
let { status, ms } = await runArmed(id);
|
|
381
|
+
let { status, ms, deadlock } = await runArmed(id);
|
|
382
|
+
if (status === 'Polluted')
|
|
383
|
+
break;
|
|
335
384
|
if (status !== 'Survived' && unchecked.length > 0) {
|
|
336
385
|
if (!(await canary()))
|
|
337
386
|
break;
|
|
338
|
-
({ status, ms } = await runArmed(id));
|
|
387
|
+
({ status, ms, deadlock } = await runArmed(id));
|
|
388
|
+
if (status === 'Polluted')
|
|
389
|
+
break;
|
|
339
390
|
}
|
|
340
391
|
if (status !== 'Survived')
|
|
341
392
|
done.add(id);
|
|
342
|
-
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 }) });
|
|
343
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
|
+
}
|
|
344
400
|
if (unchecked.length >= canaryEvery && !(await canary()))
|
|
345
401
|
break;
|
|
346
402
|
}
|
|
403
|
+
if (tornDown) {
|
|
404
|
+
if (progressPath)
|
|
405
|
+
writeFileSync(progressPath, '');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
347
408
|
if (!polluted)
|
|
348
409
|
await canary();
|
|
349
410
|
if (progressPath && ids.length)
|
|
@@ -407,11 +468,12 @@ export default class OxmutateRunner extends Base {
|
|
|
407
468
|
ns.activeMutant = armed;
|
|
408
469
|
ns.hitCount = armed ? 0 : undefined;
|
|
409
470
|
ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
|
|
471
|
+
deadlocked = false;
|
|
410
472
|
try {
|
|
411
473
|
vi.resetModules();
|
|
412
474
|
const [file] = await collectTests([filepath], this);
|
|
413
475
|
const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
|
|
414
|
-
return { ...result, timeout: ranAway() };
|
|
476
|
+
return { ...result, timeout: ranAway() || deadlocked, ...(deadlocked ? { deadlock: true } : {}) };
|
|
415
477
|
}
|
|
416
478
|
finally {
|
|
417
479
|
ns.activeMutant = undefined;
|
|
@@ -431,6 +493,8 @@ export default class OxmutateRunner extends Base {
|
|
|
431
493
|
if (progressPath)
|
|
432
494
|
writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
|
|
433
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');
|
|
434
498
|
const left = snapshotDiff(before, snapshot());
|
|
435
499
|
if (left.length)
|
|
436
500
|
return back('the mutant left a trace', left);
|
|
@@ -444,7 +508,7 @@ export default class OxmutateRunner extends Base {
|
|
|
444
508
|
if (after.length)
|
|
445
509
|
return back('the unarmed re-collection left a trace', after);
|
|
446
510
|
for (const { id: done, verdict } of held)
|
|
447
|
-
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 }) });
|
|
448
512
|
held = [];
|
|
449
513
|
every = Math.min(STATIC_CANARY_MAX, every * 2);
|
|
450
514
|
}
|
|
@@ -528,8 +592,8 @@ export default class OxmutateRunner extends Base {
|
|
|
528
592
|
if (f.result?.state === 'fail' && !failed.length)
|
|
529
593
|
failed.push(f.id);
|
|
530
594
|
}
|
|
531
|
-
const timeout = ranAway();
|
|
532
|
-
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 }) });
|
|
533
597
|
}
|
|
534
598
|
return super.onAfterRunFiles?.(files);
|
|
535
599
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -172,7 +172,7 @@ export function createPool(config, size, workDir, tag) {
|
|
|
172
172
|
if (outcome.killedFor || outcome.crashed) {
|
|
173
173
|
servers[slot] = replacement();
|
|
174
174
|
}
|
|
175
|
-
const more = after(unit, outcome);
|
|
175
|
+
const more = await after(unit, outcome);
|
|
176
176
|
queue.unshift(...more);
|
|
177
177
|
busy--;
|
|
178
178
|
signal();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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": {
|