Module: Mutineer::DaemonBackend
- Defined in:
- lib/mutineer/daemon_backend.rb
Overview
Daemon execution backend. Boots the app ONCE in a persistent subprocess under
the app's own bundle and forks per mutant, so a Rails run pays the boot cost
once instead of per mutant. Tool-side this only discovers jobs and builds the
ready-to-load payload (Prism); the daemon needs no Prism/mutineer.
When jobs > 1 each worker runs against its OWN database, which is what makes
--jobs N safe under Rails (#26): parallel verdicts are identical to serial.
Job collection, --since filtering and coverage selection stay on Runner and
are called from here, so the daemon path can never drift from the in-process
path on which mutants run or which tests narrow a mutant (score parity).
Unlike ExternalBackend, which is a leaf Runner calls into, this module owns its orchestration and calls back for that shared vocabulary.
Constant Summary collapse
- DEFAULT_TIMEOUT =
Default per-mutant timeout on the daemon path (seconds), overridden by config.daemon_timeout. Coverage narrowing usually keeps each job short; this still covers a slow suite or full-suite fallback when the map is unavailable. Named like its in-process counterpart Isolation::DEFAULT_TIMEOUT, not like ExternalBackend::SMOKE_TIMEOUT, which bounds a different thing.
60- DAEMON_TEMP_GLOB =
The daemon's per-mutant tempfile, written into the source dir so require_relative resolves. Kept in step with DaemonServer#sweep_temps.
"mutineer_daemon*.rb"
Class Method Summary collapse
-
.boot_config(config, abs_tests, coverage: false) ⇒ Hash
The boot config the daemon needs to boot the app once: where to boot, the test load roots (so
require "test_helper"resolves in every fork), framework, and whether this is Rails. -
.build_coverage_map(config, abs_tests) ⇒ Mutineer::CoverageMap?
Build the coverage map via a short-lived daemon (boots the app once, captures per-test coverage app-side, ships the map back).
-
.execute(config, operator_classes) ⇒ Array(Mutineer::AggregateResult, Hash<String,String>, Hash)
Full daemon run: collect jobs, build the coverage map once, then execute serially or across N worker daemons.
-
.job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map) ⇒ Mutineer::Result
private
private
Build the payload for one job, run it on the given daemon/worker, and attach the subject/mutation/id.
-
.result_for(verdict) ⇒ Mutineer::Result
private
private
Map a daemon verdict string to a Result.
-
.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map) ⇒ Array<Mutineer::Result>
private
private
Parallel path: N daemon handles, each pinned to its own worker slot (own DB).
-
.run_serial(jobs, config, abs_tests, coverage_map, source_map) ⇒ Array<Mutineer::Result>
private
private
Serial path: one daemon (worker 0), one mutant at a time.
-
.schema_path(config) ⇒ String?
private
private
Absolute path to the app's
db/schema.rbif it exists, else nil. -
.warn_coverage_fallback(reason = "unknown") ⇒ void
private
private
Stderr note when daemon coverage is unavailable (full --test set per mutant).
Class Method Details
.boot_config(config, abs_tests, coverage: false) ⇒ Hash
The boot config the daemon needs to boot the app once: where to boot, the test
load roots (so require "test_helper" resolves in every fork), framework, and
whether this is Rails.
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/mutineer/daemon_backend.rb', line 277 def self.boot_config(config, abs_tests, coverage: false) { project_root: config.project_root, boot: File.(config.boot || "config/environment", config.project_root), load_paths: Runner.test_load_roots(abs_tests), source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps framework: config.framework, rails: config.rails, # Schema for per-worker DB isolation. Sent when present; the daemon # skips worker-DB schema loading if the path is absent (e.g. structure.sql apps). schema: schema_path(config), # Coverage narrowing. Only the short-lived map-building daemon starts # Coverage (before boot); worker daemons boot with it OFF (no wasted # instrumentation/memory across every mutant fork). `sources`/`tests` are the # map-build inputs. coverage: coverage, sources: config.sources.map { |s| File.(s, config.project_root) }, tests: abs_tests } end |
.build_coverage_map(config, abs_tests) ⇒ Mutineer::CoverageMap?
Build the coverage map via a short-lived daemon (boots the app once, captures per-test coverage app-side, ships the map back). Returns a query-only CoverageMap, or nil when the build fails / returns empty. Callers then run the full --test set. Coverage-build IPC has no wall-clock (same limitation as in-process build_via_fork). A normal nonempty map scores like in-process; nil falls back to the full suite (more testing, not comparable).
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/mutineer/daemon_backend.rb', line 101 def self.build_coverage_map(config, abs_tests) client = DaemonClient.new(boot: boot_config(config, abs_tests, coverage: true), app_root: config.project_root).start data = begin client.coverage ensure client.quit end # A red unmutated suite must abort, even when the shipped map is empty. # Falling back to the full --test set would treat those failures as kills. if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any? Runner.abort_if_unclean!(CoverageMap.from_data( map: data["map"] || {}, failed_test_files: data["failed_test_files"] || [], project_root: config.project_root, failed_clean_tests: data["failed_clean_tests"] )) end unless data && !(data["map"] || {}).empty? reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map" warn_coverage_fallback(reason) return nil end CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [], project_root: config.project_root) rescue DaemonBootError => e warn_coverage_fallback("#{e.class}: #{e.}") nil end |
.execute(config, operator_classes) ⇒ Array(Mutineer::AggregateResult, Hash<String,String>, Hash)
Full daemon run: collect jobs, build the coverage map once, then execute serially or across N worker daemons. Fail-fast forces serial so the survivor set matches jobs 1.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/mutineer/daemon_backend.rb', line 48 def self.execute(config, operator_classes) jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes) jobs = Runner.filter_since(jobs, source_map, config) if config.since abs_tests = config.tests.map { |t| File.(t, config.project_root) } # Nothing to mutate (`--since` matched no changed line, or every mutant is # suppressed). Return before booting anything: the coverage daemon and the # worker daemons below each boot the whole app, and README documents # `--since origin/<base>` for PR CI, where a docs-only PR is routine. if jobs.empty? # The daemon sweeps orphaned temps at boot and nothing boots here, so sweep # tool-side. A file a hard-killed run left in app/models breaks the app's own # Zeitwerk boot, not just Mutineer's next run. Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB) return [AggregateResult.new(ignored_results), source_map, extras] end # Build the coverage map once (app-side). nil when the build fails: runners # fall back to the full --test set (and emit a stderr warning) rather than # mis-scoring everything as no_coverage. coverage_map = build_coverage_map(config, abs_tests) # Worker count = resolved --jobs, capped at the job count (no idle daemons). # >1 → N concurrent daemon handles, each on its OWN worker DB (N-handles, the # spike-proven shape). 1 → the serial single-daemon path. --fail-fast forces # serial: parallel's stop flag fires on the first survivor by WALL-CLOCK, not # input index, so the verdict set would diverge from serial (a different, # non-deterministic survivor set/score). The "identical to --jobs 1" guarantee # below only holds when fail-fast cannot race. worker_count = [config.jobs || 1, 1].max worker_count = 1 if config.fail_fast worker_count = [worker_count, jobs.size].min results = if worker_count > 1 run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map) else run_serial(jobs, config, abs_tests, coverage_map, source_map) end [AggregateResult.new(results + ignored_results), source_map, extras] end |
.job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map) ⇒ Mutineer::Result (private)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Build the payload for one job, run it on the given daemon/worker, and attach
the subject/mutation/id. Shared body of both daemon paths, so --jobs 1 and
--jobs N classify an identical fault identically.
Error model, in one place because both paths call this: a crash while running
ONE mutant is Mutineer::DaemonClient's business: it respawns and answers "error".
Nothing is caught here on purpose — anything reaching this far is either
Mutineer::DaemonBootError, which must end the run, or a defect, which must stay visible.
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/mutineer/daemon_backend.rb', line 242 def self.job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map) subject, mutation, id = job source = source_map[subject.file] mutated = mutation.apply(source) # Skip an invalid mutant tool-side: never ship a payload that would fail to # load and read as a false `killed`. # Narrow to covering tests (shared with the in-process path via # Runner.coverage_selection, so scores match). :verdict = no_coverage/uncapturable, # no fork. No map (build failed) → run the full --test set (fallback, not # narrowed). sel = coverage_map && Runner.coverage_selection(subject.file, mutation, subject, source, coverage_map) r = if Parser.parse_string(mutated).errors.any? Result.skipped elsif sel && sel[0] == :verdict sel[1] else verdict = client.request( id: req_id, worker: worker, timeout: config.daemon_timeout || DEFAULT_TIMEOUT, payload: { "code" => mutated, "source_file" => File.(subject.file, config.project_root) }, tests: sel ? sel[1] : abs_tests ) result_for(verdict) end r.with(subject: subject, mutation: mutation, id: id) end |
.result_for(verdict) ⇒ Mutineer::Result (private)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Map a daemon verdict string to a Result. The daemon reports the four run-time states it can decide; pre-fork states (skipped/no_coverage/…) are resolved tool-side before a request is ever sent.
318 319 320 321 322 323 324 325 |
# File 'lib/mutineer/daemon_backend.rb', line 318 def self.result_for(verdict) case verdict when "survived" then Result.survived when "killed" then Result.killed when "timeout" then Result.timeout else Result.error("daemon verdict: #{verdict}") end end |
.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map) ⇒ Array<Mutineer::Result> (private)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Parallel path: N daemon handles, each pinned to its own worker slot (own DB). A shared queue of job indices feeds N tool-side threads; results are placed by input index so the verdict set matches serial. Callers must not pass fail_fast here (execute forces serial for fail-fast). Per-mutant crashes are classified in job_result, shared with the serial path; a Mutineer::DaemonBootError ends the run here rather than scoring the remainder against a daemon that has given up.
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/mutineer/daemon_backend.rb', line 176 def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map) results = Array.new(jobs.size) progress = Progress.new(jobs.size) queue = Queue.new jobs.each_index { |i| queue << i } # Built one at a time so a refused spawn part-way (EMFILE under a high --jobs) # can still quit the daemons already up. Array.new would lose every reference. clients = [] begin worker_count.times do clients << DaemonClient.new(boot: boot_config(config, abs_tests), app_root: config.project_root).start end rescue StandardError clients.each(&:quit) raise end clients.each_with_index.map do |client, worker| Thread.new do # The abort below is re-raised by join and reported once there; without # this Ruby also dumps the thread's backtrace, which the serial path never # does. Same fault, same output, whatever --jobs is set to. Thread.current.report_on_exception = false loop do i = begin queue.pop(true) rescue ThreadError break end results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map) progress.tick end rescue DaemonBootError # The daemon gave up for good. Stop feeding the other workers rather # than letting them score the rest of the run against a dead client; # Thread#join re-raises this and ends the run. queue.clear raise ensure client.quit end end.each(&:join) # Every job was popped by some worker and every pop assigns, so no slot can # be nil here: an escaping exception aborts the run via join instead. results end |
.run_serial(jobs, config, abs_tests, coverage_map, source_map) ⇒ Array<Mutineer::Result> (private)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Serial path: one daemon (worker 0), one mutant at a time. Honors --fail-fast (stop at the first survivor).
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/mutineer/daemon_backend.rb', line 149 def self.run_serial(jobs, config, abs_tests, coverage_map, source_map) client = DaemonClient.new(boot: boot_config(config, abs_tests), app_root: config.project_root).start results = [] progress = Progress.new(jobs.size) begin jobs.each_with_index do |job, i| r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map) results << r progress.tick break if config.fail_fast && r.survived? end ensure client.quit end results end |
.schema_path(config) ⇒ String? (private)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Absolute path to the app's db/schema.rb if it exists, else nil. Used by the
daemon to schema-load each fork's isolated worker database. Only schema.rb
is supported this pass; structure.sql apps get nil and fall back to
whatever the worker DB already holds.
306 307 308 309 |
# File 'lib/mutineer/daemon_backend.rb', line 306 def self.schema_path(config) path = File.("db/schema.rb", config.project_root) File.exist?(path) ? path : nil end |
.warn_coverage_fallback(reason = "unknown") ⇒ void (private)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
This method returns an undefined value.
Stderr note when daemon coverage is unavailable (full --test set per mutant).
138 139 140 141 |
# File 'lib/mutineer/daemon_backend.rb', line 138 def self.warn_coverage_fallback(reason = "unknown") warn "[mutineer] daemon coverage map unavailable (#{reason}); running every " \ "mutant against the full --test set (score not comparable to an in-process run)." end |