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.
@@ -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
- # No require_relative "runner" on purpose: runner.rb requires this file, and the
9
- # reverse edge makes Ruby warn "circular require considered harmful" on every -w
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 stay on {Runner} and
23
- # are called from here, so the daemon path can never drift from the in-process
24
- # path on which mutants run or which tests narrow a mutant (score parity).
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 calls back for that shared vocabulary.
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. Kept in step with DaemonServer#sweep_temps.
38
- 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
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 {Runner.collect_jobs} extras.
50
+ # source map, and the {JobPlan.collect_jobs} extras.
48
51
  def self.execute(config, operator_classes)
49
- jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
50
- jobs = Runner.filter_since(jobs, source_map, config) if config.since
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
- results =
82
- if worker_count > 1
83
- run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
84
- else
85
- run_serial(jobs, config, abs_tests, coverage_map, source_map)
86
- 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
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. Coverage-build IPC has no wall-clock (same limitation as
95
- # in-process build_via_fork). A normal nonempty map scores like in-process;
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
- 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?
112
- Runner.abort_if_unclean!(CoverageMap.from_data(
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
- return nil
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 = DaemonClient.new(boot: boot_config(config, abs_tests),
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
- client.quit
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; a {DaemonBootError} ends the run
172
- # 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.
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
- # Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
183
- # 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.
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
- 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
189
304
  end
190
- rescue StandardError
191
- clients.each(&:quit)
192
- raise
305
+ ensure
306
+ prepare_client&.quit
193
307
  end
194
308
 
195
- clients.each_with_index.map do |client, worker|
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 DaemonBootError
211
- # The daemon gave up for good. Stop feeding the other workers rather
212
- # than letting them score the rest of the run against a dead client;
213
- # 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.
214
324
  queue.clear
215
- raise
325
+ errors << e
216
326
  ensure
217
327
  client.quit
218
328
  end
219
- 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?
220
334
 
221
335
  # Every job was popped by some worker and every pop assigns, so no slot can
222
- # be nil here: an escaping exception aborts the run via join instead.
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
- # Runner.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
250
- # no fork. No map (build failed) → run the full --test set (fallback, not
251
- # narrowed).
252
- sel = coverage_map && Runner.coverage_selection(subject.file, mutation, subject, source, 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
- result_for(verdict)
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 test
270
- # load roots (so `require "test_helper"` resolves in every fork), framework, and
271
- # whether this is Rails.
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
- load_paths: Runner.test_load_roots(abs_tests),
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: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
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
- # skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
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. Used by the
302
- # daemon to schema-load each fork's isolated worker database. Only `schema.rb`
303
- # is supported this pass; `structure.sql` apps get nil and fall back to
304
- # whatever the worker DB already holds.
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 = ready && ready["error"] ? ready["error"] : "daemon exited before the handshake"
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
- @errio.puts("[mutineer] daemon crashed — respawning (#{@restarts}/#{MAX_RESTARTS})")
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 (caller treats as a crash).
353
- def read_line
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