Module: Mutineer::ExternalBackend
- Defined in:
- lib/mutineer/external_backend.rb
Overview
#27 (U3): the external execution backend. Runs the user's --test-command as a
subprocess in the app's OWN runtime (whatever Ruby its bundle resolves to), so
mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
This is deliberately NOT a TestRunners framework adapter: those return an
Integer 0/1 from inside a fork and are dispatched by framework name. This is a
whole backend — it spawns a process, enforces a wall-clock timeout, and maps
the exit status to a Result. The mapping is the SAME direction as in-process
(suite passes => survived, suite fails => killed) but coarser: it cannot tell an
infrastructure error from a genuine kill, so the smoke check (below) guards the
persistent case and the score is disclosed as an upper bound (KTD-3/KTD-6).
Constant Summary collapse
- SMOKE_TIMEOUT =
Generous ceiling for the one-off smoke/calibration run (a cold app boot plus the full suite). The per-mutant timeout is derived from how long this took.
900- POLL =
Poll interval for the deadline wait loop. Independent of Isolation's loop — this backend waits on an external process TREE, not an in-process fork.
0.02
Class Method Summary collapse
-
.build_argv(command, files) ⇒ Array<String>
Turn a command template into an argv array (no shell → no eval, no injection).
-
.run(command, files, timeout:, verbose: false) ⇒ Mutineer::Result
Runs the command for ONE mutant against whatever is currently on disk (the caller has already swapped the mutant in via FileSwap).
-
.smoke_check!(command, files, timeout: SMOKE_TIMEOUT) ⇒ Float
Pre-flight: run the command once against the UNMUTATED tree.
-
.spawn_capture(command, files, timeout) ⇒ Object
private
Spawns the command to a captured combined-output tempfile, enforces a wall-clock timeout (SIGKILL past the deadline), and returns [kind, exit_code, output, elapsed].
-
.tail(output, lines = 40) ⇒ Object
private
Last ~40 lines of captured output, for a smoke-failure message.
-
.wait_with_timeout(pid, timeout) ⇒ Array(Symbol, Integer, nil)
private
Waits for the spawned pid, SIGKILLing its process group past the deadline.
Class Method Details
.build_argv(command, files) ⇒ Array<String>
Turn a command template into an argv array (no shell → no eval, no
injection). The %{files} token expands IN PLACE to N separate argv
elements — one per path, unescaped — so a path containing a space stays a
single argument. It is not a space-joined string.
40 41 42 |
# File 'lib/mutineer/external_backend.rb', line 40 def self.build_argv(command, files) Shellwords.split(command).flat_map { |tok| tok == "%{files}" ? files : [tok] } end |
.run(command, files, timeout:, verbose: false) ⇒ Mutineer::Result
Runs the command for ONE mutant against whatever is currently on disk (the
caller has already swapped the mutant in via FileSwap). Maps the outcome to a
Result. Env is inherited by the subprocess, so RAILS_ENV=test mutineer …
reaches the child with no parsing here.
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/mutineer/external_backend.rb', line 54 def self.run(command, files, timeout:, verbose: false) kind, code, output, = spawn_capture(command, files, timeout) case kind when :timeout # A timeout is the one non-pass we flag by default — a normal kill is also # a non-zero exit, so notifying on every non-zero would spam every kill. warn "[mutineer] test-command exceeded #{timeout}s and was killed (scored timeout)." warn output if verbose && !output.empty? Result.timeout else # :exited return Result.survived if code&.zero? warn output if verbose && !output.empty? Result.killed end end |
.smoke_check!(command, files, timeout: SMOKE_TIMEOUT) ⇒ Float
Pre-flight: run the command once against the UNMUTATED tree. Green (exit 0) returns the elapsed seconds (used to calibrate the per-mutant timeout); anything else raises SmokeCheckError so the run aborts before scoring.
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/mutineer/external_backend.rb', line 80 def self.smoke_check!(command, files, timeout: SMOKE_TIMEOUT) kind, code, output, elapsed = spawn_capture(command, files, timeout) return elapsed if kind == :exited && code&.zero? reason = if kind == :timeout then "did not finish within #{timeout}s" elsif code.nil? then "was terminated by a signal" else "exited #{code}" end detail = output.empty? ? "" : "\n--- last output ---\n#{tail(output)}" raise SmokeCheckError, "the test command #{reason} against the UNMUTATED source — the " \ "environment looks broken (check DB, RAILS_ENV, migrations), not the " \ "tests weak.#{detail}" end |
.spawn_capture(command, files, timeout) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Spawns the command to a captured combined-output tempfile, enforces a wall-clock timeout (SIGKILL past the deadline), and returns [kind, exit_code, output, elapsed]. Mirrors Isolation's single-waiter loop: we are the only caller of waitpid on this pid, so the kill can never hit a reaped/recycled pid.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/mutineer/external_backend.rb', line 103 def self.spawn_capture(command, files, timeout) argv = build_argv(command, files) # Unreachable in practice (validate_test_command! guarantees a non-empty # command with %{files}); a neutral ArgumentError, not the smoke-specific # SmokeCheckError, since this is not a smoke-check failure. raise ArgumentError, "--test-command produced an empty command" if argv.empty? out = Tempfile.create("mutineer_ext") start = Process.clock_gettime(Process::CLOCK_MONOTONIC) # `pgroup: true` puts the child in its OWN process group so a timeout can # kill the whole tree (`bundle exec rails test` forks parallel workers; # spring/bundler add more) — killing only the leader would orphan workers # that keep holding the shared DB, corrupting later serial mutants. The # explicit [program, argv0] form guarantees the no-shell exec path even for a # degenerate single-element argv (Process.spawn(*argv) would route a lone # metachar-bearing string through /bin/sh, breaking the argv-only invariant). pid = Process.spawn([argv.first, argv.first], *argv[1..], out: out, err: %i[child out], pgroup: true) kind, code = wait_with_timeout(pid, timeout) elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start out.rewind [kind, code, out.read, elapsed] ensure if out out.close File.unlink(out.path) rescue nil # rubocop:disable Style/RescueModifier end end |
.tail(output, lines = 40) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Last ~40 lines of captured output, for a smoke-failure message.
164 165 166 |
# File 'lib/mutineer/external_backend.rb', line 164 def self.tail(output, lines = 40) output.lines.last(lines).join end |
.wait_with_timeout(pid, timeout) ⇒ Array(Symbol, Integer, nil)
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Waits for the spawned pid, SIGKILLing its process group past the deadline. Single-waiter deadline loop (mirrors Isolation), so the kill can never hit a reaped/recycled pid.
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/mutineer/external_backend.rb', line 139 def self.wait_with_timeout(pid, timeout) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout loop do reaped, status = Process.waitpid2(pid, Process::WNOHANG) return [:exited, status.exitstatus] if reaped if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline # Kill the whole process GROUP (negative pid) so forked test workers die # with the leader; fall back to the leader alone if the group is already # gone. The child led its group (pgroup: true at spawn). begin Process.kill(:KILL, -pid) rescue Errno::ESRCH, Errno::EPERM Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier end Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier return [:timeout, nil] end sleep POLL end end |