mutineer-mongoid 2.0.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 +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- metadata +143 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require_relative "child_stdout"
|
|
6
|
+
require_relative "orphan_guard"
|
|
7
|
+
|
|
8
|
+
module Mutineer
|
|
9
|
+
# App-side daemon (persistent worker).
|
|
10
|
+
#
|
|
11
|
+
# Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
|
|
12
|
+
# `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
|
|
13
|
+
# requests over stdin/stdout as newline-delimited JSON (the protocol keeps a
|
|
14
|
+
# private copy of the original stdout; the app's own stdout goes to stderr).
|
|
15
|
+
# For each request it FORKS a child that loads the mutated source text the
|
|
16
|
+
# tool sent, runs the covering tests, and exits with a status the parent
|
|
17
|
+
# decodes into a verdict.
|
|
18
|
+
#
|
|
19
|
+
# HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
|
|
20
|
+
# mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
|
|
21
|
+
# mutineer. So it requires ONLY stdlib + the app's own boot file; it
|
|
22
|
+
# re-implements the fork/timeout/decode loop rather than requiring
|
|
23
|
+
# `isolation.rb` (which pulls in Prism). All parsing/mutation happened
|
|
24
|
+
# tool-side; the daemon only `load`s text.
|
|
25
|
+
#
|
|
26
|
+
# Protocol (one JSON object per line, both directions):
|
|
27
|
+
# boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
|
|
28
|
+
# "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
|
|
29
|
+
# ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
|
|
30
|
+
# run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
|
|
31
|
+
# "tests":["test/models/order_test.rb"],"timeout":30}
|
|
32
|
+
# verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
|
|
33
|
+
# quit in : {"cmd":"quit"}
|
|
34
|
+
#
|
|
35
|
+
# Worker isolation: when the app is Rails, each fork is routed to its own
|
|
36
|
+
# database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so
|
|
37
|
+
# concurrent workers cannot clobber each other's transactional fixtures.
|
|
38
|
+
# `worker` defaults to 0 (serial). SQLite this pass; Postgres provisioning is
|
|
39
|
+
# not yet implemented.
|
|
40
|
+
#
|
|
41
|
+
# Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite
|
|
42
|
+
# failed), 2=error (child raised AROUND the test: load, boot, or worker-DB
|
|
43
|
+
# routing failure); parent-detected timeout. Tagging an in-test DB error (one
|
|
44
|
+
# fired inside a test body, vs at routing time) as `error` rather than
|
|
45
|
+
# `killed` is only observable under the concurrent gate and is not yet
|
|
46
|
+
# implemented.
|
|
47
|
+
module DaemonServer
|
|
48
|
+
# Poll interval (seconds) for the per-fork deadline wait loop.
|
|
49
|
+
POLL = 0.02
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
|
|
53
|
+
#
|
|
54
|
+
# @param input [IO] request stream.
|
|
55
|
+
# @param output [IO, nil] verdict stream. nil (the default) reserves the
|
|
56
|
+
# process's original stdout for the protocol and points fd 1 at stderr.
|
|
57
|
+
# @param errio [IO] diagnostics stream (never the IPC channel).
|
|
58
|
+
# @return [void]
|
|
59
|
+
def run(input: $stdin, output: nil, errio: $stderr)
|
|
60
|
+
# Only a stream `run` reserved itself is the daemon's to close in a fork.
|
|
61
|
+
@protocol = output ? nil : reserve_protocol_output
|
|
62
|
+
output ||= @protocol
|
|
63
|
+
@errio = errio
|
|
64
|
+
@output = output
|
|
65
|
+
boot_line = input.gets
|
|
66
|
+
return if boot_line.nil? # client vanished before boot
|
|
67
|
+
|
|
68
|
+
boot!(JSON.parse(boot_line.strip))
|
|
69
|
+
output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
|
|
70
|
+
output.flush
|
|
71
|
+
|
|
72
|
+
input.each_line do |line|
|
|
73
|
+
line = line.strip
|
|
74
|
+
next if line.empty?
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
req = JSON.parse(line)
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
# A corrupt line has no id to address a reply to (and the client only
|
|
80
|
+
# ever sends valid JSON, so it cannot be a pending request). Log and
|
|
81
|
+
# read on rather than write an unaddressable verdict onto the channel.
|
|
82
|
+
@errio.puts("[daemon] dropped unparseable line: #{e.message}")
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
break if req["cmd"] == "quit"
|
|
86
|
+
|
|
87
|
+
# Build the coverage map app-side and ship it to the tool, which then
|
|
88
|
+
# selects covering tests per mutant. One-shot control message.
|
|
89
|
+
if req["cmd"] == "coverage"
|
|
90
|
+
output.puts(JSON.generate(build_coverage_map))
|
|
91
|
+
output.flush
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
output.puts(JSON.generate(run_mutant(req)))
|
|
96
|
+
output.flush
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Move the protocol channel off fd 1 before the app boots. The app may
|
|
103
|
+
# print through `puts`, `STDOUT`, or a raw fd 1 write (a logger, a
|
|
104
|
+
# subprocess). A line like that ahead of the ready message would break
|
|
105
|
+
# the client's JSON read. The protocol keeps a private dup of the
|
|
106
|
+
# original stdout. Fd 1 then points at stderr, so app output stays
|
|
107
|
+
# visible in the tool's diagnostics.
|
|
108
|
+
#
|
|
109
|
+
# @return [IO] the reserved protocol stream.
|
|
110
|
+
def reserve_protocol_output
|
|
111
|
+
protocol = STDOUT.dup
|
|
112
|
+
STDOUT.reopen(STDERR)
|
|
113
|
+
$stdout = STDOUT
|
|
114
|
+
protocol
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
|
|
118
|
+
# and inherited by every fork. Never requires mutineer.
|
|
119
|
+
def boot!(cfg)
|
|
120
|
+
@cfg = cfg
|
|
121
|
+
@framework = cfg.fetch("framework", "minitest")
|
|
122
|
+
@source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
|
|
123
|
+
Dir.chdir(cfg["project_root"]) if cfg["project_root"]
|
|
124
|
+
ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
|
|
125
|
+
Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
|
|
126
|
+
# Start Coverage BEFORE the app loads, so booted source lines are
|
|
127
|
+
# instrumented. The map build (build_via_fork) forks this booted parent.
|
|
128
|
+
if cfg["coverage"]
|
|
129
|
+
require "coverage"
|
|
130
|
+
Coverage.start(lines: true, methods: true)
|
|
131
|
+
end
|
|
132
|
+
# Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
|
|
133
|
+
# source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
|
|
134
|
+
# tempfile's non-constant name during autoload setup.
|
|
135
|
+
sweep_temps
|
|
136
|
+
require File.expand_path(cfg["boot"]) if cfg["boot"]
|
|
137
|
+
# The --require files, after the boot as in-process (Runner.execute), so
|
|
138
|
+
# the coverage peek sees what they ran at load.
|
|
139
|
+
Array(cfg["require_paths"]).each { |f| require File.expand_path(f) }
|
|
140
|
+
setup_worker_db(cfg) if cfg["rails"] || defined?(Mongoid::Clients)
|
|
141
|
+
if @framework == "rspec" && !mongoid_worker_db
|
|
142
|
+
raise "RSpec daemon execution currently requires Mongoid"
|
|
143
|
+
end
|
|
144
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
145
|
+
# Boot failed (bad boot path, app error). Tell the client and exit so it
|
|
146
|
+
# can surface a clean error rather than hang on the handshake.
|
|
147
|
+
@output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
|
|
148
|
+
@output.flush
|
|
149
|
+
exit!(1)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Load the per-worker DB adapter app-side (sibling gem file, by relative path
|
|
153
|
+
# so it bypasses the app bundle, like this daemon itself). Mongoid gets
|
|
154
|
+
# disposable child databases. ActiveRecord retains its SQLite slot adapter.
|
|
155
|
+
# A Mongoid setup failure aborts boot before any specs can run.
|
|
156
|
+
def setup_worker_db(cfg)
|
|
157
|
+
require_relative "rails_worker_db"
|
|
158
|
+
require_relative "mongoid_worker_db"
|
|
159
|
+
@worker_db = if MongoidWorkerDb.available?
|
|
160
|
+
MongoidWorkerDb.new
|
|
161
|
+
elsif RailsWorkerDb.available?
|
|
162
|
+
RailsWorkerDb
|
|
163
|
+
end
|
|
164
|
+
schema = cfg["schema"] && File.expand_path(cfg["schema"])
|
|
165
|
+
@schema_path = schema if schema && File.exist?(schema)
|
|
166
|
+
# Each worker slot is seeded once, on first use (not every mutant fork).
|
|
167
|
+
@slot_ready = {}
|
|
168
|
+
rescue LoadError => e
|
|
169
|
+
@errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
|
|
170
|
+
@worker_db = nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Build the coverage map app-side (Coverage was started at boot) and return
|
|
174
|
+
# it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
|
|
175
|
+
# Capture forks route to worker 0's DB (isolated, serial). On any failure
|
|
176
|
+
# return an empty map + an error string. The tool then falls back to the
|
|
177
|
+
# full test set rather than mis-scoring everything as no_coverage.
|
|
178
|
+
def build_coverage_map
|
|
179
|
+
require_relative "coverage_map"
|
|
180
|
+
root = @cfg["project_root"] || Dir.pwd
|
|
181
|
+
cmap = CoverageMap.new(
|
|
182
|
+
source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
|
|
183
|
+
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
184
|
+
boot_path: @cfg["boot"], require_paths: Array(@cfg["require_paths"]),
|
|
185
|
+
framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
|
|
186
|
+
capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
187
|
+
).build_via_fork(after_fork: coverage_after_fork,
|
|
188
|
+
before_fork: mongoid_worker_db&.method(:before_fork),
|
|
189
|
+
cleanup: mongoid_worker_db&.method(:cleanup),
|
|
190
|
+
cache_key: mongoid_worker_db&.coverage_cache_key)
|
|
191
|
+
# Nothing reads Coverage after the map, so a mutant forked from this
|
|
192
|
+
# daemon does not pay for it (#228).
|
|
193
|
+
Coverage.suspend if Coverage.running?
|
|
194
|
+
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
|
|
195
|
+
"failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
|
|
196
|
+
"load_methods" => cmap.load_methods.to_a }
|
|
197
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
198
|
+
@errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
|
|
199
|
+
{ "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Fork-safety hook for coverage capture. Each capture fork drops its copy
|
|
203
|
+
# of the protocol channel (see close_protocol) and, when the app has a
|
|
204
|
+
# worker-DB adapter, routes to a fresh copy of the base DB in worker 0's
|
|
205
|
+
# slot (captures run serially, so one worker is enough).
|
|
206
|
+
def coverage_after_fork
|
|
207
|
+
worker_db = @worker_db
|
|
208
|
+
schema = @schema_path
|
|
209
|
+
lambda do
|
|
210
|
+
close_protocol
|
|
211
|
+
worker_db&.after_fork(0, schema, seed: true)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A forked child never answers on the protocol channel. Closing its
|
|
216
|
+
# inherited copy lets a daemon crash read as EOF on the client at once,
|
|
217
|
+
# not only after a slow or hung child exits. A caller-supplied output
|
|
218
|
+
# (for example STDOUT) is left open.
|
|
219
|
+
def close_protocol
|
|
220
|
+
@protocol&.close rescue nil # rubocop:disable Style/RescueModifier
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Fork a child to run one mutant in isolation; decode its exit into a verdict.
|
|
224
|
+
def run_mutant(req)
|
|
225
|
+
timeout = req.fetch("timeout", 30)
|
|
226
|
+
worker = req.fetch("worker", 0)
|
|
227
|
+
# Seed the slot's DB until the first killed/survived fork for this worker slot.
|
|
228
|
+
first_use = @worker_db && !@slot_ready[worker]
|
|
229
|
+
daemon = Process.pid
|
|
230
|
+
mongoid_worker_db&.before_fork
|
|
231
|
+
pid = fork do
|
|
232
|
+
# New process group so a per-fork timeout can SIGKILL the whole subtree,
|
|
233
|
+
# and silence the child's stdout so test output stays out of the diagnostics.
|
|
234
|
+
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
235
|
+
OrphanGuard.start(daemon)
|
|
236
|
+
code =
|
|
237
|
+
begin
|
|
238
|
+
ChildStdout.silence
|
|
239
|
+
close_protocol
|
|
240
|
+
# Route THIS fork at its own worker database before any test loads.
|
|
241
|
+
# A routing failure raises here and is scored `error`, never a false verdict.
|
|
242
|
+
@worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
|
|
243
|
+
apply_payload(req["payload"])
|
|
244
|
+
run_tests(Array(req["tests"]))
|
|
245
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
246
|
+
@errio.puts("[daemon-child] #{e.class}: #{e.message}")
|
|
247
|
+
2
|
|
248
|
+
end
|
|
249
|
+
exit!(code)
|
|
250
|
+
end
|
|
251
|
+
verdict = wait_verdict(pid, timeout)
|
|
252
|
+
# Mark ready only when the child finished cleanly after seeding
|
|
253
|
+
# (killed/survived). Timeout can interrupt mid-copy; error is a routing
|
|
254
|
+
# failure. Both leave the slot unready so the next fork seeds again.
|
|
255
|
+
@slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
|
|
256
|
+
# A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
|
|
257
|
+
# so it cannot outlive the run or trip Zeitwerk on a later fork.
|
|
258
|
+
sweep_temps if verdict == "timeout"
|
|
259
|
+
{ "id" => req["id"], "verdict" => verdict }
|
|
260
|
+
ensure
|
|
261
|
+
mongoid_worker_db&.cleanup(pid) if pid
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Mongoid owns disposable child databases; ActiveRecord keeps its existing
|
|
265
|
+
# slot lifecycle. Do not invoke Mongoid cleanup on an ActiveRecord adapter.
|
|
266
|
+
# @return [MongoidWorkerDb, nil]
|
|
267
|
+
def mongoid_worker_db
|
|
268
|
+
@worker_db if defined?(MongoidWorkerDb) && @worker_db.is_a?(MongoidWorkerDb)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Remove orphaned mutant tempfiles from the source dirs (parent-side; the
|
|
272
|
+
# SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
|
|
273
|
+
def sweep_temps
|
|
274
|
+
@source_dirs.to_a.each do |dir|
|
|
275
|
+
Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
|
|
276
|
+
File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Single-waiter deadline loop (mirrors Isolation.run and
|
|
282
|
+
# ExternalBackend.wait_with_timeout, re-implemented here because Isolation
|
|
283
|
+
# pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
|
|
284
|
+
# the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
|
|
285
|
+
# the kill/reap/decode logic must be applied to all three in lockstep.
|
|
286
|
+
# CoverageMap#await_child applies the same deadline and group kill to
|
|
287
|
+
# coverage capture.
|
|
288
|
+
# SIGKILL the child's process group past the deadline; a signalled child
|
|
289
|
+
# (nil exitstatus) is `error`.
|
|
290
|
+
def wait_verdict(pid, timeout)
|
|
291
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
292
|
+
loop do
|
|
293
|
+
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
294
|
+
if reaped
|
|
295
|
+
return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
|
|
296
|
+
end
|
|
297
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
298
|
+
begin
|
|
299
|
+
Process.kill(:KILL, -pid)
|
|
300
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
301
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
302
|
+
end
|
|
303
|
+
begin
|
|
304
|
+
_reaped, status = Process.waitpid2(pid)
|
|
305
|
+
if status && status.exited? && !status.signaled?
|
|
306
|
+
return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
|
|
307
|
+
end
|
|
308
|
+
rescue Errno::ECHILD
|
|
309
|
+
# already reaped
|
|
310
|
+
end
|
|
311
|
+
return "timeout"
|
|
312
|
+
end
|
|
313
|
+
sleep POLL
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Write the tool-built mutated text beside the real source and `load` it,
|
|
318
|
+
# reopening the mutated class/method in THIS child only. It goes in the
|
|
319
|
+
# source file's directory (like Isolation.apply_whole_file) so a
|
|
320
|
+
# `require_relative` in the mutated source resolves against its real
|
|
321
|
+
# neighbours. Writing it to the tmpdir would LoadError on such files and
|
|
322
|
+
# score a spurious `error` that diverges from the in-process path. The
|
|
323
|
+
# Zeitwerk hazard (a stray `.rb` in an autoload dir) is handled by the
|
|
324
|
+
# boot/timeout `sweep_temps`, not by relocating the file. Same path for
|
|
325
|
+
# reload (whole file) and redefine (wrapped snippet).
|
|
326
|
+
def apply_payload(payload)
|
|
327
|
+
dir = File.dirname(File.expand_path(payload.fetch("source_file")))
|
|
328
|
+
Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
|
|
329
|
+
f.write(payload.fetch("code"))
|
|
330
|
+
f.flush
|
|
331
|
+
load f.path
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Load the covering test files and run them; 0 = all passed (survived),
|
|
336
|
+
# 1 = a failure/error (killed). RSpec uses the existing fork-safe runner.
|
|
337
|
+
def run_tests(tests)
|
|
338
|
+
if @framework == "rspec"
|
|
339
|
+
require_relative "mongoid_failures"
|
|
340
|
+
return MongoidFailures.run(tests)
|
|
341
|
+
end
|
|
342
|
+
raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"
|
|
343
|
+
|
|
344
|
+
require "minitest"
|
|
345
|
+
require "rails/test_help" if defined?(Rails)
|
|
346
|
+
tests.each { |t| load File.expand_path(t) }
|
|
347
|
+
Minitest.run([]) ? 0 : 1
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
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
|
+
# External execution backend. Runs the user's `--test-command` as a subprocess
|
|
14
|
+
# in the app's own runtime (whatever Ruby its bundle resolves to), so mutineer
|
|
15
|
+
# (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.
|
|
24
|
+
#
|
|
25
|
+
# Child environment: bundler/gem env injected by Mutineer's Ruby is stripped, and
|
|
26
|
+
# version-manager concrete version bins (e.g. `~/.rbenv/versions/3.4.9/bin`) are
|
|
27
|
+
# removed from PATH so shims / `.ruby-version` can select the app's Ruby (#32).
|
|
28
|
+
module ExternalBackend
|
|
29
|
+
# Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
|
|
30
|
+
# the full suite). The per-mutant timeout is derived from how long this took.
|
|
31
|
+
SMOKE_TIMEOUT = 900
|
|
32
|
+
# Poll interval for the deadline wait loop. Independent of Isolation's loop —
|
|
33
|
+
# this backend waits on an external process TREE, not an in-process fork.
|
|
34
|
+
POLL = 0.02
|
|
35
|
+
|
|
36
|
+
# PATH entries that pin a concrete Ruby under a version manager (ahead of
|
|
37
|
+
# shims). Optional trailing slash; normal bins are left alone.
|
|
38
|
+
VERSION_BIN_PATH = %r{
|
|
39
|
+
(?:
|
|
40
|
+
/\.rbenv/versions/[^/]+/bin
|
|
41
|
+
|/\.asdf/installs/ruby/[^/]+/bin
|
|
42
|
+
|/\.rubies/[^/]+/bin
|
|
43
|
+
|/rubies/[^/]+/bin
|
|
44
|
+
)/?\z
|
|
45
|
+
}x
|
|
46
|
+
|
|
47
|
+
# Env keys Process.spawn must unset (nil value) so Mutineer's Ruby/bundler
|
|
48
|
+
# context does not leak. Spawn merges env onto the parent; omitting a key
|
|
49
|
+
# does NOT clear it.
|
|
50
|
+
CLEAR_ENV_KEYS = %w[
|
|
51
|
+
BUNDLER_VERSION RBENV_VERSION ASDF_RUBY_VERSION RBENV_DIR
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# Turn a command template into an argv array (no shell → no eval, no
|
|
55
|
+
# injection). The <tt>%{files}</tt> token expands IN PLACE to N separate argv
|
|
56
|
+
# elements — one per path, unescaped — so a path containing a space stays a
|
|
57
|
+
# single argument. It is not a space-joined string.
|
|
58
|
+
#
|
|
59
|
+
# @param command [String] the --test-command template (contains <tt>%{files}</tt>).
|
|
60
|
+
# @param files [Array<String>] test file paths to substitute.
|
|
61
|
+
# @return [Array<String>] argv.
|
|
62
|
+
def self.build_argv(command, files)
|
|
63
|
+
Shellwords.split(command).flat_map { |tok| tok == "%{files}" ? files : [tok] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Environment delta for the test-command child. Process.spawn merges this
|
|
67
|
+
# onto the parent env: set a key to +nil+ to unset it. Scrubs Mutineer/bundler
|
|
68
|
+
# injection and version-manager PATH pins so shims / `.ruby-version` can win.
|
|
69
|
+
# Keeps other vars (e.g. `RAILS_ENV`) so setting them on the Mutineer command
|
|
70
|
+
# still reaches the suite.
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
# @return [Hash{String => String, nil}] env for Process.spawn.
|
|
74
|
+
def self.child_env
|
|
75
|
+
env = {}
|
|
76
|
+
cleared_pin = false
|
|
77
|
+
ENV.each_key do |k|
|
|
78
|
+
next unless clear_env_key?(k)
|
|
79
|
+
|
|
80
|
+
env[k] = nil
|
|
81
|
+
cleared_pin = true
|
|
82
|
+
end
|
|
83
|
+
path = ENV["PATH"].to_s.split(File::PATH_SEPARATOR)
|
|
84
|
+
kept = path.reject { |p| p.match?(VERSION_BIN_PATH) }
|
|
85
|
+
scrubbed_bin = kept.size != path.size
|
|
86
|
+
# Only reorder PATH when we scrubbed a pin; otherwise leave the user's PATH alone.
|
|
87
|
+
path = if cleared_pin || scrubbed_bin
|
|
88
|
+
(version_manager_shim_dirs + kept).uniq
|
|
89
|
+
else
|
|
90
|
+
kept
|
|
91
|
+
end
|
|
92
|
+
env["PATH"] = path.join(File::PATH_SEPARATOR)
|
|
93
|
+
env
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True when the key must be unset in the child (bundler/gem/version-manager).
|
|
97
|
+
#
|
|
98
|
+
# @api private
|
|
99
|
+
# @param key [String] environment variable name.
|
|
100
|
+
# @return [Boolean]
|
|
101
|
+
def self.clear_env_key?(key)
|
|
102
|
+
key.start_with?("BUNDLE_", "RUBY", "GEM_") || CLEAR_ENV_KEYS.include?(key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Existing shim directories for rbenv / asdf (chruby uses PATH without shims).
|
|
106
|
+
#
|
|
107
|
+
# @api private
|
|
108
|
+
# @return [Array<String>] absolute shim paths that exist.
|
|
109
|
+
def self.version_manager_shim_dirs
|
|
110
|
+
home = ENV["HOME"]
|
|
111
|
+
return [] if home.nil? || home.empty?
|
|
112
|
+
|
|
113
|
+
[
|
|
114
|
+
File.join(home, ".rbenv", "shims"),
|
|
115
|
+
File.join(home, ".asdf", "shims")
|
|
116
|
+
].select { |d| File.directory?(d) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Runs the command for ONE mutant against whatever is currently on disk (the
|
|
120
|
+
# caller has already swapped the mutant in via FileSwap). Maps the outcome to a
|
|
121
|
+
# Result. Uses {child_env} so version-manager PATH pins from Mutineer's Ruby do
|
|
122
|
+
# not force the suite onto the wrong interpreter.
|
|
123
|
+
#
|
|
124
|
+
# @param command [String] the --test-command template.
|
|
125
|
+
# @param files [Array<String>] test file paths.
|
|
126
|
+
# @param timeout [Numeric] per-mutant wall-clock timeout in seconds.
|
|
127
|
+
# @param verbose [Boolean] print the child's captured output on a non-pass.
|
|
128
|
+
# @return [Mutineer::Result]
|
|
129
|
+
def self.run(command, files, timeout:, verbose: false)
|
|
130
|
+
kind, code, output, = spawn_capture(command, files, timeout)
|
|
131
|
+
case kind
|
|
132
|
+
when :timeout
|
|
133
|
+
# A timeout is the one non-pass we flag by default — a normal kill is also
|
|
134
|
+
# a non-zero exit, so notifying on every non-zero would spam every kill.
|
|
135
|
+
warn "[mutineer] test-command exceeded #{timeout}s and was killed (scored timeout)."
|
|
136
|
+
warn output if verbose && !output.empty?
|
|
137
|
+
Result.timeout
|
|
138
|
+
else # :exited
|
|
139
|
+
return Result.survived if code&.zero?
|
|
140
|
+
# Signal death (nil exitstatus) is infrastructure, not a suite assertion
|
|
141
|
+
# failure — match Isolation/daemon (error), do not inflate kill rate.
|
|
142
|
+
if code.nil?
|
|
143
|
+
warn output if verbose && !output.empty?
|
|
144
|
+
return Result.error("test-command terminated by signal")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
warn output if verbose && !output.empty?
|
|
148
|
+
Result.killed
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Pre-flight: run the command once against the UNMUTATED tree. Green (exit 0)
|
|
153
|
+
# returns the elapsed seconds (used to calibrate the per-mutant timeout);
|
|
154
|
+
# anything else raises SmokeCheckError so the run aborts before scoring.
|
|
155
|
+
#
|
|
156
|
+
# @param command [String] the --test-command template.
|
|
157
|
+
# @param files [Array<String>] test file paths.
|
|
158
|
+
# @param timeout [Numeric] ceiling for the calibration run.
|
|
159
|
+
# @return [Float] elapsed seconds of the clean run.
|
|
160
|
+
# @raise [Mutineer::SmokeCheckError] when the clean suite is not green.
|
|
161
|
+
def self.smoke_check!(command, files, timeout: SMOKE_TIMEOUT)
|
|
162
|
+
kind, code, output, elapsed = spawn_capture(command, files, timeout)
|
|
163
|
+
return elapsed if kind == :exited && code&.zero?
|
|
164
|
+
|
|
165
|
+
reason =
|
|
166
|
+
if kind == :timeout then "did not finish within #{timeout}s"
|
|
167
|
+
elsif code.nil? then "was terminated by a signal"
|
|
168
|
+
else "exited #{code}"
|
|
169
|
+
end
|
|
170
|
+
detail = output.empty? ? "" : "\n--- last output ---\n#{tail(output)}"
|
|
171
|
+
hint = ruby_version_mismatch_hint(output)
|
|
172
|
+
raise SmokeCheckError,
|
|
173
|
+
"the test command #{reason} against the UNMUTATED source — " \
|
|
174
|
+
"#{hint || generic_env_hint}.#{detail}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Generic smoke-failure framing when no Ruby-version mismatch is detected.
|
|
178
|
+
#
|
|
179
|
+
# @api private
|
|
180
|
+
# @return [String]
|
|
181
|
+
def self.generic_env_hint
|
|
182
|
+
"the unmutated suite is not green (failing tests, or a broken environment: " \
|
|
183
|
+
"DB, RAILS_ENV, migrations) — fix that before scoring"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Targeted hint when Bundler reports a RubyVersionMismatch (common under
|
|
187
|
+
# rbenv/asdf/chruby when Mutineer's version bin sits ahead of shims on PATH).
|
|
188
|
+
#
|
|
189
|
+
# @api private
|
|
190
|
+
# @param output [String] captured child stdout+stderr.
|
|
191
|
+
# @return [String, nil] hint text, or nil when not a version mismatch.
|
|
192
|
+
def self.ruby_version_mismatch_hint(output)
|
|
193
|
+
return nil if output.nil? || output.empty?
|
|
194
|
+
return nil unless output.match?(/RubyVersionMismatch|Your Ruby version is .* but your Gemfile specified/i)
|
|
195
|
+
|
|
196
|
+
ran = output[/Your Ruby version is ([0-9.]+)/i, 1]
|
|
197
|
+
want = output[/Gemfile specified ([0-9.]+)/i, 1]
|
|
198
|
+
parts = +"Detected a Ruby version mismatch"
|
|
199
|
+
parts << ": the test command ran under #{ran}" if ran
|
|
200
|
+
parts << " but the app expects #{want}" if want
|
|
201
|
+
parts << ". Mutineer already scrubbed version-manager bins and RBENV_VERSION/" \
|
|
202
|
+
"ASDF_RUBY_VERSION from the child env; the suite still picked the wrong " \
|
|
203
|
+
"Ruby. Use a wrapper that re-selects the app Ruby and ensure " \
|
|
204
|
+
".ruby-version / .tool-versions is present " \
|
|
205
|
+
"(see README: Apps on Ruby < 3.4 → Under a version manager)"
|
|
206
|
+
parts
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Spawns the command to a captured combined-output tempfile, enforces a
|
|
210
|
+
# wall-clock timeout (SIGKILL past the deadline), and returns
|
|
211
|
+
# [kind, exit_code, output, elapsed]. Mirrors Isolation's single-waiter loop:
|
|
212
|
+
# we are the only caller of waitpid on this pid, so the kill can never hit a
|
|
213
|
+
# reaped/recycled pid.
|
|
214
|
+
#
|
|
215
|
+
# @api private
|
|
216
|
+
def self.spawn_capture(command, files, timeout)
|
|
217
|
+
argv = build_argv(command, files)
|
|
218
|
+
# Unreachable in practice (validate_test_command! guarantees a non-empty
|
|
219
|
+
# command with %{files}); a neutral ArgumentError, not the smoke-specific
|
|
220
|
+
# SmokeCheckError, since this is not a smoke-check failure.
|
|
221
|
+
raise ArgumentError, "--test-command produced an empty command" if argv.empty?
|
|
222
|
+
|
|
223
|
+
out = Tempfile.create("mutineer_ext")
|
|
224
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
225
|
+
# `pgroup: true` puts the child in its OWN process group so a timeout can
|
|
226
|
+
# kill the whole tree (`bundle exec rails test` forks parallel workers;
|
|
227
|
+
# spring/bundler add more) — killing only the leader would orphan workers
|
|
228
|
+
# that keep holding the shared DB, corrupting later serial mutants. The
|
|
229
|
+
# explicit [program, argv0] form guarantees the no-shell exec path even for a
|
|
230
|
+
# degenerate single-element argv (Process.spawn(*argv) would route a lone
|
|
231
|
+
# metachar-bearing string through /bin/sh, breaking the argv-only invariant).
|
|
232
|
+
pid = Process.spawn(child_env, [argv.first, argv.first], *argv[1..],
|
|
233
|
+
out: out, err: %i[child out], pgroup: true)
|
|
234
|
+
kind, code = wait_with_timeout(pid, timeout)
|
|
235
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
236
|
+
out.rewind
|
|
237
|
+
[kind, code, out.read, elapsed]
|
|
238
|
+
ensure
|
|
239
|
+
if out
|
|
240
|
+
out.close
|
|
241
|
+
File.unlink(out.path) rescue nil # rubocop:disable Style/RescueModifier
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Waits for the spawned pid, SIGKILLing its process group past the deadline.
|
|
246
|
+
# Single-waiter deadline loop (mirrors Isolation), so the kill can never hit a
|
|
247
|
+
# reaped/recycled pid.
|
|
248
|
+
#
|
|
249
|
+
# @api private
|
|
250
|
+
# @param pid [Integer] the spawned child pid.
|
|
251
|
+
# @param timeout [Numeric] wall-clock deadline in seconds.
|
|
252
|
+
# @return [Array(Symbol, Integer, nil)] `[:exited, code]` or `[:timeout, nil]`.
|
|
253
|
+
def self.wait_with_timeout(pid, timeout)
|
|
254
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
255
|
+
loop do
|
|
256
|
+
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
257
|
+
return [:exited, status.exitstatus] if reaped
|
|
258
|
+
|
|
259
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
260
|
+
# Kill the whole process GROUP (negative pid) so forked test workers die
|
|
261
|
+
# with the leader; fall back to the leader alone if the group is already
|
|
262
|
+
# gone. The child led its group (pgroup: true at spawn).
|
|
263
|
+
begin
|
|
264
|
+
Process.kill(:KILL, -pid)
|
|
265
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
266
|
+
Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
|
|
267
|
+
end
|
|
268
|
+
begin
|
|
269
|
+
_reaped, status = Process.waitpid2(pid)
|
|
270
|
+
# Finished cleanly between poll and kill — honor real exit code.
|
|
271
|
+
return [:exited, status.exitstatus] if status && status.exited? && !status.signaled?
|
|
272
|
+
rescue Errno::ECHILD
|
|
273
|
+
# already reaped
|
|
274
|
+
end
|
|
275
|
+
return [:timeout, nil]
|
|
276
|
+
end
|
|
277
|
+
sleep POLL
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Last ~40 lines of captured output, for a smoke-failure message.
|
|
282
|
+
#
|
|
283
|
+
# @api private
|
|
284
|
+
def self.tail(output, lines = 40)
|
|
285
|
+
output.lines.last(lines).join
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|