oxmutate 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scheduler.js CHANGED
@@ -12,6 +12,8 @@ import { fork } from 'node:child_process';
12
12
  import { readFileSync, writeFileSync } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { beatFile, classifyStall, CPU_WINDOW_MS, cpuShare, readBeat } from './heartbeat.js';
16
+ import { env, stderr } from './runtime.js';
15
17
  const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
16
18
  /** The tail of a server's stderr kept for an error message. */
17
19
  const STDERR_TAIL = 4000;
@@ -28,15 +30,23 @@ function inFlight(progress) {
28
30
  return undefined;
29
31
  }
30
32
  }
33
+ const PERCENT = 100;
34
+ /** How many CPU samples a server keeps: a little over the window. */
35
+ const CPU_SAMPLES_KEPT = 8;
31
36
  class Server {
32
37
  child;
33
38
  ready;
34
39
  stderr = '';
40
+ /** Where this server's test workers beat (heartbeat.ts). */
41
+ beat;
42
+ /** The server's latest CPU readings, oldest first (child.ts sends them). */
43
+ cpu = [];
35
44
  onMessage;
36
45
  onExit;
37
46
  exited = false;
38
- constructor(configFile, cwd, execArgv) {
47
+ constructor(configFile, cwd, execArgv, workDir) {
39
48
  this.child = fork(childJs, [configFile], { cwd, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], execArgv });
49
+ this.beat = beatFile(workDir, this.child.pid);
40
50
  this.child.stderr?.on('data', (d) => (this.stderr = (this.stderr + d.toString()).slice(-STDERR_TAIL)));
41
51
  this.ready = new Promise((resolve) => {
42
52
  const first = (m) => {
@@ -48,7 +58,15 @@ class Server {
48
58
  this.child.on('message', first);
49
59
  this.child.once('exit', () => resolve(false));
50
60
  });
51
- this.child.on('message', (m) => this.onMessage?.(m));
61
+ this.child.on('message', (m) => {
62
+ if (m.type === 'cpu') {
63
+ this.cpu.push({ at: m.at ?? 0, us: m.us ?? 0 });
64
+ if (this.cpu.length > CPU_SAMPLES_KEPT)
65
+ this.cpu.shift();
66
+ return;
67
+ }
68
+ this.onMessage?.(m);
69
+ });
52
70
  this.child.once('exit', (code) => {
53
71
  this.exited = true;
54
72
  this.onExit?.(code);
@@ -78,9 +96,18 @@ class Server {
78
96
  if (!line)
79
97
  return;
80
98
  const [id = '', since = '0'] = line.split(' ');
81
- if (Date.now() - Number(since) > budget(id)) {
99
+ const now = Date.now();
100
+ if (now - Number(since) > budget(id)) {
101
+ // Read before the kill: an idle stop is a Timeout as it stands, a busy one is re-run (heartbeat.ts).
102
+ const beat = readBeat(this.beat, now);
103
+ const share = cpuShare(this.cpu, now);
104
+ const stall = classifyStall({ beat, cpuShare: share });
105
+ if (env['OXMUTATE_DEBUG_WATCHDOG']) {
106
+ const cpu = share === undefined ? '?' : `${String(Math.round(share * PERCENT))}%`;
107
+ stderr.write(`oxmutate watchdog: stop ${id} after ${String(now - Number(since))} ms; ${stall}; beat ${Number.isFinite(beat.age) ? `${String(beat.age)} ms ago` : 'never'}${beat.inTest ? ' in a test' : ''}; cpu ${cpu} over ${String(CPU_WINDOW_MS)} ms\n`);
108
+ }
82
109
  this.child.kill('SIGKILL');
83
- finish({ killedFor: id, stderr: this.stderr });
110
+ finish({ killedFor: id, stall, stderr: this.stderr });
84
111
  }
85
112
  }, WATCHDOG_POLL_MS);
86
113
  this.onMessage = (m) => {
@@ -104,13 +131,21 @@ class Server {
104
131
  export function createPool(config, size, workDir, tag) {
105
132
  const configFile = path.join(workDir, `server-${tag}.json`);
106
133
  writeFileSync(configFile, JSON.stringify(config));
107
- const stats = { spawned: 0, killed: 0, crashed: 0 };
134
+ const stats = { spawned: 0, killed: 0, idle: 0, crashed: 0 };
108
135
  const spawn = () => {
109
136
  stats.spawned++;
110
- return new Server(configFile, config.root, config.nodeArgs ?? []);
137
+ return new Server(configFile, config.root, config.nodeArgs ?? [], workDir);
111
138
  };
112
139
  const servers = Array.from({ length: size }, spawn);
113
140
  let spare = spawn();
141
+ const tally = (outcome) => {
142
+ if (outcome.killedFor)
143
+ stats.killed++;
144
+ if (outcome.stall === 'idle')
145
+ stats.idle++;
146
+ if (outcome.crashed)
147
+ stats.crashed++;
148
+ };
114
149
  const replacement = () => {
115
150
  const next = spare ?? spawn();
116
151
  spare = spawn();
@@ -118,7 +153,7 @@ export function createPool(config, size, workDir, tag) {
118
153
  };
119
154
  return {
120
155
  stats,
121
- async drain(units, budget, after) {
156
+ async drain(units, budget, after, prepare) {
122
157
  const queue = [...units];
123
158
  let busy = 0;
124
159
  const waiters = [];
@@ -126,9 +161,16 @@ export function createPool(config, size, workDir, tag) {
126
161
  for (const w of waiters.splice(0))
127
162
  w();
128
163
  };
164
+ /** The next unit `prepare` keeps, or undefined when the queue is empty. */
165
+ const take = () => {
166
+ for (let unit = queue.shift(); unit; unit = queue.shift())
167
+ if (!prepare || prepare(unit))
168
+ return unit;
169
+ return undefined;
170
+ };
129
171
  await Promise.all(servers.map(async (_, slot) => {
130
172
  for (;;) {
131
- const unit = queue.shift();
173
+ const unit = take();
132
174
  if (!unit) {
133
175
  if (busy === 0) {
134
176
  signal();
@@ -145,10 +187,7 @@ export function createPool(config, size, workDir, tag) {
145
187
  await server.ready;
146
188
  }
147
189
  const outcome = await server.run(unit, (id) => budget(unit, id));
148
- if (outcome.killedFor)
149
- stats.killed++;
150
- if (outcome.crashed)
151
- stats.crashed++;
190
+ tally(outcome);
152
191
  if (outcome.killedFor || outcome.crashed) {
153
192
  servers[slot] = replacement();
154
193
  }
@@ -45,6 +45,7 @@ const share = (n, of) => (of ? (PERCENT * n) / of : 0);
45
45
  const located = ({ id, file, line, mutatorName, replacement }) => ({ id, file, line, mutatorName, replacement });
46
46
  const median = (xs) => {
47
47
  const sorted = [...xs].sort((a, b) => a - b);
48
+ // Stryker disable next-line NumericLiteral: guarded by `sorted.length`, so the fallback never runs
48
49
  return sorted.length ? (sorted[Math.floor(sorted.length / 2)] ?? 0) : 0;
49
50
  };
50
51
  const testsMiss = (m) => m.status === 'Survived' || m.status === 'NoCoverage';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
5
5
  "license": "MIT",
6
6
  "repository": {