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>)
Full daemon run: collect jobs, build the coverage map once, then execute serially or across N worker daemons.
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.
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/mutineer/daemon_backend.rb', line 260 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).
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/mutineer/daemon_backend.rb', line 99 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 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>)
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.
46 47 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 |
# File 'lib/mutineer/daemon_backend.rb', line 46 def self.execute(config, operator_classes) jobs, ignored_results, source_map = 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] 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] end |