mutineer 1.5.0 → 1.7.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 +473 -0
- data/README.md +352 -26
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +184 -24
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +541 -92
- data/lib/mutineer/daemon_backend.rb +227 -86
- data/lib/mutineer/daemon_client.rb +72 -9
- data/lib/mutineer/daemon_server.rb +229 -51
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +15 -2
- data/lib/mutineer/job_plan.rb +458 -0
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +100 -0
- data/lib/mutineer/project.rb +411 -21
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1477 -48
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +83 -12
- data/lib/mutineer/result.rb +49 -8
- data/lib/mutineer/runner.rb +66 -290
- data/lib/mutineer/statement_lines.rb +54 -7
- data/lib/mutineer/subject.rb +24 -2
- data/lib/mutineer/test_runners/rspec.rb +54 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +5 -0
- metadata +11 -1
|
@@ -3,15 +3,21 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "tempfile"
|
|
5
5
|
require_relative "child_stdout"
|
|
6
|
+
require_relative "child_coverage"
|
|
7
|
+
require_relative "orphan_guard"
|
|
8
|
+
require_relative "concern_blocks"
|
|
9
|
+
require_relative "redaction"
|
|
6
10
|
|
|
7
11
|
module Mutineer
|
|
8
12
|
# App-side daemon (persistent worker).
|
|
9
13
|
#
|
|
10
14
|
# Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
|
|
11
15
|
# `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
|
|
12
|
-
# requests over stdin/stdout as newline-delimited JSON
|
|
13
|
-
#
|
|
14
|
-
#
|
|
16
|
+
# requests over stdin/stdout as newline-delimited JSON (the protocol keeps a
|
|
17
|
+
# private copy of the original stdout; the app's own stdout goes to stderr).
|
|
18
|
+
# For each request it FORKS a child that loads the mutated source text the
|
|
19
|
+
# tool sent, runs the covering tests, and exits with a status the parent
|
|
20
|
+
# decodes into a verdict.
|
|
15
21
|
#
|
|
16
22
|
# HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
|
|
17
23
|
# mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
|
|
@@ -21,19 +27,21 @@ module Mutineer
|
|
|
21
27
|
# tool-side; the daemon only `load`s text.
|
|
22
28
|
#
|
|
23
29
|
# Protocol (one JSON object per line, both directions):
|
|
24
|
-
# boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
|
|
30
|
+
# boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
|
|
25
31
|
# "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
|
|
26
|
-
# ready out: {"ready":true,"ruby":"3.3.6"
|
|
32
|
+
# ready out: {"ready":true,"ruby":"3.3.6","database_count":1}
|
|
33
|
+
# (or {"ready":false,"error":"..."} then exit)
|
|
34
|
+
# provision: {"cmd":"provision","slots":N} -> {"ok":true} or {"ok":false,"error":"..."}
|
|
27
35
|
# run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
|
|
28
36
|
# "tests":["test/models/order_test.rb"],"timeout":30}
|
|
29
37
|
# verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
|
|
30
38
|
# quit in : {"cmd":"quit"}
|
|
31
39
|
#
|
|
32
40
|
# Worker isolation: when the app is Rails, each fork is routed to its own
|
|
33
|
-
# database
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
41
|
+
# database via {RailsWorkerDb} BEFORE any test loads, so concurrent workers
|
|
42
|
+
# cannot clobber each other's transactional fixtures. `worker` defaults to 0
|
|
43
|
+
# (serial). SQLite copies a file per slot. Postgres and MySQL use
|
|
44
|
+
# `<base>-mutineer-<w>`, created once before the worker daemons start.
|
|
37
45
|
#
|
|
38
46
|
# Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite
|
|
39
47
|
# failed), 2=error (child raised AROUND the test: load, boot, or worker-DB
|
|
@@ -44,22 +52,46 @@ module Mutineer
|
|
|
44
52
|
module DaemonServer
|
|
45
53
|
# Poll interval (seconds) for the per-fork deadline wait loop.
|
|
46
54
|
POLL = 0.02
|
|
55
|
+
# Basename glob for a mutant file written beside a source file.
|
|
56
|
+
# Tempfile puts hyphens in the name, so it is not a constant name. The
|
|
57
|
+
# leading dot hides a new file from Zeitwerk (#253); a file an older
|
|
58
|
+
# version left behind has none, so the glob matches both.
|
|
59
|
+
DAEMON_TEMP_BASENAME = "{.,}mutineer_daemon*.rb"
|
|
60
|
+
|
|
61
|
+
# Skips daemon mutant files when Zeitwerk scans an autoload directory.
|
|
62
|
+
# The boot sweep keeps a file another process still holds. Zeitwerk skips a
|
|
63
|
+
# hidden one (#253), but one an older version wrote has no dot, and it would
|
|
64
|
+
# stop the next boot, because its name is not a constant.
|
|
65
|
+
module DaemonTempIgnore
|
|
66
|
+
# @return [void]
|
|
67
|
+
def setup
|
|
68
|
+
dirs = Array(Mutineer::DaemonServer.instance_variable_get(:@source_dirs))
|
|
69
|
+
dirs.each do |dir|
|
|
70
|
+
ignore(File.join(dir, Mutineer::DaemonServer::DAEMON_TEMP_BASENAME))
|
|
71
|
+
end
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
end
|
|
47
75
|
|
|
48
76
|
class << self
|
|
49
77
|
# Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
|
|
50
78
|
#
|
|
51
79
|
# @param input [IO] request stream.
|
|
52
|
-
# @param output [IO] verdict stream.
|
|
80
|
+
# @param output [IO, nil] verdict stream. nil (the default) reserves the
|
|
81
|
+
# process's original stdout for the protocol and points fd 1 at stderr.
|
|
53
82
|
# @param errio [IO] diagnostics stream (never the IPC channel).
|
|
54
83
|
# @return [void]
|
|
55
|
-
def run(input: $stdin, output:
|
|
84
|
+
def run(input: $stdin, output: nil, errio: $stderr)
|
|
85
|
+
# Only a stream `run` reserved itself is the daemon's to close in a fork.
|
|
86
|
+
@protocol = output ? nil : reserve_protocol_output
|
|
87
|
+
output ||= @protocol
|
|
56
88
|
@errio = errio
|
|
57
89
|
@output = output
|
|
58
90
|
boot_line = input.gets
|
|
59
91
|
return if boot_line.nil? # client vanished before boot
|
|
60
92
|
|
|
61
93
|
boot!(JSON.parse(boot_line.strip))
|
|
62
|
-
output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
|
|
94
|
+
output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION, "database_count" => database_count))
|
|
63
95
|
output.flush
|
|
64
96
|
|
|
65
97
|
input.each_line do |line|
|
|
@@ -77,6 +109,14 @@ module Mutineer
|
|
|
77
109
|
end
|
|
78
110
|
break if req["cmd"] == "quit"
|
|
79
111
|
|
|
112
|
+
# Create worker databases before coverage forks or mutant forks.
|
|
113
|
+
# The first daemon does this. A restarted worker daemon does not.
|
|
114
|
+
if req["cmd"] == "provision"
|
|
115
|
+
output.puts(JSON.generate(provision_slots(req["slots"])))
|
|
116
|
+
output.flush
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
80
120
|
# Build the coverage map app-side and ship it to the tool, which then
|
|
81
121
|
# selects covering tests per mutant. One-shot control message.
|
|
82
122
|
if req["cmd"] == "coverage"
|
|
@@ -92,6 +132,21 @@ module Mutineer
|
|
|
92
132
|
|
|
93
133
|
private
|
|
94
134
|
|
|
135
|
+
# Move the protocol channel off fd 1 before the app boots. The app may
|
|
136
|
+
# print through `puts`, `STDOUT`, or a raw fd 1 write (a logger, a
|
|
137
|
+
# subprocess). A line like that ahead of the ready message would break
|
|
138
|
+
# the client's JSON read. The protocol keeps a private dup of the
|
|
139
|
+
# original stdout. Fd 1 then points at stderr, so app output stays
|
|
140
|
+
# visible in the tool's diagnostics.
|
|
141
|
+
#
|
|
142
|
+
# @return [IO] the reserved protocol stream.
|
|
143
|
+
def reserve_protocol_output
|
|
144
|
+
protocol = STDOUT.dup
|
|
145
|
+
STDOUT.reopen(STDERR)
|
|
146
|
+
$stdout = STDOUT
|
|
147
|
+
protocol
|
|
148
|
+
end
|
|
149
|
+
|
|
95
150
|
# BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
|
|
96
151
|
# and inherited by every fork. Never requires mutineer.
|
|
97
152
|
def boot!(cfg)
|
|
@@ -105,18 +160,27 @@ module Mutineer
|
|
|
105
160
|
# instrumented. The map build (build_via_fork) forks this booted parent.
|
|
106
161
|
if cfg["coverage"]
|
|
107
162
|
require "coverage"
|
|
108
|
-
Coverage.start(lines: true)
|
|
163
|
+
Coverage.start(lines: true, methods: true)
|
|
109
164
|
end
|
|
110
|
-
# Clear
|
|
111
|
-
#
|
|
112
|
-
#
|
|
165
|
+
# Clear a mutant tempfile a prior SIGKILLed child orphaned in a source
|
|
166
|
+
# dir BEFORE the app boots. Zeitwerk skips a hidden one, but it would
|
|
167
|
+
# choke on the non-constant name of one an older version wrote without
|
|
168
|
+
# the dot (#253). A file another
|
|
169
|
+
# live process still owns stays: this runs before the database lock,
|
|
170
|
+
# so a refused boot must not delete that run's mutant. The kept file
|
|
171
|
+
# is hidden from Zeitwerk instead.
|
|
113
172
|
sweep_temps
|
|
173
|
+
install_zeitwerk_ignore! if cfg["rails"]
|
|
114
174
|
require File.expand_path(cfg["boot"]) if cfg["boot"]
|
|
175
|
+
@zeitwerk_trace&.disable
|
|
176
|
+
# The --require files, after the boot as in-process (Runner.execute), so
|
|
177
|
+
# the coverage peek sees what they ran at load.
|
|
178
|
+
Array(cfg["require_paths"]).each { |f| require File.expand_path(f) }
|
|
115
179
|
setup_worker_db(cfg) if cfg["rails"]
|
|
116
180
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
117
181
|
# Boot failed (bad boot path, app error). Tell the client and exit so it
|
|
118
182
|
# can surface a clean error rather than hang on the handshake.
|
|
119
|
-
@output.puts(JSON.generate("ready" => false, "error" =>
|
|
183
|
+
@output.puts(JSON.generate("ready" => false, "error" => describe_error(e)))
|
|
120
184
|
@output.flush
|
|
121
185
|
exit!(1)
|
|
122
186
|
end
|
|
@@ -124,22 +188,61 @@ module Mutineer
|
|
|
124
188
|
# Load the per-worker DB adapter app-side (sibling gem file, by relative path
|
|
125
189
|
# so it bypasses the app bundle, like this daemon itself). No-op unless the
|
|
126
190
|
# app has ActiveRecord. Records the adapter + schema path so each fork can
|
|
127
|
-
# route to its own database.
|
|
128
|
-
#
|
|
191
|
+
# route to its own database.
|
|
192
|
+
#
|
|
193
|
+
# `db_role` "prepare" waits for a provision command (the first daemon).
|
|
194
|
+
# "worker" only takes the shared lock (a worker daemon, including a
|
|
195
|
+
# respawn). Anything else provisions now, which is what a direct client
|
|
196
|
+
# that never sends the command needs. SQLite provisioning does nothing.
|
|
129
197
|
def setup_worker_db(cfg)
|
|
130
198
|
require_relative "rails_worker_db"
|
|
131
199
|
@worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
|
|
132
200
|
schema = cfg["schema"] && File.expand_path(cfg["schema"])
|
|
133
201
|
@schema_path = schema if schema && File.exist?(schema)
|
|
134
|
-
#
|
|
135
|
-
|
|
202
|
+
# Each SQLite slot is seeded once, on first use (not every mutant fork).
|
|
203
|
+
# Postgres and MySQL slots are copied at provision time, so this flag is unused there.
|
|
204
|
+
@slot_ready = {}
|
|
205
|
+
return unless @worker_db
|
|
206
|
+
|
|
207
|
+
case cfg["db_role"]
|
|
208
|
+
when "prepare"
|
|
209
|
+
nil
|
|
210
|
+
when "worker"
|
|
211
|
+
@worker_db.prepare_worker!
|
|
212
|
+
else
|
|
213
|
+
@worker_db.provision(cfg.fetch("slots", 2).to_i)
|
|
214
|
+
end
|
|
136
215
|
rescue LoadError => e
|
|
137
|
-
@errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
|
|
216
|
+
@errio.puts("[daemon] worker-DB routing unavailable: #{redact(e.message)}")
|
|
138
217
|
@worker_db = nil
|
|
139
218
|
end
|
|
140
219
|
|
|
220
|
+
# Database configs in the booted test environment. 1 when the app has no
|
|
221
|
+
# worker-DB adapter. The tool uses this to force a single worker.
|
|
222
|
+
#
|
|
223
|
+
# @return [Integer]
|
|
224
|
+
def database_count
|
|
225
|
+
return 1 unless @worker_db
|
|
226
|
+
|
|
227
|
+
@worker_db.database_count
|
|
228
|
+
rescue StandardError
|
|
229
|
+
1
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Run {RailsWorkerDb.provision} and report success or the cause. A failure
|
|
233
|
+
# stays on this reply. It does not become a mutant verdict.
|
|
234
|
+
#
|
|
235
|
+
# @param slots [Integer, String]
|
|
236
|
+
# @return [Hash]
|
|
237
|
+
def provision_slots(slots)
|
|
238
|
+
@worker_db.provision(Integer(slots)) if @worker_db
|
|
239
|
+
{ "ok" => true }
|
|
240
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
241
|
+
{ "ok" => false, "error" => describe_error(e) }
|
|
242
|
+
end
|
|
243
|
+
|
|
141
244
|
# Build the coverage map app-side (Coverage was started at boot) and return
|
|
142
|
-
# it as `{map, failed_test_files}` for the tool to select covering tests.
|
|
245
|
+
# it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
|
|
143
246
|
# Capture forks route to worker 0's DB (isolated, serial). On any failure
|
|
144
247
|
# return an empty map + an error string. The tool then falls back to the
|
|
145
248
|
# full test set rather than mis-scoring everything as no_coverage.
|
|
@@ -149,67 +252,134 @@ module Mutineer
|
|
|
149
252
|
cmap = CoverageMap.new(
|
|
150
253
|
source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
|
|
151
254
|
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
152
|
-
boot_path: @cfg["boot"],
|
|
255
|
+
boot_path: @cfg["boot"], require_paths: Array(@cfg["require_paths"]),
|
|
256
|
+
framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
|
|
153
257
|
capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
154
258
|
).build_via_fork(after_fork: coverage_after_fork)
|
|
259
|
+
# Nothing reads Coverage after the map, so a mutant forked from this
|
|
260
|
+
# daemon does not pay for it (#228).
|
|
261
|
+
Coverage.suspend if Coverage.running?
|
|
155
262
|
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
|
|
156
|
-
"failed_clean_tests" => cmap.failed_clean_tests
|
|
263
|
+
"failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
|
|
264
|
+
"load_methods" => cmap.load_methods.to_a }
|
|
157
265
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
158
|
-
|
|
159
|
-
|
|
266
|
+
error = describe_error(e)
|
|
267
|
+
@errio.puts("[daemon] coverage build failed: #{error}")
|
|
268
|
+
{ "map" => {}, "failed_test_files" => [], "error" => error }
|
|
160
269
|
end
|
|
161
270
|
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
271
|
+
# `Class: message` for an error the daemon reports, with credentials hidden.
|
|
272
|
+
# Rails parses DATABASE_URL itself, so its URI error can carry the password.
|
|
273
|
+
#
|
|
274
|
+
# @param error [Exception]
|
|
275
|
+
# @return [String]
|
|
276
|
+
def describe_error(error)
|
|
277
|
+
redact("#{error.class}: #{error.message}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Hide database credentials in text bound for the client or stderr.
|
|
281
|
+
#
|
|
282
|
+
# @param text [String]
|
|
283
|
+
# @return [String]
|
|
284
|
+
def redact(text)
|
|
285
|
+
Redaction.text(text)
|
|
286
|
+
end
|
|
167
287
|
|
|
288
|
+
# Fork-safety hook for coverage capture. Each capture fork drops its copy
|
|
289
|
+
# of the protocol channel (see close_protocol) and, when the app has a
|
|
290
|
+
# worker-DB adapter, routes to a fresh copy of the base DB in worker 0's
|
|
291
|
+
# slot (captures run serially, so one worker is enough).
|
|
292
|
+
def coverage_after_fork
|
|
293
|
+
worker_db = @worker_db
|
|
168
294
|
schema = @schema_path
|
|
169
|
-
|
|
295
|
+
lambda do
|
|
296
|
+
close_protocol
|
|
297
|
+
worker_db&.after_fork(0, schema, seed: true)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# A forked child never answers on the protocol channel. Closing its
|
|
302
|
+
# inherited copy lets a daemon crash read as EOF on the client at once,
|
|
303
|
+
# not only after a slow or hung child exits. A caller-supplied output
|
|
304
|
+
# (for example STDOUT) is left open.
|
|
305
|
+
def close_protocol
|
|
306
|
+
@protocol&.close rescue nil # rubocop:disable Style/RescueModifier
|
|
170
307
|
end
|
|
171
308
|
|
|
172
309
|
# Fork a child to run one mutant in isolation; decode its exit into a verdict.
|
|
173
310
|
def run_mutant(req)
|
|
174
311
|
timeout = req.fetch("timeout", 30)
|
|
175
312
|
worker = req.fetch("worker", 0)
|
|
176
|
-
#
|
|
177
|
-
|
|
313
|
+
# Seed the slot's DB until the first killed/survived fork for this worker slot.
|
|
314
|
+
first_use = @worker_db && !@slot_ready[worker]
|
|
315
|
+
daemon = Process.pid
|
|
178
316
|
pid = fork do
|
|
179
317
|
# New process group so a per-fork timeout can SIGKILL the whole subtree,
|
|
180
|
-
# and silence the child's stdout so test output
|
|
318
|
+
# and silence the child's stdout so test output stays out of the diagnostics.
|
|
181
319
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
320
|
+
OrphanGuard.start(daemon)
|
|
182
321
|
code =
|
|
183
322
|
begin
|
|
184
323
|
ChildStdout.silence
|
|
324
|
+
ChildCoverage.allow_start
|
|
325
|
+
close_protocol
|
|
185
326
|
# Route THIS fork at its own worker database before any test loads.
|
|
186
327
|
# A routing failure raises here and is scored `error`, never a false verdict.
|
|
187
|
-
@worker_db&.after_fork(worker,
|
|
328
|
+
@worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
|
|
188
329
|
apply_payload(req["payload"])
|
|
189
330
|
run_tests(Array(req["tests"]))
|
|
190
331
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
191
|
-
@errio.puts("[daemon-child] #{e
|
|
332
|
+
@errio.puts("[daemon-child] #{describe_error(e)}")
|
|
192
333
|
2
|
|
193
334
|
end
|
|
194
335
|
exit!(code)
|
|
195
336
|
end
|
|
196
337
|
verdict = wait_verdict(pid, timeout)
|
|
197
|
-
# Mark ready only when the child finished cleanly after
|
|
198
|
-
# (killed/survived). Timeout can interrupt mid-
|
|
199
|
-
#
|
|
200
|
-
@
|
|
338
|
+
# Mark ready only when the child finished cleanly after seeding
|
|
339
|
+
# (killed/survived). Timeout can interrupt mid-copy; error is a routing
|
|
340
|
+
# failure. Both leave the slot unready so the next fork seeds again.
|
|
341
|
+
@slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
|
|
201
342
|
# A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
|
|
202
|
-
# so it cannot outlive the run
|
|
343
|
+
# so it cannot outlive the run.
|
|
203
344
|
sweep_temps if verdict == "timeout"
|
|
204
345
|
{ "id" => req["id"], "verdict" => verdict }
|
|
205
346
|
end
|
|
206
347
|
|
|
348
|
+
# Hide kept mutant files from Zeitwerk before the app builds its loaders.
|
|
349
|
+
# The loader class may not exist yet. Watch for it, then stop watching.
|
|
350
|
+
#
|
|
351
|
+
# @return [void]
|
|
352
|
+
def install_zeitwerk_ignore!
|
|
353
|
+
if defined?(Zeitwerk::Loader)
|
|
354
|
+
prepend_daemon_temp_ignore
|
|
355
|
+
return
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
@zeitwerk_trace = TracePoint.new(:end) do |event|
|
|
359
|
+
next unless event.self.is_a?(Class) && event.self.name == "Zeitwerk::Loader"
|
|
360
|
+
|
|
361
|
+
@zeitwerk_trace.disable
|
|
362
|
+
prepend_daemon_temp_ignore
|
|
363
|
+
end
|
|
364
|
+
@zeitwerk_trace.enable
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# @return [void]
|
|
368
|
+
def prepend_daemon_temp_ignore
|
|
369
|
+
return if Zeitwerk::Loader.ancestors.include?(DaemonTempIgnore)
|
|
370
|
+
|
|
371
|
+
Zeitwerk::Loader.prepend(DaemonTempIgnore)
|
|
372
|
+
end
|
|
373
|
+
|
|
207
374
|
# Remove orphaned mutant tempfiles from the source dirs (parent-side; the
|
|
208
|
-
# SIGKILL path cannot run the child's ensure). Mirrors
|
|
375
|
+
# SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
|
|
376
|
+
# Skips a file another process still owns ({OrphanGuard.mutant_file_in_use?}).
|
|
209
377
|
def sweep_temps
|
|
210
378
|
@source_dirs.to_a.each do |dir|
|
|
211
|
-
Dir.glob(File.join(dir,
|
|
212
|
-
|
|
379
|
+
Dir.glob(File.join(dir, DAEMON_TEMP_BASENAME)).each do |path|
|
|
380
|
+
next if OrphanGuard.mutant_file_in_use?(path)
|
|
381
|
+
|
|
382
|
+
File.unlink(path) rescue nil # rubocop:disable Style/RescueModifier
|
|
213
383
|
end
|
|
214
384
|
end
|
|
215
385
|
end
|
|
@@ -219,6 +389,8 @@ module Mutineer
|
|
|
219
389
|
# pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
|
|
220
390
|
# the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
|
|
221
391
|
# the kill/reap/decode logic must be applied to all three in lockstep.
|
|
392
|
+
# CoverageMap#await_child applies the same deadline and group kill to
|
|
393
|
+
# coverage capture.
|
|
222
394
|
# SIGKILL the child's process group past the deadline; a signalled child
|
|
223
395
|
# (nil exitstatus) is `error`.
|
|
224
396
|
def wait_verdict(pid, timeout)
|
|
@@ -254,15 +426,21 @@ module Mutineer
|
|
|
254
426
|
# `require_relative` in the mutated source resolves against its real
|
|
255
427
|
# neighbours. Writing it to the tmpdir would LoadError on such files and
|
|
256
428
|
# score a spurious `error` that diverges from the in-process path. The
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
#
|
|
429
|
+
# leading dot keeps the file away from Zeitwerk, which skips hidden files:
|
|
430
|
+
# loading it can be the first time Zeitwerk scans a namespace directory,
|
|
431
|
+
# and a parallel worker's file may sit there too (#253). `sweep_temps` only
|
|
432
|
+
# removes orphans and never touches a live file. Same path for reload
|
|
433
|
+
# (whole file) and redefine (wrapped snippet). The backend sends the whole
|
|
434
|
+
# file, so the concern blocks it stores replace the forgotten ones (#256).
|
|
260
435
|
def apply_payload(payload)
|
|
261
436
|
dir = File.dirname(File.expand_path(payload.fetch("source_file")))
|
|
262
|
-
Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
|
|
437
|
+
Tempfile.create([".mutineer_daemon", ".rb"], dir) do |f|
|
|
438
|
+
# Held until this block ends, including SIGKILL (the kernel drops it).
|
|
439
|
+
# Another run's sweep sees the lock and does not delete the file.
|
|
440
|
+
f.flock(File::LOCK_EX) rescue nil # rubocop:disable Style/RescueModifier
|
|
263
441
|
f.write(payload.fetch("code"))
|
|
264
442
|
f.flush
|
|
265
|
-
load f.path
|
|
443
|
+
ConcernBlocks.replace(payload.fetch("source_file")) { load f.path }
|
|
266
444
|
end
|
|
267
445
|
end
|
|
268
446
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "shellwords"
|
|
4
4
|
require "tempfile"
|
|
5
5
|
require_relative "result"
|
|
6
|
+
require_relative "rails_run_env"
|
|
6
7
|
|
|
7
8
|
module Mutineer
|
|
8
9
|
# Raised when the smoke check (the unmutated suite) is not green, so the run
|
|
@@ -25,6 +26,7 @@ module Mutineer
|
|
|
25
26
|
# Child environment: bundler/gem env injected by Mutineer's Ruby is stripped, and
|
|
26
27
|
# version-manager concrete version bins (e.g. `~/.rbenv/versions/3.4.9/bin`) are
|
|
27
28
|
# removed from PATH so shims / `.ruby-version` can select the app's Ruby (#32).
|
|
29
|
+
# PARALLEL_WORKERS and DISABLE_SPRING are set to 1 ({RailsRunEnv}).
|
|
28
30
|
module ExternalBackend
|
|
29
31
|
# Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
|
|
30
32
|
# the full suite). The per-mutant timeout is derived from how long this took.
|
|
@@ -32,6 +34,9 @@ module Mutineer
|
|
|
32
34
|
# Poll interval for the deadline wait loop. Independent of Isolation's loop —
|
|
33
35
|
# this backend waits on an external process TREE, not an in-process fork.
|
|
34
36
|
POLL = 0.02
|
|
37
|
+
# Exit codes a shell or wrapper uses when the command could not run at all
|
|
38
|
+
# (126: not executable, 127: not found). Scored `error`, never `killed`.
|
|
39
|
+
CANNOT_RUN_CODES = [126, 127].freeze
|
|
35
40
|
|
|
36
41
|
# PATH entries that pin a concrete Ruby under a version manager (ahead of
|
|
37
42
|
# shims). Optional trailing slash; normal bins are left alone.
|
|
@@ -67,7 +72,8 @@ module Mutineer
|
|
|
67
72
|
# onto the parent env: set a key to +nil+ to unset it. Scrubs Mutineer/bundler
|
|
68
73
|
# injection and version-manager PATH pins so shims / `.ruby-version` can win.
|
|
69
74
|
# Keeps other vars (e.g. `RAILS_ENV`) so setting them on the Mutineer command
|
|
70
|
-
# still reaches the suite.
|
|
75
|
+
# still reaches the suite. Also sets +PARALLEL_WORKERS+ to 1 and
|
|
76
|
+
# +DISABLE_SPRING+ to 1. A wrapper that sets either variable again wins.
|
|
71
77
|
#
|
|
72
78
|
# @api private
|
|
73
79
|
# @return [Hash{String => String, nil}] env for Process.spawn.
|
|
@@ -90,6 +96,7 @@ module Mutineer
|
|
|
90
96
|
kept
|
|
91
97
|
end
|
|
92
98
|
env["PATH"] = path.join(File::PATH_SEPARATOR)
|
|
99
|
+
RailsRunEnv.pin_child!(env, spring: true)
|
|
93
100
|
env
|
|
94
101
|
end
|
|
95
102
|
|
|
@@ -145,6 +152,10 @@ module Mutineer
|
|
|
145
152
|
end
|
|
146
153
|
|
|
147
154
|
warn output if verbose && !output.empty?
|
|
155
|
+
# 126 (cannot execute) and 127 (command not found) come from a wrapper
|
|
156
|
+
# such as `bundle exec` that could not start the suite: no test ran.
|
|
157
|
+
return Result.error("test-command could not start (exit #{code})") if CANNOT_RUN_CODES.include?(code)
|
|
158
|
+
|
|
148
159
|
Result.killed
|
|
149
160
|
end
|
|
150
161
|
end
|
data/lib/mutineer/file_swap.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Mutineer
|
|
|
21
21
|
# which makes leaving the file mutated the one genuinely dangerous failure mode.
|
|
22
22
|
#
|
|
23
23
|
# Defense in depth, mirroring the tempfile-orphan discipline
|
|
24
|
-
# (`
|
|
24
|
+
# (`JobPlan.sweep_orphans`, `isolation.rb` tempfiles):
|
|
25
25
|
# - exclusive OS ownership (flock) is acquired before swap or recovery;
|
|
26
26
|
# - the original bytes are held in memory AND written to a sibling backup;
|
|
27
27
|
# - `ensure` restores from memory around every mutant;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "mutator_registry"
|
|
6
|
+
|
|
7
|
+
module Mutineer
|
|
8
|
+
# Writes a starter `.mutineer.yml` and prints the first run command.
|
|
9
|
+
# It does not replace an existing file unless `--force` is set.
|
|
10
|
+
module Init
|
|
11
|
+
# Usage line for a bad invocation.
|
|
12
|
+
USAGE = "Usage: mutineer init [--rails] [--force]"
|
|
13
|
+
|
|
14
|
+
# Writes the config and prints the first command.
|
|
15
|
+
# 0 is success. 2 is a usage error, a refused overwrite, or a missing directory.
|
|
16
|
+
#
|
|
17
|
+
# @param argv [Array<String>] arguments after `init`.
|
|
18
|
+
# @param out [IO] where the command goes.
|
|
19
|
+
# @param err [IO] where errors go.
|
|
20
|
+
# @param dir [String] directory that receives `.mutineer.yml`.
|
|
21
|
+
# @return [Integer] 0 or 2.
|
|
22
|
+
def self.run(argv, out: $stdout, err: $stderr, dir: Dir.pwd)
|
|
23
|
+
rails = false
|
|
24
|
+
force = false
|
|
25
|
+
help = false
|
|
26
|
+
parser = OptionParser.new do |o|
|
|
27
|
+
o.banner = USAGE
|
|
28
|
+
o.on("--rails") { rails = true }
|
|
29
|
+
o.on("--force") { force = true }
|
|
30
|
+
o.on("--help") { help = true }
|
|
31
|
+
end
|
|
32
|
+
begin
|
|
33
|
+
parser.parse!(argv)
|
|
34
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
35
|
+
err.puts "mutineer: #{e.message}"
|
|
36
|
+
return 2
|
|
37
|
+
end
|
|
38
|
+
if help
|
|
39
|
+
out.puts USAGE
|
|
40
|
+
return 0
|
|
41
|
+
end
|
|
42
|
+
unless argv.empty?
|
|
43
|
+
err.puts "mutineer: init takes no source paths"
|
|
44
|
+
return 2
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
path = File.join(dir, CONFIG_FILE)
|
|
48
|
+
if File.directory?(path)
|
|
49
|
+
err.puts "mutineer: #{CONFIG_FILE} is a directory"
|
|
50
|
+
return 2
|
|
51
|
+
end
|
|
52
|
+
if File.exist?(path) && !force
|
|
53
|
+
err.puts "mutineer: #{CONFIG_FILE} already exists. Pass --force to replace it."
|
|
54
|
+
return 2
|
|
55
|
+
end
|
|
56
|
+
# The command writes one file. It does not create the project directory.
|
|
57
|
+
unless File.directory?(dir)
|
|
58
|
+
err.puts "mutineer: cannot write #{CONFIG_FILE}: no such directory"
|
|
59
|
+
return 2
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
File.write(path, template(rails: rails))
|
|
64
|
+
rescue SystemCallError => e
|
|
65
|
+
err.puts "mutineer: cannot write #{CONFIG_FILE}: #{e.message}"
|
|
66
|
+
return 2
|
|
67
|
+
end
|
|
68
|
+
command = command_for(dir, rails: rails)
|
|
69
|
+
if command
|
|
70
|
+
out.puts command
|
|
71
|
+
else
|
|
72
|
+
err.puts "mutineer: no source folder found. Create app/models or lib, then run mutineer on that folder."
|
|
73
|
+
end
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Starter config. `since` stays commented so the first run is a full scan.
|
|
78
|
+
# `--rails` also sets `rails: true` and leaves `daemon` commented.
|
|
79
|
+
#
|
|
80
|
+
# @param rails [Boolean] when true, set `rails: true`.
|
|
81
|
+
# @return [String] YAML text.
|
|
82
|
+
def self.template(rails:)
|
|
83
|
+
operators = MutatorRegistry::DEFAULT_NAMES.map { |name| " - #{name}" }.join("\n")
|
|
84
|
+
head = <<~YAML
|
|
85
|
+
# Mutineer config. Command-line flags override this file.
|
|
86
|
+
# These operators are the default set.
|
|
87
|
+
operators:
|
|
88
|
+
YAML
|
|
89
|
+
tail = <<~YAML
|
|
90
|
+
# since: origin/main
|
|
91
|
+
# Leave this commented so the first run is a full scan.
|
|
92
|
+
YAML
|
|
93
|
+
body = "#{head}#{operators}\n\n#{tail}"
|
|
94
|
+
return body unless rails
|
|
95
|
+
|
|
96
|
+
extra = <<~YAML
|
|
97
|
+
rails: true
|
|
98
|
+
|
|
99
|
+
# daemon: true
|
|
100
|
+
# Leave this commented until the support matrix says the daemon fits.
|
|
101
|
+
# The matrix is on https://davidteren.github.io/mutineer/rails.html#daemon
|
|
102
|
+
YAML
|
|
103
|
+
"#{body.rstrip}\n\n#{extra}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The first run command. Plain init names `lib`.
|
|
107
|
+
# `--rails` names each existing folder among `app/models`, `app/services`, and `lib`.
|
|
108
|
+
# A missing folder is left out: `mutineer run` rejects a path that is not there.
|
|
109
|
+
#
|
|
110
|
+
# @param dir [String] project directory.
|
|
111
|
+
# @param rails [Boolean] when true, print the Rails source folders.
|
|
112
|
+
# @return [String, nil] the command, or nil when no Rails source folder exists.
|
|
113
|
+
def self.command_for(dir, rails:)
|
|
114
|
+
return "mutineer run lib" unless rails
|
|
115
|
+
|
|
116
|
+
folders = ["app/models", "app/services", "lib"].select do |folder|
|
|
117
|
+
File.directory?(File.join(dir, folder))
|
|
118
|
+
end
|
|
119
|
+
return if folders.empty?
|
|
120
|
+
|
|
121
|
+
"mutineer run #{folders.join(" ")}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|