mutineer 1.4.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 +475 -0
- data/README.md +224 -28
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +117 -20
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +533 -100
- data/lib/mutineer/daemon_backend.rb +51 -33
- data/lib/mutineer/daemon_client.rb +212 -22
- data/lib/mutineer/daemon_server.rb +74 -27
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +92 -6
- 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 +14 -68
- data/lib/mutineer/minitest_integration.rb +75 -11
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +344 -21
- data/lib/mutineer/rails_worker_db.rb +83 -9
- data/lib/mutineer/reporter.rb +306 -53
- data/lib/mutineer/result.rb +73 -5
- data/lib/mutineer/runner.rb +123 -242
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +282 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +2 -0
- metadata +10 -3
|
@@ -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,41 +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"),
|
|
179
|
+
capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
|
|
153
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?
|
|
154
184
|
{ "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
|
|
155
|
-
"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 }
|
|
156
187
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
157
188
|
@errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
|
|
158
189
|
{ "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
|
|
159
190
|
end
|
|
160
191
|
|
|
161
|
-
# Fork-safety hook for coverage capture
|
|
162
|
-
#
|
|
163
|
-
#
|
|
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).
|
|
164
196
|
def coverage_after_fork
|
|
165
|
-
|
|
166
|
-
|
|
197
|
+
worker_db = @worker_db
|
|
167
198
|
schema = @schema_path
|
|
168
|
-
|
|
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
|
|
169
211
|
end
|
|
170
212
|
|
|
171
213
|
# Fork a child to run one mutant in isolation; decode its exit into a verdict.
|
|
172
214
|
def run_mutant(req)
|
|
173
215
|
timeout = req.fetch("timeout", 30)
|
|
174
216
|
worker = req.fetch("worker", 0)
|
|
175
|
-
#
|
|
176
|
-
|
|
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
|
|
177
220
|
pid = fork do
|
|
178
221
|
# New process group so a per-fork timeout can SIGKILL the whole subtree,
|
|
179
|
-
# and silence the child's stdout so test output
|
|
222
|
+
# and silence the child's stdout so test output stays out of the diagnostics.
|
|
180
223
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
224
|
+
OrphanGuard.start(daemon)
|
|
181
225
|
code =
|
|
182
226
|
begin
|
|
183
227
|
ChildStdout.silence
|
|
228
|
+
close_protocol
|
|
184
229
|
# Route THIS fork at its own worker database before any test loads.
|
|
185
230
|
# A routing failure raises here and is scored `error`, never a false verdict.
|
|
186
|
-
@worker_db&.after_fork(worker,
|
|
231
|
+
@worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
|
|
187
232
|
apply_payload(req["payload"])
|
|
188
233
|
run_tests(Array(req["tests"]))
|
|
189
234
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
@@ -193,10 +238,10 @@ module Mutineer
|
|
|
193
238
|
exit!(code)
|
|
194
239
|
end
|
|
195
240
|
verdict = wait_verdict(pid, timeout)
|
|
196
|
-
# Mark ready only when the child finished cleanly after
|
|
197
|
-
# (killed/survived). Timeout can interrupt mid-
|
|
198
|
-
#
|
|
199
|
-
@
|
|
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)
|
|
200
245
|
# A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
|
|
201
246
|
# so it cannot outlive the run or trip Zeitwerk on a later fork.
|
|
202
247
|
sweep_temps if verdict == "timeout"
|
|
@@ -204,7 +249,7 @@ module Mutineer
|
|
|
204
249
|
end
|
|
205
250
|
|
|
206
251
|
# Remove orphaned mutant tempfiles from the source dirs (parent-side; the
|
|
207
|
-
# SIGKILL path cannot run the child's ensure). Mirrors
|
|
252
|
+
# SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
|
|
208
253
|
def sweep_temps
|
|
209
254
|
@source_dirs.to_a.each do |dir|
|
|
210
255
|
Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
|
|
@@ -218,6 +263,8 @@ module Mutineer
|
|
|
218
263
|
# pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
|
|
219
264
|
# the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
|
|
220
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.
|
|
221
268
|
# SIGKILL the child's process group past the deadline; a signalled child
|
|
222
269
|
# (nil exitstatus) is `error`.
|
|
223
270
|
def wait_verdict(pid, timeout)
|
|
@@ -52,11 +52,11 @@ module Mutineer
|
|
|
52
52
|
].freeze
|
|
53
53
|
|
|
54
54
|
# Turn a command template into an argv array (no shell → no eval, no
|
|
55
|
-
# injection). The
|
|
55
|
+
# injection). The <tt>%{files}</tt> token expands IN PLACE to N separate argv
|
|
56
56
|
# elements — one per path, unescaped — so a path containing a space stays a
|
|
57
57
|
# single argument. It is not a space-joined string.
|
|
58
58
|
#
|
|
59
|
-
# @param command [String] the --test-command template (contains
|
|
59
|
+
# @param command [String] the --test-command template (contains <tt>%{files}</tt>).
|
|
60
60
|
# @param files [Array<String>] test file paths to substitute.
|
|
61
61
|
# @return [Array<String>] argv.
|
|
62
62
|
def self.build_argv(command, files)
|
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
|
@@ -4,6 +4,7 @@ require "tempfile"
|
|
|
4
4
|
require_relative "result"
|
|
5
5
|
require_relative "parser"
|
|
6
6
|
require_relative "child_stdout"
|
|
7
|
+
require_relative "kill_channel"
|
|
7
8
|
|
|
8
9
|
module Mutineer
|
|
9
10
|
# Fork-based isolation for running one mutant. The block runs in a child
|
|
@@ -23,6 +24,9 @@ module Mutineer
|
|
|
23
24
|
class Isolation
|
|
24
25
|
DEFAULT_TIMEOUT = 10 # seconds
|
|
25
26
|
|
|
27
|
+
# Seconds between the parent's checks on a running child.
|
|
28
|
+
POLL_INTERVAL = 0.005
|
|
29
|
+
|
|
26
30
|
# Runs the block in a forked child. The block's return value (an Integer
|
|
27
31
|
# exit code) or any explicit `exit` is honoured; an unhandled exception
|
|
28
32
|
# becomes exit 2 with the cause written to STDERR.
|
|
@@ -30,18 +34,30 @@ module Mutineer
|
|
|
30
34
|
# The child silences its stdout (see {ChildStdout.silence}) before the
|
|
31
35
|
# block runs, so test output never reaches the user. Stderr stays open.
|
|
32
36
|
#
|
|
37
|
+
# With `channel: true` (a `--matrix` run) the block gets the write end of a
|
|
38
|
+
# pipe for {KillChannel} lines. The parent reads it while it waits, so a
|
|
39
|
+
# child with more than a pipe buffer to say never blocks, and attaches the
|
|
40
|
+
# lines to the Result as {Kills}. The verdict is the one a run without
|
|
41
|
+
# `--matrix` gives (see {.finish}).
|
|
42
|
+
#
|
|
33
43
|
# @param timeout [Integer] timeout in seconds.
|
|
44
|
+
# @param channel [Boolean] open a {KillChannel} pipe for the block.
|
|
45
|
+
# @yieldparam channel [IO, nil] the pipe's write end, or nil without `channel`.
|
|
34
46
|
# @yieldreturn [Integer] child exit status.
|
|
35
47
|
# @return [Mutineer::Result] result from the child process.
|
|
36
|
-
def self.run(timeout: DEFAULT_TIMEOUT)
|
|
48
|
+
def self.run(timeout: DEFAULT_TIMEOUT, channel: false)
|
|
49
|
+
rd, wr = IO.pipe if channel
|
|
50
|
+
wr&.sync = true
|
|
37
51
|
pid = fork do
|
|
52
|
+
rd&.close
|
|
38
53
|
# Own process group so a timeout kill can reap grandchildren (match
|
|
39
54
|
# daemon/external backends). Best-effort: if setpgid fails, kill the pid.
|
|
40
55
|
Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
|
|
41
56
|
code = 0
|
|
42
57
|
begin
|
|
43
58
|
ChildStdout.silence
|
|
44
|
-
|
|
59
|
+
# A block that takes no channel (a zero-arity lambda) still runs.
|
|
60
|
+
result = channel ? yield(wr) : yield
|
|
45
61
|
code = result.is_a?(Integer) ? result : 0
|
|
46
62
|
rescue SystemExit => e
|
|
47
63
|
code = e.status
|
|
@@ -57,13 +73,17 @@ module Mutineer
|
|
|
57
73
|
exit!(code)
|
|
58
74
|
end
|
|
59
75
|
|
|
76
|
+
wr&.close
|
|
77
|
+
buffer = +"" if rd
|
|
78
|
+
reading = !rd.nil?
|
|
79
|
+
|
|
60
80
|
# Single-threaded deadline poll: we are the ONLY caller of waitpid on this
|
|
61
81
|
# pid, so we never reap-then-kill. SIGKILL the process group only after
|
|
62
82
|
# WNOHANG shows the child is still alive past the deadline.
|
|
63
83
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
64
84
|
loop do
|
|
65
85
|
reaped, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
66
|
-
return decode(status) if reaped
|
|
86
|
+
return finish(decode(status), rd, buffer, finished: true) if reaped
|
|
67
87
|
|
|
68
88
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
69
89
|
begin
|
|
@@ -74,14 +94,79 @@ module Mutineer
|
|
|
74
94
|
begin
|
|
75
95
|
_reaped, status = Process.waitpid2(pid)
|
|
76
96
|
# Child may have finished cleanly between WNOHANG and kill; honor it.
|
|
77
|
-
return decode(status) if status && status.exited? && !status.signaled?
|
|
97
|
+
return finish(decode(status), rd, buffer, finished: true) if status && status.exited? && !status.signaled?
|
|
78
98
|
rescue Errno::ECHILD
|
|
79
99
|
# already reaped
|
|
80
100
|
end
|
|
81
|
-
return Result.timeout
|
|
101
|
+
return finish(Result.timeout, rd, buffer, finished: false)
|
|
102
|
+
end
|
|
103
|
+
if reading
|
|
104
|
+
IO.select([rd], nil, nil, POLL_INTERVAL)
|
|
105
|
+
reading = drain(rd, buffer)
|
|
106
|
+
else
|
|
107
|
+
sleep POLL_INTERVAL
|
|
82
108
|
end
|
|
83
|
-
sleep 0.005
|
|
84
109
|
end
|
|
110
|
+
ensure
|
|
111
|
+
rd&.close
|
|
112
|
+
# Closed after the fork in the normal path; still open if fork raised.
|
|
113
|
+
wr.close if wr && !wr.closed?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Reads what the channel holds now, without blocking.
|
|
117
|
+
#
|
|
118
|
+
# @api private
|
|
119
|
+
# @param rd [IO] the channel's read end.
|
|
120
|
+
# @param buffer [String] bytes read so far; appended to.
|
|
121
|
+
# @return [Boolean] false once the channel is at end of file.
|
|
122
|
+
def self.drain(rd, buffer)
|
|
123
|
+
loop do
|
|
124
|
+
chunk = rd.read_nonblock(65_536, exception: false)
|
|
125
|
+
return true if chunk == :wait_readable
|
|
126
|
+
return false if chunk.nil?
|
|
127
|
+
|
|
128
|
+
buffer << chunk
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The Result to return. Without a channel it is `result` itself. With one,
|
|
133
|
+
# the rest of the channel is read, and the Result carries the {Kills} it
|
|
134
|
+
# names.
|
|
135
|
+
#
|
|
136
|
+
# The verdict matches a run without `--matrix`, which skips everything after
|
|
137
|
+
# the first failing serial test except the code around it (see
|
|
138
|
+
# {KillChannel}). So when a serial test has named a kill and the child then
|
|
139
|
+
# ended inside a region that run would skip (an exit, a crash or the
|
|
140
|
+
# timeout in a later test, class or group), the mutant is `killed`: the
|
|
141
|
+
# plain run never reached that point. An end anywhere else keeps the exit
|
|
142
|
+
# status, because the plain run reaches that code too: the rest of the
|
|
143
|
+
# failing test's class wrapper or group hooks, or the suite hooks. A kill in
|
|
144
|
+
# the parallel tests of a Minitest run also keeps it, because the stop
|
|
145
|
+
# cannot skip tests already queued.
|
|
146
|
+
#
|
|
147
|
+
# The row is complete only when the child ended before the timeout, sent a
|
|
148
|
+
# valid stream with both `start` and `end`, lost no line, and its kills
|
|
149
|
+
# agree with the verdict (a killed mutant names a killer; a survivor names
|
|
150
|
+
# none). An invalid stream promotes nothing, and its tests are dropped: an
|
|
151
|
+
# out-of-order line means the parent cannot tell which outcomes are real.
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
# @param result [Mutineer::Result] the verdict from the exit status or the timeout.
|
|
155
|
+
# @param rd [IO, nil] the channel's read end.
|
|
156
|
+
# @param buffer [String, nil] bytes read so far.
|
|
157
|
+
# @param finished [Boolean] the child ended before the timeout.
|
|
158
|
+
# @return [Mutineer::Result]
|
|
159
|
+
def self.finish(result, rd, buffer, finished:)
|
|
160
|
+
return result unless rd
|
|
161
|
+
|
|
162
|
+
drain(rd, buffer)
|
|
163
|
+
report = KillChannel.parse(buffer)
|
|
164
|
+
return result.with(kills: Kills.new(killed_by: [], ran: [], complete: false)) if report.invalid
|
|
165
|
+
|
|
166
|
+
result = Result.killed if report.started && report.serial_kill && report.skipping.positive?
|
|
167
|
+
agrees = result.killed? ? report.killed.any? : result.survived? && report.killed.empty?
|
|
168
|
+
complete = finished && report.started && report.finished && report.lost.zero? && agrees
|
|
169
|
+
result.with(kills: Kills.new(killed_by: report.killed, ran: report.ran, complete: complete))
|
|
85
170
|
end
|
|
86
171
|
|
|
87
172
|
# Strategy 7a (default): write the whole mutated file and `load` it, which
|
|
@@ -149,6 +234,7 @@ module Mutineer
|
|
|
149
234
|
else
|
|
150
235
|
mutated_def
|
|
151
236
|
end
|
|
237
|
+
inner = "#{subject.block_owner}.class_eval do\n#{inner}\nend" if subject.block_owner
|
|
152
238
|
wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
|
|
153
239
|
|
|
154
240
|
# A snippet that fails to reparse must NOT silently fall through to
|