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
-
.apply_payload(payload) ⇒ Object
private
Write the tool-built mutated text beside the real source and
loadit, reopening the mutated class/method in THIS child only. -
.boot!(cfg) ⇒ Object
private
BOOT ONCE.
-
.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. -
.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).
-
.run(input: $stdin, output: $stdout, errio: $stderr) ⇒ void
Serve the protocol on the given IO pair (defaults to stdio).
-
.run_mutant(req) ⇒ Object
private
Fork a child to run one mutant in isolation; decode its exit into a verdict.
-
.run_tests(tests) ⇒ Object
private
Load the covering test files and run them; 0 = all passed (survived), 1 = a failure/error (killed).
-
.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).
-
.sweep_temps ⇒ Object
private
Remove orphaned mutant tempfiles from the source dirs (parent-side; the SIGKILL path cannot run the child's ensure).
-
.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).
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.(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.(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.(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.(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.}")) @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.}") { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.}" } 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.
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.}") 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.}") 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.(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.(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.}") @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 |