mutineer 1.5.0 → 1.6.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 +225 -0
- data/README.md +28 -3
- data/lib/mutineer/cli.rb +5 -4
- data/lib/mutineer/coverage_map.rb +372 -86
- data/lib/mutineer/daemon_backend.rb +48 -33
- data/lib/mutineer/daemon_client.rb +42 -9
- data/lib/mutineer/daemon_server.rb +73 -27
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +1 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/project.rb +337 -21
- data/lib/mutineer/rails_worker_db.rb +82 -8
- data/lib/mutineer/reporter.rb +20 -3
- data/lib/mutineer/result.rb +36 -4
- data/lib/mutineer/runner.rb +48 -284
- data/lib/mutineer/statement_lines.rb +40 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/rspec.rb +39 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +1 -0
- metadata +3 -1
|
@@ -5,10 +5,7 @@ require_relative "result"
|
|
|
5
5
|
require_relative "coverage_map"
|
|
6
6
|
require_relative "daemon_client"
|
|
7
7
|
require_relative "progress"
|
|
8
|
-
|
|
9
|
-
# reverse edge makes Ruby warn "circular require considered harmful" on every -w
|
|
10
|
-
# load. Runner is loaded first on every real path; requiring this file alone leaves
|
|
11
|
-
# it undefined. Rationale and the real fix: #75.
|
|
8
|
+
require_relative "job_plan"
|
|
12
9
|
|
|
13
10
|
module Mutineer
|
|
14
11
|
# Daemon execution backend. Boots the app ONCE in a persistent subprocess under
|
|
@@ -19,12 +16,12 @@ module Mutineer
|
|
|
19
16
|
# When jobs > 1 each worker runs against its OWN database, which is what makes
|
|
20
17
|
# `--jobs N` safe under Rails (#26): parallel verdicts are identical to serial.
|
|
21
18
|
#
|
|
22
|
-
# Job collection, `--since` filtering and coverage selection
|
|
23
|
-
#
|
|
24
|
-
#
|
|
19
|
+
# Job collection, `--since` filtering and coverage selection live in {JobPlan},
|
|
20
|
+
# which the in-process path calls too, so the daemon path can never drift from
|
|
21
|
+
# it on which mutants run or which tests narrow a mutant (score parity).
|
|
25
22
|
#
|
|
26
23
|
# Unlike {ExternalBackend}, which is a leaf {Runner} calls into, this module owns
|
|
27
|
-
# its orchestration and
|
|
24
|
+
# its orchestration and takes that shared vocabulary from {JobPlan}.
|
|
28
25
|
module DaemonBackend
|
|
29
26
|
# Default per-mutant timeout on the daemon path (seconds), overridden by
|
|
30
27
|
# config.daemon_timeout. Coverage narrowing usually keeps each job short; this
|
|
@@ -44,10 +41,10 @@ module Mutineer
|
|
|
44
41
|
# @param config [Mutineer::Config] run configuration (daemon set).
|
|
45
42
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
46
43
|
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
47
|
-
# source map, and the {
|
|
44
|
+
# source map, and the {JobPlan.collect_jobs} extras.
|
|
48
45
|
def self.execute(config, operator_classes)
|
|
49
|
-
jobs, ignored_results, source_map, extras =
|
|
50
|
-
jobs =
|
|
46
|
+
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
47
|
+
jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
|
|
51
48
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
52
49
|
|
|
53
50
|
# Nothing to mutate (`--since` matched no changed line, or every mutant is
|
|
@@ -58,7 +55,7 @@ module Mutineer
|
|
|
58
55
|
# The daemon sweeps orphaned temps at boot and nothing boots here, so sweep
|
|
59
56
|
# tool-side. A file a hard-killed run left in app/models breaks the app's own
|
|
60
57
|
# Zeitwerk boot, not just Mutineer's next run.
|
|
61
|
-
|
|
58
|
+
JobPlan.sweep_orphans(JobPlan.source_dirs(config), DAEMON_TEMP_GLOB)
|
|
62
59
|
return [AggregateResult.new(ignored_results), source_map, extras]
|
|
63
60
|
end
|
|
64
61
|
|
|
@@ -91,8 +88,11 @@ module Mutineer
|
|
|
91
88
|
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
|
92
89
|
# per-test coverage app-side, ships the map back). Returns a query-only
|
|
93
90
|
# CoverageMap, or nil when the build fails / returns empty. Callers then run the
|
|
94
|
-
# full --test set.
|
|
95
|
-
#
|
|
91
|
+
# full --test set. An empty map that came with boot lines returns a map with
|
|
92
|
+
# those lines only: it narrows nothing, but still classifies `ran_at_load`.
|
|
93
|
+
# Each capture is bounded by capture_timeout (#101); the client's wait for
|
|
94
|
+
# the whole map is not, because its length grows with the number of test
|
|
95
|
+
# files. A normal nonempty map scores like in-process;
|
|
96
96
|
# nil falls back to the full suite (more testing, not comparable).
|
|
97
97
|
#
|
|
98
98
|
# @param config [Mutineer::Config] the run config.
|
|
@@ -109,7 +109,7 @@ module Mutineer
|
|
|
109
109
|
# A red unmutated suite must abort, even when the shipped map is empty.
|
|
110
110
|
# Falling back to the full --test set would treat those failures as kills.
|
|
111
111
|
if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any?
|
|
112
|
-
|
|
112
|
+
JobPlan.abort_if_unclean!(CoverageMap.from_data(
|
|
113
113
|
map: data["map"] || {},
|
|
114
114
|
failed_test_files: data["failed_test_files"] || [],
|
|
115
115
|
project_root: config.project_root,
|
|
@@ -120,11 +120,21 @@ module Mutineer
|
|
|
120
120
|
unless data && !(data["map"] || {}).empty?
|
|
121
121
|
reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
|
|
122
122
|
warn_coverage_fallback(reason)
|
|
123
|
-
|
|
123
|
+
# No narrowing ({job_result} runs every test), but the lines and
|
|
124
|
+
# methods that ran at boot still classify a survivor on one of them.
|
|
125
|
+
load_lines = data.is_a?(Hash) ? Array(data["load_lines"]) : []
|
|
126
|
+
load_methods = data.is_a?(Hash) ? Array(data["load_methods"]) : []
|
|
127
|
+
return nil if load_lines.empty? && load_methods.empty?
|
|
128
|
+
|
|
129
|
+
return CoverageMap.from_data(map: {}, failed_test_files: [], project_root: config.project_root,
|
|
130
|
+
load_lines: load_lines, load_methods: load_methods)
|
|
124
131
|
end
|
|
125
132
|
|
|
126
133
|
CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
|
|
127
|
-
project_root: config.project_root
|
|
134
|
+
project_root: config.project_root, load_lines: data["load_lines"] || [],
|
|
135
|
+
load_methods: data["load_methods"] || [])
|
|
136
|
+
rescue DaemonBootTimeout
|
|
137
|
+
raise # a second daemon for the mutant runs would hang just as long
|
|
128
138
|
rescue DaemonBootError => e
|
|
129
139
|
warn_coverage_fallback("#{e.class}: #{e.message}")
|
|
130
140
|
nil
|
|
@@ -246,10 +256,10 @@ module Mutineer
|
|
|
246
256
|
# Skip an invalid mutant tool-side: never ship a payload that would fail to
|
|
247
257
|
# load and read as a false `killed`.
|
|
248
258
|
# Narrow to covering tests (shared with the in-process path via
|
|
249
|
-
#
|
|
250
|
-
# no fork. No map (build failed) → run the full --test set
|
|
251
|
-
# narrowed).
|
|
252
|
-
sel = coverage_map &&
|
|
259
|
+
# JobPlan.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
|
|
260
|
+
# no fork. No map, or an empty one (build failed) → run the full --test set
|
|
261
|
+
# (fallback, not narrowed).
|
|
262
|
+
sel = coverage_map && !coverage_map.map.empty? && JobPlan.coverage_selection(subject.file, mutation, subject, source, coverage_map)
|
|
253
263
|
r =
|
|
254
264
|
if Parser.parse_string(mutated).errors.any?
|
|
255
265
|
Result.skipped
|
|
@@ -261,14 +271,16 @@ module Mutineer
|
|
|
261
271
|
payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
|
|
262
272
|
tests: sel ? sel[1] : abs_tests
|
|
263
273
|
)
|
|
264
|
-
|
|
274
|
+
# A survivor whose line ran at load, as in-process (Runner.run).
|
|
275
|
+
JobPlan.load_verdict(result_for(verdict), subject.file, mutation, subject, source, coverage_map)
|
|
265
276
|
end
|
|
266
277
|
r.with(subject: subject, mutation: mutation, id: id)
|
|
267
278
|
end
|
|
268
279
|
|
|
269
|
-
# The boot config the daemon needs to boot the app once: where to boot, the
|
|
270
|
-
#
|
|
271
|
-
# whether this
|
|
280
|
+
# The boot config the daemon needs to boot the app once: where to boot, the
|
|
281
|
+
# --require files to load after it, the test load roots (so
|
|
282
|
+
# `require "test_helper"` resolves in every fork), framework, and whether this
|
|
283
|
+
# is Rails.
|
|
272
284
|
#
|
|
273
285
|
# @param config [Mutineer::Config] the run config.
|
|
274
286
|
# @param abs_tests [Array<String>] absolute --test paths.
|
|
@@ -278,13 +290,16 @@ module Mutineer
|
|
|
278
290
|
{
|
|
279
291
|
project_root: config.project_root,
|
|
280
292
|
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
281
|
-
|
|
293
|
+
# Required after the boot, as in-process (Runner.execute); also part of
|
|
294
|
+
# the coverage digest.
|
|
295
|
+
require_paths: config.require_paths.map { |f| File.expand_path(f, config.project_root) },
|
|
296
|
+
load_paths: JobPlan.test_load_roots(abs_tests),
|
|
282
297
|
cache_dir: File.expand_path(config.cache_dir, config.project_root),
|
|
283
|
-
source_dirs:
|
|
298
|
+
source_dirs: JobPlan.source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
284
299
|
framework: config.framework,
|
|
285
300
|
rails: config.rails,
|
|
286
|
-
# Schema for per-worker DB isolation. Sent when present; the daemon
|
|
287
|
-
#
|
|
301
|
+
# Schema for per-worker DB isolation. Sent when present; the daemon loads
|
|
302
|
+
# it over a worker's copy of the test DB only when that copy is out of date.
|
|
288
303
|
schema: schema_path(config),
|
|
289
304
|
# Coverage narrowing. Only the short-lived map-building daemon starts
|
|
290
305
|
# Coverage (before boot); worker daemons boot with it OFF (no wasted
|
|
@@ -298,10 +313,10 @@ module Mutineer
|
|
|
298
313
|
}
|
|
299
314
|
end
|
|
300
315
|
|
|
301
|
-
# Absolute path to the app's `db/schema.rb` if it exists, else nil.
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
#
|
|
316
|
+
# Absolute path to the app's `db/schema.rb` if it exists, else nil. Each worker
|
|
317
|
+
# DB starts as a copy of the test DB; the daemon loads this file over the copy
|
|
318
|
+
# when the copy's schema differs. `structure.sql` apps get nil and keep the
|
|
319
|
+
# copy as it is.
|
|
305
320
|
#
|
|
306
321
|
# @param config [Mutineer::Config] the run config.
|
|
307
322
|
# @api private
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "io/wait"
|
|
4
5
|
require "open3"
|
|
5
6
|
require_relative "external_backend"
|
|
6
7
|
|
|
@@ -12,6 +13,11 @@ module Mutineer
|
|
|
12
13
|
# to a runtime error (exit 1).
|
|
13
14
|
class DaemonBootError < StandardError; end
|
|
14
15
|
|
|
16
|
+
# A boot that has not answered the handshake within DaemonClient::BOOT_TIMEOUT
|
|
17
|
+
# (#101). Unlike other boot errors it is not worth retrying with a second
|
|
18
|
+
# daemon, which would wait just as long.
|
|
19
|
+
class DaemonBootTimeout < DaemonBootError; end
|
|
20
|
+
|
|
15
21
|
# Tool-side handle for the app-side daemon.
|
|
16
22
|
#
|
|
17
23
|
# Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
|
|
@@ -29,6 +35,13 @@ module Mutineer
|
|
|
29
35
|
DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
|
|
30
36
|
# How many times to respawn a crashing daemon before aborting the run.
|
|
31
37
|
MAX_RESTARTS = 3
|
|
38
|
+
# Seconds a verdict may lag the request's own timeout. The daemon enforces
|
|
39
|
+
# that timeout on the mutant child, so a reply later than this means the
|
|
40
|
+
# daemon itself is wedged (#101): it is killed and respawned.
|
|
41
|
+
REPLY_GRACE = 30
|
|
42
|
+
# Seconds the daemon gets to boot the app and answer the handshake. A boot
|
|
43
|
+
# that never returns ends the run instead of hanging it (#101).
|
|
44
|
+
BOOT_TIMEOUT = 600
|
|
32
45
|
# Bundler's marker for a variable that was unset before it activated.
|
|
33
46
|
BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
|
|
34
47
|
|
|
@@ -83,7 +96,7 @@ module Mutineer
|
|
|
83
96
|
reply =
|
|
84
97
|
begin
|
|
85
98
|
send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
|
|
86
|
-
read_line
|
|
99
|
+
read_line(timeout + REPLY_GRACE)
|
|
87
100
|
rescue Errno::EPIPE, IOError
|
|
88
101
|
nil
|
|
89
102
|
end
|
|
@@ -117,7 +130,7 @@ module Mutineer
|
|
|
117
130
|
return unless @stdin
|
|
118
131
|
|
|
119
132
|
send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
|
|
120
|
-
@wait_thr&.join
|
|
133
|
+
@wait_thr&.join(REPLY_GRACE) # a wedged daemon is killed by close_io
|
|
121
134
|
ensure
|
|
122
135
|
close_io
|
|
123
136
|
end
|
|
@@ -315,16 +328,21 @@ module Mutineer
|
|
|
315
328
|
end
|
|
316
329
|
|
|
317
330
|
send_line(@boot)
|
|
318
|
-
read_line
|
|
331
|
+
read_line(BOOT_TIMEOUT)
|
|
319
332
|
rescue SystemCallError, IOError => e
|
|
320
333
|
close_io
|
|
321
334
|
raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
|
|
322
335
|
end
|
|
323
336
|
|
|
324
337
|
unless ready && ready["ready"]
|
|
325
|
-
detail =
|
|
338
|
+
detail =
|
|
339
|
+
if ready && ready["error"] then ready["error"]
|
|
340
|
+
elsif @timed_out then "the daemon did not finish booting within #{BOOT_TIMEOUT}s"
|
|
341
|
+
else "daemon exited before the handshake"
|
|
342
|
+
end
|
|
343
|
+
timed_out = @timed_out
|
|
326
344
|
close_io
|
|
327
|
-
raise DaemonBootError, "daemon failed to boot under the app bundle: #{detail}"
|
|
345
|
+
raise (timed_out ? DaemonBootTimeout : DaemonBootError), "daemon failed to boot under the app bundle: #{detail}"
|
|
328
346
|
end
|
|
329
347
|
end
|
|
330
348
|
|
|
@@ -333,10 +351,11 @@ module Mutineer
|
|
|
333
351
|
close_io
|
|
334
352
|
@restarts += 1
|
|
335
353
|
if @restarts > MAX_RESTARTS
|
|
336
|
-
raise DaemonBootError, "daemon crashed #{@restarts} times; aborting the run"
|
|
354
|
+
raise DaemonBootError, "daemon crashed or stopped answering #{@restarts} times; aborting the run"
|
|
337
355
|
end
|
|
338
356
|
|
|
339
|
-
@
|
|
357
|
+
cause = @timed_out ? "stopped answering" : "crashed"
|
|
358
|
+
@errio.puts("[mutineer] daemon #{cause} — respawning (#{@restarts}/#{MAX_RESTARTS})")
|
|
340
359
|
spawn_daemon
|
|
341
360
|
end
|
|
342
361
|
|
|
@@ -349,8 +368,15 @@ module Mutineer
|
|
|
349
368
|
@stdin.flush
|
|
350
369
|
end
|
|
351
370
|
|
|
352
|
-
# Read one JSON reply line; nil on EOF/dead pipe
|
|
353
|
-
|
|
371
|
+
# Read one JSON reply line; nil on EOF/dead pipe, or when no line arrives
|
|
372
|
+
# within `timeout` seconds (caller treats either as a crash).
|
|
373
|
+
#
|
|
374
|
+
# @param timeout [Numeric, nil] seconds to wait; nil waits for the reply.
|
|
375
|
+
# @return [Hash, nil]
|
|
376
|
+
def read_line(timeout = nil)
|
|
377
|
+
@timed_out = timeout && !@stdout.wait_readable(timeout)
|
|
378
|
+
return nil if @timed_out
|
|
379
|
+
|
|
354
380
|
line = @stdout.gets
|
|
355
381
|
line && JSON.parse(line.strip)
|
|
356
382
|
rescue IOError, Errno::EPIPE, JSON::ParserError
|
|
@@ -362,6 +388,13 @@ module Mutineer
|
|
|
362
388
|
#
|
|
363
389
|
# @return [void]
|
|
364
390
|
def close_io
|
|
391
|
+
# A wedged daemon may never read the closed stdin, so the reap below would
|
|
392
|
+
# wait forever: kill it first. An exited daemon makes this a no-op.
|
|
393
|
+
begin
|
|
394
|
+
Process.kill(:KILL, @wait_thr.pid) if @wait_thr&.alive?
|
|
395
|
+
rescue Errno::ESRCH
|
|
396
|
+
nil # it exited between the check and the kill
|
|
397
|
+
end
|
|
365
398
|
@drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
|
|
366
399
|
[@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
|
|
367
400
|
@wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "tempfile"
|
|
5
5
|
require_relative "child_stdout"
|
|
6
|
+
require_relative "orphan_guard"
|
|
6
7
|
|
|
7
8
|
module Mutineer
|
|
8
9
|
# App-side daemon (persistent worker).
|
|
9
10
|
#
|
|
10
11
|
# Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
|
|
11
12
|
# `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
|
-
#
|
|
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.
|
|
15
18
|
#
|
|
16
19
|
# HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
|
|
17
20
|
# mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
|
|
@@ -21,7 +24,7 @@ module Mutineer
|
|
|
21
24
|
# tool-side; the daemon only `load`s text.
|
|
22
25
|
#
|
|
23
26
|
# Protocol (one JSON object per line, both directions):
|
|
24
|
-
# boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
|
|
27
|
+
# boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
|
|
25
28
|
# "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
|
|
26
29
|
# ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
|
|
27
30
|
# run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
|
|
@@ -49,10 +52,14 @@ module Mutineer
|
|
|
49
52
|
# Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
|
|
50
53
|
#
|
|
51
54
|
# @param input [IO] request stream.
|
|
52
|
-
# @param output [IO] verdict 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.
|
|
53
57
|
# @param errio [IO] diagnostics stream (never the IPC channel).
|
|
54
58
|
# @return [void]
|
|
55
|
-
def run(input: $stdin, output:
|
|
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
|
|
56
63
|
@errio = errio
|
|
57
64
|
@output = output
|
|
58
65
|
boot_line = input.gets
|
|
@@ -92,6 +99,21 @@ module Mutineer
|
|
|
92
99
|
|
|
93
100
|
private
|
|
94
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
|
+
|
|
95
117
|
# BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
|
|
96
118
|
# and inherited by every fork. Never requires mutineer.
|
|
97
119
|
def boot!(cfg)
|
|
@@ -105,13 +127,16 @@ module Mutineer
|
|
|
105
127
|
# instrumented. The map build (build_via_fork) forks this booted parent.
|
|
106
128
|
if cfg["coverage"]
|
|
107
129
|
require "coverage"
|
|
108
|
-
Coverage.start(lines: true)
|
|
130
|
+
Coverage.start(lines: true, methods: true)
|
|
109
131
|
end
|
|
110
132
|
# Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
|
|
111
133
|
# source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
|
|
112
134
|
# tempfile's non-constant name during autoload setup.
|
|
113
135
|
sweep_temps
|
|
114
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) }
|
|
115
140
|
setup_worker_db(cfg) if cfg["rails"]
|
|
116
141
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
117
142
|
# Boot failed (bad boot path, app error). Tell the client and exit so it
|
|
@@ -131,15 +156,15 @@ module Mutineer
|
|
|
131
156
|
@worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
|
|
132
157
|
schema = cfg["schema"] && File.expand_path(cfg["schema"])
|
|
133
158
|
@schema_path = schema if schema && File.exist?(schema)
|
|
134
|
-
#
|
|
135
|
-
@
|
|
159
|
+
# Each worker slot is seeded once, on first use (not every mutant fork).
|
|
160
|
+
@slot_ready = {}
|
|
136
161
|
rescue LoadError => e
|
|
137
162
|
@errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
|
|
138
163
|
@worker_db = nil
|
|
139
164
|
end
|
|
140
165
|
|
|
141
166
|
# 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.
|
|
167
|
+
# it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
|
|
143
168
|
# Capture forks route to worker 0's DB (isolated, serial). On any failure
|
|
144
169
|
# return an empty map + an error string. The tool then falls back to the
|
|
145
170
|
# full test set rather than mis-scoring everything as no_coverage.
|
|
@@ -149,42 +174,61 @@ module Mutineer
|
|
|
149
174
|
cmap = CoverageMap.new(
|
|
150
175
|
source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
|
|
151
176
|
load_paths: Array(@cfg["load_paths"]), project_root: root,
|
|
152
|
-
boot_path: @cfg["boot"],
|
|
177
|
+
boot_path: @cfg["boot"], require_paths: Array(@cfg["require_paths"]),
|
|
178
|
+
framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
|
|
153
179
|
capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
154
180
|
).build_via_fork(after_fork: coverage_after_fork)
|
|
181
|
+
# Nothing reads Coverage after the map, so a mutant forked from this
|
|
182
|
+
# daemon does not pay for it (#228).
|
|
183
|
+
Coverage.suspend if Coverage.running?
|
|
155
184
|
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
|
|
156
|
-
"failed_clean_tests" => cmap.failed_clean_tests
|
|
185
|
+
"failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
|
|
186
|
+
"load_methods" => cmap.load_methods.to_a }
|
|
157
187
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
158
188
|
@errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
|
|
159
189
|
{ "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
|
|
160
190
|
end
|
|
161
191
|
|
|
162
|
-
# Fork-safety hook for coverage capture
|
|
163
|
-
#
|
|
164
|
-
#
|
|
192
|
+
# Fork-safety hook for coverage capture. Each capture fork drops its copy
|
|
193
|
+
# of the protocol channel (see close_protocol) and, when the app has a
|
|
194
|
+
# worker-DB adapter, routes to a fresh copy of the base DB in worker 0's
|
|
195
|
+
# slot (captures run serially, so one worker is enough).
|
|
165
196
|
def coverage_after_fork
|
|
166
|
-
|
|
167
|
-
|
|
197
|
+
worker_db = @worker_db
|
|
168
198
|
schema = @schema_path
|
|
169
|
-
|
|
199
|
+
lambda do
|
|
200
|
+
close_protocol
|
|
201
|
+
worker_db&.after_fork(0, schema, seed: true)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A forked child never answers on the protocol channel. Closing its
|
|
206
|
+
# inherited copy lets a daemon crash read as EOF on the client at once,
|
|
207
|
+
# not only after a slow or hung child exits. A caller-supplied output
|
|
208
|
+
# (for example STDOUT) is left open.
|
|
209
|
+
def close_protocol
|
|
210
|
+
@protocol&.close rescue nil # rubocop:disable Style/RescueModifier
|
|
170
211
|
end
|
|
171
212
|
|
|
172
213
|
# Fork a child to run one mutant in isolation; decode its exit into a verdict.
|
|
173
214
|
def run_mutant(req)
|
|
174
215
|
timeout = req.fetch("timeout", 30)
|
|
175
216
|
worker = req.fetch("worker", 0)
|
|
176
|
-
#
|
|
177
|
-
|
|
217
|
+
# Seed the slot's DB until the first killed/survived fork for this worker slot.
|
|
218
|
+
first_use = @worker_db && !@slot_ready[worker]
|
|
219
|
+
daemon = Process.pid
|
|
178
220
|
pid = fork do
|
|
179
221
|
# New process group so a per-fork timeout can SIGKILL the whole subtree,
|
|
180
|
-
# and silence the child's stdout so test output
|
|
222
|
+
# and silence the child's stdout so test output stays out of the diagnostics.
|
|
181
223
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
224
|
+
OrphanGuard.start(daemon)
|
|
182
225
|
code =
|
|
183
226
|
begin
|
|
184
227
|
ChildStdout.silence
|
|
228
|
+
close_protocol
|
|
185
229
|
# Route THIS fork at its own worker database before any test loads.
|
|
186
230
|
# A routing failure raises here and is scored `error`, never a false verdict.
|
|
187
|
-
@worker_db&.after_fork(worker,
|
|
231
|
+
@worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
|
|
188
232
|
apply_payload(req["payload"])
|
|
189
233
|
run_tests(Array(req["tests"]))
|
|
190
234
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
@@ -194,10 +238,10 @@ module Mutineer
|
|
|
194
238
|
exit!(code)
|
|
195
239
|
end
|
|
196
240
|
verdict = wait_verdict(pid, timeout)
|
|
197
|
-
# Mark ready only when the child finished cleanly after
|
|
198
|
-
# (killed/survived). Timeout can interrupt mid-
|
|
199
|
-
#
|
|
200
|
-
@
|
|
241
|
+
# Mark ready only when the child finished cleanly after seeding
|
|
242
|
+
# (killed/survived). Timeout can interrupt mid-copy; error is a routing
|
|
243
|
+
# failure. Both leave the slot unready so the next fork seeds again.
|
|
244
|
+
@slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
|
|
201
245
|
# A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
|
|
202
246
|
# so it cannot outlive the run or trip Zeitwerk on a later fork.
|
|
203
247
|
sweep_temps if verdict == "timeout"
|
|
@@ -205,7 +249,7 @@ module Mutineer
|
|
|
205
249
|
end
|
|
206
250
|
|
|
207
251
|
# Remove orphaned mutant tempfiles from the source dirs (parent-side; the
|
|
208
|
-
# SIGKILL path cannot run the child's ensure). Mirrors
|
|
252
|
+
# SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
|
|
209
253
|
def sweep_temps
|
|
210
254
|
@source_dirs.to_a.each do |dir|
|
|
211
255
|
Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
|
|
@@ -219,6 +263,8 @@ module Mutineer
|
|
|
219
263
|
# pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
|
|
220
264
|
# the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
|
|
221
265
|
# the kill/reap/decode logic must be applied to all three in lockstep.
|
|
266
|
+
# CoverageMap#await_child applies the same deadline and group kill to
|
|
267
|
+
# coverage capture.
|
|
222
268
|
# SIGKILL the child's process group past the deadline; a signalled child
|
|
223
269
|
# (nil exitstatus) is `error`.
|
|
224
270
|
def wait_verdict(pid, timeout)
|
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;
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -234,6 +234,7 @@ module Mutineer
|
|
|
234
234
|
else
|
|
235
235
|
mutated_def
|
|
236
236
|
end
|
|
237
|
+
inner = "#{subject.block_owner}.class_eval do\n#{inner}\nend" if subject.block_owner
|
|
237
238
|
wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
|
|
238
239
|
|
|
239
240
|
# A snippet that fails to reparse must NOT silently fall through to
|