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.
@@ -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>;
@@ -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) => 2 * isolatedBudget(id), afterStatic, keepUndecided);
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) => slack * isolatedBudget(id), (unit, outcome) => {
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.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';
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
- if (!file || probed.has(file))
126
+ const scope = test.suite?.id ?? file;
127
+ if (!file || !scope || probed.has(scope) || runOnce.has(file))
120
128
  return;
121
- probed.add(file);
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
- await afterEach(t, t.context, cleanups);
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 run();
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
- polluted = true;
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
- await teardown();
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
- status = isHitLimit(e) ? 'Timeout' : 'Killed';
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.2.4",
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": {