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.
@@ -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
- def summary(results, invalid_count:, empty_plan: false)
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
- "score" => empty_plan ? nil : Terminal.score(counts),
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
- "exit_reason" => exit_reason(counts, empty_plan)
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 exit_reason(counts, empty_plan)
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
- def summary(results, invalid_count:, empty_plan: false)
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
- def summary(results, invalid_count:, empty_plan: false)
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
- @out.puts format("Mutation score: %.1f%%", score(counts) * 100) unless empty_plan
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
- Result = Data.define(:mutation, :status, :details) do
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