Class: Mutineer::DaemonClient

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

Overview

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



36
37
38
39
40
41
42
43
# File 'lib/mutineer/daemon_client.rb', line 36

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

#app_env ⇒ Object (private)

Cleaned environment for the app bundle: strip the gem's bundler/Ruby context so bundle exec resolves the APP's Gemfile under the requested Ruby.



122
123
124
125
126
127
128
# File 'lib/mutineer/daemon_client.rb', line 122

def app_env
  env = ENV.to_h.reject { |k, _| k.start_with?("BUNDLE_", "RUBY", "GEM_") }
  env["BUNDLE_GEMFILE"] = @gemfile
  env["RBENV_VERSION"] = @ruby_version if @ruby_version
  env["RAILS_ENV"] ||= "test" if @boot[:rails] || @boot["rails"]
  env
end

#close_io ⇒ void (private)

This method returns an undefined value.

Close the IPC pipes, stop the stderr-drain thread, and reap the daemon so a respawn or quit leaves no leaked fd, thread, or zombie.



207
208
209
210
211
212
# File 'lib/mutineer/daemon_client.rb', line 207

def close_io
  @drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
  [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
  @wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
  @stdin = @stdout = @stderr = @drain = @wait_thr = nil
end

#coverage ⇒ Hash?

Ask the daemon to build the coverage map app-side and return it. One-shot control message (no id). On success, returns {"map"=>..., "failed_test_files"=>..., "failed_clean_tests"=>...}. On coverage-build failure, returns {"map"=>{}, "failed_test_files"=>[], "error"=>...}. Returns nil if the daemon vanished. The caller then falls back to running the full test set (no narrowing) rather than mis-scoring, except a red unmutated suite which aborts.

Returns:

  • (Hash, nil) —

    the coverage payload, or nil on a dead pipe.



99
100
101
102
103
104
# File 'lib/mutineer/daemon_client.rb', line 99

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

#quit ⇒ void

This method returns an undefined value.

Graceful shutdown; leaves no orphaned daemon/child.



109
110
111
112
113
114
115
116
# File 'lib/mutineer/daemon_client.rb', line 109

def quit
  return unless @stdin

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

#read_line ⇒ Object (private)

Read one JSON reply line; nil on EOF/dead pipe (caller treats as a crash).



196
197
198
199
200
201
# File 'lib/mutineer/daemon_client.rb', line 196

def read_line
  line = @stdout.gets
  line && JSON.parse(line.strip)
rescue IOError, Errno::EPIPE, JSON::ParserError
  nil
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 under the "code" key, path under "source_file".

  • 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> for isolation. Defaults to 0 (serial).

Returns:

  • (String) —

    one of survived/killed/error/timeout.

Raises:



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_client.rb', line 65

def request(id:, payload:, tests:, timeout:, worker: 0)
  # close_io nils the pipes, so a client whose respawn never completed would
  # otherwise fail per-mutant forever (NoMethodError on nil) and let the backend
  # score every remaining mutant against nothing. Deadness is a property of the
  # client, not of whichever exception happened to escape.
  raise DaemonBootError, "daemon is not running" if @stdin.nil?

  # 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

#restart! ⇒ Object (private)

Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.



175
176
177
178
179
180
181
182
183
184
# File 'lib/mutineer/daemon_client.rb', line 175

def restart!
  close_io
  @restarts += 1
  if @restarts > MAX_RESTARTS
    raise DaemonBootError, "daemon crashed #{@restarts} times; aborting the run"
  end

  @errio.puts("[mutineer] daemon crashed — respawning (#{@restarts}/#{MAX_RESTARTS})")
  spawn_daemon
end

#send_line(obj) ⇒ void (private)

This method returns an undefined value.

Write one JSON object as a line to the daemon.

Parameters:

  • obj (Hash) —

    the message to encode.



190
191
192
193
# File 'lib/mutineer/daemon_client.rb', line 190

def send_line(obj)
  @stdin.puts(JSON.generate(obj))
  @stdin.flush
end

#spawn_daemon ⇒ void (private)

This method returns an undefined value.

Spawn the daemon under the app bundle and complete the ready handshake.

Raises:



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/mutineer/daemon_client.rb', line 134

def spawn_daemon
  # Plain `bundle exec ruby`, NOT `rbenv exec`, which would break CI and any
  # non-rbenv setup. When bundler/ruby are rbenv shims, the RBENV_VERSION
  # carried in app_env still selects the app's Ruby; otherwise the active
  # Ruby is used.
  # Everything up to the handshake is terminal, not one mutant's problem: a spawn
  # the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
  # resolve) and a daemon that dies before accepting the boot payload (EPIPE on
  # the write) both leave a client that cannot recover. Raise the class that ends
  # the run — a SystemCallError would reach the CLI as a usage error (exit 2).
  ready =
    begin
      @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
        app_env, "bundle", "exec", "ruby",
        "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run", chdir: @app_root
      )
      # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
      # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
      # the rescue swallows the benign EBADF/IOError raised when close_io closes
      # the pipe out from under an in-flight copy_stream.
      @drain = Thread.new do # rubocop:disable ThreadSafety/NewThread
        IO.copy_stream(@stderr, @errio)
      rescue IOError, Errno::EBADF
        nil
      end

      send_line(@boot)
      read_line
    rescue SystemCallError, IOError => e
      close_io
      raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
    end

  unless ready && ready["ready"]
    detail = ready && ready["error"] ? ready["error"] : "daemon exited before the handshake"
    close_io
    raise DaemonBootError, "daemon failed to boot under the app bundle: #{detail}"
  end
end

#start ⇒ self

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)


49
50
51
52
# File 'lib/mutineer/daemon_client.rb', line 49

def start
  spawn_daemon
  self
end