mutineer 1.5.0 → 1.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +473 -0
- data/README.md +352 -26
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +184 -24
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +541 -92
- data/lib/mutineer/daemon_backend.rb +227 -86
- data/lib/mutineer/daemon_client.rb +72 -9
- data/lib/mutineer/daemon_server.rb +229 -51
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +15 -2
- data/lib/mutineer/job_plan.rb +458 -0
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +100 -0
- data/lib/mutineer/project.rb +411 -21
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1477 -48
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +83 -12
- data/lib/mutineer/result.rb +49 -8
- data/lib/mutineer/runner.rb +66 -290
- data/lib/mutineer/statement_lines.rb +54 -7
- data/lib/mutineer/subject.rb +24 -2
- data/lib/mutineer/test_runners/rspec.rb +54 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +5 -0
- metadata +11 -1
|
@@ -5,10 +5,8 @@ require_relative "result"
|
|
|
5
5
|
require_relative "coverage_map"
|
|
6
6
|
require_relative "daemon_client"
|
|
7
7
|
require_relative "progress"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# load. Runner is loaded first on every real path; requiring this file alone leaves
|
|
11
|
-
# it undefined. Rationale and the real fix: #75.
|
|
8
|
+
require_relative "job_plan"
|
|
9
|
+
require_relative "orphan_guard"
|
|
12
10
|
|
|
13
11
|
module Mutineer
|
|
14
12
|
# Daemon execution backend. Boots the app ONCE in a persistent subprocess under
|
|
@@ -19,12 +17,12 @@ module Mutineer
|
|
|
19
17
|
# When jobs > 1 each worker runs against its OWN database, which is what makes
|
|
20
18
|
# `--jobs N` safe under Rails (#26): parallel verdicts are identical to serial.
|
|
21
19
|
#
|
|
22
|
-
# Job collection, `--since` filtering and coverage selection
|
|
23
|
-
#
|
|
24
|
-
#
|
|
20
|
+
# Job collection, `--since` filtering and coverage selection live in {JobPlan},
|
|
21
|
+
# which the in-process path calls too, so the daemon path can never drift from
|
|
22
|
+
# it on which mutants run or which tests narrow a mutant (score parity).
|
|
25
23
|
#
|
|
26
24
|
# Unlike {ExternalBackend}, which is a leaf {Runner} calls into, this module owns
|
|
27
|
-
# its orchestration and
|
|
25
|
+
# its orchestration and takes that shared vocabulary from {JobPlan}.
|
|
28
26
|
module DaemonBackend
|
|
29
27
|
# Default per-mutant timeout on the daemon path (seconds), overridden by
|
|
30
28
|
# config.daemon_timeout. Coverage narrowing usually keeps each job short; this
|
|
@@ -34,8 +32,13 @@ module Mutineer
|
|
|
34
32
|
DEFAULT_TIMEOUT = 60
|
|
35
33
|
|
|
36
34
|
# The daemon's per-mutant tempfile, written into the source dir so
|
|
37
|
-
# require_relative resolves.
|
|
38
|
-
|
|
35
|
+
# require_relative resolves. The leading dot hides it from Zeitwerk (#253);
|
|
36
|
+
# older versions wrote it without one. Kept in step with DaemonServer#sweep_temps.
|
|
37
|
+
DAEMON_TEMP_GLOB = "{.,}mutineer_daemon*.rb"
|
|
38
|
+
|
|
39
|
+
# Seconds the end-of-run sweep waits for an orphaned child to die. Its
|
|
40
|
+
# watchdog notices a dead daemon within {OrphanGuard::POLL}.
|
|
41
|
+
SWEEP_GRACE = (OrphanGuard::POLL * 2) + 0.5
|
|
39
42
|
|
|
40
43
|
# Full daemon run: collect jobs, build the coverage map once, then execute
|
|
41
44
|
# serially or across N worker daemons. Fail-fast forces serial so the survivor
|
|
@@ -44,28 +47,17 @@ module Mutineer
|
|
|
44
47
|
# @param config [Mutineer::Config] run configuration (daemon set).
|
|
45
48
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
46
49
|
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
47
|
-
# source map, and the {
|
|
50
|
+
# source map, and the {JobPlan.collect_jobs} extras.
|
|
48
51
|
def self.execute(config, operator_classes)
|
|
49
|
-
jobs, ignored_results, source_map, extras =
|
|
50
|
-
jobs =
|
|
52
|
+
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
53
|
+
jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
|
|
51
54
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
52
55
|
|
|
53
56
|
# Nothing to mutate (`--since` matched no changed line, or every mutant is
|
|
54
57
|
# suppressed). Return before booting anything: the coverage daemon and the
|
|
55
58
|
# worker daemons below each boot the whole app, and README documents
|
|
56
59
|
# `--since origin/<base>` for PR CI, where a docs-only PR is routine.
|
|
57
|
-
if jobs.empty?
|
|
58
|
-
# The daemon sweeps orphaned temps at boot and nothing boots here, so sweep
|
|
59
|
-
# tool-side. A file a hard-killed run left in app/models breaks the app's own
|
|
60
|
-
# Zeitwerk boot, not just Mutineer's next run.
|
|
61
|
-
Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB)
|
|
62
|
-
return [AggregateResult.new(ignored_results), source_map, extras]
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Build the coverage map once (app-side). nil when the build fails: runners
|
|
66
|
-
# fall back to the full --test set (and emit a stderr warning) rather than
|
|
67
|
-
# mis-scoring everything as no_coverage.
|
|
68
|
-
coverage_map = build_coverage_map(config, abs_tests)
|
|
60
|
+
return [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
|
|
69
61
|
|
|
70
62
|
# Worker count = resolved --jobs, capped at the job count (no idle daemons).
|
|
71
63
|
# >1 → N concurrent daemon handles, each on its OWN worker DB (N-handles, the
|
|
@@ -74,25 +66,70 @@ module Mutineer
|
|
|
74
66
|
# input index, so the verdict set would diverge from serial (a different,
|
|
75
67
|
# non-deterministic survivor set/score). The "identical to --jobs 1" guarantee
|
|
76
68
|
# below only holds when fail-fast cannot race.
|
|
69
|
+
# A second database config is only known after the app boots, so the first
|
|
70
|
+
# daemon can still lower this count before any worker starts.
|
|
77
71
|
worker_count = [config.jobs || 1, 1].max
|
|
78
72
|
worker_count = 1 if config.fail_fast
|
|
79
73
|
worker_count = [worker_count, jobs.size].min
|
|
80
74
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
# One booted process copies worker databases, then builds the coverage map.
|
|
76
|
+
# A cached map still provisions: the copy has to happen while no worker is
|
|
77
|
+
# connected to the template database. That process keeps its database lock
|
|
78
|
+
# until the worker daemons have taken theirs.
|
|
79
|
+
prepare_client = nil
|
|
80
|
+
begin
|
|
81
|
+
coverage_map, worker_count, prepare_client = prepare_run(config, abs_tests, worker_count)
|
|
82
|
+
|
|
83
|
+
results =
|
|
84
|
+
if worker_count > 1
|
|
85
|
+
run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map, prepare_client)
|
|
86
|
+
else
|
|
87
|
+
run_serial(jobs, config, abs_tests, coverage_map, source_map, prepare_client)
|
|
88
|
+
end
|
|
89
|
+
ensure
|
|
90
|
+
# run_serial and run_parallel quit this client once workers hold locks.
|
|
91
|
+
# Quit again here when they never reached that point. A second quit is a no-op.
|
|
92
|
+
prepare_client&.quit
|
|
93
|
+
end
|
|
87
94
|
|
|
88
95
|
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
96
|
+
ensure
|
|
97
|
+
sweep_daemon_temps(config)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Removes daemon mutant files left in the source dirs, on every exit path.
|
|
101
|
+
# The daemon sweeps at boot, but an empty run boots nothing, and a daemon
|
|
102
|
+
# that dies mid-mutant orphans a child that lives on until its watchdog
|
|
103
|
+
# fires (#250). The respawned daemon sweeps before that, and a run that
|
|
104
|
+
# ends or aborts right after the crash does too. So a file whose owner is
|
|
105
|
+
# still alive gets {SWEEP_GRACE} to go. A clean run leaves no file and pays
|
|
106
|
+
# nothing. A file a concurrent run still owns stays in place, but looks the
|
|
107
|
+
# same as a dying child's, so this run then waits the full grace.
|
|
108
|
+
#
|
|
109
|
+
# @param config [Mutineer::Config] the run config.
|
|
110
|
+
# @return [void]
|
|
111
|
+
def self.sweep_daemon_temps(config)
|
|
112
|
+
dirs = JobPlan.source_dirs(config)
|
|
113
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + SWEEP_GRACE
|
|
114
|
+
loop do
|
|
115
|
+
still_owned = JobPlan.sweep_orphans(dirs, DAEMON_TEMP_GLOB)
|
|
116
|
+
break if still_owned.empty?
|
|
117
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
118
|
+
|
|
119
|
+
sleep 0.1
|
|
120
|
+
end
|
|
121
|
+
rescue SystemCallError
|
|
122
|
+
nil # never replace the run's own result or exception with a cleanup error
|
|
89
123
|
end
|
|
90
124
|
|
|
91
125
|
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
|
92
126
|
# per-test coverage app-side, ships the map back). Returns a query-only
|
|
93
127
|
# CoverageMap, or nil when the build fails / returns empty. Callers then run the
|
|
94
|
-
# full --test set.
|
|
95
|
-
#
|
|
128
|
+
# full --test set. An empty map that came with boot lines returns a map with
|
|
129
|
+
# those lines only: it narrows nothing, but still classifies `ran_at_load`.
|
|
130
|
+
# Each capture is bounded by capture_timeout (#101); the client's wait for
|
|
131
|
+
# the whole map is not, because its length grows with the number of test
|
|
132
|
+
# files. A normal nonempty map scores like in-process;
|
|
96
133
|
# nil falls back to the full suite (more testing, not comparable).
|
|
97
134
|
#
|
|
98
135
|
# @param config [Mutineer::Config] the run config.
|
|
@@ -101,15 +138,68 @@ module Mutineer
|
|
|
101
138
|
def self.build_coverage_map(config, abs_tests)
|
|
102
139
|
client = DaemonClient.new(boot: boot_config(config, abs_tests, coverage: true),
|
|
103
140
|
app_root: config.project_root).start
|
|
104
|
-
|
|
105
|
-
client
|
|
141
|
+
begin
|
|
142
|
+
coverage_map_from_client(client, config)
|
|
106
143
|
ensure
|
|
107
144
|
client.quit
|
|
108
145
|
end
|
|
146
|
+
rescue DaemonBootTimeout
|
|
147
|
+
raise
|
|
148
|
+
rescue DaemonBootError => e
|
|
149
|
+
warn_coverage_fallback("#{e.class}: #{e.message}")
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Boot the first daemon, copy worker databases, then read the coverage map
|
|
154
|
+
# from that same process. A boot failure still falls back to the full test
|
|
155
|
+
# set. A provisioning failure does not: it stops the run.
|
|
156
|
+
#
|
|
157
|
+
# @param config [Mutineer::Config]
|
|
158
|
+
# @param abs_tests [Array<String>]
|
|
159
|
+
# @param worker_count [Integer] slots requested before the database count is known.
|
|
160
|
+
# @return [Array] the coverage map (or nil), the worker count to start, and
|
|
161
|
+
# the prepare daemon. The caller quits that daemon after worker daemons
|
|
162
|
+
# hold their locks. It is nil when this process could not boot one.
|
|
163
|
+
def self.prepare_run(config, abs_tests, worker_count)
|
|
164
|
+
client = nil
|
|
165
|
+
begin
|
|
166
|
+
client = DaemonClient.new(boot: boot_config(config, abs_tests, coverage: true),
|
|
167
|
+
app_root: config.project_root).start
|
|
168
|
+
rescue DaemonBootTimeout
|
|
169
|
+
raise
|
|
170
|
+
rescue DaemonBootError => e
|
|
171
|
+
warn_coverage_fallback("#{e.class}: #{e.message}")
|
|
172
|
+
return [nil, worker_count, nil]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
begin
|
|
176
|
+
count = client.database_count.to_i
|
|
177
|
+
if count > 1 && worker_count > 1
|
|
178
|
+
warn "[mutineer] this app has #{count} databases; Mutineer runs one worker. " \
|
|
179
|
+
"Parallel runs support one database."
|
|
180
|
+
worker_count = 1
|
|
181
|
+
end
|
|
182
|
+
# Raises DaemonBootError. Callers must not score mutants after this.
|
|
183
|
+
client.provision(worker_count)
|
|
184
|
+
[coverage_map_from_client(client, config), worker_count, client]
|
|
185
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
186
|
+
# An interrupt must also quit it, or the daemon keeps its database lock.
|
|
187
|
+
client.quit
|
|
188
|
+
raise
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Turn one daemon's coverage reply into a map. Does not start or stop the daemon.
|
|
193
|
+
#
|
|
194
|
+
# @param client [Mutineer::DaemonClient]
|
|
195
|
+
# @param config [Mutineer::Config]
|
|
196
|
+
# @return [Mutineer::CoverageMap, nil]
|
|
197
|
+
def self.coverage_map_from_client(client, config)
|
|
198
|
+
data = client.coverage
|
|
109
199
|
# A red unmutated suite must abort, even when the shipped map is empty.
|
|
110
200
|
# Falling back to the full --test set would treat those failures as kills.
|
|
111
201
|
if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any?
|
|
112
|
-
|
|
202
|
+
JobPlan.abort_if_unclean!(CoverageMap.from_data(
|
|
113
203
|
map: data["map"] || {},
|
|
114
204
|
failed_test_files: data["failed_test_files"] || [],
|
|
115
205
|
project_root: config.project_root,
|
|
@@ -120,11 +210,21 @@ module Mutineer
|
|
|
120
210
|
unless data && !(data["map"] || {}).empty?
|
|
121
211
|
reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
|
|
122
212
|
warn_coverage_fallback(reason)
|
|
123
|
-
|
|
213
|
+
# No narrowing ({job_result} runs every test), but the lines and
|
|
214
|
+
# methods that ran at boot still classify a survivor on one of them.
|
|
215
|
+
load_lines = data.is_a?(Hash) ? Array(data["load_lines"]) : []
|
|
216
|
+
load_methods = data.is_a?(Hash) ? Array(data["load_methods"]) : []
|
|
217
|
+
return nil if load_lines.empty? && load_methods.empty?
|
|
218
|
+
|
|
219
|
+
return CoverageMap.from_data(map: {}, failed_test_files: [], project_root: config.project_root,
|
|
220
|
+
load_lines: load_lines, load_methods: load_methods)
|
|
124
221
|
end
|
|
125
222
|
|
|
126
223
|
CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
|
|
127
|
-
project_root: config.project_root
|
|
224
|
+
project_root: config.project_root, load_lines: data["load_lines"] || [],
|
|
225
|
+
load_methods: data["load_methods"] || [])
|
|
226
|
+
rescue DaemonBootTimeout
|
|
227
|
+
raise # a second daemon for the mutant runs would hang just as long
|
|
128
228
|
rescue DaemonBootError => e
|
|
129
229
|
warn_coverage_fallback("#{e.class}: #{e.message}")
|
|
130
230
|
nil
|
|
@@ -145,59 +245,69 @@ module Mutineer
|
|
|
145
245
|
# (stop at the first survivor).
|
|
146
246
|
#
|
|
147
247
|
# @api private
|
|
248
|
+
# @param prepare_client [Mutineer::DaemonClient, nil] the daemon that copied
|
|
249
|
+
# the databases. Quit once this worker has booted and taken its lock.
|
|
148
250
|
# @return [Array<Mutineer::Result>] results in input order.
|
|
149
|
-
def self.run_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
150
|
-
client =
|
|
151
|
-
app_root: config.project_root).start
|
|
152
|
-
results = []
|
|
153
|
-
progress = Progress.new(jobs.size)
|
|
251
|
+
def self.run_serial(jobs, config, abs_tests, coverage_map, source_map, prepare_client = nil)
|
|
252
|
+
client = nil
|
|
154
253
|
begin
|
|
254
|
+
client = DaemonClient.new(boot: boot_config(config, abs_tests),
|
|
255
|
+
app_root: config.project_root).start
|
|
256
|
+
prepare_client&.quit
|
|
257
|
+
prepare_client = nil
|
|
258
|
+
results = []
|
|
259
|
+
progress = Progress.new(jobs.size)
|
|
155
260
|
jobs.each_with_index do |job, i|
|
|
156
261
|
r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
157
262
|
results << r
|
|
158
263
|
progress.tick
|
|
159
264
|
break if config.fail_fast && r.survived?
|
|
160
265
|
end
|
|
266
|
+
results
|
|
161
267
|
ensure
|
|
162
|
-
|
|
268
|
+
prepare_client&.quit
|
|
269
|
+
client&.quit
|
|
163
270
|
end
|
|
164
|
-
results
|
|
165
271
|
end
|
|
166
272
|
|
|
167
273
|
# Parallel path: N daemon handles, each pinned to its own worker slot (own DB).
|
|
168
274
|
# A shared queue of job indices feeds N tool-side threads; results are placed by
|
|
169
275
|
# input index so the verdict set matches serial. Callers must not pass fail_fast
|
|
170
276
|
# here ({execute} forces serial for fail-fast). Per-mutant crashes are classified
|
|
171
|
-
# in {job_result}, shared with the serial path
|
|
172
|
-
#
|
|
277
|
+
# in {job_result}, shared with the serial path. Any error that escapes a worker
|
|
278
|
+
# (a {DaemonBootError}, or a defect) clears the queue, and the run ends once
|
|
279
|
+
# every worker has joined, rather than scoring the remainder first.
|
|
173
280
|
#
|
|
174
281
|
# @api private
|
|
282
|
+
# @param prepare_client [Mutineer::DaemonClient, nil] the daemon that copied
|
|
283
|
+
# the databases. Quit once every worker has booted and taken its lock.
|
|
175
284
|
# @return [Array<Mutineer::Result>] one result per input job, in input order.
|
|
176
|
-
def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
285
|
+
def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map, prepare_client = nil)
|
|
177
286
|
results = Array.new(jobs.size)
|
|
178
287
|
progress = Progress.new(jobs.size)
|
|
179
288
|
queue = Queue.new
|
|
289
|
+
errors = Queue.new
|
|
180
290
|
jobs.each_index { |i| queue << i }
|
|
181
291
|
|
|
182
|
-
#
|
|
183
|
-
#
|
|
292
|
+
# Both lists grow one entry at a time, so a refused spawn part-way (EMFILE
|
|
293
|
+
# under a high --jobs) or a Ctrl-C leaves the ensure below every daemon
|
|
294
|
+
# already up. Each client joins its list before it starts, so one
|
|
295
|
+
# interrupted mid-boot is quit too. The prepare daemon stays up through
|
|
296
|
+
# this loop so its lock still covers the databases until each worker
|
|
297
|
+
# holds one.
|
|
184
298
|
clients = []
|
|
299
|
+
threads = []
|
|
185
300
|
begin
|
|
186
301
|
worker_count.times do
|
|
187
|
-
clients << DaemonClient.new(boot: boot_config(config, abs_tests),
|
|
188
|
-
|
|
302
|
+
clients << DaemonClient.new(boot: boot_config(config, abs_tests), app_root: config.project_root)
|
|
303
|
+
clients.last.start
|
|
189
304
|
end
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
raise
|
|
305
|
+
ensure
|
|
306
|
+
prepare_client&.quit
|
|
193
307
|
end
|
|
194
308
|
|
|
195
|
-
clients.each_with_index
|
|
196
|
-
Thread.new do
|
|
197
|
-
# The abort below is re-raised by join and reported once there; without
|
|
198
|
-
# this Ruby also dumps the thread's backtrace, which the serial path never
|
|
199
|
-
# does. Same fault, same output, whatever --jobs is set to.
|
|
200
|
-
Thread.current.report_on_exception = false
|
|
309
|
+
clients.each_with_index do |client, worker|
|
|
310
|
+
threads << Thread.new do
|
|
201
311
|
loop do
|
|
202
312
|
i = begin
|
|
203
313
|
queue.pop(true)
|
|
@@ -207,20 +317,35 @@ module Mutineer
|
|
|
207
317
|
results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
208
318
|
progress.tick
|
|
209
319
|
end
|
|
210
|
-
rescue
|
|
211
|
-
# The daemon gave up for good. Stop feeding the
|
|
212
|
-
#
|
|
213
|
-
#
|
|
320
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
321
|
+
# The daemon gave up for good, or a defect escaped. Stop feeding the
|
|
322
|
+
# other workers, so they stop after their current mutant, and hand the
|
|
323
|
+
# error to the main thread. The thread itself ends cleanly.
|
|
214
324
|
queue.clear
|
|
215
|
-
|
|
325
|
+
errors << e
|
|
216
326
|
ensure
|
|
217
327
|
client.quit
|
|
218
328
|
end
|
|
219
|
-
end
|
|
329
|
+
end
|
|
330
|
+
# Join every worker before raising, so all daemons have quit and the
|
|
331
|
+
# end-of-run sweep sees each child gone.
|
|
332
|
+
threads.each(&:join)
|
|
333
|
+
raise errors.pop unless errors.empty?
|
|
220
334
|
|
|
221
335
|
# Every job was popped by some worker and every pop assigns, so no slot can
|
|
222
|
-
# be nil here:
|
|
336
|
+
# be nil here: a worker error is raised above instead.
|
|
223
337
|
results
|
|
338
|
+
ensure
|
|
339
|
+
# An exception from outside (Ctrl-C, a caller's Timeout) skips the joins
|
|
340
|
+
# above. Kill every worker first, so their daemons quit at the same time,
|
|
341
|
+
# then wait for them. A second Ctrl-C during that wait still exits.
|
|
342
|
+
queue&.clear
|
|
343
|
+
threads&.each(&:kill)
|
|
344
|
+
threads&.each(&:join)
|
|
345
|
+
# Quit every client too. One may have no thread yet (startup failed or
|
|
346
|
+
# was interrupted), and a thread killed before its block began never runs
|
|
347
|
+
# its own ensure. A second quit of a client is a no-op.
|
|
348
|
+
clients&.each(&:quit)
|
|
224
349
|
end
|
|
225
350
|
|
|
226
351
|
# Build the payload for one job, run it on the given daemon/worker, and attach
|
|
@@ -246,51 +371,66 @@ module Mutineer
|
|
|
246
371
|
# Skip an invalid mutant tool-side: never ship a payload that would fail to
|
|
247
372
|
# load and read as a false `killed`.
|
|
248
373
|
# Narrow to covering tests (shared with the in-process path via
|
|
249
|
-
#
|
|
250
|
-
# no fork. No map (build failed) → run the full --test set
|
|
251
|
-
# narrowed).
|
|
252
|
-
sel = coverage_map &&
|
|
374
|
+
# JobPlan.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
|
|
375
|
+
# no fork. No map, or an empty one (build failed) → run the full --test set
|
|
376
|
+
# (fallback, not narrowed).
|
|
377
|
+
sel = coverage_map && !coverage_map.map.empty? && JobPlan.coverage_selection(subject.file, mutation, subject, source, coverage_map)
|
|
253
378
|
r =
|
|
254
379
|
if Parser.parse_string(mutated).errors.any?
|
|
255
380
|
Result.skipped
|
|
256
381
|
elsif sel && sel[0] == :verdict
|
|
257
382
|
sel[1]
|
|
383
|
+
elsif subject.unplaceable?("reload") # the daemon always reloads the whole file
|
|
384
|
+
# A :maybe mutant that cannot be placed stays no_coverage, as in-process.
|
|
385
|
+
sel && sel[0] == :maybe ? Result.no_coverage : Result.unplaceable
|
|
258
386
|
else
|
|
259
387
|
verdict = client.request(
|
|
260
388
|
id: req_id, worker: worker, timeout: config.daemon_timeout || DEFAULT_TIMEOUT,
|
|
261
389
|
payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
|
|
262
390
|
tests: sel ? sel[1] : abs_tests
|
|
263
391
|
)
|
|
264
|
-
|
|
392
|
+
# A survivor whose line ran at load, as in-process (Runner.run).
|
|
393
|
+
loaded = JobPlan.load_verdict(result_for(verdict), subject.file, mutation, subject, source, coverage_map)
|
|
394
|
+
# Tests that may not have run the mutant, as in-process (#254).
|
|
395
|
+
JobPlan.selection_verdict(sel && sel[0], loaded)
|
|
265
396
|
end
|
|
266
397
|
r.with(subject: subject, mutation: mutation, id: id)
|
|
267
398
|
end
|
|
268
399
|
|
|
269
|
-
# The boot config the daemon needs to boot the app once: where to boot, the
|
|
270
|
-
#
|
|
271
|
-
# whether this
|
|
400
|
+
# The boot config the daemon needs to boot the app once: where to boot, the
|
|
401
|
+
# --require files to load after it, the test load roots (so
|
|
402
|
+
# `require "test_helper"` resolves in every fork), framework, and whether this
|
|
403
|
+
# is Rails.
|
|
272
404
|
#
|
|
273
405
|
# @param config [Mutineer::Config] the run config.
|
|
274
406
|
# @param abs_tests [Array<String>] absolute --test paths.
|
|
275
407
|
# @param coverage [Boolean] whether this daemon builds the coverage map.
|
|
408
|
+
# @param db_role [String, nil] "prepare" (copy databases on command),
|
|
409
|
+
# "worker" (lock only), or nil to pick from `coverage`.
|
|
276
410
|
# @return [Hash] the boot config shipped to the daemon.
|
|
277
|
-
def self.boot_config(config, abs_tests, coverage: false)
|
|
411
|
+
def self.boot_config(config, abs_tests, coverage: false, db_role: nil)
|
|
278
412
|
{
|
|
279
413
|
project_root: config.project_root,
|
|
280
414
|
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
281
|
-
|
|
415
|
+
# Required after the boot, as in-process (Runner.execute); also part of
|
|
416
|
+
# the coverage digest.
|
|
417
|
+
require_paths: config.require_paths.map { |f| File.expand_path(f, config.project_root) },
|
|
418
|
+
load_paths: JobPlan.test_load_roots(abs_tests),
|
|
282
419
|
cache_dir: File.expand_path(config.cache_dir, config.project_root),
|
|
283
|
-
source_dirs:
|
|
420
|
+
source_dirs: JobPlan.source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
284
421
|
framework: config.framework,
|
|
285
422
|
rails: config.rails,
|
|
286
|
-
# Schema for per-worker DB isolation. Sent when present; the daemon
|
|
287
|
-
#
|
|
423
|
+
# Schema for per-worker DB isolation. Sent when present; the daemon loads
|
|
424
|
+
# it over a worker's copy of the test DB only when that copy is out of date.
|
|
288
425
|
schema: schema_path(config),
|
|
289
426
|
# Coverage narrowing. Only the short-lived map-building daemon starts
|
|
290
427
|
# Coverage (before boot); worker daemons boot with it OFF (no wasted
|
|
291
428
|
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
292
429
|
# map-build inputs.
|
|
293
430
|
coverage: coverage,
|
|
431
|
+
# "prepare" copies worker databases when asked. "worker" only locks.
|
|
432
|
+
# A direct client that omits this provisions at boot instead.
|
|
433
|
+
db_role: db_role || (coverage ? "prepare" : "worker"),
|
|
294
434
|
# The map-building daemon's capture limit (--capture-timeout); nil = default.
|
|
295
435
|
capture_timeout: config.capture_timeout,
|
|
296
436
|
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
@@ -298,10 +438,10 @@ module Mutineer
|
|
|
298
438
|
}
|
|
299
439
|
end
|
|
300
440
|
|
|
301
|
-
# Absolute path to the app's `db/schema.rb` if it exists, else nil.
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
#
|
|
441
|
+
# Absolute path to the app's `db/schema.rb` if it exists, else nil. Each worker
|
|
442
|
+
# DB starts as a copy of the test DB; the daemon loads this file over the copy
|
|
443
|
+
# when the copy's schema differs. `structure.sql` apps get nil and keep the
|
|
444
|
+
# copy as it is.
|
|
305
445
|
#
|
|
306
446
|
# @param config [Mutineer::Config] the run config.
|
|
307
447
|
# @api private
|
|
@@ -331,6 +471,7 @@ module Mutineer
|
|
|
331
471
|
# tests drive directly: {boot_config} from the zero-dep suite and
|
|
332
472
|
# {build_coverage_map} from the daemon suite. Everything else is daemon-pipeline
|
|
333
473
|
# internals with no caller outside this file.
|
|
334
|
-
private_class_method :run_serial, :run_parallel, :job_result, :schema_path, :result_for
|
|
474
|
+
private_class_method :sweep_daemon_temps, :run_serial, :run_parallel, :job_result, :schema_path, :result_for,
|
|
475
|
+
:prepare_run, :coverage_map_from_client
|
|
335
476
|
end
|
|
336
477
|
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "io/wait"
|
|
4
5
|
require "open3"
|
|
5
6
|
require_relative "external_backend"
|
|
7
|
+
require_relative "rails_run_env"
|
|
6
8
|
|
|
7
9
|
module Mutineer
|
|
8
10
|
# Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
|
|
@@ -12,6 +14,11 @@ module Mutineer
|
|
|
12
14
|
# to a runtime error (exit 1).
|
|
13
15
|
class DaemonBootError < StandardError; end
|
|
14
16
|
|
|
17
|
+
# A boot that has not answered the handshake within DaemonClient::BOOT_TIMEOUT
|
|
18
|
+
# (#101). Unlike other boot errors it is not worth retrying with a second
|
|
19
|
+
# daemon, which would wait just as long.
|
|
20
|
+
class DaemonBootTimeout < DaemonBootError; end
|
|
21
|
+
|
|
15
22
|
# Tool-side handle for the app-side daemon.
|
|
16
23
|
#
|
|
17
24
|
# Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
|
|
@@ -29,6 +36,13 @@ module Mutineer
|
|
|
29
36
|
DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
|
|
30
37
|
# How many times to respawn a crashing daemon before aborting the run.
|
|
31
38
|
MAX_RESTARTS = 3
|
|
39
|
+
# Seconds a verdict may lag the request's own timeout. The daemon enforces
|
|
40
|
+
# that timeout on the mutant child, so a reply later than this means the
|
|
41
|
+
# daemon itself is wedged (#101): it is killed and respawned.
|
|
42
|
+
REPLY_GRACE = 30
|
|
43
|
+
# Seconds the daemon gets to boot the app and answer the handshake. A boot
|
|
44
|
+
# that never returns ends the run instead of hanging it (#101).
|
|
45
|
+
BOOT_TIMEOUT = 600
|
|
32
46
|
# Bundler's marker for a variable that was unset before it activated.
|
|
33
47
|
BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
|
|
34
48
|
|
|
@@ -58,6 +72,32 @@ module Mutineer
|
|
|
58
72
|
self
|
|
59
73
|
end
|
|
60
74
|
|
|
75
|
+
# How many database configs the booted test environment reported.
|
|
76
|
+
# 1 when the daemon did not send a count.
|
|
77
|
+
#
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
def database_count
|
|
80
|
+
@database_count || 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Ask the daemon to create `slots` worker databases. Raises when the copy
|
|
84
|
+
# fails or another run holds the database, so the caller stops before any
|
|
85
|
+
# mutant is scored.
|
|
86
|
+
#
|
|
87
|
+
# @param slots [Integer] worker slots to create.
|
|
88
|
+
# @return [void]
|
|
89
|
+
# @raise [Mutineer::DaemonBootError]
|
|
90
|
+
def provision(slots)
|
|
91
|
+
raise DaemonBootError, "daemon is not running" if @stdin.nil?
|
|
92
|
+
|
|
93
|
+
send_line("cmd" => "provision", "slots" => slots)
|
|
94
|
+
reply = read_line(BOOT_TIMEOUT)
|
|
95
|
+
return if reply && reply["ok"]
|
|
96
|
+
|
|
97
|
+
detail = reply && reply["error"] ? reply["error"] : "provision failed"
|
|
98
|
+
raise DaemonBootError, detail
|
|
99
|
+
end
|
|
100
|
+
|
|
61
101
|
# Run one mutant: ship the payload + covering tests, return the verdict string.
|
|
62
102
|
# On a daemon crash (EOF/dead pipe) respawn (bounded) and return `"error"` for
|
|
63
103
|
# this mutant. Never a wrong verdict, never a wedged run.
|
|
@@ -83,7 +123,7 @@ module Mutineer
|
|
|
83
123
|
reply =
|
|
84
124
|
begin
|
|
85
125
|
send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
|
|
86
|
-
read_line
|
|
126
|
+
read_line(timeout + REPLY_GRACE)
|
|
87
127
|
rescue Errno::EPIPE, IOError
|
|
88
128
|
nil
|
|
89
129
|
end
|
|
@@ -117,7 +157,7 @@ module Mutineer
|
|
|
117
157
|
return unless @stdin
|
|
118
158
|
|
|
119
159
|
send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
|
|
120
|
-
@wait_thr&.join
|
|
160
|
+
@wait_thr&.join(REPLY_GRACE) # a wedged daemon is killed by close_io
|
|
121
161
|
ensure
|
|
122
162
|
close_io
|
|
123
163
|
end
|
|
@@ -137,6 +177,8 @@ module Mutineer
|
|
|
137
177
|
env["BUNDLE_GEMFILE"] = @gemfile
|
|
138
178
|
env["RBENV_VERSION"] = @ruby_version if @ruby_version
|
|
139
179
|
env["RAILS_ENV"] = "test" if rails_boot? && !env.key?("RAILS_ENV")
|
|
180
|
+
# The daemon loads tests after boot. Rails reads this when parallelize runs.
|
|
181
|
+
RailsRunEnv.pin_child!(env)
|
|
140
182
|
env
|
|
141
183
|
end
|
|
142
184
|
|
|
@@ -315,17 +357,23 @@ module Mutineer
|
|
|
315
357
|
end
|
|
316
358
|
|
|
317
359
|
send_line(@boot)
|
|
318
|
-
read_line
|
|
360
|
+
read_line(BOOT_TIMEOUT)
|
|
319
361
|
rescue SystemCallError, IOError => e
|
|
320
362
|
close_io
|
|
321
363
|
raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
|
|
322
364
|
end
|
|
323
365
|
|
|
324
366
|
unless ready && ready["ready"]
|
|
325
|
-
detail =
|
|
367
|
+
detail =
|
|
368
|
+
if ready && ready["error"] then ready["error"]
|
|
369
|
+
elsif @timed_out then "the daemon did not finish booting within #{BOOT_TIMEOUT}s"
|
|
370
|
+
else "daemon exited before the handshake"
|
|
371
|
+
end
|
|
372
|
+
timed_out = @timed_out
|
|
326
373
|
close_io
|
|
327
|
-
raise DaemonBootError, "daemon failed to boot under the app bundle: #{detail}"
|
|
374
|
+
raise (timed_out ? DaemonBootTimeout : DaemonBootError), "daemon failed to boot under the app bundle: #{detail}"
|
|
328
375
|
end
|
|
376
|
+
@database_count = ready["database_count"] || 1
|
|
329
377
|
end
|
|
330
378
|
|
|
331
379
|
# Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.
|
|
@@ -333,10 +381,11 @@ module Mutineer
|
|
|
333
381
|
close_io
|
|
334
382
|
@restarts += 1
|
|
335
383
|
if @restarts > MAX_RESTARTS
|
|
336
|
-
raise DaemonBootError, "daemon crashed #{@restarts} times; aborting the run"
|
|
384
|
+
raise DaemonBootError, "daemon crashed or stopped answering #{@restarts} times; aborting the run"
|
|
337
385
|
end
|
|
338
386
|
|
|
339
|
-
@
|
|
387
|
+
cause = @timed_out ? "stopped answering" : "crashed"
|
|
388
|
+
@errio.puts("[mutineer] daemon #{cause} — respawning (#{@restarts}/#{MAX_RESTARTS})")
|
|
340
389
|
spawn_daemon
|
|
341
390
|
end
|
|
342
391
|
|
|
@@ -349,8 +398,15 @@ module Mutineer
|
|
|
349
398
|
@stdin.flush
|
|
350
399
|
end
|
|
351
400
|
|
|
352
|
-
# Read one JSON reply line; nil on EOF/dead pipe
|
|
353
|
-
|
|
401
|
+
# Read one JSON reply line; nil on EOF/dead pipe, or when no line arrives
|
|
402
|
+
# within `timeout` seconds (caller treats either as a crash).
|
|
403
|
+
#
|
|
404
|
+
# @param timeout [Numeric, nil] seconds to wait; nil waits for the reply.
|
|
405
|
+
# @return [Hash, nil]
|
|
406
|
+
def read_line(timeout = nil)
|
|
407
|
+
@timed_out = timeout && !@stdout.wait_readable(timeout)
|
|
408
|
+
return nil if @timed_out
|
|
409
|
+
|
|
354
410
|
line = @stdout.gets
|
|
355
411
|
line && JSON.parse(line.strip)
|
|
356
412
|
rescue IOError, Errno::EPIPE, JSON::ParserError
|
|
@@ -362,6 +418,13 @@ module Mutineer
|
|
|
362
418
|
#
|
|
363
419
|
# @return [void]
|
|
364
420
|
def close_io
|
|
421
|
+
# A wedged daemon may never read the closed stdin, so the reap below would
|
|
422
|
+
# wait forever: kill it first. An exited daemon makes this a no-op.
|
|
423
|
+
begin
|
|
424
|
+
Process.kill(:KILL, @wait_thr.pid) if @wait_thr&.alive?
|
|
425
|
+
rescue Errno::ESRCH
|
|
426
|
+
nil # it exited between the check and the kill
|
|
427
|
+
end
|
|
365
428
|
@drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
|
|
366
429
|
[@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
|
|
367
430
|
@wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
|