mutineer 1.6.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 +248 -0
- data/README.md +324 -23
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +182 -23
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +175 -12
- data/lib/mutineer/daemon_backend.rb +182 -56
- data/lib/mutineer/daemon_client.rb +30 -0
- data/lib/mutineer/daemon_server.rb +157 -25
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +14 -2
- data/lib/mutineer/job_plan.rb +129 -23
- data/lib/mutineer/migrate.rb +705 -0
- 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 +72 -0
- data/lib/mutineer/project.rb +80 -6
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1408 -53
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +66 -12
- data/lib/mutineer/result.rb +22 -13
- data/lib/mutineer/runner.rb +24 -12
- data/lib/mutineer/statement_lines.rb +15 -8
- data/lib/mutineer/subject.rb +22 -4
- data/lib/mutineer/test_runners/rspec.rb +15 -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 +4 -0
- metadata +9 -1
|
@@ -6,6 +6,7 @@ require_relative "coverage_map"
|
|
|
6
6
|
require_relative "daemon_client"
|
|
7
7
|
require_relative "progress"
|
|
8
8
|
require_relative "job_plan"
|
|
9
|
+
require_relative "orphan_guard"
|
|
9
10
|
|
|
10
11
|
module Mutineer
|
|
11
12
|
# Daemon execution backend. Boots the app ONCE in a persistent subprocess under
|
|
@@ -31,8 +32,13 @@ module Mutineer
|
|
|
31
32
|
DEFAULT_TIMEOUT = 60
|
|
32
33
|
|
|
33
34
|
# The daemon's per-mutant tempfile, written into the source dir so
|
|
34
|
-
# require_relative resolves.
|
|
35
|
-
|
|
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
|
|
36
42
|
|
|
37
43
|
# Full daemon run: collect jobs, build the coverage map once, then execute
|
|
38
44
|
# serially or across N worker daemons. Fail-fast forces serial so the survivor
|
|
@@ -44,25 +50,14 @@ module Mutineer
|
|
|
44
50
|
# source map, and the {JobPlan.collect_jobs} extras.
|
|
45
51
|
def self.execute(config, operator_classes)
|
|
46
52
|
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
47
|
-
jobs = JobPlan.
|
|
53
|
+
jobs, ignored_results, extras[:unscoped_mutants] = JobPlan.scope_since(jobs, ignored_results, source_map, config)
|
|
48
54
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
49
55
|
|
|
50
56
|
# Nothing to mutate (`--since` matched no changed line, or every mutant is
|
|
51
57
|
# suppressed). Return before booting anything: the coverage daemon and the
|
|
52
58
|
# worker daemons below each boot the whole app, and README documents
|
|
53
59
|
# `--since origin/<base>` for PR CI, where a docs-only PR is routine.
|
|
54
|
-
if jobs.empty?
|
|
55
|
-
# The daemon sweeps orphaned temps at boot and nothing boots here, so sweep
|
|
56
|
-
# tool-side. A file a hard-killed run left in app/models breaks the app's own
|
|
57
|
-
# Zeitwerk boot, not just Mutineer's next run.
|
|
58
|
-
JobPlan.sweep_orphans(JobPlan.source_dirs(config), DAEMON_TEMP_GLOB)
|
|
59
|
-
return [AggregateResult.new(ignored_results), source_map, extras]
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Build the coverage map once (app-side). nil when the build fails: runners
|
|
63
|
-
# fall back to the full --test set (and emit a stderr warning) rather than
|
|
64
|
-
# mis-scoring everything as no_coverage.
|
|
65
|
-
coverage_map = build_coverage_map(config, abs_tests)
|
|
60
|
+
return [AggregateResult.new(ignored_results), source_map, extras] if jobs.empty?
|
|
66
61
|
|
|
67
62
|
# Worker count = resolved --jobs, capped at the job count (no idle daemons).
|
|
68
63
|
# >1 → N concurrent daemon handles, each on its OWN worker DB (N-handles, the
|
|
@@ -71,18 +66,60 @@ module Mutineer
|
|
|
71
66
|
# input index, so the verdict set would diverge from serial (a different,
|
|
72
67
|
# non-deterministic survivor set/score). The "identical to --jobs 1" guarantee
|
|
73
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.
|
|
74
71
|
worker_count = [config.jobs || 1, 1].max
|
|
75
72
|
worker_count = 1 if config.fail_fast
|
|
76
73
|
worker_count = [worker_count, jobs.size].min
|
|
77
74
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
84
94
|
|
|
85
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
|
|
86
123
|
end
|
|
87
124
|
|
|
88
125
|
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
|
@@ -101,11 +138,64 @@ 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?
|
|
@@ -155,59 +245,69 @@ module Mutineer
|
|
|
155
245
|
# (stop at the first survivor).
|
|
156
246
|
#
|
|
157
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.
|
|
158
250
|
# @return [Array<Mutineer::Result>] results in input order.
|
|
159
|
-
def self.run_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
160
|
-
client =
|
|
161
|
-
app_root: config.project_root).start
|
|
162
|
-
results = []
|
|
163
|
-
progress = Progress.new(jobs.size)
|
|
251
|
+
def self.run_serial(jobs, config, abs_tests, coverage_map, source_map, prepare_client = nil)
|
|
252
|
+
client = nil
|
|
164
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)
|
|
165
260
|
jobs.each_with_index do |job, i|
|
|
166
261
|
r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
167
262
|
results << r
|
|
168
263
|
progress.tick
|
|
169
264
|
break if config.fail_fast && r.survived?
|
|
170
265
|
end
|
|
266
|
+
results
|
|
171
267
|
ensure
|
|
172
|
-
|
|
268
|
+
prepare_client&.quit
|
|
269
|
+
client&.quit
|
|
173
270
|
end
|
|
174
|
-
results
|
|
175
271
|
end
|
|
176
272
|
|
|
177
273
|
# Parallel path: N daemon handles, each pinned to its own worker slot (own DB).
|
|
178
274
|
# A shared queue of job indices feeds N tool-side threads; results are placed by
|
|
179
275
|
# input index so the verdict set matches serial. Callers must not pass fail_fast
|
|
180
276
|
# here ({execute} forces serial for fail-fast). Per-mutant crashes are classified
|
|
181
|
-
# in {job_result}, shared with the serial path
|
|
182
|
-
#
|
|
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.
|
|
183
280
|
#
|
|
184
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.
|
|
185
284
|
# @return [Array<Mutineer::Result>] one result per input job, in input order.
|
|
186
|
-
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)
|
|
187
286
|
results = Array.new(jobs.size)
|
|
188
287
|
progress = Progress.new(jobs.size)
|
|
189
288
|
queue = Queue.new
|
|
289
|
+
errors = Queue.new
|
|
190
290
|
jobs.each_index { |i| queue << i }
|
|
191
291
|
|
|
192
|
-
#
|
|
193
|
-
#
|
|
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.
|
|
194
298
|
clients = []
|
|
299
|
+
threads = []
|
|
195
300
|
begin
|
|
196
301
|
worker_count.times do
|
|
197
|
-
clients << DaemonClient.new(boot: boot_config(config, abs_tests),
|
|
198
|
-
|
|
302
|
+
clients << DaemonClient.new(boot: boot_config(config, abs_tests), app_root: config.project_root)
|
|
303
|
+
clients.last.start
|
|
199
304
|
end
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
raise
|
|
305
|
+
ensure
|
|
306
|
+
prepare_client&.quit
|
|
203
307
|
end
|
|
204
308
|
|
|
205
|
-
clients.each_with_index
|
|
206
|
-
Thread.new do
|
|
207
|
-
# The abort below is re-raised by join and reported once there; without
|
|
208
|
-
# this Ruby also dumps the thread's backtrace, which the serial path never
|
|
209
|
-
# does. Same fault, same output, whatever --jobs is set to.
|
|
210
|
-
Thread.current.report_on_exception = false
|
|
309
|
+
clients.each_with_index do |client, worker|
|
|
310
|
+
threads << Thread.new do
|
|
211
311
|
loop do
|
|
212
312
|
i = begin
|
|
213
313
|
queue.pop(true)
|
|
@@ -217,20 +317,35 @@ module Mutineer
|
|
|
217
317
|
results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
218
318
|
progress.tick
|
|
219
319
|
end
|
|
220
|
-
rescue
|
|
221
|
-
# The daemon gave up for good. Stop feeding the
|
|
222
|
-
#
|
|
223
|
-
#
|
|
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.
|
|
224
324
|
queue.clear
|
|
225
|
-
|
|
325
|
+
errors << e
|
|
226
326
|
ensure
|
|
227
327
|
client.quit
|
|
228
328
|
end
|
|
229
|
-
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?
|
|
230
334
|
|
|
231
335
|
# Every job was popped by some worker and every pop assigns, so no slot can
|
|
232
|
-
# be nil here:
|
|
336
|
+
# be nil here: a worker error is raised above instead.
|
|
233
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)
|
|
234
349
|
end
|
|
235
350
|
|
|
236
351
|
# Build the payload for one job, run it on the given daemon/worker, and attach
|
|
@@ -265,6 +380,9 @@ module Mutineer
|
|
|
265
380
|
Result.skipped
|
|
266
381
|
elsif sel && sel[0] == :verdict
|
|
267
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
|
|
268
386
|
else
|
|
269
387
|
verdict = client.request(
|
|
270
388
|
id: req_id, worker: worker, timeout: config.daemon_timeout || DEFAULT_TIMEOUT,
|
|
@@ -272,7 +390,9 @@ module Mutineer
|
|
|
272
390
|
tests: sel ? sel[1] : abs_tests
|
|
273
391
|
)
|
|
274
392
|
# A survivor whose line ran at load, as in-process (Runner.run).
|
|
275
|
-
JobPlan.load_verdict(result_for(verdict), subject.file, mutation, subject, source, coverage_map)
|
|
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)
|
|
276
396
|
end
|
|
277
397
|
r.with(subject: subject, mutation: mutation, id: id)
|
|
278
398
|
end
|
|
@@ -285,8 +405,10 @@ module Mutineer
|
|
|
285
405
|
# @param config [Mutineer::Config] the run config.
|
|
286
406
|
# @param abs_tests [Array<String>] absolute --test paths.
|
|
287
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`.
|
|
288
410
|
# @return [Hash] the boot config shipped to the daemon.
|
|
289
|
-
def self.boot_config(config, abs_tests, coverage: false)
|
|
411
|
+
def self.boot_config(config, abs_tests, coverage: false, db_role: nil)
|
|
290
412
|
{
|
|
291
413
|
project_root: config.project_root,
|
|
292
414
|
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
@@ -306,6 +428,9 @@ module Mutineer
|
|
|
306
428
|
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
307
429
|
# map-build inputs.
|
|
308
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"),
|
|
309
434
|
# The map-building daemon's capture limit (--capture-timeout); nil = default.
|
|
310
435
|
capture_timeout: config.capture_timeout,
|
|
311
436
|
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
@@ -346,6 +471,7 @@ module Mutineer
|
|
|
346
471
|
# tests drive directly: {boot_config} from the zero-dep suite and
|
|
347
472
|
# {build_coverage_map} from the daemon suite. Everything else is daemon-pipeline
|
|
348
473
|
# internals with no caller outside this file.
|
|
349
|
-
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
|
|
350
476
|
end
|
|
351
477
|
end
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "io/wait"
|
|
5
5
|
require "open3"
|
|
6
6
|
require_relative "external_backend"
|
|
7
|
+
require_relative "rails_run_env"
|
|
7
8
|
|
|
8
9
|
module Mutineer
|
|
9
10
|
# Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
|
|
@@ -71,6 +72,32 @@ module Mutineer
|
|
|
71
72
|
self
|
|
72
73
|
end
|
|
73
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
|
+
|
|
74
101
|
# Run one mutant: ship the payload + covering tests, return the verdict string.
|
|
75
102
|
# On a daemon crash (EOF/dead pipe) respawn (bounded) and return `"error"` for
|
|
76
103
|
# this mutant. Never a wrong verdict, never a wedged run.
|
|
@@ -150,6 +177,8 @@ module Mutineer
|
|
|
150
177
|
env["BUNDLE_GEMFILE"] = @gemfile
|
|
151
178
|
env["RBENV_VERSION"] = @ruby_version if @ruby_version
|
|
152
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)
|
|
153
182
|
env
|
|
154
183
|
end
|
|
155
184
|
|
|
@@ -344,6 +373,7 @@ module Mutineer
|
|
|
344
373
|
close_io
|
|
345
374
|
raise (timed_out ? DaemonBootTimeout : DaemonBootError), "daemon failed to boot under the app bundle: #{detail}"
|
|
346
375
|
end
|
|
376
|
+
@database_count = ready["database_count"] || 1
|
|
347
377
|
end
|
|
348
378
|
|
|
349
379
|
# Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.
|