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.



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



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

Parameters:

  • config (Mutineer::Config)

    run configuration (daemon set).

  • operator_classes (Array<Class>)

    resolved operators.

Returns:



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