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
|
@@ -12,21 +12,23 @@ require_relative "test_runners"
|
|
|
12
12
|
|
|
13
13
|
module Mutineer
|
|
14
14
|
# Maps `(source_file, line) -> [test_files]` so each mutant runs only against
|
|
15
|
-
# the tests that actually exercise its line. Built once
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# the tests that actually exercise its line. Built once, then queried per
|
|
16
|
+
# mutant via #tests_for. Persisted to .mutineer/coverage.json with a
|
|
17
|
+
# content-based digest that rebuilds the map whenever any tracked file changes.
|
|
18
18
|
#
|
|
19
|
-
# Keys are "file:line" strings (relative to project_root) everywhere
|
|
20
|
-
# memory and on disk
|
|
19
|
+
# Keys are "file:line" strings (relative to project_root) everywhere, in
|
|
20
|
+
# memory and on disk, so load/save needs no key transformation.
|
|
21
21
|
class CoverageMap
|
|
22
|
-
|
|
22
|
+
# Seconds per coverage subprocess before the parent kills it.
|
|
23
|
+
DEFAULT_CAPTURE_TIMEOUT = 120
|
|
23
24
|
|
|
24
25
|
attr_reader :project_root, :failed_test_files, :phase_a_ran, :map
|
|
25
26
|
|
|
26
|
-
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
|
|
27
|
-
# app-side and ships `map` + `failed_test_files` over IPC; the tool
|
|
28
|
-
# here for per-mutant selection). Skips the capture machinery
|
|
29
|
-
# three fields #tests_for / #method_uncapturable? read are
|
|
27
|
+
# Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
|
|
28
|
+
# map app-side and ships `map` + `failed_test_files` over IPC; the tool
|
|
29
|
+
# reconstructs it here for per-mutant selection). Skips the capture machinery
|
|
30
|
+
# entirely: only the three fields #tests_for / #method_uncapturable? read are
|
|
31
|
+
# set.
|
|
30
32
|
#
|
|
31
33
|
# @param map [Hash] the "file:line" => [test_files] map.
|
|
32
34
|
# @param failed_test_files [Array<String>] test files whose capture failed.
|
|
@@ -58,14 +60,14 @@ module Mutineer
|
|
|
58
60
|
@phase_a_ran = false
|
|
59
61
|
end
|
|
60
62
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
+
# Standalone entry: load the cached map when the content digest matches,
|
|
64
|
+
# otherwise rebuild from subprocesses and overwrite the cache.
|
|
63
65
|
def build_or_load
|
|
64
66
|
warn_external_sources
|
|
65
67
|
cached_or { run_phase_a }
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
# Boot-mode
|
|
70
|
+
# Boot-mode build: Coverage is already running in the parent (started before
|
|
69
71
|
# the app booted, so booted source lines are instrumented). A clean `ruby`
|
|
70
72
|
# subprocess has no booted env, so per-test coverage is captured by FORKING
|
|
71
73
|
# the booted parent instead. Inverts into the same map #tests_for reads, and
|
|
@@ -76,25 +78,25 @@ module Mutineer
|
|
|
76
78
|
cached_or { run_phase_a_via_fork(after_fork: after_fork) }
|
|
77
79
|
end
|
|
78
80
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
81
|
+
# Lookup: the test files that cover `file:line`, or [] when none do.
|
|
82
|
+
# Per-file granularity; upgrade to per-method when throughput warrants
|
|
83
|
+
# (requires Minitest method isolation + finer Coverage tracking).
|
|
82
84
|
def tests_for(file, line)
|
|
83
85
|
@map["#{relativize(file)}:#{line}"] || []
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
#
|
|
87
|
-
# rather than a genuine coverage gap? True iff
|
|
88
|
-
#
|
|
88
|
+
# Is this source file's empty coverage the result of an *errored* capture
|
|
89
|
+
# rather than a genuine coverage gap? True iff some capture failed this run
|
|
90
|
+
# AND this file got zero coverage from any successful capture AND a failed
|
|
89
91
|
# test file maps to it by the standard _test/_spec naming convention. Derived
|
|
90
|
-
# purely from already-persisted state (@map keys + @failed_test_files); no
|
|
91
|
-
# no new cached field, no digest change.
|
|
92
|
+
# purely from already-persisted state (@map keys + @failed_test_files); no
|
|
93
|
+
# rerun, no new cached field, no digest change.
|
|
92
94
|
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# persist per-file coverage per successful run and diff against the failed
|
|
97
|
-
# or record test->source targets explicitly.
|
|
95
|
+
# File-level, convention-based attribution. A line covered only by a failed
|
|
96
|
+
# test in an otherwise-covered file stays no_coverage (condition 2), and a
|
|
97
|
+
# source with no naming-convention test match is never tainted. Upgrade path:
|
|
98
|
+
# persist per-file coverage per successful run and diff against the failed
|
|
99
|
+
# set, or record test->source targets explicitly.
|
|
98
100
|
def uncapturable_source?(file)
|
|
99
101
|
return false if @failed_test_files.empty?
|
|
100
102
|
|
|
@@ -104,14 +106,14 @@ module Mutineer
|
|
|
104
106
|
failed_test_targets.include?(File.basename(rel, ".rb"))
|
|
105
107
|
end
|
|
106
108
|
|
|
107
|
-
#
|
|
109
|
+
# Per-method taint. A mutant on a line whose enclosing method got zero
|
|
108
110
|
# successful coverage, in a file a failed sibling test targets, is
|
|
109
|
-
# :uncapturable (the capture that would have covered it errored)
|
|
111
|
+
# :uncapturable (the capture that would have covered it errored), NOT a
|
|
110
112
|
# genuine gap. A method with any covered line means its uncovered lines are a
|
|
111
113
|
# real :no_coverage. A failed capture emits no coverage, so per-line intent is
|
|
112
|
-
# unknowable; method-range + successful coverage is the finest derivable
|
|
113
|
-
# Fully-failed files behave exactly as uncapturable_source? did
|
|
114
|
-
# range has zero coverage)
|
|
114
|
+
# unknowable; method-range + successful coverage is the finest derivable
|
|
115
|
+
# signal. Fully-failed files behave exactly as uncapturable_source? did
|
|
116
|
+
# (every method range has zero coverage).
|
|
115
117
|
#
|
|
116
118
|
# @param file [String] source file path.
|
|
117
119
|
# @param line_range [Range] 1-based enclosing-method line range.
|
|
@@ -155,7 +157,7 @@ module Mutineer
|
|
|
155
157
|
self
|
|
156
158
|
end
|
|
157
159
|
|
|
158
|
-
# Runs standalone
|
|
160
|
+
# Runs standalone coverage capture.
|
|
159
161
|
#
|
|
160
162
|
# @api private
|
|
161
163
|
def run_phase_a
|
|
@@ -171,11 +173,11 @@ module Mutineer
|
|
|
171
173
|
end
|
|
172
174
|
end
|
|
173
175
|
|
|
174
|
-
# Boot-mode
|
|
176
|
+
# Boot-mode capture. For each test file, fork the booted parent; the child
|
|
175
177
|
# resets its Coverage delta, runs that ONE test, and marshals back the raw
|
|
176
178
|
# per-source coverage counts. record() inverts them exactly as the subprocess
|
|
177
|
-
# path does.
|
|
178
|
-
#
|
|
179
|
+
# path does. Serial fork (one test at a time): boot apps fork cheaply via COW
|
|
180
|
+
# and per-test isolation matters more than throughput here.
|
|
179
181
|
def run_phase_a_via_fork(after_fork:)
|
|
180
182
|
@phase_a_ran = true
|
|
181
183
|
@map = {}
|
|
@@ -183,9 +185,9 @@ module Mutineer
|
|
|
183
185
|
abs_sources = abs_source_paths
|
|
184
186
|
|
|
185
187
|
@test_paths.each do |test_path|
|
|
186
|
-
# Tri-state payload
|
|
187
|
-
#
|
|
188
|
-
#
|
|
188
|
+
# Tri-state payload: Hash = coverage, String = error diagnostic from the
|
|
189
|
+
# child, nil = pipe gone / empty. The String diagnostic is what becomes
|
|
190
|
+
# an :uncapturable status.
|
|
189
191
|
case (coverage = fork_capture(absolute(test_path), abs_sources, after_fork))
|
|
190
192
|
when Hash then record(coverage, test_path)
|
|
191
193
|
when String
|
|
@@ -201,7 +203,7 @@ module Mutineer
|
|
|
201
203
|
# fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
|
|
202
204
|
def fork_capture(abs_test, abs_sources, after_fork)
|
|
203
205
|
rd, wr = IO.pipe
|
|
204
|
-
#
|
|
206
|
+
# Marshal output is binary: an un-binmoded pipe can raise
|
|
205
207
|
# Encoding::UndefinedConversionError on write, which the child's rescue then
|
|
206
208
|
# swallows, losing the real error and yielding a bare "no result".
|
|
207
209
|
rd.binmode
|
|
@@ -210,9 +212,9 @@ module Mutineer
|
|
|
210
212
|
rd.close
|
|
211
213
|
payload =
|
|
212
214
|
begin
|
|
213
|
-
# Fork-safety hook: the in-process path reconnects AR; the daemon
|
|
214
|
-
# to its worker DB. Nil (non-Rails) = no-op. Injected so this
|
|
215
|
-
# neither Runner (Prism) nor Rails.
|
|
215
|
+
# Fork-safety hook: the in-process path reconnects AR; the daemon
|
|
216
|
+
# routes to its worker DB. Nil (non-Rails) = no-op. Injected so this
|
|
217
|
+
# file needs neither Runner (Prism) nor Rails.
|
|
216
218
|
after_fork&.call
|
|
217
219
|
Coverage.result(clear: true, stop: false) # discard pre-test delta
|
|
218
220
|
TestRunners.for(@framework).run([abs_test])
|
|
@@ -222,7 +224,7 @@ module Mutineer
|
|
|
222
224
|
.select { |f, _| abs_sources.include?(f) }
|
|
223
225
|
.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
224
226
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
225
|
-
#
|
|
227
|
+
# Stringify (an arbitrary Exception may not marshal); the parent
|
|
226
228
|
# surfaces this under --verbose. A String marshals safely over the pipe.
|
|
227
229
|
"#{e.class}: #{e.message}#{e.backtrace&.first ? " @ #{e.backtrace.first}" : ''}"
|
|
228
230
|
end
|
|
@@ -239,7 +241,7 @@ module Mutineer
|
|
|
239
241
|
data = rd.read
|
|
240
242
|
rd.close
|
|
241
243
|
_, status = Process.waitpid2(pid)
|
|
242
|
-
#
|
|
244
|
+
# An empty pipe means the child died before writing (e.g. a hard crash,
|
|
243
245
|
# OOM, or a signal from the test's own subprocess handling). Report HOW it
|
|
244
246
|
# died (exit status / signal) as a diagnostic string so --verbose has
|
|
245
247
|
# something actionable instead of a silent "no result".
|
|
@@ -266,8 +268,8 @@ module Mutineer
|
|
|
266
268
|
|
|
267
269
|
# Spawns a fresh `ruby` reading an inline script from stdin. A fork would
|
|
268
270
|
# miss already-loaded app lines, so Coverage must start in a clean process
|
|
269
|
-
# before any source is loaded
|
|
270
|
-
#
|
|
271
|
+
# before any source is loaded. Returns the parsed Coverage.result hash, or
|
|
272
|
+
# nil when the subprocess failed (logged + skipped).
|
|
271
273
|
def capture(test_path)
|
|
272
274
|
out = +""
|
|
273
275
|
status = nil
|
|
@@ -275,8 +277,8 @@ module Mutineer
|
|
|
275
277
|
stdin.write(subprocess_script(test_path))
|
|
276
278
|
stdin.close
|
|
277
279
|
reader = Thread.new { out << stdout.read }
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
+
# Bound the subprocess with a wall clock: a hanging test file must not
|
|
281
|
+
# wedge the whole run before any per-mutant timeout.
|
|
280
282
|
unless wait_thr.join(@capture_timeout)
|
|
281
283
|
Process.kill(:KILL, wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
282
284
|
reader.kill
|
|
@@ -374,7 +376,7 @@ module Mutineer
|
|
|
374
376
|
rel_test = relativize(test_path)
|
|
375
377
|
coverage.each do |abs_file, data|
|
|
376
378
|
rel = relativize(abs_file)
|
|
377
|
-
next if rel.start_with?("/") # outside project_root
|
|
379
|
+
next if rel.start_with?("/") # outside project_root: not our source
|
|
378
380
|
|
|
379
381
|
counts = data.is_a?(Array) ? data : data["lines"]
|
|
380
382
|
counts.each_with_index do |count, idx|
|
|
@@ -385,7 +387,7 @@ module Mutineer
|
|
|
385
387
|
end
|
|
386
388
|
end
|
|
387
389
|
|
|
388
|
-
#
|
|
390
|
+
# Digest each file's ROLE + relative path + content length + content, plus
|
|
389
391
|
# the load_paths. Without role/path/length delimiters the digest collides
|
|
390
392
|
# (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
|
|
391
393
|
# accepting a stale cached map.
|
|
@@ -426,7 +428,7 @@ module Mutineer
|
|
|
426
428
|
end
|
|
427
429
|
end
|
|
428
430
|
|
|
429
|
-
#
|
|
431
|
+
# A configured source that resolves outside project_root would silently be
|
|
430
432
|
# dropped (its coverage relativizes to an absolute path). Warn instead.
|
|
431
433
|
def warn_external_sources
|
|
432
434
|
@source_paths.each do |p|
|
|
@@ -452,7 +454,7 @@ module Mutineer
|
|
|
452
454
|
|
|
453
455
|
JSON.parse(File.read(cache_path))
|
|
454
456
|
rescue JSON::ParserError
|
|
455
|
-
nil # corrupt cache
|
|
457
|
+
nil # corrupt cache: rebuild from scratch
|
|
456
458
|
end
|
|
457
459
|
|
|
458
460
|
# Saves the coverage cache.
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
require_relative "result"
|
|
5
|
+
require_relative "coverage_map"
|
|
6
|
+
require_relative "daemon_client"
|
|
7
|
+
# No require_relative "runner" on purpose: runner.rb requires this file, and the
|
|
8
|
+
# reverse edge makes Ruby warn "circular require considered harmful" on every -w
|
|
9
|
+
# load. Runner is loaded first on every real path; requiring this file alone leaves
|
|
10
|
+
# it undefined. Rationale and the real fix: #75.
|
|
11
|
+
|
|
12
|
+
module Mutineer
|
|
13
|
+
# Daemon execution backend. Boots the app ONCE in a persistent subprocess under
|
|
14
|
+
# the app's own bundle and forks per mutant, so a Rails run pays the boot cost
|
|
15
|
+
# once instead of per mutant. Tool-side this only discovers jobs and builds the
|
|
16
|
+
# ready-to-`load` payload (Prism); the daemon needs no Prism/mutineer.
|
|
17
|
+
#
|
|
18
|
+
# When jobs > 1 each worker runs against its OWN database, which is what makes
|
|
19
|
+
# `--jobs N` safe under Rails (#26): parallel verdicts are identical to serial.
|
|
20
|
+
#
|
|
21
|
+
# Job collection, `--since` filtering and coverage selection stay on {Runner} and
|
|
22
|
+
# are called from here, so the daemon path can never drift from the in-process
|
|
23
|
+
# path on which mutants run or which tests narrow a mutant (score parity).
|
|
24
|
+
#
|
|
25
|
+
# Unlike {ExternalBackend}, which is a leaf {Runner} calls into, this module owns
|
|
26
|
+
# its orchestration and calls back for that shared vocabulary.
|
|
27
|
+
module DaemonBackend
|
|
28
|
+
# Default per-mutant timeout on the daemon path (seconds), overridden by
|
|
29
|
+
# config.daemon_timeout. Coverage narrowing usually keeps each job short; this
|
|
30
|
+
# still covers a slow suite or full-suite fallback when the map is unavailable.
|
|
31
|
+
# Named like its in-process counterpart {Isolation::DEFAULT_TIMEOUT}, not like
|
|
32
|
+
# {ExternalBackend::SMOKE_TIMEOUT}, which bounds a different thing.
|
|
33
|
+
DEFAULT_TIMEOUT = 60
|
|
34
|
+
|
|
35
|
+
# The daemon's per-mutant tempfile, written into the source dir so
|
|
36
|
+
# require_relative resolves. Kept in step with DaemonServer#sweep_temps.
|
|
37
|
+
DAEMON_TEMP_GLOB = "mutineer_daemon*.rb"
|
|
38
|
+
|
|
39
|
+
# Full daemon run: collect jobs, build the coverage map once, then execute
|
|
40
|
+
# serially or across N worker daemons. Fail-fast forces serial so the survivor
|
|
41
|
+
# set matches jobs 1.
|
|
42
|
+
#
|
|
43
|
+
# @param config [Mutineer::Config] run configuration (daemon set).
|
|
44
|
+
# @param operator_classes [Array<Class>] resolved operators.
|
|
45
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
|
|
46
|
+
def self.execute(config, operator_classes)
|
|
47
|
+
jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
|
|
48
|
+
jobs = Runner.filter_since(jobs, source_map, config) if config.since
|
|
49
|
+
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
50
|
+
|
|
51
|
+
# Nothing to mutate (`--since` matched no changed line, or every mutant is
|
|
52
|
+
# suppressed). Return before booting anything: the coverage daemon and the
|
|
53
|
+
# worker daemons below each boot the whole app, and README documents
|
|
54
|
+
# `--since origin/<base>` for PR CI, where a docs-only PR is routine.
|
|
55
|
+
if jobs.empty?
|
|
56
|
+
# The daemon sweeps orphaned temps at boot and nothing boots here, so sweep
|
|
57
|
+
# tool-side. A file a hard-killed run left in app/models breaks the app's own
|
|
58
|
+
# Zeitwerk boot, not just Mutineer's next run.
|
|
59
|
+
Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB)
|
|
60
|
+
return [AggregateResult.new(ignored_results), source_map]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Build the coverage map once (app-side). nil when the build fails: runners
|
|
64
|
+
# fall back to the full --test set (and emit a stderr warning) rather than
|
|
65
|
+
# mis-scoring everything as no_coverage.
|
|
66
|
+
coverage_map = build_coverage_map(config, abs_tests)
|
|
67
|
+
|
|
68
|
+
# Worker count = resolved --jobs, capped at the job count (no idle daemons).
|
|
69
|
+
# >1 → N concurrent daemon handles, each on its OWN worker DB (N-handles, the
|
|
70
|
+
# spike-proven shape). 1 → the serial single-daemon path. --fail-fast forces
|
|
71
|
+
# serial: parallel's stop flag fires on the first survivor by WALL-CLOCK, not
|
|
72
|
+
# input index, so the verdict set would diverge from serial (a different,
|
|
73
|
+
# non-deterministic survivor set/score). The "identical to --jobs 1" guarantee
|
|
74
|
+
# below only holds when fail-fast cannot race.
|
|
75
|
+
worker_count = [config.jobs || 1, 1].max
|
|
76
|
+
worker_count = 1 if config.fail_fast
|
|
77
|
+
worker_count = [worker_count, jobs.size].min
|
|
78
|
+
|
|
79
|
+
results =
|
|
80
|
+
if worker_count > 1
|
|
81
|
+
run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
82
|
+
else
|
|
83
|
+
run_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
[AggregateResult.new(results + ignored_results), source_map]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
|
90
|
+
# per-test coverage app-side, ships the map back). Returns a query-only
|
|
91
|
+
# CoverageMap, or nil when the build fails / returns empty. Callers then run the
|
|
92
|
+
# full --test set. Coverage-build IPC has no wall-clock (same limitation as
|
|
93
|
+
# in-process build_via_fork). A normal nonempty map scores like in-process;
|
|
94
|
+
# nil falls back to the full suite (more testing, not comparable).
|
|
95
|
+
#
|
|
96
|
+
# @param config [Mutineer::Config] the run config.
|
|
97
|
+
# @param abs_tests [Array<String>] absolute --test paths.
|
|
98
|
+
# @return [Mutineer::CoverageMap, nil]
|
|
99
|
+
def self.build_coverage_map(config, abs_tests)
|
|
100
|
+
client = DaemonClient.new(boot: boot_config(config, abs_tests, coverage: true),
|
|
101
|
+
app_root: config.project_root).start
|
|
102
|
+
data = begin
|
|
103
|
+
client.coverage
|
|
104
|
+
ensure
|
|
105
|
+
client.quit
|
|
106
|
+
end
|
|
107
|
+
unless data && !(data["map"] || {}).empty?
|
|
108
|
+
reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
|
|
109
|
+
warn_coverage_fallback(reason)
|
|
110
|
+
return nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
|
|
114
|
+
project_root: config.project_root)
|
|
115
|
+
rescue DaemonBootError => e
|
|
116
|
+
warn_coverage_fallback("#{e.class}: #{e.message}")
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Stderr note when daemon coverage is unavailable (full --test set per mutant).
|
|
121
|
+
#
|
|
122
|
+
# @api private
|
|
123
|
+
# @param reason [String] short cause (boot error message, empty map, …).
|
|
124
|
+
# @return [void]
|
|
125
|
+
def self.warn_coverage_fallback(reason = "unknown")
|
|
126
|
+
warn "[mutineer] daemon coverage map unavailable (#{reason}); running every " \
|
|
127
|
+
"mutant against the full --test set (score not comparable to an in-process run)."
|
|
128
|
+
end
|
|
129
|
+
private_class_method :warn_coverage_fallback
|
|
130
|
+
|
|
131
|
+
# Serial path: one daemon (worker 0), one mutant at a time. Honors --fail-fast
|
|
132
|
+
# (stop at the first survivor).
|
|
133
|
+
#
|
|
134
|
+
# @api private
|
|
135
|
+
# @return [Array<Mutineer::Result>] results in input order.
|
|
136
|
+
def self.run_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
137
|
+
client = DaemonClient.new(boot: boot_config(config, abs_tests),
|
|
138
|
+
app_root: config.project_root).start
|
|
139
|
+
results = []
|
|
140
|
+
begin
|
|
141
|
+
jobs.each_with_index do |job, i|
|
|
142
|
+
r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
143
|
+
results << r
|
|
144
|
+
break if config.fail_fast && r.survived?
|
|
145
|
+
end
|
|
146
|
+
ensure
|
|
147
|
+
client.quit
|
|
148
|
+
end
|
|
149
|
+
results
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Parallel path: N daemon handles, each pinned to its own worker slot (own DB).
|
|
153
|
+
# A shared queue of job indices feeds N tool-side threads; results are placed by
|
|
154
|
+
# input index so the verdict set matches serial. Callers must not pass fail_fast
|
|
155
|
+
# here ({execute} forces serial for fail-fast). Per-mutant crashes are classified
|
|
156
|
+
# in {job_result}, shared with the serial path; a {DaemonBootError} ends the run
|
|
157
|
+
# here rather than scoring the remainder against a daemon that has given up.
|
|
158
|
+
#
|
|
159
|
+
# @api private
|
|
160
|
+
# @return [Array<Mutineer::Result>] one result per input job, in input order.
|
|
161
|
+
def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
162
|
+
results = Array.new(jobs.size)
|
|
163
|
+
queue = Queue.new
|
|
164
|
+
jobs.each_index { |i| queue << i }
|
|
165
|
+
|
|
166
|
+
# Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
|
|
167
|
+
# can still quit the daemons already up. Array.new would lose every reference.
|
|
168
|
+
clients = []
|
|
169
|
+
begin
|
|
170
|
+
worker_count.times do
|
|
171
|
+
clients << DaemonClient.new(boot: boot_config(config, abs_tests),
|
|
172
|
+
app_root: config.project_root).start
|
|
173
|
+
end
|
|
174
|
+
rescue StandardError
|
|
175
|
+
clients.each(&:quit)
|
|
176
|
+
raise
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
clients.each_with_index.map do |client, worker|
|
|
180
|
+
Thread.new do
|
|
181
|
+
# The abort below is re-raised by join and reported once there; without
|
|
182
|
+
# this Ruby also dumps the thread's backtrace, which the serial path never
|
|
183
|
+
# does. Same fault, same output, whatever --jobs is set to.
|
|
184
|
+
Thread.current.report_on_exception = false
|
|
185
|
+
loop do
|
|
186
|
+
i = begin
|
|
187
|
+
queue.pop(true)
|
|
188
|
+
rescue ThreadError
|
|
189
|
+
break
|
|
190
|
+
end
|
|
191
|
+
results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
192
|
+
end
|
|
193
|
+
rescue DaemonBootError
|
|
194
|
+
# The daemon gave up for good. Stop feeding the other workers rather
|
|
195
|
+
# than letting them score the rest of the run against a dead client;
|
|
196
|
+
# Thread#join re-raises this and ends the run.
|
|
197
|
+
queue.clear
|
|
198
|
+
raise
|
|
199
|
+
ensure
|
|
200
|
+
client.quit
|
|
201
|
+
end
|
|
202
|
+
end.each(&:join)
|
|
203
|
+
|
|
204
|
+
# Every job was popped by some worker and every pop assigns, so no slot can
|
|
205
|
+
# be nil here: an escaping exception aborts the run via join instead.
|
|
206
|
+
results
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Build the payload for one job, run it on the given daemon/worker, and attach
|
|
210
|
+
# the subject/mutation/id. Shared body of both daemon paths, so `--jobs 1` and
|
|
211
|
+
# `--jobs N` classify an identical fault identically.
|
|
212
|
+
#
|
|
213
|
+
# Error model, in one place because both paths call this: a crash while running
|
|
214
|
+
# ONE mutant is {DaemonClient}'s business: it respawns and answers `"error"`.
|
|
215
|
+
# Nothing is caught here on purpose — anything reaching this far is either
|
|
216
|
+
# {DaemonBootError}, which must end the run, or a defect, which must stay visible.
|
|
217
|
+
#
|
|
218
|
+
# @param job [Array(Mutineer::Subject, Mutineer::Mutation, String)] the work item.
|
|
219
|
+
# @param req_id [Integer] request id (echoed back for IPC ordering safety).
|
|
220
|
+
# @param client [Mutineer::DaemonClient] the daemon handle to run on.
|
|
221
|
+
# @param worker [Integer] the worker slot (→ worker DB) this daemon routes to.
|
|
222
|
+
# @api private
|
|
223
|
+
# @raise [Mutineer::DaemonBootError] when the daemon has given up; ends the run.
|
|
224
|
+
# @return [Mutineer::Result] the decorated result.
|
|
225
|
+
def self.job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
|
|
226
|
+
subject, mutation, id = job
|
|
227
|
+
source = source_map[subject.file]
|
|
228
|
+
mutated = mutation.apply(source)
|
|
229
|
+
# Skip an invalid mutant tool-side: never ship a payload that would fail to
|
|
230
|
+
# load and read as a false `killed`.
|
|
231
|
+
# Narrow to covering tests (shared with the in-process path via
|
|
232
|
+
# Runner.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
|
|
233
|
+
# no fork. No map (build failed) → run the full --test set (fallback, not
|
|
234
|
+
# narrowed).
|
|
235
|
+
sel = coverage_map && Runner.coverage_selection(subject.file, mutation, subject, source, coverage_map)
|
|
236
|
+
r =
|
|
237
|
+
if Parser.parse_string(mutated).errors.any?
|
|
238
|
+
Result.skipped
|
|
239
|
+
elsif sel && sel[0] == :verdict
|
|
240
|
+
sel[1]
|
|
241
|
+
else
|
|
242
|
+
verdict = client.request(
|
|
243
|
+
id: req_id, worker: worker, timeout: config.daemon_timeout || DEFAULT_TIMEOUT,
|
|
244
|
+
payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
|
|
245
|
+
tests: sel ? sel[1] : abs_tests
|
|
246
|
+
)
|
|
247
|
+
result_for(verdict)
|
|
248
|
+
end
|
|
249
|
+
r.with(subject: subject, mutation: mutation, id: id)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The boot config the daemon needs to boot the app once: where to boot, the test
|
|
253
|
+
# load roots (so `require "test_helper"` resolves in every fork), framework, and
|
|
254
|
+
# whether this is Rails.
|
|
255
|
+
#
|
|
256
|
+
# @param config [Mutineer::Config] the run config.
|
|
257
|
+
# @param abs_tests [Array<String>] absolute --test paths.
|
|
258
|
+
# @param coverage [Boolean] whether this daemon builds the coverage map.
|
|
259
|
+
# @return [Hash] the boot config shipped to the daemon.
|
|
260
|
+
def self.boot_config(config, abs_tests, coverage: false)
|
|
261
|
+
{
|
|
262
|
+
project_root: config.project_root,
|
|
263
|
+
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
264
|
+
load_paths: Runner.test_load_roots(abs_tests),
|
|
265
|
+
source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
266
|
+
framework: config.framework,
|
|
267
|
+
rails: config.rails,
|
|
268
|
+
# Schema for per-worker DB isolation. Sent when present; the daemon
|
|
269
|
+
# skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
|
|
270
|
+
schema: schema_path(config),
|
|
271
|
+
# Coverage narrowing. Only the short-lived map-building daemon starts
|
|
272
|
+
# Coverage (before boot); worker daemons boot with it OFF (no wasted
|
|
273
|
+
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
274
|
+
# map-build inputs.
|
|
275
|
+
coverage: coverage,
|
|
276
|
+
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
277
|
+
tests: abs_tests
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
|
|
282
|
+
# daemon to schema-load each fork's isolated worker database. Only `schema.rb`
|
|
283
|
+
# is supported this pass; `structure.sql` apps get nil and fall back to
|
|
284
|
+
# whatever the worker DB already holds.
|
|
285
|
+
#
|
|
286
|
+
# @param config [Mutineer::Config] the run config.
|
|
287
|
+
# @api private
|
|
288
|
+
# @return [String, nil] absolute schema path or nil.
|
|
289
|
+
def self.schema_path(config)
|
|
290
|
+
path = File.expand_path("db/schema.rb", config.project_root)
|
|
291
|
+
File.exist?(path) ? path : nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Map a daemon verdict string to a Result. The daemon reports the four
|
|
295
|
+
# run-time states it can decide; pre-fork states (skipped/no_coverage/…) are
|
|
296
|
+
# resolved tool-side before a request is ever sent.
|
|
297
|
+
#
|
|
298
|
+
# @param verdict [String] the daemon's verdict word.
|
|
299
|
+
# @api private
|
|
300
|
+
# @return [Mutineer::Result] the matching result.
|
|
301
|
+
def self.result_for(verdict)
|
|
302
|
+
case verdict
|
|
303
|
+
when "survived" then Result.survived
|
|
304
|
+
when "killed" then Result.killed
|
|
305
|
+
when "timeout" then Result.timeout
|
|
306
|
+
else Result.error("daemon verdict: #{verdict}")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The module's contract is {execute} (the backend entry point) plus the two the
|
|
311
|
+
# tests drive directly: {boot_config} from the zero-dep suite and
|
|
312
|
+
# {build_coverage_map} from the daemon suite. Everything else is daemon-pipeline
|
|
313
|
+
# internals with no caller outside this file.
|
|
314
|
+
private_class_method :run_serial, :run_parallel, :job_result, :schema_path, :result_for
|
|
315
|
+
end
|
|
316
|
+
end
|