Module: Mutineer::DaemonServer
- Defined in:
- lib/mutineer/daemon_server.rb
Overview
#26/#27 Phase 2a — the 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 (KTD-2/R4): 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 (#26/U5): when the app is Rails, each fork is routed to its own
database <db>-<worker> via RailsWorkerDb BEFORE any test loads, so concurrent
workers can't clobber each other's transactional fixtures. worker defaults to 0
(serial). SQLite this pass; Postgres provisioning is U10.
Verdict mapping (KTD-5, Phase-2a honest limit): 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 a U6 concern — only observable under the concurrent gate.
Constant Summary collapse
- POLL =
Poll interval (seconds) for the per-fork deadline wait loop.
0.02
Class Method Summary collapse
-
.run(input: $stdin, output: $stdout, errio: $stderr) ⇒ void
Serve the protocol on the given IO pair (defaults to stdio).
Class Method Details
.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.
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_server.rb', line 51 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 can't 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" # #26/U7: 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 |