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

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.

Parameters:

  • config (Mutineer::Config) —

    the run config.

  • abs_tests (Array<String>) —

    absolute --test paths.

  • coverage (Boolean) (defaults to: false) —

    whether this daemon builds the coverage map.

Returns:

  • (Hash) —

    the boot config shipped to the daemon.



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.expand_path(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.expand_path(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).

Parameters:

  • config (Mutineer::Config) —

    the run config.

  • abs_tests (Array<String>) —

    absolute --test paths.

Returns:



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.message}")
  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.

Parameters:

  • config (Mutineer::Config) —

    run configuration (daemon set).

  • operator_classes (Array<Class>) —

    resolved operators.

Returns:



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.expand_path(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.

Parameters:

  • job (Array(Mutineer::Subject, Mutineer::Mutation, String)) —

    the work item.

  • req_id (Integer) —

    request id (echoed back for IPC ordering safety).

  • client (Mutineer::DaemonClient) —

    the daemon handle to run on.

  • worker (Integer) —

    the worker slot (→ worker DB) this daemon routes to.

Returns:

Raises:



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.expand_path(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.

Parameters:

  • verdict (String) —

    the daemon's verdict word.

Returns:



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.

Returns:



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).

Returns:



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.

Parameters:

Returns:

  • (String, nil) —

    absolute schema path or nil.



306
307
308
309
# File 'lib/mutineer/daemon_backend.rb', line 306

def self.schema_path(config)
  path = File.expand_path("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).

Parameters:

  • reason (String) (defaults to: "unknown") —

    short cause (boot error message, empty map, …).



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