active_mutator 0.6.1 → 0.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/README.md +37 -9
- data/lib/active_mutator/abort_flag.rb +53 -0
- data/lib/active_mutator/baseline.rb +132 -58
- data/lib/active_mutator/cli.rb +16 -1
- data/lib/active_mutator/config.rb +8 -1
- data/lib/active_mutator/config_file.rb +17 -1
- data/lib/active_mutator/coverage_map.rb +4 -1
- data/lib/active_mutator/diagnostics/ndjson.rb +25 -0
- data/lib/active_mutator/diagnostics/text.rb +110 -0
- data/lib/active_mutator/events.rb +55 -0
- data/lib/active_mutator/memory_ceiling.rb +59 -0
- data/lib/active_mutator/memory_probe.rb +82 -0
- data/lib/active_mutator/reporter/github.rb +13 -2
- data/lib/active_mutator/reporter/json.rb +19 -5
- data/lib/active_mutator/reporter/stryker_json.rb +7 -1
- data/lib/active_mutator/reporter/terminal.rb +31 -2
- data/lib/active_mutator/result.rb +7 -1
- data/lib/active_mutator/runner.rb +198 -63
- data/lib/active_mutator/sampler.rb +69 -0
- data/lib/active_mutator/scheduler.rb +105 -61
- data/lib/active_mutator/since_filter.rb +30 -7
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator.rb +7 -0
- metadata +8 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "monitor"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
# Internal event bus for run diagnostics. Producers call
|
|
5
|
+
# `emit(type, **fields)`; listeners get a frozen Event. With no listeners
|
|
6
|
+
# emit returns at once, so a default run pays nothing. Not a public hook
|
|
7
|
+
# API: the NDJSON file (--events) is the public contract.
|
|
8
|
+
class Events
|
|
9
|
+
# at: wall time; elapsed: monotonic seconds since the bus was built.
|
|
10
|
+
Event = Data.define(:type, :at, :elapsed, :fields)
|
|
11
|
+
|
|
12
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, wall: -> { Time.now })
|
|
13
|
+
@clock = clock
|
|
14
|
+
@wall = wall
|
|
15
|
+
@started = clock.call
|
|
16
|
+
@listeners = []
|
|
17
|
+
# The memory sampler emits from its own thread. A Monitor, not a Mutex:
|
|
18
|
+
# the sampler also emits from inside its own listener call (a sample
|
|
19
|
+
# at each phase boundary), which re-enters on the same thread.
|
|
20
|
+
@lock = Monitor.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A listener is anything with #call(event), or a block.
|
|
24
|
+
def subscribe(listener = nil, &block)
|
|
25
|
+
@listeners << (listener || block)
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Not an endless def: its one line runs at load, so coverage can't tie
|
|
30
|
+
# it to the specs that call it.
|
|
31
|
+
def listening?
|
|
32
|
+
!@listeners.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Emits phase_start, runs the block, emits phase_end, and returns the
|
|
36
|
+
# block's value. A phase that raises gets no phase_end, so the last
|
|
37
|
+
# unmatched phase_start marks where a run died.
|
|
38
|
+
def phase(name, **fields)
|
|
39
|
+
emit(:phase_start, phase: name, **fields)
|
|
40
|
+
result = yield
|
|
41
|
+
emit(:phase_end, phase: name)
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def emit(type, **fields)
|
|
46
|
+
return if @listeners.empty?
|
|
47
|
+
|
|
48
|
+
@lock.synchronize do
|
|
49
|
+
event = Event.new(type: type, at: @wall.call, elapsed: @clock.call - @started, fields: fields.freeze)
|
|
50
|
+
@listeners.each { |listener| listener.call(event) }
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# --max-rss: a ceiling on the gem's total memory (the parent, its workers,
|
|
3
|
+
# and the baseline child), read from the sampler's `memory` events. Warns
|
|
4
|
+
# once at 90%. At 100% it trips the abort flag, and whoever owns the
|
|
5
|
+
# running processes kills them, so the run ends with exit 3 and a partial
|
|
6
|
+
# score instead of an OOM kill that leaves nothing.
|
|
7
|
+
class MemoryCeiling
|
|
8
|
+
SIZE = /\A(\d+(?:\.\d+)?)([gm]?)\z/i
|
|
9
|
+
WARN_AT = 0.9
|
|
10
|
+
# Parsing coverage.json peaked at about 5x the file's size (7x with the
|
|
11
|
+
# CoverageMap built from it). JSON.parse holds Ruby's global lock, so
|
|
12
|
+
# no sample or signal gets through until it returns: the size is checked
|
|
13
|
+
# before the parse instead.
|
|
14
|
+
PARSE_COST = 5
|
|
15
|
+
|
|
16
|
+
# "6G", "6144M", or plain MB ("6144") to kB; nil for anything else.
|
|
17
|
+
def self.parse_kb(text)
|
|
18
|
+
match = SIZE.match(text.to_s.strip)
|
|
19
|
+
return unless match
|
|
20
|
+
|
|
21
|
+
mb = Float(match[1]) * (match[2].casecmp?("g") ? 1024 : 1)
|
|
22
|
+
kb = (mb * 1024).round
|
|
23
|
+
kb if kb.positive?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(max_rss_kb:, events:, abort:)
|
|
27
|
+
@max_rss_kb = max_rss_kb
|
|
28
|
+
@events = events
|
|
29
|
+
@abort = abort
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Events listener. Runs on whichever thread emitted the sample.
|
|
33
|
+
def call(event)
|
|
34
|
+
fields = event.fields
|
|
35
|
+
if event.type == :memory
|
|
36
|
+
@last_total_kb = fields[:total_pss_kb] || @last_total_kb
|
|
37
|
+
check(fields[:total_pss_kb])
|
|
38
|
+
elsif event.type == :phase_start && fields[:phase] == :coverage_load
|
|
39
|
+
check(@last_total_kb.to_i + (fields[:bytes] / 1024 * PARSE_COST), coverage_bytes: fields[:bytes])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Quiet once the run is stopping: samples keep coming until it exits.
|
|
46
|
+
def check(total_kb, **extra)
|
|
47
|
+
return if total_kb.nil? || @abort.tripped?
|
|
48
|
+
|
|
49
|
+
fields = { total_pss_kb: total_kb, max_rss_kb: @max_rss_kb, **extra }
|
|
50
|
+
if total_kb >= @max_rss_kb
|
|
51
|
+
@events.emit(:memory_ceiling, **fields)
|
|
52
|
+
@abort.trip!(:memory_ceiling)
|
|
53
|
+
elsif !@warned && total_kb >= @max_rss_kb * WARN_AT
|
|
54
|
+
@warned = true
|
|
55
|
+
@events.emit(:memory_warning, **fields)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# Reads process and system memory for run diagnostics and --max-rss.
|
|
3
|
+
#
|
|
4
|
+
# Linux: /proc/<pid>/status (VmRSS, VmHWM) and smaps_rollup (Pss), plus
|
|
5
|
+
# meminfo, pressure/memory, and loadavg for the system fields.
|
|
6
|
+
# macOS: one `ps -o pid=,rss=` per call for every pid; no peak, Pss, or
|
|
7
|
+
# system fields.
|
|
8
|
+
# other: nothing; diagnostics still print, memory fields blank.
|
|
9
|
+
#
|
|
10
|
+
# `proc_root`, `platform`, and `ps` are injectable so specs read fixtures.
|
|
11
|
+
class MemoryProbe
|
|
12
|
+
DEFAULT_PS = lambda do |pids|
|
|
13
|
+
IO.popen(["ps", "-o", "pid=,rss=", "-p", pids.join(",")], err: File::NULL, &:read)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# This process's peak RSS in KB (VmHWM). A worker can exit between two
|
|
17
|
+
# memory samples, so it reports its own peak. nil off Linux.
|
|
18
|
+
def self.peak_rss_kb(proc_root: "/proc")
|
|
19
|
+
status_kb(File.join(proc_root, "self", "status"), "VmHWM")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.status_kb(path, key)
|
|
23
|
+
line = File.foreach(path).find { |l| l.start_with?("#{key}:") }
|
|
24
|
+
line && line[/\d+/].to_i
|
|
25
|
+
rescue SystemCallError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(proc_root: "/proc", platform: RUBY_PLATFORM, ps: DEFAULT_PS)
|
|
30
|
+
@proc_root = proc_root
|
|
31
|
+
@ps = ps
|
|
32
|
+
@mode = if File.directory?(proc_root) then :linux
|
|
33
|
+
elsif platform.include?("darwin") then :ps
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# {pid => {rss_kb:, hwm_kb:, pss_kb:}}; pids that are gone are left out.
|
|
38
|
+
def processes(pids)
|
|
39
|
+
case @mode
|
|
40
|
+
when :linux then pids.filter_map { |pid| linux_process(pid) }.to_h
|
|
41
|
+
when :ps then pids.empty? ? {} : ps_processes(pids)
|
|
42
|
+
else {}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Linux only; nil elsewhere. Each field is nil when its file is missing.
|
|
47
|
+
def system
|
|
48
|
+
return unless @mode == :linux
|
|
49
|
+
|
|
50
|
+
meminfo = File.join(@proc_root, "meminfo")
|
|
51
|
+
{ mem_available_kb: self.class.status_kb(meminfo, "MemAvailable"),
|
|
52
|
+
swap_total_kb: self.class.status_kb(meminfo, "SwapTotal"),
|
|
53
|
+
swap_free_kb: self.class.status_kb(meminfo, "SwapFree"),
|
|
54
|
+
psi_some_avg10: read(File.join("pressure", "memory"))&.[](/^some avg10=([\d.]+)/, 1)&.to_f,
|
|
55
|
+
load1: read("loadavg")&.split&.first&.to_f }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def linux_process(pid)
|
|
61
|
+
status = File.join(@proc_root, pid.to_s, "status")
|
|
62
|
+
rss = self.class.status_kb(status, "VmRSS")
|
|
63
|
+
return unless rss
|
|
64
|
+
|
|
65
|
+
[pid, { rss_kb: rss, hwm_kb: self.class.status_kb(status, "VmHWM"),
|
|
66
|
+
pss_kb: self.class.status_kb(File.join(@proc_root, pid.to_s, "smaps_rollup"), "Pss") }]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ps_processes(pids)
|
|
70
|
+
@ps.call(pids).lines.to_h do |line|
|
|
71
|
+
pid, rss = line.split.map(&:to_i)
|
|
72
|
+
[pid, { rss_kb: rss, hwm_kb: nil, pss_kb: nil }]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read(rel)
|
|
77
|
+
File.read(File.join(@proc_root, rel))
|
|
78
|
+
rescue SystemCallError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -12,13 +12,24 @@ module ActiveMutator
|
|
|
12
12
|
|
|
13
13
|
def on_result(result) = @terminal.on_result(result)
|
|
14
14
|
|
|
15
|
-
def summary(results, invalid_count:, empty_plan: false)
|
|
16
|
-
@terminal.summary(results, invalid_count: invalid_count, empty_plan: empty_plan)
|
|
15
|
+
def summary(results, invalid_count:, empty_plan: false, aborted: nil)
|
|
16
|
+
@terminal.summary(results, invalid_count: invalid_count, empty_plan: empty_plan, aborted: aborted)
|
|
17
17
|
results.select { |r| r.status == :survived }.each { |r| annotate(r) }
|
|
18
|
+
annotate_abort(aborted, results) if aborted
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
23
|
+
# One annotation for the whole run, after the survivors that did finish.
|
|
24
|
+
def annotate_abort(aborted, results)
|
|
25
|
+
reason = Terminal::ABORT_LABELS.fetch(aborted[:reason])
|
|
26
|
+
lines = ["The run stopped early (#{reason}), so this is not a full result."]
|
|
27
|
+
in_flight = aborted[:in_flight].map { |entry| Terminal.in_flight_label(entry) }
|
|
28
|
+
lines << "In flight: #{in_flight.join("; ")}" unless in_flight.empty?
|
|
29
|
+
lines << "Partial mutation score: #{Terminal.partial_score(results, aborted[:planned])}"
|
|
30
|
+
@out.puts "::error title=Mutation run aborted::#{encode(lines.join("\n"))}"
|
|
31
|
+
end
|
|
32
|
+
|
|
22
33
|
def annotate(result)
|
|
23
34
|
m = result.mutation
|
|
24
35
|
file = m.subject.file.delete_prefix(@root.chomp("/") + "/")
|
|
@@ -11,21 +11,33 @@ module ActiveMutator
|
|
|
11
11
|
|
|
12
12
|
# An empty plan has no score (null) and its own exit_reason, so a CI
|
|
13
13
|
# consumer can tell "nothing to mutate" from "everything was killed" (#45).
|
|
14
|
-
|
|
14
|
+
# An aborted run has `complete: false`, and its score covers only the
|
|
15
|
+
# mutants that finished (null if none did).
|
|
16
|
+
def summary(results, invalid_count:, empty_plan: false, aborted: nil)
|
|
15
17
|
counts = Terminal.counts(results)
|
|
16
18
|
@out.puts JSON.pretty_generate(
|
|
17
|
-
"
|
|
19
|
+
"complete" => aborted.nil?,
|
|
20
|
+
"score" => score(results, counts, empty_plan, aborted),
|
|
18
21
|
"counts" => counts.transform_keys(&:to_s),
|
|
19
22
|
"invalid" => invalid_count,
|
|
20
23
|
"operators" => OperatorStats.call(results),
|
|
21
24
|
"results" => results.map { |r| serialize(r) },
|
|
22
|
-
"
|
|
25
|
+
"in_flight" => aborted ? aborted[:in_flight] : [],
|
|
26
|
+
"planned" => aborted ? aborted[:planned] : results.size,
|
|
27
|
+
"exit_reason" => exit_reason(counts, empty_plan, aborted)
|
|
23
28
|
)
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
private
|
|
27
32
|
|
|
28
|
-
def
|
|
33
|
+
def score(results, counts, empty_plan, aborted)
|
|
34
|
+
return nil if empty_plan || (aborted && results.empty?)
|
|
35
|
+
|
|
36
|
+
Terminal.score(counts)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def exit_reason(counts, empty_plan, aborted)
|
|
40
|
+
return aborted[:reason] == :memory_ceiling ? "memory_ceiling" : "interrupted" if aborted
|
|
29
41
|
return "empty_plan" if empty_plan
|
|
30
42
|
return "unaccepted_survivors" if counts[:survived].positive?
|
|
31
43
|
return "worker_errors" if counts[:error].positive?
|
|
@@ -43,7 +55,9 @@ module ActiveMutator
|
|
|
43
55
|
"line" => m.line,
|
|
44
56
|
"original" => m.original_snippet,
|
|
45
57
|
"replacement" => m.edit.replacement,
|
|
46
|
-
"details" => result.details
|
|
58
|
+
"details" => result.details,
|
|
59
|
+
"seconds" => result.seconds,
|
|
60
|
+
"peak_rss_kb" => result.peak_rss_kb
|
|
47
61
|
}
|
|
48
62
|
end
|
|
49
63
|
end
|
|
@@ -34,13 +34,19 @@ module ActiveMutator
|
|
|
34
34
|
|
|
35
35
|
# A zero-mutant report is valid schema output, so an empty plan writes the
|
|
36
36
|
# same file with no files/mutants; the flag is accepted for contract parity.
|
|
37
|
-
|
|
37
|
+
# An aborted run reports the finished mutants only: the schema has no
|
|
38
|
+
# "didn't finish" status that wouldn't skew the score.
|
|
39
|
+
def summary(results, invalid_count:, empty_plan: false, aborted: nil)
|
|
38
40
|
report = build_report(results, invalid_count)
|
|
39
41
|
path = File.join(@root, REPORT_PATH)
|
|
40
42
|
# An empty plan skips the baseline, which used to create this dir.
|
|
41
43
|
FileUtils.mkdir_p(File.dirname(path))
|
|
42
44
|
AtomicFile.write(path, JSON.pretty_generate(report))
|
|
43
45
|
@out.puts "", "", "Stryker report written to #{REPORT_PATH}"
|
|
46
|
+
return unless aborted
|
|
47
|
+
|
|
48
|
+
@out.puts "Run aborted (#{Terminal::ABORT_LABELS.fetch(aborted[:reason])}): " \
|
|
49
|
+
"the report covers only the #{results.size} mutants that finished"
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
private
|
|
@@ -3,6 +3,7 @@ module ActiveMutator
|
|
|
3
3
|
class Terminal
|
|
4
4
|
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A",
|
|
5
5
|
skipped: "-" }.freeze
|
|
6
|
+
ABORT_LABELS = { sigint: "SIGINT", sigterm: "SIGTERM", memory_ceiling: "memory ceiling" }.freeze
|
|
6
7
|
|
|
7
8
|
def initialize(out: $stdout)
|
|
8
9
|
@out = out
|
|
@@ -21,12 +22,18 @@ module ActiveMutator
|
|
|
21
22
|
|
|
22
23
|
# `empty_plan: true` means a --since/--subject scope planned nothing: the
|
|
23
24
|
# count block still prints, but there is no score to report (#45).
|
|
24
|
-
|
|
25
|
+
# `aborted: {reason:, in_flight:, planned:}` means the run stopped early
|
|
26
|
+
# and `results` holds only the mutants that finished.
|
|
27
|
+
def summary(results, invalid_count:, empty_plan: false, aborted: nil)
|
|
25
28
|
counts = self.class.counts(results)
|
|
26
29
|
@out.puts "", ""
|
|
27
30
|
counts.each { |status, count| @out.puts "#{status}: #{count}" }
|
|
28
31
|
@out.puts "invalid (discarded): #{invalid_count}"
|
|
29
|
-
|
|
32
|
+
if aborted
|
|
33
|
+
print_aborted(aborted, results)
|
|
34
|
+
elsif !empty_plan
|
|
35
|
+
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
36
|
+
end
|
|
30
37
|
print_group("Surviving mutants:", results.select { |r| r.status == :survived })
|
|
31
38
|
print_group("Errored mutants (not detected):", results.select { |r| r.status == :error })
|
|
32
39
|
print_group("Timed-out mutants (counted as detected):", results.select { |r| r.status == :timeout })
|
|
@@ -48,10 +55,32 @@ module ActiveMutator
|
|
|
48
55
|
detected.to_f / denominator
|
|
49
56
|
end
|
|
50
57
|
|
|
58
|
+
# "71.3% (412 of 764 mutants)": the score over the finished mutants,
|
|
59
|
+
# and how many finished out of the plan (unknown if planning never ended).
|
|
60
|
+
def self.partial_score(results, planned)
|
|
61
|
+
score = results.empty? ? "n/a" : format("%.1f%%", score(counts(results)) * 100)
|
|
62
|
+
"#{score} (#{results.size}#{" of #{planned}" if planned} mutants)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.in_flight_label(entry)
|
|
66
|
+
"##{entry[:seq]} #{entry[:subject]} (#{entry[:file]}:#{entry[:line]}) #{entry[:description]}"
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
private
|
|
52
70
|
|
|
53
71
|
def score(counts) = self.class.score(counts)
|
|
54
72
|
|
|
73
|
+
# Never labeled "Mutation score:", so nothing scraping the log can take
|
|
74
|
+
# a partial run for a finished one.
|
|
75
|
+
def print_aborted(aborted, results)
|
|
76
|
+
@out.puts "", "Run aborted (#{ABORT_LABELS.fetch(aborted[:reason])}): partial results"
|
|
77
|
+
unless aborted[:in_flight].empty?
|
|
78
|
+
@out.puts "In flight (stopped before a verdict):"
|
|
79
|
+
aborted[:in_flight].each { |entry| @out.puts " #{self.class.in_flight_label(entry)}" }
|
|
80
|
+
end
|
|
81
|
+
@out.puts "Partial mutation score: #{self.class.partial_score(results, aborted[:planned])}"
|
|
82
|
+
end
|
|
83
|
+
|
|
55
84
|
def print_operator_stats(stats)
|
|
56
85
|
@out.puts "", "Equivalent-rate by operator (survived / (killed + survived)):"
|
|
57
86
|
stats.sort_by { |_, s| -s["equivalent_rate"] }.each do |operator, s|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# status: :killed | :survived | :timeout | :error | :uncovered | :accepted | :skipped
|
|
3
|
-
|
|
3
|
+
# seconds: worker wall time; peak_rss_kb: the worker's own peak RSS (nil
|
|
4
|
+
# off Linux). Both nil for results that never ran in a worker.
|
|
5
|
+
Result = Data.define(:mutation, :status, :details, :seconds, :peak_rss_kb) do
|
|
6
|
+
def initialize(mutation:, status:, details:, seconds: nil, peak_rss_kb: nil)
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
|
|
4
10
|
def detected? = %i[killed timeout].include?(status)
|
|
5
11
|
end
|
|
6
12
|
end
|