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.
@@ -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. Kept in step with DaemonServer#sweep_temps.
35
- DAEMON_TEMP_GLOB = "mutineer_daemon*.rb"
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.filter_since(jobs, source_map, config) if config.since
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
- results =
79
- if worker_count > 1
80
- run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
81
- else
82
- run_serial(jobs, config, abs_tests, coverage_map, source_map)
83
- end
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
- data = begin
105
- client.coverage
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 = DaemonClient.new(boot: boot_config(config, abs_tests),
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
- client.quit
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; a {DaemonBootError} ends the run
182
- # here rather than scoring the remainder against a daemon that has given up.
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
- # Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
193
- # can still quit the daemons already up. Array.new would lose every reference.
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
- app_root: config.project_root).start
302
+ clients << DaemonClient.new(boot: boot_config(config, abs_tests), app_root: config.project_root)
303
+ clients.last.start
199
304
  end
200
- rescue StandardError
201
- clients.each(&:quit)
202
- raise
305
+ ensure
306
+ prepare_client&.quit
203
307
  end
204
308
 
205
- clients.each_with_index.map do |client, worker|
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 DaemonBootError
221
- # The daemon gave up for good. Stop feeding the other workers rather
222
- # than letting them score the rest of the run against a dead client;
223
- # Thread#join re-raises this and ends the run.
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
- raise
325
+ errors << e
226
326
  ensure
227
327
  client.quit
228
328
  end
229
- end.each(&:join)
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: an escaping exception aborts the run via join instead.
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.