mutineer 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +67 -1
- data/lib/mutineer/cli.rb +100 -0
- data/lib/mutineer/config.rb +8 -2
- data/lib/mutineer/coverage_map.rb +27 -7
- data/lib/mutineer/daemon_client.rb +187 -0
- data/lib/mutineer/daemon_server.rb +261 -0
- data/lib/mutineer/external_backend.rb +168 -0
- data/lib/mutineer/file_swap.rb +95 -0
- data/lib/mutineer/rails_worker_db.rb +161 -0
- data/lib/mutineer/runner.rb +384 -45
- data/lib/mutineer/version.rb +1 -1
- metadata +6 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Mutineer
|
|
7
|
+
# #26/#27 Phase 2a — the app-side daemon (persistent worker).
|
|
8
|
+
#
|
|
9
|
+
# Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
|
|
10
|
+
# `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
|
|
11
|
+
# requests over stdin/stdout as newline-delimited JSON. For each request it FORKS
|
|
12
|
+
# a child that loads the mutated source text the tool sent, runs the covering
|
|
13
|
+
# tests, and exits with a status the parent decodes into a verdict.
|
|
14
|
+
#
|
|
15
|
+
# HARD CONSTRAINT (KTD-2/R4): this file must be loadable WITHOUT Prism or the rest
|
|
16
|
+
# of mutineer — the app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
|
|
17
|
+
# mutineer. So it requires ONLY stdlib + the app's own boot file; it re-implements
|
|
18
|
+
# the fork/timeout/decode loop rather than requiring `isolation.rb` (which pulls in
|
|
19
|
+
# Prism). All parsing/mutation happened tool-side; the daemon only `load`s text.
|
|
20
|
+
#
|
|
21
|
+
# Protocol (one JSON object per line, both directions):
|
|
22
|
+
# boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
|
|
23
|
+
# "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
|
|
24
|
+
# ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
|
|
25
|
+
# run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
|
|
26
|
+
# "tests":["test/models/order_test.rb"],"timeout":30}
|
|
27
|
+
# verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
|
|
28
|
+
# quit in : {"cmd":"quit"}
|
|
29
|
+
#
|
|
30
|
+
# Worker isolation (#26/U5): when the app is Rails, each fork is routed to its own
|
|
31
|
+
# database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so concurrent
|
|
32
|
+
# workers can't clobber each other's transactional fixtures. `worker` defaults to 0
|
|
33
|
+
# (serial). SQLite this pass; Postgres provisioning is U10.
|
|
34
|
+
#
|
|
35
|
+
# Verdict mapping (KTD-5, Phase-2a honest limit): child exit 0=survived (suite
|
|
36
|
+
# passed), 1=killed (suite failed), 2=error (child raised AROUND the test — load,
|
|
37
|
+
# boot, or worker-DB routing failure); parent-detected timeout. Tagging an in-TEST DB
|
|
38
|
+
# error (one fired inside a test body, vs at routing time) as `error` rather than
|
|
39
|
+
# `killed` is a U6 concern — only observable under the concurrent gate.
|
|
40
|
+
module DaemonServer
|
|
41
|
+
# Poll interval (seconds) for the per-fork deadline wait loop.
|
|
42
|
+
POLL = 0.02
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
|
|
46
|
+
#
|
|
47
|
+
# @param input [IO] request stream.
|
|
48
|
+
# @param output [IO] verdict stream.
|
|
49
|
+
# @param errio [IO] diagnostics stream (never the IPC channel).
|
|
50
|
+
# @return [void]
|
|
51
|
+
def run(input: $stdin, output: $stdout, errio: $stderr)
|
|
52
|
+
@errio = errio
|
|
53
|
+
@output = output
|
|
54
|
+
boot_line = input.gets
|
|
55
|
+
return if boot_line.nil? # client vanished before boot
|
|
56
|
+
|
|
57
|
+
boot!(JSON.parse(boot_line.strip))
|
|
58
|
+
output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
|
|
59
|
+
output.flush
|
|
60
|
+
|
|
61
|
+
input.each_line do |line|
|
|
62
|
+
line = line.strip
|
|
63
|
+
next if line.empty?
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
req = JSON.parse(line)
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
# A corrupt line has no id to address a reply to (and the client only ever
|
|
69
|
+
# sends valid JSON, so it can't be a pending request) — log and read on
|
|
70
|
+
# rather than write an unaddressable verdict onto the channel.
|
|
71
|
+
@errio.puts("[daemon] dropped unparseable line: #{e.message}")
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
break if req["cmd"] == "quit"
|
|
75
|
+
|
|
76
|
+
# #26/U7: build the coverage map app-side and ship it to the tool, which
|
|
77
|
+
# then selects covering tests per mutant. One-shot control message.
|
|
78
|
+
if req["cmd"] == "coverage"
|
|
79
|
+
output.puts(JSON.generate(build_coverage_map))
|
|
80
|
+
output.flush
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
output.puts(JSON.generate(run_mutant(req)))
|
|
85
|
+
output.flush
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# BOOT ONCE. chdir + require the app's boot file so the whole app is loaded and
|
|
92
|
+
# inherited by every fork. Never requires mutineer.
|
|
93
|
+
def boot!(cfg)
|
|
94
|
+
@cfg = cfg
|
|
95
|
+
@framework = cfg.fetch("framework", "minitest")
|
|
96
|
+
@source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
|
|
97
|
+
Dir.chdir(cfg["project_root"]) if cfg["project_root"]
|
|
98
|
+
ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
|
|
99
|
+
Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
|
|
100
|
+
# #26/U7: start Coverage BEFORE the app loads, so booted source lines are
|
|
101
|
+
# instrumented — the map build (build_via_fork) forks this booted parent.
|
|
102
|
+
if cfg["coverage"]
|
|
103
|
+
require "coverage"
|
|
104
|
+
Coverage.start(lines: true)
|
|
105
|
+
end
|
|
106
|
+
# Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
|
|
107
|
+
# source dir BEFORE the app boots — Zeitwerk would otherwise choke on the
|
|
108
|
+
# tempfile's non-constant name during autoload setup.
|
|
109
|
+
sweep_temps
|
|
110
|
+
require File.expand_path(cfg["boot"]) if cfg["boot"]
|
|
111
|
+
setup_worker_db(cfg) if cfg["rails"]
|
|
112
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
113
|
+
# Boot failed (bad boot path, app error) — tell the client and exit so it can
|
|
114
|
+
# surface a clean error rather than hang on the handshake.
|
|
115
|
+
@output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
|
|
116
|
+
@output.flush
|
|
117
|
+
exit!(1)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Load the per-worker DB adapter app-side (sibling gem file, by relative path so
|
|
121
|
+
# it bypasses the app bundle — like this daemon itself). No-op unless the app has
|
|
122
|
+
# ActiveRecord. Records the adapter + schema path so each fork can route to its
|
|
123
|
+
# own database (#26 isolation, U5). SQLite-only this pass; a non-SQLite config
|
|
124
|
+
# raises in the fork and reads as `error`, never a mis-routed verdict.
|
|
125
|
+
def setup_worker_db(cfg)
|
|
126
|
+
require_relative "rails_worker_db"
|
|
127
|
+
@worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
|
|
128
|
+
schema = cfg["schema"] && File.expand_path(cfg["schema"])
|
|
129
|
+
@schema_path = schema if schema && File.exist?(schema)
|
|
130
|
+
rescue LoadError => e
|
|
131
|
+
@errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
|
|
132
|
+
@worker_db = nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# #26/U7: build the coverage map app-side (Coverage was started at boot) and
|
|
136
|
+
# return it as `{map, failed_test_files}` for the tool to select covering tests.
|
|
137
|
+
# Capture forks route to worker 0's DB (isolated, serial). On any failure return
|
|
138
|
+
# an empty map + an error string — the tool then falls back to the full test set
|
|
139
|
+
# rather than mis-scoring everything as no_coverage.
|
|
140
|
+
def build_coverage_map
|
|
141
|
+
require_relative "coverage_map"
|
|
142
|
+
root = @cfg["project_root"] || Dir.pwd
|
|
143
|
+
cmap = CoverageMap.new(
|
|
144
|
+
source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
|
|
145
|
+
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
146
|
+
boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
|
|
147
|
+
).build_via_fork(after_fork: coverage_after_fork)
|
|
148
|
+
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files }
|
|
149
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
150
|
+
@errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
|
|
151
|
+
{ "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Fork-safety hook for coverage capture: route each capture fork to worker 0's
|
|
155
|
+
# isolated DB (captures run serially, so one worker is enough). Nil when the app
|
|
156
|
+
# has no worker-DB adapter (non-Rails) — capture then runs as before.
|
|
157
|
+
def coverage_after_fork
|
|
158
|
+
return nil unless @worker_db
|
|
159
|
+
|
|
160
|
+
schema = @schema_path
|
|
161
|
+
-> { @worker_db.after_fork(0, schema) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Fork a child to run one mutant in isolation; decode its exit into a verdict.
|
|
165
|
+
def run_mutant(req)
|
|
166
|
+
timeout = req.fetch("timeout", 30)
|
|
167
|
+
worker = req.fetch("worker", 0)
|
|
168
|
+
pid = fork do
|
|
169
|
+
# New process group so a per-fork timeout can SIGKILL the whole subtree
|
|
170
|
+
# (carries the Phase-1 pgroup discipline), and silence the child's stdout so
|
|
171
|
+
# test-framework output can never corrupt the IPC pipe (KTD-6).
|
|
172
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
173
|
+
$stdout.reopen(File::NULL, "w")
|
|
174
|
+
code =
|
|
175
|
+
begin
|
|
176
|
+
# Route THIS fork at its own worker database before any test loads, so
|
|
177
|
+
# concurrent workers can't clobber each other's fixtures (#26). A routing
|
|
178
|
+
# failure raises here and is scored `error` below, never a false verdict.
|
|
179
|
+
@worker_db&.after_fork(worker, @schema_path)
|
|
180
|
+
apply_payload(req["payload"])
|
|
181
|
+
run_tests(Array(req["tests"]))
|
|
182
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
183
|
+
@errio.puts("[daemon-child] #{e.class}: #{e.message}")
|
|
184
|
+
2
|
|
185
|
+
end
|
|
186
|
+
exit!(code)
|
|
187
|
+
end
|
|
188
|
+
verdict = wait_verdict(pid, timeout)
|
|
189
|
+
# A SIGKILLed timeout child skipped its Tempfile unlink — sweep the orphan so
|
|
190
|
+
# it can't outlive the run or trip Zeitwerk on a later fork.
|
|
191
|
+
sweep_temps if verdict == "timeout"
|
|
192
|
+
{ "id" => req["id"], "verdict" => verdict }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Remove orphaned mutant tempfiles from the source dirs (parent-side; the
|
|
196
|
+
# SIGKILL path can't run the child's ensure). Mirrors Runner.sweep_orphans.
|
|
197
|
+
def sweep_temps
|
|
198
|
+
@source_dirs.to_a.each do |dir|
|
|
199
|
+
Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
|
|
200
|
+
File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Single-waiter deadline loop (mirrors Isolation.run and
|
|
206
|
+
# ExternalBackend.wait_with_timeout, re-implemented here because Isolation pulls
|
|
207
|
+
# in Prism which is forbidden app-side). NOTE: this is the 3rd copy of the
|
|
208
|
+
# waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline — a fix to the
|
|
209
|
+
# kill/reap/decode logic must be applied to all three in lockstep. SIGKILL the
|
|
210
|
+
# child's process group past the deadline; a signalled child (nil exitstatus) is
|
|
211
|
+
# `error`.
|
|
212
|
+
def wait_verdict(pid, timeout)
|
|
213
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
214
|
+
loop do
|
|
215
|
+
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
216
|
+
if reaped
|
|
217
|
+
return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
|
|
218
|
+
end
|
|
219
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
220
|
+
begin
|
|
221
|
+
Process.kill(:KILL, -pid)
|
|
222
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
223
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
224
|
+
end
|
|
225
|
+
Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
226
|
+
return "timeout"
|
|
227
|
+
end
|
|
228
|
+
sleep POLL
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Write the tool-built mutated text beside the real source and `load` it —
|
|
233
|
+
# reopening the mutated class/method in THIS child only. It goes in the source
|
|
234
|
+
# file's directory (like Isolation.apply_whole_file) so a `require_relative` in
|
|
235
|
+
# the mutated source resolves against its real neighbours — writing it to the
|
|
236
|
+
# tmpdir would LoadError on such files and score a spurious `error` that
|
|
237
|
+
# diverges from the in-process path. The Zeitwerk hazard (a stray `.rb` in an
|
|
238
|
+
# autoload dir) is handled by the boot/timeout `sweep_temps`, not by relocating
|
|
239
|
+
# the file. Same path for reload (whole file) and redefine (wrapped snippet).
|
|
240
|
+
def apply_payload(payload)
|
|
241
|
+
dir = File.dirname(File.expand_path(payload.fetch("source_file")))
|
|
242
|
+
Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
|
|
243
|
+
f.write(payload.fetch("code"))
|
|
244
|
+
f.flush
|
|
245
|
+
load f.path
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Load the covering test files and run them; 0 = all passed (survived),
|
|
250
|
+
# 1 = a failure/error (killed). Minitest only in 2a (rspec is a later unit).
|
|
251
|
+
def run_tests(tests)
|
|
252
|
+
raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"
|
|
253
|
+
|
|
254
|
+
require "minitest"
|
|
255
|
+
require "rails/test_help" if defined?(Rails)
|
|
256
|
+
tests.each { |t| load File.expand_path(t) }
|
|
257
|
+
Minitest.run([]) ? 0 : 1
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require_relative "result"
|
|
6
|
+
|
|
7
|
+
module Mutineer
|
|
8
|
+
# Raised when the smoke check (the unmutated suite) is not green, so the run
|
|
9
|
+
# aborts before scoring — a broken environment must never be reported as strong
|
|
10
|
+
# tests. The CLI maps this to a runtime error (exit 1), not a usage error.
|
|
11
|
+
class SmokeCheckError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# #27 (U3): the external execution backend. Runs the user's `--test-command` as a
|
|
14
|
+
# subprocess in the app's OWN runtime (whatever Ruby its bundle resolves to), so
|
|
15
|
+
# mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
|
|
16
|
+
#
|
|
17
|
+
# This is deliberately NOT a `TestRunners` framework adapter: those return an
|
|
18
|
+
# Integer 0/1 from inside a fork and are dispatched by framework name. This is a
|
|
19
|
+
# whole backend — it spawns a process, enforces a wall-clock timeout, and maps
|
|
20
|
+
# the exit status to a Result. The mapping is the SAME direction as in-process
|
|
21
|
+
# (suite passes => survived, suite fails => killed) but coarser: it cannot tell an
|
|
22
|
+
# infrastructure error from a genuine kill, so the smoke check (below) guards the
|
|
23
|
+
# persistent case and the score is disclosed as an upper bound (KTD-3/KTD-6).
|
|
24
|
+
module ExternalBackend
|
|
25
|
+
# Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
|
|
26
|
+
# the full suite). The per-mutant timeout is derived from how long this took.
|
|
27
|
+
SMOKE_TIMEOUT = 900
|
|
28
|
+
# Poll interval for the deadline wait loop. Independent of Isolation's loop —
|
|
29
|
+
# this backend waits on an external process TREE, not an in-process fork.
|
|
30
|
+
POLL = 0.02
|
|
31
|
+
|
|
32
|
+
# Turn a command template into an argv array (no shell → no eval, no
|
|
33
|
+
# injection). The `%{files}` token expands IN PLACE to N separate argv
|
|
34
|
+
# elements — one per path, unescaped — so a path containing a space stays a
|
|
35
|
+
# single argument. It is not a space-joined string.
|
|
36
|
+
#
|
|
37
|
+
# @param command [String] the --test-command template (contains %{files}).
|
|
38
|
+
# @param files [Array<String>] test file paths to substitute.
|
|
39
|
+
# @return [Array<String>] argv.
|
|
40
|
+
def self.build_argv(command, files)
|
|
41
|
+
Shellwords.split(command).flat_map { |tok| tok == "%{files}" ? files : [tok] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Runs the command for ONE mutant against whatever is currently on disk (the
|
|
45
|
+
# caller has already swapped the mutant in via FileSwap). Maps the outcome to a
|
|
46
|
+
# Result. Env is inherited by the subprocess, so `RAILS_ENV=test mutineer …`
|
|
47
|
+
# reaches the child with no parsing here.
|
|
48
|
+
#
|
|
49
|
+
# @param command [String] the --test-command template.
|
|
50
|
+
# @param files [Array<String>] test file paths.
|
|
51
|
+
# @param timeout [Numeric] per-mutant wall-clock timeout in seconds.
|
|
52
|
+
# @param verbose [Boolean] print the child's captured output on a non-pass.
|
|
53
|
+
# @return [Mutineer::Result]
|
|
54
|
+
def self.run(command, files, timeout:, verbose: false)
|
|
55
|
+
kind, code, output, = spawn_capture(command, files, timeout)
|
|
56
|
+
case kind
|
|
57
|
+
when :timeout
|
|
58
|
+
# A timeout is the one non-pass we flag by default — a normal kill is also
|
|
59
|
+
# a non-zero exit, so notifying on every non-zero would spam every kill.
|
|
60
|
+
warn "[mutineer] test-command exceeded #{timeout}s and was killed (scored timeout)."
|
|
61
|
+
warn output if verbose && !output.empty?
|
|
62
|
+
Result.timeout
|
|
63
|
+
else # :exited
|
|
64
|
+
return Result.survived if code&.zero?
|
|
65
|
+
|
|
66
|
+
warn output if verbose && !output.empty?
|
|
67
|
+
Result.killed
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Pre-flight: run the command once against the UNMUTATED tree. Green (exit 0)
|
|
72
|
+
# returns the elapsed seconds (used to calibrate the per-mutant timeout);
|
|
73
|
+
# anything else raises SmokeCheckError so the run aborts before scoring.
|
|
74
|
+
#
|
|
75
|
+
# @param command [String] the --test-command template.
|
|
76
|
+
# @param files [Array<String>] test file paths.
|
|
77
|
+
# @param timeout [Numeric] ceiling for the calibration run.
|
|
78
|
+
# @return [Float] elapsed seconds of the clean run.
|
|
79
|
+
# @raise [Mutineer::SmokeCheckError] when the clean suite is not green.
|
|
80
|
+
def self.smoke_check!(command, files, timeout: SMOKE_TIMEOUT)
|
|
81
|
+
kind, code, output, elapsed = spawn_capture(command, files, timeout)
|
|
82
|
+
return elapsed if kind == :exited && code&.zero?
|
|
83
|
+
|
|
84
|
+
reason =
|
|
85
|
+
if kind == :timeout then "did not finish within #{timeout}s"
|
|
86
|
+
elsif code.nil? then "was terminated by a signal"
|
|
87
|
+
else "exited #{code}"
|
|
88
|
+
end
|
|
89
|
+
detail = output.empty? ? "" : "\n--- last output ---\n#{tail(output)}"
|
|
90
|
+
raise SmokeCheckError,
|
|
91
|
+
"the test command #{reason} against the UNMUTATED source — the " \
|
|
92
|
+
"environment looks broken (check DB, RAILS_ENV, migrations), not the " \
|
|
93
|
+
"tests weak.#{detail}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Spawns the command to a captured combined-output tempfile, enforces a
|
|
97
|
+
# wall-clock timeout (SIGKILL past the deadline), and returns
|
|
98
|
+
# [kind, exit_code, output, elapsed]. Mirrors Isolation's single-waiter loop:
|
|
99
|
+
# we are the only caller of waitpid on this pid, so the kill can never hit a
|
|
100
|
+
# reaped/recycled pid.
|
|
101
|
+
#
|
|
102
|
+
# @api private
|
|
103
|
+
def self.spawn_capture(command, files, timeout)
|
|
104
|
+
argv = build_argv(command, files)
|
|
105
|
+
# Unreachable in practice (validate_test_command! guarantees a non-empty
|
|
106
|
+
# command with %{files}); a neutral ArgumentError, not the smoke-specific
|
|
107
|
+
# SmokeCheckError, since this is not a smoke-check failure.
|
|
108
|
+
raise ArgumentError, "--test-command produced an empty command" if argv.empty?
|
|
109
|
+
|
|
110
|
+
out = Tempfile.create("mutineer_ext")
|
|
111
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
112
|
+
# `pgroup: true` puts the child in its OWN process group so a timeout can
|
|
113
|
+
# kill the whole tree (`bundle exec rails test` forks parallel workers;
|
|
114
|
+
# spring/bundler add more) — killing only the leader would orphan workers
|
|
115
|
+
# that keep holding the shared DB, corrupting later serial mutants. The
|
|
116
|
+
# explicit [program, argv0] form guarantees the no-shell exec path even for a
|
|
117
|
+
# degenerate single-element argv (Process.spawn(*argv) would route a lone
|
|
118
|
+
# metachar-bearing string through /bin/sh, breaking the argv-only invariant).
|
|
119
|
+
pid = Process.spawn([argv.first, argv.first], *argv[1..], out: out, err: %i[child out], pgroup: true)
|
|
120
|
+
kind, code = wait_with_timeout(pid, timeout)
|
|
121
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
122
|
+
out.rewind
|
|
123
|
+
[kind, code, out.read, elapsed]
|
|
124
|
+
ensure
|
|
125
|
+
if out
|
|
126
|
+
out.close
|
|
127
|
+
File.unlink(out.path) rescue nil # rubocop:disable Style/RescueModifier
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Waits for the spawned pid, SIGKILLing its process group past the deadline.
|
|
132
|
+
# Single-waiter deadline loop (mirrors Isolation), so the kill can never hit a
|
|
133
|
+
# reaped/recycled pid.
|
|
134
|
+
#
|
|
135
|
+
# @api private
|
|
136
|
+
# @param pid [Integer] the spawned child pid.
|
|
137
|
+
# @param timeout [Numeric] wall-clock deadline in seconds.
|
|
138
|
+
# @return [Array(Symbol, Integer, nil)] `[:exited, code]` or `[:timeout, nil]`.
|
|
139
|
+
def self.wait_with_timeout(pid, timeout)
|
|
140
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
141
|
+
loop do
|
|
142
|
+
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
143
|
+
return [:exited, status.exitstatus] if reaped
|
|
144
|
+
|
|
145
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
146
|
+
# Kill the whole process GROUP (negative pid) so forked test workers die
|
|
147
|
+
# with the leader; fall back to the leader alone if the group is already
|
|
148
|
+
# gone. The child led its group (pgroup: true at spawn).
|
|
149
|
+
begin
|
|
150
|
+
Process.kill(:KILL, -pid)
|
|
151
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
152
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
153
|
+
end
|
|
154
|
+
Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
155
|
+
return [:timeout, nil]
|
|
156
|
+
end
|
|
157
|
+
sleep POLL
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Last ~40 lines of captured output, for a smoke-failure message.
|
|
162
|
+
#
|
|
163
|
+
# @api private
|
|
164
|
+
def self.tail(output, lines = 40)
|
|
165
|
+
output.lines.last(lines).join
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Raised when a source file's backup already exists as FileSwap.with begins —
|
|
5
|
+
# a second mutineer run is racing on the same file (the backup path is shared
|
|
6
|
+
# and unlocked). Aborting beats silently leaving the tree mutated.
|
|
7
|
+
class ConcurrentRunError < StandardError
|
|
8
|
+
def initialize(backup)
|
|
9
|
+
super("a backup already exists at #{backup} — is another mutineer run active " \
|
|
10
|
+
"in this directory? Aborting to avoid corrupting the source file.")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# #27 (U2): apply one whole-file mutant to the REAL source path for the external
|
|
15
|
+
# (`--test-command`) backend, and guarantee the original is restored on every
|
|
16
|
+
# exit path. A separate `bundle exec` subprocess has its own VM and cannot see an
|
|
17
|
+
# in-process `load`, so the mutant must live on disk while its suite runs — which
|
|
18
|
+
# makes leaving the file mutated the one genuinely dangerous failure mode.
|
|
19
|
+
#
|
|
20
|
+
# Defense in depth, mirroring the tempfile-orphan discipline
|
|
21
|
+
# (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
|
|
22
|
+
# - the original bytes are held in memory AND written to a sibling backup;
|
|
23
|
+
# - `ensure` restores from memory around every mutant;
|
|
24
|
+
# - the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
|
|
25
|
+
# can self-heal a left-mutated tree on the next run's startup.
|
|
26
|
+
# Only one mutant is in flight per file at a time (the external path is serial),
|
|
27
|
+
# so backups never collide.
|
|
28
|
+
module FileSwap
|
|
29
|
+
# Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
|
|
30
|
+
BACKUP_SUFFIX = ".mutineer-backup"
|
|
31
|
+
|
|
32
|
+
# Writes `mutated` to `source_file`, yields, then restores the original bytes
|
|
33
|
+
# on every exit path (normal return, exception, or `ensure`). Byte-exact:
|
|
34
|
+
# binary read/write preserves encoding, newlines, and trailing bytes.
|
|
35
|
+
#
|
|
36
|
+
# @param source_file [String] path to the real source file.
|
|
37
|
+
# @param mutated [String] mutated source text to write for the duration.
|
|
38
|
+
# @yield the block to run while the mutant is on disk.
|
|
39
|
+
# @return [Object] the block's return value.
|
|
40
|
+
def self.with(source_file, mutated)
|
|
41
|
+
backup = source_file + BACKUP_SUFFIX
|
|
42
|
+
# A backup already on disk means either a prior hard-killed run (restore_orphans
|
|
43
|
+
# should have healed it at startup) or a SECOND mutineer run racing us on the
|
|
44
|
+
# same file. The backup path is shared and unlocked, so proceeding would let
|
|
45
|
+
# us capture the other run's mutant AS the "original" and permanently mutate
|
|
46
|
+
# the tree. Refuse loudly rather than silently corrupt — and do it BEFORE
|
|
47
|
+
# `created` is set, so the ensure below never touches a backup we don't own.
|
|
48
|
+
raise ConcurrentRunError, backup if File.exist?(backup)
|
|
49
|
+
|
|
50
|
+
original = File.binread(source_file)
|
|
51
|
+
File.binwrite(backup, original)
|
|
52
|
+
created = true
|
|
53
|
+
File.binwrite(source_file, mutated)
|
|
54
|
+
yield
|
|
55
|
+
ensure
|
|
56
|
+
if created
|
|
57
|
+
File.binwrite(source_file, original)
|
|
58
|
+
File.unlink(backup) if File.exist?(backup)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Startup/after-run self-heal: restore any source file left mutated by a prior
|
|
63
|
+
# interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
|
|
64
|
+
# Prints one line to stderr when it actually heals something, so a developer
|
|
65
|
+
# knows their working tree was auto-restored (a file they did not touch).
|
|
66
|
+
#
|
|
67
|
+
# @param dirs [Array<String>] directories to sweep for orphaned backups.
|
|
68
|
+
# @return [void]
|
|
69
|
+
def self.restore_orphans(dirs)
|
|
70
|
+
healed = 0
|
|
71
|
+
dirs.uniq.each do |dir|
|
|
72
|
+
Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
|
|
73
|
+
source_file = backup.delete_suffix(BACKUP_SUFFIX)
|
|
74
|
+
backup_bytes = File.binread(backup)
|
|
75
|
+
if !File.exist?(source_file)
|
|
76
|
+
# A real user file that merely ends in our suffix, with no sibling to
|
|
77
|
+
# restore — leave it untouched (never create a file from it).
|
|
78
|
+
next
|
|
79
|
+
elsif File.binread(source_file) == backup_bytes
|
|
80
|
+
# Redundant backup (e.g. a crash between restore and unlink): nothing to
|
|
81
|
+
# heal, just clear the orphan so the next run doesn't see a false race.
|
|
82
|
+
File.unlink(backup)
|
|
83
|
+
else
|
|
84
|
+
File.binwrite(source_file, backup_bytes)
|
|
85
|
+
File.unlink(backup)
|
|
86
|
+
healed += 1
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
return if healed.zero?
|
|
91
|
+
|
|
92
|
+
warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|