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

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.

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



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