mutineer 0.11.2 → 0.11.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/lib/mutineer/baseline.rb +14 -16
- data/lib/mutineer/cli.rb +37 -39
- data/lib/mutineer/config.rb +17 -19
- data/lib/mutineer/coverage_map.rb +54 -52
- data/lib/mutineer/daemon_backend.rb +316 -0
- data/lib/mutineer/daemon_client.rb +60 -36
- data/lib/mutineer/daemon_server.rb +65 -61
- data/lib/mutineer/file_swap.rb +16 -15
- data/lib/mutineer/rails_worker_db.rb +44 -43
- data/lib/mutineer/reporter.rb +29 -31
- data/lib/mutineer/result.rb +25 -26
- data/lib/mutineer/runner.rb +69 -284
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +23 -23
- metadata +2 -1
data/lib/mutineer/runner.rb
CHANGED
|
@@ -13,21 +13,21 @@ require_relative "worker_pool"
|
|
|
13
13
|
require_relative "mutant_id"
|
|
14
14
|
require_relative "file_swap"
|
|
15
15
|
require_relative "external_backend"
|
|
16
|
-
require_relative "
|
|
16
|
+
require_relative "daemon_backend"
|
|
17
17
|
require "set"
|
|
18
18
|
|
|
19
19
|
module Mutineer
|
|
20
20
|
# Orchestrates one mutation end-to-end: apply it textually, validate the
|
|
21
21
|
# result, select its covering test files from the coverage map, then run only
|
|
22
|
-
# those against the mutated source in an isolated child process (strategy
|
|
22
|
+
# those against the mutated source in an isolated child process (strategy
|
|
23
23
|
# whole-file reload via `load`).
|
|
24
24
|
#
|
|
25
25
|
# The source file path is passed explicitly because Mutation carries only byte
|
|
26
|
-
# offsets, not its file.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# offsets, not its file. Coverage-map selection replaces a hardcoded test file:
|
|
27
|
+
# a mutation whose line no test exercises is :no_coverage (no fork); otherwise
|
|
28
|
+
# exactly the covering test files run in the child.
|
|
29
29
|
class Runner
|
|
30
|
-
# Full
|
|
30
|
+
# Full orchestration: resolve operators, discover subjects, build the
|
|
31
31
|
# coverage map, run every mutation, and aggregate. Returns
|
|
32
32
|
# [AggregateResult, source_map]. The CLI then reports + applies the exit code;
|
|
33
33
|
# the integration test asserts directly on the AggregateResult.
|
|
@@ -41,25 +41,25 @@ module Mutineer
|
|
|
41
41
|
def self.execute(config)
|
|
42
42
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
44
|
+
# External backend: run the suite as a subprocess in the app's own runtime.
|
|
45
|
+
# It does no in-process boot/require or coverage build, so branch before any
|
|
46
|
+
# of that. The in-process path below is untouched.
|
|
47
47
|
return execute_external(config, operator_classes) if config.test_command
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
return
|
|
49
|
+
# Daemon backend: boot the app ONCE in a persistent subprocess under the
|
|
50
|
+
# app's bundle and fork per mutant. Tool-side we only discover jobs + build
|
|
51
|
+
# payloads (Prism), so branch before any in-process boot.
|
|
52
|
+
return DaemonBackend.execute(config, operator_classes) if config.daemon
|
|
53
53
|
|
|
54
54
|
# Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
|
|
55
55
|
# in the parent and inherited by every fork. Do NOT manually require the
|
|
56
|
-
# sources
|
|
56
|
+
# sources. Under Zeitwerk a manual require of an autoloadable file raises;
|
|
57
57
|
# the booted env autoloads them, and subject discovery is a static Prism
|
|
58
58
|
# parse that needs nothing loaded. Standalone mode requires the sources as
|
|
59
59
|
# before so their classes exist for the children to inherit.
|
|
60
60
|
if config.boot
|
|
61
|
-
#
|
|
62
|
-
# suite
|
|
61
|
+
# Under --rails an unset RAILS_ENV boots development, where the test
|
|
62
|
+
# suite is not loaded. Coverage comes back empty and EVERY mutant is
|
|
63
63
|
# falsely reported no_coverage (score N/A, exit 0). Default it to test.
|
|
64
64
|
ensure_rails_env(config)
|
|
65
65
|
|
|
@@ -104,10 +104,11 @@ module Mutineer
|
|
|
104
104
|
|
|
105
105
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
106
106
|
|
|
107
|
-
#
|
|
108
|
-
# resolves). A SIGKILL'd child skips the tempfile's
|
|
109
|
-
# it. `ensure` is unreliable vs SIGKILL, so the
|
|
110
|
-
# before and after the run
|
|
107
|
+
# Whole-file reload writes mutineer_mutant*.rb into each source dir (so
|
|
108
|
+
# require_relative resolves). A SIGKILL'd child skips the tempfile's
|
|
109
|
+
# ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
|
|
110
|
+
# PARENT sweeps each source dir before and after the run. Orphans are
|
|
111
|
+
# impossible after a normal run.
|
|
111
112
|
dirs = source_dirs(config)
|
|
112
113
|
sweep_orphans(dirs)
|
|
113
114
|
|
|
@@ -136,12 +137,12 @@ module Mutineer
|
|
|
136
137
|
end
|
|
137
138
|
|
|
138
139
|
# Collect every (subject, mutation, id) up front so a backend can run them.
|
|
139
|
-
#
|
|
140
|
-
#
|
|
140
|
+
# A mutant the user marked known-equivalent (inline disable-line comment or
|
|
141
|
+
# .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
|
|
141
142
|
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
142
143
|
# The stable id is computed per subject (occurrence needs the full list) and
|
|
143
144
|
# carried on every job so the parent can reattach it after the run. Shared by
|
|
144
|
-
# the in-process and
|
|
145
|
+
# the in-process, external, and daemon backends so job selection can never drift.
|
|
145
146
|
#
|
|
146
147
|
# @return [Array(Array, Array<Result>, Hash<String,String>)] jobs, ignored, source_map.
|
|
147
148
|
def self.collect_jobs(config, operator_classes)
|
|
@@ -168,12 +169,12 @@ module Mutineer
|
|
|
168
169
|
[jobs, ignored_results, source_map]
|
|
169
170
|
end
|
|
170
171
|
|
|
171
|
-
#
|
|
172
|
+
# External backend orchestration. Runs each mutant's whole-file mutation on
|
|
172
173
|
# disk (crash-safe swap) and executes the user's --test-command as a subprocess
|
|
173
|
-
# in the app's own runtime. Serial by construction (
|
|
174
|
-
# per-worker isolation yet). No coverage narrowing
|
|
175
|
-
# --test set
|
|
176
|
-
#
|
|
174
|
+
# in the app's own runtime. Serial by construction (one shared DB, no
|
|
175
|
+
# per-worker isolation yet). No coverage narrowing: every mutant runs the full
|
|
176
|
+
# --test set; the score is therefore an upper bound and not comparable to an
|
|
177
|
+
# in-process run (the CLI discloses this).
|
|
177
178
|
#
|
|
178
179
|
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
179
180
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
@@ -182,7 +183,7 @@ module Mutineer
|
|
|
182
183
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
183
184
|
dirs = source_dirs(config)
|
|
184
185
|
|
|
185
|
-
# Heal any file a prior hard-killed run left mutated BEFORE reading source
|
|
186
|
+
# Heal any file a prior hard-killed run left mutated BEFORE reading source.
|
|
186
187
|
# collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
|
|
187
188
|
# still-mutated file would yield garbage offsets against the later-healed
|
|
188
189
|
# source. Heal first, then discover jobs from the clean tree.
|
|
@@ -191,11 +192,15 @@ module Mutineer
|
|
|
191
192
|
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
192
193
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
193
194
|
|
|
195
|
+
# Nothing to mutate: return before the smoke check, which runs the whole
|
|
196
|
+
# --test set to calibrate a timeout no mutant would use (#76).
|
|
197
|
+
return [AggregateResult.new(ignored_results), source_map] if jobs.empty?
|
|
198
|
+
|
|
194
199
|
# Calibrate the per-mutant timeout from the clean run (a real suite far
|
|
195
|
-
# outlasts the 10s in-process fork budget), and abort if it
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
200
|
+
# outlasts the 10s in-process fork budget), and abort if it is not green.
|
|
201
|
+
# 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
|
|
202
|
+
# a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
|
|
203
|
+
# handful cannot stall a serial run for ~45min on a slow suite.
|
|
199
204
|
smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
|
|
200
205
|
timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
|
|
201
206
|
|
|
@@ -205,7 +210,7 @@ module Mutineer
|
|
|
205
210
|
r = run_external(subject, mutation, config.test_command, abs_tests,
|
|
206
211
|
timeout: timeout, verbose: config.verbose)
|
|
207
212
|
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
208
|
-
break if config.fail_fast && r.survived? #
|
|
213
|
+
break if config.fail_fast && r.survived? # stop at the first survivor
|
|
209
214
|
end
|
|
210
215
|
ensure
|
|
211
216
|
FileSwap.restore_orphans(dirs)
|
|
@@ -214,11 +219,11 @@ module Mutineer
|
|
|
214
219
|
[AggregateResult.new(results + ignored_results), source_map]
|
|
215
220
|
end
|
|
216
221
|
|
|
217
|
-
# Runs one mutant through the external backend: apply the whole-file mutation
|
|
218
|
-
# disk, run the command, restore.
|
|
222
|
+
# Runs one mutant through the external backend: apply the whole-file mutation
|
|
223
|
+
# on disk, run the command, restore. An invalid (non-reparsing) mutant would
|
|
219
224
|
# fail to load and score a false `killed`, so skip it tool-side (Prism, already
|
|
220
|
-
# cheap) and never write the file
|
|
221
|
-
# in-process path gives at
|
|
225
|
+
# cheap) and never write the file, preserving the `skipped` verdict the
|
|
226
|
+
# in-process path gives at the pre-fork check.
|
|
222
227
|
#
|
|
223
228
|
# @return [Mutineer::Result] verdict for this mutant.
|
|
224
229
|
def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
|
|
@@ -231,185 +236,16 @@ module Mutineer
|
|
|
231
236
|
end
|
|
232
237
|
end
|
|
233
238
|
|
|
234
|
-
# Daemon backend: boot the app once in a persistent subprocess and fork per
|
|
235
|
-
# mutant. Tool-side we build the ready-to-`load` payload (whole-file reload by
|
|
236
|
-
# default) and ship it; the daemon needs no Prism/mutineer. Coverage is built
|
|
237
|
-
# once via a short-lived daemon so each mutant runs only its covering tests.
|
|
238
|
-
# When jobs > 1, each worker uses its own database (SQLite). Fail-fast forces
|
|
239
|
-
# serial so the survivor set matches jobs 1.
|
|
240
|
-
#
|
|
241
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
|
|
242
|
-
def self.execute_daemon(config, operator_classes)
|
|
243
|
-
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
244
|
-
jobs = filter_since(jobs, source_map, config) if config.since
|
|
245
|
-
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
246
|
-
|
|
247
|
-
# Build the coverage map once (app-side). nil when the build fails — runners
|
|
248
|
-
# fall back to the full --test set (and emit a stderr warning) rather than
|
|
249
|
-
# mis-scoring everything as no_coverage.
|
|
250
|
-
coverage_map = daemon_coverage_map(config, abs_tests)
|
|
251
|
-
|
|
252
|
-
# #26/U6: worker count = resolved --jobs, capped at the job count (no idle
|
|
253
|
-
# daemons). >1 → N concurrent daemon handles, each on its OWN worker DB (V6:
|
|
254
|
-
# N-handles, the spike-proven shape). 1 → the serial single-daemon path.
|
|
255
|
-
# --fail-fast forces serial: parallel's stop flag fires on the first survivor
|
|
256
|
-
# by WALL-CLOCK, not input index, so the verdict set would diverge from serial
|
|
257
|
-
# (a different, non-deterministic survivor set/score) — the "identical to
|
|
258
|
-
# --jobs 1" guarantee below only holds when fail-fast can't race.
|
|
259
|
-
worker_count = [config.jobs || 1, 1].max
|
|
260
|
-
worker_count = 1 if config.fail_fast
|
|
261
|
-
worker_count = [worker_count, jobs.size].min if jobs.size.positive?
|
|
262
|
-
|
|
263
|
-
results =
|
|
264
|
-
if worker_count > 1
|
|
265
|
-
run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
266
|
-
else
|
|
267
|
-
run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
|
274
|
-
# per-test coverage app-side, ships the map back). Returns a query-only
|
|
275
|
-
# CoverageMap, or nil when the build fails / returns empty — callers then run the
|
|
276
|
-
# full --test set. Coverage-build IPC has no wall-clock (same limitation as
|
|
277
|
-
# in-process build_via_fork). A normal nonempty map scores like in-process;
|
|
278
|
-
# nil falls back to the full suite (more testing, not comparable).
|
|
279
|
-
#
|
|
280
|
-
# @param config [Mutineer::Config] the run config.
|
|
281
|
-
# @param abs_tests [Array<String>] absolute --test paths.
|
|
282
|
-
# @return [Mutineer::CoverageMap, nil]
|
|
283
|
-
def self.daemon_coverage_map(config, abs_tests)
|
|
284
|
-
client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests, coverage: true),
|
|
285
|
-
app_root: config.project_root).start
|
|
286
|
-
data = begin
|
|
287
|
-
client.coverage
|
|
288
|
-
ensure
|
|
289
|
-
client.quit
|
|
290
|
-
end
|
|
291
|
-
unless data && !(data["map"] || {}).empty?
|
|
292
|
-
reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
|
|
293
|
-
warn_daemon_coverage_fallback(reason)
|
|
294
|
-
return nil
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
|
|
298
|
-
project_root: config.project_root)
|
|
299
|
-
rescue DaemonBootError => e
|
|
300
|
-
warn_daemon_coverage_fallback("#{e.class}: #{e.message}")
|
|
301
|
-
nil
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# Stderr note when daemon coverage is unavailable (full --test set per mutant).
|
|
305
|
-
#
|
|
306
|
-
# @api private
|
|
307
|
-
# @param reason [String] short cause (boot error message, empty map, …).
|
|
308
|
-
# @return [void]
|
|
309
|
-
def self.warn_daemon_coverage_fallback(reason = "unknown")
|
|
310
|
-
warn "[mutineer] daemon coverage map unavailable (#{reason}); running every " \
|
|
311
|
-
"mutant against the full --test set (score not comparable to an in-process run)."
|
|
312
|
-
end
|
|
313
|
-
private_class_method :warn_daemon_coverage_fallback
|
|
314
|
-
|
|
315
|
-
# Serial daemon path: one daemon (worker 0), one mutant at a time. Honors
|
|
316
|
-
# --fail-fast (#21: stop at the first survivor).
|
|
317
|
-
#
|
|
318
|
-
# @return [Array<Mutineer::Result>] results in input order.
|
|
319
|
-
def self.run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
320
|
-
client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
|
|
321
|
-
app_root: config.project_root).start
|
|
322
|
-
results = []
|
|
323
|
-
begin
|
|
324
|
-
jobs.each_with_index do |job, i|
|
|
325
|
-
r = daemon_job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
326
|
-
results << r
|
|
327
|
-
break if config.fail_fast && r.survived?
|
|
328
|
-
end
|
|
329
|
-
ensure
|
|
330
|
-
client.quit
|
|
331
|
-
end
|
|
332
|
-
results
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
# Parallel daemon path: N daemon handles, each pinned to its own worker slot
|
|
336
|
-
# (own DB). A shared queue of job indices feeds N tool-side threads; results
|
|
337
|
-
# are placed by input index so the verdict set matches serial. Callers must
|
|
338
|
-
# not pass fail_fast here (execute_daemon forces serial for fail-fast).
|
|
339
|
-
#
|
|
340
|
-
# @return [Array<Mutineer::Result>] completed results in input order.
|
|
341
|
-
def self.run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
342
|
-
results = Array.new(jobs.size)
|
|
343
|
-
queue = Queue.new
|
|
344
|
-
jobs.each_index { |i| queue << i }
|
|
345
|
-
|
|
346
|
-
clients = Array.new(worker_count) do
|
|
347
|
-
DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
|
|
348
|
-
app_root: config.project_root).start
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
clients.each_with_index.map do |client, worker|
|
|
352
|
-
Thread.new do
|
|
353
|
-
loop do
|
|
354
|
-
i = begin
|
|
355
|
-
queue.pop(true)
|
|
356
|
-
rescue ThreadError
|
|
357
|
-
break
|
|
358
|
-
end
|
|
359
|
-
results[i] = daemon_job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
360
|
-
end
|
|
361
|
-
ensure
|
|
362
|
-
client.quit
|
|
363
|
-
end
|
|
364
|
-
end.each(&:join)
|
|
365
|
-
|
|
366
|
-
results.compact
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# Build the payload for one job, run it on the given daemon/worker, and attach
|
|
370
|
-
# the subject/mutation/id — the shared body of both daemon paths.
|
|
371
|
-
#
|
|
372
|
-
# @param job [Array(Mutineer::Subject, Mutineer::Mutation, String)] the work item.
|
|
373
|
-
# @param req_id [Integer] request id (echoed back for IPC ordering safety).
|
|
374
|
-
# @param client [Mutineer::DaemonClient] the daemon handle to run on.
|
|
375
|
-
# @param worker [Integer] the worker slot (→ worker DB) this daemon routes to.
|
|
376
|
-
# @return [Mutineer::Result] the decorated result.
|
|
377
|
-
def self.daemon_job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
|
|
378
|
-
subject, mutation, id = job
|
|
379
|
-
source = source_map[subject.file]
|
|
380
|
-
mutated = mutation.apply(source)
|
|
381
|
-
# KTD-8 (carried): skip an invalid mutant tool-side — never ship a payload that
|
|
382
|
-
# would fail to load and read as a false `killed`.
|
|
383
|
-
# #26/U7: narrow to covering tests (shared with the in-process path via
|
|
384
|
-
# coverage_selection, so scores match). :verdict = no_coverage/uncapturable, no
|
|
385
|
-
# fork. No map (build failed) → run the full --test set (fallback, not narrowed).
|
|
386
|
-
sel = coverage_map && coverage_selection(subject.file, mutation, subject, source, coverage_map)
|
|
387
|
-
r =
|
|
388
|
-
if Parser.parse_string(mutated).errors.any?
|
|
389
|
-
Result.skipped
|
|
390
|
-
elsif sel && sel[0] == :verdict
|
|
391
|
-
sel[1]
|
|
392
|
-
else
|
|
393
|
-
verdict = client.request(
|
|
394
|
-
id: req_id, worker: worker, timeout: config.daemon_timeout || DAEMON_TIMEOUT,
|
|
395
|
-
payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
|
|
396
|
-
tests: sel ? sel[1] : abs_tests
|
|
397
|
-
)
|
|
398
|
-
daemon_result(verdict)
|
|
399
|
-
end
|
|
400
|
-
r.with(subject: subject, mutation: mutation, id: id)
|
|
401
|
-
end
|
|
402
|
-
|
|
403
239
|
# Coverage-based test selection, shared by the in-process ({run}) and daemon
|
|
404
|
-
# paths so both narrow identically (score parity
|
|
240
|
+
# paths so both narrow identically (score parity). Returns
|
|
405
241
|
# `[:run, abs_test_paths]` when some test covers the mutant's line, or
|
|
406
242
|
# `[:verdict, Result]` (no_coverage / uncapturable) when none do.
|
|
407
243
|
#
|
|
408
|
-
#
|
|
409
|
-
# mutant's enclosing method body got coverage from no *successful* capture but
|
|
410
|
-
# sibling test failed to capture
|
|
411
|
-
# excluded from the score denominator, so this distinction is reporting-only
|
|
412
|
-
# never changes the daemon-vs-in-process score.
|
|
244
|
+
# An empty selection is `:uncapturable` (not `:no_coverage`) when the
|
|
245
|
+
# mutant's enclosing method body got coverage from no *successful* capture but
|
|
246
|
+
# a sibling test failed to capture: the coverage was lost, not absent. Both are
|
|
247
|
+
# excluded from the score denominator, so this distinction is reporting-only
|
|
248
|
+
# and never changes the daemon-vs-in-process score.
|
|
413
249
|
#
|
|
414
250
|
# @param source_file [String] the mutated source file path.
|
|
415
251
|
# @param mutation [Mutineer::Mutation] the mutation (for its line offset).
|
|
@@ -431,59 +267,6 @@ module Mutineer
|
|
|
431
267
|
[:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
|
|
432
268
|
end
|
|
433
269
|
|
|
434
|
-
# Default per-mutant timeout on the daemon path (seconds). Coverage narrowing
|
|
435
|
-
# usually keeps each job short; this still covers a slow suite or full-suite
|
|
436
|
-
# fallback when the coverage map is unavailable.
|
|
437
|
-
DAEMON_TIMEOUT = 60
|
|
438
|
-
|
|
439
|
-
# The boot config the daemon needs to boot the app once: where to boot, the test
|
|
440
|
-
# load roots (so `require "test_helper"` resolves in every fork), framework, and
|
|
441
|
-
# whether this is Rails.
|
|
442
|
-
def self.daemon_boot_config(config, abs_tests, coverage: false)
|
|
443
|
-
{
|
|
444
|
-
project_root: config.project_root,
|
|
445
|
-
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
446
|
-
load_paths: test_load_roots(abs_tests),
|
|
447
|
-
source_dirs: source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
448
|
-
framework: config.framework,
|
|
449
|
-
rails: config.rails,
|
|
450
|
-
# #26/U5: schema for per-worker DB isolation. Sent when present; the daemon
|
|
451
|
-
# skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
|
|
452
|
-
schema: daemon_schema_path(config),
|
|
453
|
-
# #26/U7: coverage narrowing. Only the short-lived map-building daemon starts
|
|
454
|
-
# Coverage (before boot); worker daemons boot with it OFF (no wasted
|
|
455
|
-
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
456
|
-
# map-build inputs.
|
|
457
|
-
coverage: coverage,
|
|
458
|
-
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
459
|
-
tests: abs_tests
|
|
460
|
-
}
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
# Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
|
|
464
|
-
# daemon to schema-load each fork's isolated worker database (#26/U5). Only
|
|
465
|
-
# `schema.rb` is supported this pass; `structure.sql` apps get nil and fall back to
|
|
466
|
-
# whatever the worker DB already holds (Postgres provisioning is U10).
|
|
467
|
-
#
|
|
468
|
-
# @param config [Mutineer::Config] the run config.
|
|
469
|
-
# @return [String, nil] absolute schema path or nil.
|
|
470
|
-
def self.daemon_schema_path(config)
|
|
471
|
-
path = File.expand_path("db/schema.rb", config.project_root)
|
|
472
|
-
File.exist?(path) ? path : nil
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
# Map a daemon verdict string to a Result. The daemon reports the four run-time
|
|
476
|
-
# states it can decide (KTD-5); pre-fork states (skipped/no_coverage/…) are
|
|
477
|
-
# resolved tool-side before a request is ever sent.
|
|
478
|
-
def self.daemon_result(verdict)
|
|
479
|
-
case verdict
|
|
480
|
-
when "survived" then Result.survived
|
|
481
|
-
when "killed" then Result.killed
|
|
482
|
-
when "timeout" then Result.timeout
|
|
483
|
-
else Result.error("daemon verdict: #{verdict}")
|
|
484
|
-
end
|
|
485
|
-
end
|
|
486
|
-
|
|
487
270
|
# Scan a source once into { line_number => :all | Set[operator_syms] } from
|
|
488
271
|
# inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
|
|
489
272
|
# sits on the same physical line as the code it silences). A bare marker
|
|
@@ -550,9 +333,9 @@ module Mutineer
|
|
|
550
333
|
end.uniq
|
|
551
334
|
end
|
|
552
335
|
|
|
553
|
-
#
|
|
554
|
-
#
|
|
555
|
-
#
|
|
336
|
+
# When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
|
|
337
|
+
# before the app boots. Otherwise it boots development and nothing is measured.
|
|
338
|
+
# An explicitly-set RAILS_ENV is always respected.
|
|
556
339
|
def self.ensure_rails_env(config)
|
|
557
340
|
return unless config.rails
|
|
558
341
|
return unless ENV["RAILS_ENV"].nil? || ENV["RAILS_ENV"].empty?
|
|
@@ -561,25 +344,27 @@ module Mutineer
|
|
|
561
344
|
warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
|
|
562
345
|
end
|
|
563
346
|
|
|
564
|
-
# The unique absolute directories holding the sources
|
|
565
|
-
#
|
|
566
|
-
#
|
|
347
|
+
# The unique absolute directories holding the sources. Sweep target for both
|
|
348
|
+
# orphan mechanisms (in-process mutant tempfiles and external backup files),
|
|
349
|
+
# and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
|
|
350
|
+
# Shared so the path-expansion rule cannot drift between the paths.
|
|
567
351
|
#
|
|
568
|
-
# @api private
|
|
569
352
|
# @param config [Mutineer::Config] run configuration.
|
|
570
353
|
# @return [Array<String>] unique absolute source directories.
|
|
571
354
|
def self.source_dirs(config)
|
|
572
355
|
config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
|
|
573
356
|
end
|
|
574
357
|
|
|
575
|
-
# Removes stale mutant tempfiles from the given directories.
|
|
358
|
+
# Removes stale mutant tempfiles from the given directories. The daemon writes a
|
|
359
|
+
# differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
|
|
360
|
+
# tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
|
|
576
361
|
#
|
|
577
|
-
# @api private
|
|
578
362
|
# @param dirs [Array<String>] directories to sweep.
|
|
363
|
+
# @param glob [String] filename pattern to remove.
|
|
579
364
|
# @return [void]
|
|
580
|
-
def self.sweep_orphans(dirs)
|
|
365
|
+
def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
|
|
581
366
|
dirs.each do |dir|
|
|
582
|
-
Dir.glob(File.join(dir,
|
|
367
|
+
Dir.glob(File.join(dir, glob)).each do |f|
|
|
583
368
|
File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
|
|
584
369
|
end
|
|
585
370
|
end
|
|
@@ -601,13 +386,13 @@ module Mutineer
|
|
|
601
386
|
source = File.read(source_file)
|
|
602
387
|
mutated = mutation.apply(source)
|
|
603
388
|
|
|
604
|
-
# Validity rule: a mutant that
|
|
389
|
+
# Validity rule: a mutant that does not re-parse is skipped before forking.
|
|
605
390
|
return Result.skipped if Parser.parse_string(mutated).errors.any?
|
|
606
391
|
|
|
607
392
|
# Coverage selection (both standalone and boot mode): a mutation on a line
|
|
608
393
|
# no test exercises is :no_coverage (no fork); otherwise exactly the
|
|
609
394
|
# covering test files run in the child. Shared with the daemon path so both
|
|
610
|
-
# narrow identically (score parity
|
|
395
|
+
# narrow identically (score parity).
|
|
611
396
|
kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
612
397
|
return payload if kind == :verdict
|
|
613
398
|
|
|
@@ -634,9 +419,9 @@ module Mutineer
|
|
|
634
419
|
return unless defined?(ActiveRecord::Base)
|
|
635
420
|
|
|
636
421
|
base = ActiveRecord::Base
|
|
637
|
-
#
|
|
422
|
+
# Clearing connections here drops an open transactional-fixture
|
|
638
423
|
# transaction, so the test loses its fixture rows and fails. Skip the clear
|
|
639
|
-
# when a transaction is open; otherwise clear (
|
|
424
|
+
# when a transaction is open; otherwise clear (per-fork write-safety).
|
|
640
425
|
return if fixture_transaction_open?(base)
|
|
641
426
|
|
|
642
427
|
base.connection_handler.clear_all_connections!
|
|
@@ -646,9 +431,9 @@ module Mutineer
|
|
|
646
431
|
private_class_method :reconnect_active_record
|
|
647
432
|
|
|
648
433
|
# Pure, injectable predicate: true when a transactional-fixture transaction is
|
|
649
|
-
# already open on the connection. Keys off open_transactions
|
|
650
|
-
#
|
|
651
|
-
#
|
|
434
|
+
# already open on the connection. Keys off open_transactions so it is correct
|
|
435
|
+
# whenever the transaction exists, regardless of when it opened. Any probe
|
|
436
|
+
# error degrades safe to false -> caller clears (existing behaviour).
|
|
652
437
|
def self.fixture_transaction_open?(base)
|
|
653
438
|
pool = base.connection_pool
|
|
654
439
|
pool.active_connection? && base.connection.open_transactions.positive?
|
data/lib/mutineer/version.rb
CHANGED
data/lib/mutineer/worker_pool.rb
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
require_relative "result"
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
|
-
# Fixed-size fork pool
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# is stable.
|
|
6
|
+
# Fixed-size fork pool. `run` forks up to `size` children at once; each child
|
|
7
|
+
# runs the block on one work item, marshals its Result to a private pipe, and
|
|
8
|
+
# exits. The parent drains pipes with IO.select and reaps each finished child
|
|
9
|
+
# by known pid (never wait2(-1), which would steal the host suite's children),
|
|
10
|
+
# opening exactly one slot per reap, then refills. Results are returned in the
|
|
11
|
+
# SAME ORDER as `items` regardless of finish order, so verdicts are identical
|
|
12
|
+
# to a serial run and downstream output is stable.
|
|
13
13
|
#
|
|
14
14
|
# The block is run inside the child via `yield(*items[i])`; whatever it
|
|
15
15
|
# returns (a Result) is the marshaled payload. Per-mutant timeout is handled
|
|
16
|
-
# one level down by Isolation
|
|
16
|
+
# one level down by Isolation. The pool adds no separate wall clock.
|
|
17
17
|
class WorkerPool
|
|
18
18
|
# Builds a pool.
|
|
19
19
|
#
|
|
@@ -27,7 +27,7 @@ module Mutineer
|
|
|
27
27
|
# @param items [Array<Array>] work items.
|
|
28
28
|
# @param stop_when [Proc, nil] called with each collected Result; when it
|
|
29
29
|
# returns truthy, no further items are scheduled and the run drains and
|
|
30
|
-
# returns early (
|
|
30
|
+
# returns early (--fail-fast). Unscheduled slots stay nil.
|
|
31
31
|
# @yieldparam item [Array] one work item.
|
|
32
32
|
# @return [Array<Mutineer::Result>] results in input order (nil for any item
|
|
33
33
|
# left unscheduled by an early stop).
|
|
@@ -51,15 +51,15 @@ module Mutineer
|
|
|
51
51
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
54
|
+
# The child must ALWAYS hard-exit. If yield raises, marshal an error Result
|
|
55
|
+
# and exit! in `ensure`. Otherwise the child unwinds normally and our
|
|
56
|
+
# Minitest at_exit autorun re-runs the parent suite inside the worker,
|
|
57
57
|
# losing the real error.
|
|
58
58
|
def fill(items, queue, running)
|
|
59
59
|
while running.size < @size && !queue.empty?
|
|
60
60
|
idx = queue.shift
|
|
61
61
|
rd, wr = IO.pipe
|
|
62
|
-
rd.binmode #
|
|
62
|
+
rd.binmode # Marshal output is binary: keep the pipe byte-exact
|
|
63
63
|
wr.binmode
|
|
64
64
|
begin
|
|
65
65
|
pid = fork do
|
|
@@ -94,13 +94,13 @@ module Mutineer
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# Drain pipes with IO.select and reap a child only on EOF
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
97
|
+
# Drain pipes with IO.select and reap a child only on EOF. The old code
|
|
98
|
+
# reaped first and read after, but a child whose payload exceeds the OS pipe
|
|
99
|
+
# buffer (~64KB) blocks on `write` before it can exit, so it was never reaped
|
|
100
|
+
# and the pool deadlocked. Reading concurrently keeps the pipe drained so the
|
|
101
|
+
# child can finish and exit; EOF means it closed its write end (done writing).
|
|
102
|
+
# We waitpid only OUR known pids (never wait2(-1), which would steal the host
|
|
103
|
+
# suite's children).
|
|
104
104
|
def reap(results, running)
|
|
105
105
|
return if running.empty?
|
|
106
106
|
|
|
@@ -116,7 +116,7 @@ module Mutineer
|
|
|
116
116
|
rd.close
|
|
117
117
|
Process.waitpid(pid) # reap the now-finished child (no zombie)
|
|
118
118
|
running.delete(pid)
|
|
119
|
-
# Return the collected Result so the caller's stop_when
|
|
119
|
+
# Return the collected Result so the caller's stop_when can see it.
|
|
120
120
|
return results[idx] = decode(buf)
|
|
121
121
|
end
|
|
122
122
|
buf << chunk
|
|
@@ -124,8 +124,8 @@ module Mutineer
|
|
|
124
124
|
end
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
127
|
+
# A partial/garbage Marshal stream (dead worker) must not crash the pool.
|
|
128
|
+
# Degrade to an error Result.
|
|
129
129
|
# @param data [String] marshaled payload.
|
|
130
130
|
# @return [Mutineer::Result] decoded result or error result.
|
|
131
131
|
def decode(data)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutineer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.11.
|
|
4
|
+
version: 0.11.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- lib/mutineer/cli.rb
|
|
72
72
|
- lib/mutineer/config.rb
|
|
73
73
|
- lib/mutineer/coverage_map.rb
|
|
74
|
+
- lib/mutineer/daemon_backend.rb
|
|
74
75
|
- lib/mutineer/daemon_client.rb
|
|
75
76
|
- lib/mutineer/daemon_server.rb
|
|
76
77
|
- lib/mutineer/external_backend.rb
|