Class: Mutineer::DaemonClient
- Inherits:
-
Object
- Object
- Mutineer::DaemonClient
- 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.("daemon_server.rb", __dir__)
- MAX_RESTARTS =
How many times to respawn a crashing daemon before aborting the run.
3
Instance Method Summary collapse
-
#app_env ⇒ Object
private
Cleaned environment for the app bundle: strip the gem's bundler/Ruby context so
bundle execresolves the APP's Gemfile under the requested Ruby. -
#close_io ⇒ void
private
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.
-
#coverage ⇒ Hash?
Ask the daemon to build the coverage map app-side and return it.
-
#initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr) ⇒ DaemonClient
constructor
A new instance of DaemonClient.
-
#quit ⇒ void
Graceful shutdown; leaves no orphaned daemon/child.
-
#read_line ⇒ Object
private
Read one JSON reply line; nil on EOF/dead pipe (caller treats as a crash).
-
#request(id:, payload:, tests:, timeout:, worker: 0) ⇒ String
Run one mutant: ship the payload + covering tests, return the verdict string.
-
#restart! ⇒ Object
private
Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.
-
#send_line(obj) ⇒ void
private
Write one JSON object as a line to the daemon.
-
#spawn_daemon ⇒ void
private
Spawn the daemon under the app bundle and complete the ready handshake.
-
#start ⇒ self
Spawn the daemon and complete the ready handshake.
Constructor Details
#initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr) ⇒ DaemonClient
Returns a new instance of DaemonClient.
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.
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.
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.
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.
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.}" 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).
49 50 51 52 |
# File 'lib/mutineer/daemon_client.rb', line 49 def start spawn_daemon self end |