active_mutator 0.6.0 → 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.
@@ -8,43 +8,63 @@ module ActiveMutator
8
8
  class Scheduler
9
9
  OrphanedError = Class.new(Error)
10
10
 
11
+ # first_seq: phase-2 escalation runs on its own scheduler; starting its
12
+ # sequence after phase 1's keeps mutant numbers unique across the run.
11
13
  def initialize(jobs:, worker: Worker.method(:run), on_result: nil,
12
- calibrators: nil, orphaned: -> { Process.ppid == 1 })
14
+ calibrators: nil, orphaned: -> { Process.ppid == 1 },
15
+ events: Events.new, first_seq: 1, abort: AbortFlag.new)
16
+ @abort = abort
13
17
  @jobs = jobs
14
18
  @worker = worker
15
19
  @on_result = on_result
16
20
  @calibrators = calibrators
17
21
  @orphaned = orphaned
22
+ @events = events
23
+ @next_seq = first_seq
18
24
  @last_logged_scale = {} # lane => last scale logged for that lane
19
25
  end
20
26
 
27
+ # Signals are the Runner's: its AbortFlag trips, and the poll loop here
28
+ # kills every worker and raises Aborted with what finished.
21
29
  def run(items)
22
- previous_traps = nil
23
30
  running = {}
24
- previous_traps = install_signal_handlers(running)
25
31
  results = []
26
- # Browser-covered mutants each boot Chrome + an app server; running them
27
- # concurrently melts CPUs and manufactures false timeouts. Parallel lane
28
- # first at full width, then the serial lane one at a time.
29
- results.concat(run_pool(items.select { |i| i.lane == :parallel }, @jobs, running))
30
- results.concat(run_pool(items.select { |i| i.lane == :serial }, 1, running))
32
+ @abort.deferred do
33
+ # Browser-covered mutants each boot Chrome + an app server; running
34
+ # them concurrently melts CPUs and manufactures false timeouts.
35
+ # Parallel lane first at full width, then the serial lane one at a time.
36
+ run_pool(items.select { |i| i.lane == :parallel }, @jobs, running, results)
37
+ run_pool(items.select { |i| i.lane == :serial }, 1, running, results)
38
+ end
31
39
  results
32
40
  ensure
33
- restore_traps(previous_traps) if previous_traps
41
+ cleanup(running)
34
42
  end
35
43
 
36
44
  private
37
45
 
38
- def run_pool(items, width, running)
46
+ def run_pool(items, width, running, results)
39
47
  queue = items.dup
40
- results = []
41
48
  until queue.empty? && running.empty?
42
49
  abort_if_orphaned!(running)
43
- spawn(queue.shift, running) while running.size < width && !queue.empty?
50
+ abort!(running, results) if @abort.tripped?
51
+ # A trip mid-fill stops the forking; the next pass kills what started.
52
+ spawn(queue.shift, running) while running.size < width && !queue.empty? && !@abort.tripped?
44
53
  reap(running, results)
45
54
  sleep 0.02 unless running.empty?
46
55
  end
47
- results
56
+ end
57
+
58
+ # CI allows only seconds between SIGTERM and SIGKILL, so kill every
59
+ # worker group and move on without waiting for any of them.
60
+ def abort!(running, results)
61
+ in_flight = running.map do |pid, entry|
62
+ m = entry[:item].mutation
63
+ { seq: entry[:seq], pid: pid, subject: m.subject.name, file: m.subject.file, line: m.line,
64
+ description: m.description }
65
+ end
66
+ cleanup(running, wait: false)
67
+ raise Aborted.new(@abort.reason, results: results, in_flight: in_flight)
48
68
  end
49
69
 
50
70
  # SIGKILL on the parent (or a closed terminal, or CI teardown) cannot be
@@ -54,18 +74,27 @@ module ActiveMutator
54
74
  def abort_if_orphaned!(running)
55
75
  return unless @orphaned.call
56
76
 
57
- running.each_key do |pid|
58
- kill(pid)
59
- rescue StandardError
77
+ raise OrphanedError, "parent process died; aborting mutation run"
78
+ end
79
+
80
+ def cleanup(running, wait: true)
81
+ running.each_key { |pid| signal_group(pid) }
82
+ running.each do |pid, entry|
83
+ entry[:reader].close
84
+ entry[:stderr_file].close!
85
+ Process.waitpid(pid) if wait
86
+ rescue Errno::ECHILD
60
87
  nil
61
88
  end
62
89
  running.clear
63
- raise OrphanedError, "parent process died; aborting mutation run"
64
90
  end
65
91
 
66
92
  STDERR_TAIL_LINES = 20
67
93
 
68
94
  def spawn(item, running)
95
+ calibrator = calibrator_for(item)
96
+ budget = calibrator ? calibrator.budget_for(item) : item.timeout
97
+ log_scale(calibrator, item.lane)
69
98
  reader, writer = IO.pipe
70
99
  stderr_file = Tempfile.new("active_mutator-worker")
71
100
  pid = fork do
@@ -78,55 +107,86 @@ module ActiveMutator
78
107
  # harmless everywhere else.
79
108
  ENV["PGGSSENCMODE"] ||= "disable"
80
109
  @worker.call(item.mutation, item.example_ids, writer)
110
+ # A second line, after the worker's own report: the worker can exit
111
+ # between two memory samples, so it reports its peak itself.
112
+ writer.puts("", JSON.generate("peak_rss_kb" => MemoryProbe.peak_rss_kb))
81
113
  writer.close
82
114
  Process.exit!(0)
83
115
  end
84
116
  writer.close
85
- calibrator = calibrator_for(item)
86
- budget = calibrator ? calibrator.budget_for(item) : item.timeout
87
- log_scale(calibrator, item.lane)
88
117
  started = now
118
+ seq = @next_seq
119
+ @next_seq += 1
89
120
  running[pid] = { reader: reader, item: item, started: started, stderr_file: stderr_file,
90
- budget: budget, deadline: started + budget }
121
+ budget: budget, deadline: started + budget, seq: seq, payload: +"" }
122
+ emit_start(item, pid, seq, budget) if @events.listening?
123
+ ensure
124
+ unless pid
125
+ reader&.close
126
+ writer&.close
127
+ stderr_file&.close!
128
+ end
129
+ end
130
+
131
+ def emit_start(item, pid, seq, budget)
132
+ m = item.mutation
133
+ @events.emit(:mutant_start, seq: seq, pid: pid, lane: item.lane, budget: budget,
134
+ examples: item.example_ids.size, subject: m.subject.name,
135
+ file: m.subject.file, line: m.line, description: m.description)
91
136
  end
92
137
 
93
138
  def reap(running, results)
94
139
  running.to_a.each do |pid, entry|
95
- done, _status = Process.waitpid2(pid, Process::WNOHANG)
96
- if done
97
- running.delete(pid)
140
+ # Read while the worker runs so a full pipe cannot prevent its exit.
141
+ # A descendant may retain the writer after that exit; keep the group
142
+ # tracked until EOF so aborts and the deadline still apply to it.
143
+ chunk = entry[:reader].read_nonblock(65_536, exception: false)
144
+ entry[:payload] << chunk if chunk.is_a?(String)
145
+ entry[:eof] = true if chunk.nil?
146
+ entry[:exited] ||= Process.waitpid(pid, Process::WNOHANG)
147
+ if entry[:exited] && entry[:eof]
98
148
  result = finish(entry)
99
- if result.status == :killed
100
- calibrator_for(entry[:item])&.record(now - entry[:started], entry[:budget])
101
- end
102
- results << result
149
+ calibrator_for(entry[:item])&.record(result.seconds, entry[:budget]) if result.status == :killed
150
+ results << complete(pid, entry, result)
151
+ running.delete(pid)
103
152
  elsif now > entry[:deadline]
104
153
  kill(pid)
105
- running.delete(pid)
106
154
  entry[:reader].close
107
155
  entry[:stderr_file].close!
108
- details = format("timed out after %.1fs (budget %.1fs)", now - entry[:started], entry[:budget])
109
- results << report(Result.new(mutation: entry[:item].mutation, status: :timeout, details: details))
156
+ seconds = now - entry[:started]
157
+ details = format("timed out after %.1fs (budget %.1fs)", seconds, entry[:budget])
158
+ results << complete(pid, entry, Result.new(mutation: entry[:item].mutation, status: :timeout,
159
+ details: details, seconds: seconds))
160
+ running.delete(pid)
110
161
  end
111
162
  end
112
163
  end
113
164
 
114
165
  def finish(entry)
115
- payload = entry[:reader].read.to_s
166
+ seconds = now - entry[:started]
167
+ report_line, stats_line = entry[:payload].lines.map(&:strip).reject(&:empty?)
116
168
  entry[:reader].close
117
169
  stderr_tail = stderr_tail(entry[:stderr_file])
118
- data = payload.empty? ? nil : JSON.parse(payload)
170
+ data = report_line && JSON.parse(report_line)
119
171
  # A self-mutation of Worker#emit can produce well-formed JSON without a
120
172
  # "status" key (or with a non-Hash root); treat any unusable payload as
121
173
  # a worker error instead of crashing the whole run.
122
174
  reported = data.is_a?(Hash) && data.key?("status")
123
175
  status = reported ? data["status"].to_sym : :error
124
176
  details = reported ? data["details"] : unreported_details(stderr_tail)
177
+ peak = JSON.parse(stats_line)["peak_rss_kb"] if stats_line
125
178
  rescue JSON::ParserError
126
- report(Result.new(mutation: entry[:item].mutation, status: :error,
127
- details: "worker emitted unparseable payload"))
179
+ Result.new(mutation: entry[:item].mutation, status: :error,
180
+ details: "worker emitted unparseable payload", seconds: seconds)
128
181
  else
129
- report(Result.new(mutation: entry[:item].mutation, status: status, details: details))
182
+ Result.new(mutation: entry[:item].mutation, status: status, details: details,
183
+ seconds: seconds, peak_rss_kb: peak)
184
+ end
185
+
186
+ def complete(pid, entry, result)
187
+ @events.emit(:mutant_end, seq: entry[:seq], pid: pid, status: result.status,
188
+ seconds: result.seconds, peak_rss_kb: result.peak_rss_kb)
189
+ report(result)
130
190
  end
131
191
 
132
192
  def report(result)
@@ -149,14 +209,7 @@ module ActiveMutator
149
209
  end
150
210
 
151
211
  def kill(pid)
152
- Process.kill("KILL", -pid) # negative pid = whole process group
153
- rescue Errno::ESRCH, Errno::EPERM
154
- # Group not established yet (setpgid race) or already gone: direct kill.
155
- begin
156
- Process.kill("KILL", pid)
157
- rescue Errno::ESRCH
158
- nil
159
- end
212
+ signal_group(pid)
160
213
  ensure
161
214
  begin
162
215
  Process.waitpid(pid)
@@ -165,26 +218,17 @@ module ActiveMutator
165
218
  end
166
219
  end
167
220
 
168
- # Returns {sig => previous_handler} so #run can restore on exit,
169
- # otherwise our traps permanently replace the host's (e.g. RSpec's Ctrl-C).
170
- def install_signal_handlers(running)
171
- %w[INT TERM].to_h do |sig|
172
- previous = trap(sig) do
173
- running.each_key do |pid|
174
- Process.kill("KILL", -pid)
175
- rescue StandardError
176
- nil
177
- end
178
- exit(130)
179
- end
180
- [sig, previous]
221
+ def signal_group(pid)
222
+ Process.kill("KILL", -pid) # negative pid = whole process group
223
+ rescue Errno::ESRCH, Errno::EPERM
224
+ # Group not established yet (setpgid race) or already gone: direct kill.
225
+ begin
226
+ Process.kill("KILL", pid)
227
+ rescue Errno::ESRCH
228
+ nil
181
229
  end
182
230
  end
183
231
 
184
- def restore_traps(previous)
185
- previous.each { |sig, handler| trap(sig, handler || "DEFAULT") }
186
- end
187
-
188
232
  def calibrator_for(item)
189
233
  @calibrators && @calibrators[item.lane]
190
234
  end
@@ -7,17 +7,32 @@ module ActiveMutator
7
7
 
8
8
  def self.parse(diff_text)
9
9
  changed = Hash.new { |h, k| h[k] = [] }
10
+ each_hunk(diff_text) do |file, start, count|
11
+ count.times { |i| changed[file] << start + i }
12
+ end
13
+ changed.reject { |_, lines| lines.empty? }
14
+ end
15
+
16
+ # New-side line each pure deletion follows (0 = top of file), per file.
17
+ # The deletion sits between that line and the next, so it is inside a
18
+ # subject only when the subject spans both.
19
+ def self.deletion_gaps(diff_text)
20
+ gaps = Hash.new { |h, k| h[k] = [] }
21
+ each_hunk(diff_text) do |file, start, count|
22
+ gaps[file] << start if count.zero?
23
+ end
24
+ gaps
25
+ end
26
+
27
+ def self.each_hunk(diff_text)
10
28
  current = nil
11
29
  diff_text.each_line do |line|
12
30
  if line.start_with?("+++ b/")
13
31
  current = line.delete_prefix("+++ b/").strip
14
32
  elsif current && (match = HUNK.match(line))
15
- start = match[1].to_i
16
- count = (match[2] || "1").to_i
17
- count.times { |i| changed[current] << start + i }
33
+ yield current, match[1].to_i, (match[2] || "1").to_i
18
34
  end
19
35
  end
20
- changed.reject { |_, lines| lines.empty? }
21
36
  end
22
37
 
23
38
  # Every file the diff touched on the new side, including deletion-only
@@ -29,7 +44,36 @@ module ActiveMutator
29
44
  end.uniq
30
45
  end
31
46
 
47
+ # Tokens that never change what the code does.
48
+ NOISE = %i[IGNORED_NEWLINE EMBDOC_BEGIN EMBDOC_LINE EMBDOC_END EOF].freeze
49
+
50
+ # True when two sources differ only in comments and blank lines. Magic
51
+ # comments (`# frozen_string_literal: true`) change behavior, so they
52
+ # count as code; so does anything that fails to parse.
53
+ def self.same_code?(old_source, new_source)
54
+ old_code = code_of(old_source)
55
+ !old_code.nil? && old_code == code_of(new_source)
56
+ end
57
+
58
+ def self.code_of(source)
59
+ result = Prism.parse_lex(source)
60
+ return nil if result.failure?
61
+
62
+ # A trailing comment swallows its line's newline, so comments become a
63
+ # newline instead of vanishing; runs of newlines collapse to one.
64
+ tokens = result.value.last.filter_map do |token, _state|
65
+ next if NOISE.include?(token.type)
66
+
67
+ %i[COMMENT NEWLINE].include?(token.type) ? :NEWLINE : [token.type, token.value]
68
+ end
69
+ tokens = tokens.chunk_while { |a, b| a == :NEWLINE && b == :NEWLINE }.map(&:first)
70
+ tokens.shift if tokens.first == :NEWLINE
71
+ tokens.pop if tokens.last == :NEWLINE
72
+ [tokens, result.magic_comments.map { |c| [c.key, c.value] }]
73
+ end
74
+
32
75
  def initialize(ref:, root:)
76
+ @ref = ref
33
77
  @root = root
34
78
  diff = IO.popen(
35
79
  ["git", "-C", root, "diff", "--unified=0", ref, "--", "*.rb"], &:read
@@ -37,6 +81,7 @@ module ActiveMutator
37
81
  raise Error, "git diff #{ref} failed" unless $?.success?
38
82
 
39
83
  @changed = self.class.parse(diff)
84
+ @gaps = self.class.deletion_gaps(diff)
40
85
  @touched = self.class.touched_files(diff)
41
86
  untracked = IO.popen(
42
87
  ["git", "-C", root, "ls-files", "--others", "--exclude-standard", "--", "*.rb"], &:read
@@ -53,12 +98,28 @@ module ActiveMutator
53
98
  # further git calls.
54
99
  def changed_files = @touched
55
100
 
101
+ # Whether a changed file's edits since the ref are only comments. Reads
102
+ # the old side with `git show`; a file missing there (untracked, renamed)
103
+ # prints nothing, so it compares against an empty source and any code in
104
+ # it counts.
105
+ def comment_only?(path)
106
+ old_source = IO.popen(["git", "-C", @root, "show", "#{@ref}:#{path}"], err: File::NULL, &:read)
107
+ self.class.same_code?(old_source, File.read(File.join(@root, path)))
108
+ end
109
+
110
+ # Whether the file only lost lines since the ref: touched, but with no
111
+ # added or changed lines (untracked files count as all added).
112
+ def deletion_only?(path) = !@changed.key?(path)
113
+
56
114
  def cover?(subject)
57
- lines = @changed[subject.file.delete_prefix("#{@root}/")]
58
- return false unless lines
115
+ rel = subject.file.delete_prefix("#{@root}/")
116
+ lines = @changed[rel]
59
117
  return true if lines == :all
60
118
 
61
- lines.any? { |line| subject.line_range.cover?(line) }
119
+ range = subject.line_range
120
+ return true if lines&.any? { |line| range.cover?(line) }
121
+
122
+ @gaps.fetch(rel, []).any? { |gap| range.cover?(gap) && range.cover?(gap + 1) }
62
123
  end
63
124
  end
64
125
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.6.0"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -25,6 +25,11 @@ require_relative "active_mutator/operators/negation_removal"
25
25
  require_relative "active_mutator/mutation"
26
26
  require_relative "active_mutator/analysis"
27
27
  require_relative "active_mutator/engine"
28
+ require_relative "active_mutator/abort_flag"
29
+ require_relative "active_mutator/memory_ceiling"
30
+ require_relative "active_mutator/events"
31
+ require_relative "active_mutator/diagnostics/text"
32
+ require_relative "active_mutator/diagnostics/ndjson"
28
33
  require_relative "active_mutator/atomic_file"
29
34
  require_relative "active_mutator/coverage_map"
30
35
  require_relative "active_mutator/baseline"
@@ -32,6 +37,8 @@ require_relative "active_mutator/baseline_delta"
32
37
  require_relative "active_mutator/defined_constants"
33
38
  require_relative "active_mutator/inserter"
34
39
  require_relative "active_mutator/closure_reload"
40
+ require_relative "active_mutator/memory_probe"
41
+ require_relative "active_mutator/sampler"
35
42
  require_relative "active_mutator/worker"
36
43
  require_relative "active_mutator/result"
37
44
  require_relative "active_mutator/work_item"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_mutator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John
@@ -70,6 +70,7 @@ files:
70
70
  - README.md
71
71
  - exe/active_mutator
72
72
  - lib/active_mutator.rb
73
+ - lib/active_mutator/abort_flag.rb
73
74
  - lib/active_mutator/accepted_ledger.rb
74
75
  - lib/active_mutator/analysis.rb
75
76
  - lib/active_mutator/atomic_file.rb
@@ -83,10 +84,15 @@ files:
83
84
  - lib/active_mutator/config_file.rb
84
85
  - lib/active_mutator/coverage_map.rb
85
86
  - lib/active_mutator/defined_constants.rb
87
+ - lib/active_mutator/diagnostics/ndjson.rb
88
+ - lib/active_mutator/diagnostics/text.rb
86
89
  - lib/active_mutator/edit.rb
87
90
  - lib/active_mutator/engine.rb
91
+ - lib/active_mutator/events.rb
88
92
  - lib/active_mutator/fingerprint.rb
89
93
  - lib/active_mutator/inserter.rb
94
+ - lib/active_mutator/memory_ceiling.rb
95
+ - lib/active_mutator/memory_probe.rb
90
96
  - lib/active_mutator/mutation.rb
91
97
  - lib/active_mutator/operators/base.rb
92
98
  - lib/active_mutator/operators/call_swap.rb
@@ -104,6 +110,7 @@ files:
104
110
  - lib/active_mutator/reporter/terminal.rb
105
111
  - lib/active_mutator/result.rb
106
112
  - lib/active_mutator/runner.rb
113
+ - lib/active_mutator/sampler.rb
107
114
  - lib/active_mutator/scheduler.rb
108
115
  - lib/active_mutator/since_filter.rb
109
116
  - lib/active_mutator/source_location.rb