Class: Mutineer::DaemonClient

Inherits:
Object
  • Object
show all
Defined in:
lib/mutineer/daemon_client.rb

Overview

#26/#27 Phase 2a — the TOOL-side handle for the app-side daemon.

Spawns daemon_server.rb UNDER THE APP'S BUNDLE/RUBY (cleaned env so the gem's bundler context never leaks; the daemon file is loaded by absolute path with -r, which bypasses the app bundle that has no mutineer), completes the ready handshake, then ships per-mutant payloads and reads structured verdicts. If the daemon dies mid-run it respawns (bounded) and marks the in-flight mutant error rather than corrupting the run. Reuses the cleaned-env spawn + stderr-drain proven in the spike driver and the spawn discipline of ExternalBackend.

Constant Summary collapse

DAEMON_PATH =

Absolute path to the daemon entry, loaded app-side by -r (bypasses the bundle).

File.expand_path("daemon_server.rb", __dir__)
MAX_RESTARTS =

How many times to respawn a crashing daemon before aborting the run.

3

Instance Method Summary collapse

Constructor Details

#initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr) ⇒ DaemonClient

Returns a new instance of DaemonClient.

Parameters:

  • boot (Hash)

    boot config sent to the daemon: project_root, boot, load_paths, framework, rails.

  • app_root (String)

    directory to spawn the daemon in (the app root).

  • ruby_version (String, nil) (defaults to: nil)

    RBENV_VERSION for the app's Ruby (nil = inherit).

  • gemfile (String, nil) (defaults to: nil)

    BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).

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

    where daemon stderr is drained.



32
33
34
35
36
37
38
39
# File 'lib/mutineer/daemon_client.rb', line 32

def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
  @boot = boot
  @app_root = app_root
  @ruby_version = ruby_version
  @gemfile = gemfile || File.join(app_root, "Gemfile")
  @errio = errio
  @restarts = 0
end

Instance Method Details

#coverageHash?

#26/U7: ask the daemon to build the coverage map app-side and return it. One-shot control message (no id). Returns {"map"=>..., "failed_test_files"=>...} (possibly with an "error"), or nil if the daemon vanished — the caller then falls back to running the full test set (no narrowing) rather than mis-scoring.

Returns:

  • (Hash, nil)

    the coverage payload, or nil on a dead pipe.



85
86
87
88
89
90
# File 'lib/mutineer/daemon_client.rb', line 85

def coverage
  send_line("cmd" => "coverage")
  read_line
rescue Errno::EPIPE, IOError
  nil
end

#quitvoid

This method returns an undefined value.

Graceful shutdown; leaves no orphaned daemon/child.



95
96
97
98
99
100
101
102
# File 'lib/mutineer/daemon_client.rb', line 95

def quit
  return unless @stdin

  send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
  @wait_thr&.join
ensure
  close_io
end

#request(id:, payload:, tests:, timeout:, worker: 0) ⇒ String

Run one mutant: ship the payload + covering tests, return the verdict string. On a daemon crash (EOF/dead pipe) respawn (bounded) and return "error" for this mutant — never a wrong verdict, never a wedged run.

Parameters:

  • id (Integer)

    request id (echoed back for ordering safety).

  • payload (Hash)

    => mutated ruby, "source_file" => path.

  • tests (Array<String>)

    covering test file paths.

  • timeout (Numeric)

    per-mutant wall-clock timeout (seconds).

  • worker (Integer) (defaults to: 0)

    worker slot; the daemon routes the fork to <db>-<worker> (#26 isolation). Defaults to 0 (serial in U5).

Returns:

  • (String)

    one of survived/killed/error/timeout.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/mutineer/daemon_client.rb', line 61

def request(id:, payload:, tests:, timeout:, worker: 0)
  # A crash can surface on the WRITE (daemon died idle between requests →
  # Errno::EPIPE) as well as the read (EOF), so guard both: either way, respawn
  # for future mutants and score THIS one error (re-running a crash-causing
  # mutant could loop). Never let a dead pipe abort the whole run.
  reply =
    begin
      send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
      read_line
    rescue Errno::EPIPE, IOError
      nil
    end
  return reply["verdict"] if reply && reply["id"] == id

  restart!
  "error"
end

#startself

Spawn the daemon and complete the ready handshake. Raises DaemonBootError on failure (surfaced by the CLI as a clean runtime error, not a hang).

Returns:

  • (self)


45
46
47
48
# File 'lib/mutineer/daemon_client.rb', line 45

def start
  spawn_daemon
  self
end