Module: Mutineer::DaemonServer

Defined in:
lib/mutineer/daemon_server.rb

Overview

App-side daemon (persistent worker).

Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via bundle exec ruby). It boots the app ONCE, then serves per-mutant test-run requests over stdin/stdout as newline-delimited JSON. For each request it FORKS a child that loads the mutated source text the tool sent, runs the covering tests, and exits with a status the parent decodes into a verdict.

HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no mutineer. So it requires ONLY stdlib + the app's own boot file; it re-implements the fork/timeout/decode loop rather than requiring isolation.rb (which pulls in Prism). All parsing/mutation happened tool-side; the daemon only loads text.

Protocol (one JSON object per line, both directions):

boot in  : {"cmd":"boot","project_root":"...","boot":"config/environment",
          "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
ready out: {"ready":true,"ruby":"3.3.6"}   (or {"ready":false,"error":"..."} then exit)
run  in  : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
          "tests":["test/models/order_test.rb"],"timeout":30}
verdict  : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
quit in  : {"cmd":"quit"}

Worker isolation: when the app is Rails, each fork is routed to its own database <db>-<worker> via RailsWorkerDb BEFORE any test loads, so concurrent workers cannot clobber each other's transactional fixtures. worker defaults to 0 (serial). SQLite this pass; Postgres provisioning is not yet implemented.

Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite failed), 2=error (child raised AROUND the test: load, boot, or worker-DB routing failure); parent-detected timeout. Tagging an in-test DB error (one fired inside a test body, vs at routing time) as error rather than killed is only observable under the concurrent gate and is not yet implemented.

Constant Summary collapse

POLL =

Poll interval (seconds) for the per-fork deadline wait loop.

0.02

Class Method Summary collapse

Class Method Details

.apply_payload(payload) ⇒ Object (private)

Write the tool-built mutated text beside the real source and load it, reopening the mutated class/method in THIS child only. It goes in the source file's directory (like Isolation.apply_whole_file) so a require_relative in the mutated source resolves against its real neighbours. Writing it to the tmpdir would LoadError on such files and score a spurious error that diverges from the in-process path. The Zeitwerk hazard (a stray .rb in an autoload dir) is handled by the boot/timeout sweep_temps, not by relocating the file. Same path for reload (whole file) and redefine (wrapped snippet).



259
260
261
262
263
264
265
266
# File 'lib/mutineer/daemon_server.rb', line 259

def apply_payload(payload)
  dir = File.dirname(File.expand_path(payload.fetch("source_file")))
  Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
    f.write(payload.fetch("code"))
    f.flush
    load f.path
  end
end

.boot!(cfg) ⇒ Object (private)

BOOT ONCE. chdir + require the app's boot file so the whole app is loaded and inherited by every fork. Never requires mutineer.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/mutineer/daemon_server.rb', line 97

def boot!(cfg)
  @cfg = cfg
  @framework = cfg.fetch("framework", "minitest")
  @source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
  Dir.chdir(cfg["project_root"]) if cfg["project_root"]
  ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
  Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
  # Start Coverage BEFORE the app loads, so booted source lines are
  # instrumented. The map build (build_via_fork) forks this booted parent.
  if cfg["coverage"]
    require "coverage"
    Coverage.start(lines: true)
  end
  # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
  # source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
  # tempfile's non-constant name during autoload setup.
  sweep_temps
  require File.expand_path(cfg["boot"]) if cfg["boot"]
  setup_worker_db(cfg) if cfg["rails"]
rescue Exception => e # rubocop:disable Lint/RescueException
  # Boot failed (bad boot path, app error). Tell the client and exit so it
  # can surface a clean error rather than hang on the handshake.
  @output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
  @output.flush
  exit!(1)
end

.build_coverage_map ⇒ Object (private)

Build the coverage map app-side (Coverage was started at boot) and return it as {map, failed_test_files} for the tool to select covering tests. Capture forks route to worker 0's DB (isolated, serial). On any failure return an empty map + an error string. The tool then falls back to the full test set rather than mis-scoring everything as no_coverage.



146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/mutineer/daemon_server.rb', line 146

def build_coverage_map
  require_relative "coverage_map"
  root = @cfg["project_root"] || Dir.pwd
  cmap = CoverageMap.new(
    source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
    load_paths: Array(@cfg["load_paths"]), project_root: root,
    boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
  ).build_via_fork(after_fork: coverage_after_fork)
  { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
    "failed_clean_tests" => cmap.failed_clean_tests }
rescue Exception => e # rubocop:disable Lint/RescueException
  @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
  { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
end

.coverage_after_fork ⇒ Object (private)

Fork-safety hook for coverage capture: route each capture fork to worker 0's isolated DB (captures run serially, so one worker is enough). Nil when the app has no worker-DB adapter (non-Rails). Capture then runs as before.



164
165
166
167
168
169
# File 'lib/mutineer/daemon_server.rb', line 164

def coverage_after_fork
  return nil unless @worker_db

  schema = @schema_path
  -> { @worker_db.after_fork(0, schema) }
end

.run(input: $stdin, output: $stdout, errio: $stderr) ⇒ void

This method returns an undefined value.

Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.

Parameters:

  • input (IO) (defaults to: $stdin) —

    request stream.

  • output (IO) (defaults to: $stdout) —

    verdict stream.

  • errio (IO) (defaults to: $stderr) —

    diagnostics stream (never the IPC channel).



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
90
91
# File 'lib/mutineer/daemon_server.rb', line 55

def run(input: $stdin, output: $stdout, errio: $stderr)
  @errio = errio
  @output = output
  boot_line = input.gets
  return if boot_line.nil? # client vanished before boot

  boot!(JSON.parse(boot_line.strip))
  output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
  output.flush

  input.each_line do |line|
    line = line.strip
    next if line.empty?

    begin
      req = JSON.parse(line)
    rescue JSON::ParserError => e
      # A corrupt line has no id to address a reply to (and the client only
      # ever sends valid JSON, so it cannot be a pending request). Log and
      # read on rather than write an unaddressable verdict onto the channel.
      @errio.puts("[daemon] dropped unparseable line: #{e.message}")
      next
    end
    break if req["cmd"] == "quit"

    # Build the coverage map app-side and ship it to the tool, which then
    # selects covering tests per mutant. One-shot control message.
    if req["cmd"] == "coverage"
      output.puts(JSON.generate(build_coverage_map))
      output.flush
      next
    end

    output.puts(JSON.generate(run_mutant(req)))
    output.flush
  end
end

.run_mutant(req) ⇒ Object (private)

Fork a child to run one mutant in isolation; decode its exit into a verdict.



172
173
174
175
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
# File 'lib/mutineer/daemon_server.rb', line 172

def run_mutant(req)
  timeout = req.fetch("timeout", 30)
  worker  = req.fetch("worker", 0)
  # Load schema until the first killed/survived fork for this worker slot.
  schema_for_fork = (@worker_db && @schema_path && !@schema_ready[worker]) ? @schema_path : nil
  pid = fork do
    # New process group so a per-fork timeout can SIGKILL the whole subtree,
    # and silence the child's stdout so test output never corrupts the IPC pipe.
    Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
    code =
      begin
        ChildStdout.silence
        # Route THIS fork at its own worker database before any test loads.
        # A routing failure raises here and is scored `error`, never a false verdict.
        @worker_db&.after_fork(worker, schema_for_fork)
        apply_payload(req["payload"])
        run_tests(Array(req["tests"]))
      rescue Exception => e # rubocop:disable Lint/RescueException
        @errio.puts("[daemon-child] #{e.class}: #{e.message}")
        2
      end
    exit!(code)
  end
  verdict = wait_verdict(pid, timeout)
  # Mark ready only when the child finished cleanly after schema load
  # (killed/survived). Timeout can interrupt mid-load_schema; error is a
  # routing failure. Both leave the slot unready so the next fork reloads.
  @schema_ready[worker] = true if schema_for_fork && %w[killed survived].include?(verdict)
  # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
  # so it cannot outlive the run or trip Zeitwerk on a later fork.
  sweep_temps if verdict == "timeout"
  { "id" => req["id"], "verdict" => verdict }
end

.run_tests(tests) ⇒ Object (private)

Load the covering test files and run them; 0 = all passed (survived), 1 = a failure/error (killed). Minitest only; rspec is not yet on this path.



270
271
272
273
274
275
276
277
# File 'lib/mutineer/daemon_server.rb', line 270

def run_tests(tests)
  raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"

  require "minitest"
  require "rails/test_help" if defined?(Rails)
  tests.each { |t| load File.expand_path(t) }
  Minitest.run([]) ? 0 : 1
end

.setup_worker_db(cfg) ⇒ Object (private)

Load the per-worker DB adapter app-side (sibling gem file, by relative path so it bypasses the app bundle, like this daemon itself). No-op unless the app has ActiveRecord. Records the adapter + schema path so each fork can route to its own database. SQLite-only this pass; a non-SQLite config raises in the fork and reads as error, never a mis-routed verdict.



129
130
131
132
133
134
135
136
137
138
139
# File 'lib/mutineer/daemon_server.rb', line 129

def setup_worker_db(cfg)
  require_relative "rails_worker_db"
  @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
  schema = cfg["schema"] && File.expand_path(cfg["schema"])
  @schema_path = schema if schema && File.exist?(schema)
  # Schema is loaded once per worker slot on first use (not every mutant fork).
  @schema_ready = {}
rescue LoadError => e
  @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
  @worker_db = nil
end

.sweep_temps ⇒ Object (private)

Remove orphaned mutant tempfiles from the source dirs (parent-side; the SIGKILL path cannot run the child's ensure). Mirrors Runner.sweep_orphans.



208
209
210
211
212
213
214
# File 'lib/mutineer/daemon_server.rb', line 208

def sweep_temps
  @source_dirs.to_a.each do |dir|
    Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
      File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
    end
  end
end

.wait_verdict(pid, timeout) ⇒ Object (private)

Single-waiter deadline loop (mirrors Isolation.run and ExternalBackend.wait_with_timeout, re-implemented here because Isolation pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to the kill/reap/decode logic must be applied to all three in lockstep. SIGKILL the child's process group past the deadline; a signalled child (nil exitstatus) is error.



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/mutineer/daemon_server.rb', line 223

def wait_verdict(pid, timeout)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  loop do
    reaped, status = Process.waitpid2(pid, Process::WNOHANG)
    if reaped
      return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
    end
    if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
      begin
        Process.kill(:KILL, -pid)
      rescue Errno::ESRCH, Errno::EPERM
        Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
      end
      begin
        _reaped, status = Process.waitpid2(pid)
        if status && status.exited? && !status.signaled?
          return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
        end
      rescue Errno::ECHILD
        # already reaped
      end
      return "timeout"
    end
    sleep POLL
  end
end