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.
@@ -5,62 +5,35 @@ module ActiveMutator
5
5
  # What discovery saw, beyond the final subject list. `scanned_files` are
6
6
  # root-relative source files after path expansion and excludes, minus
7
7
  # spec_paths; `since_candidates` are the --since diff's files among them;
8
- # `since_matched_all` are the since-covered subjects before --no-class-level
9
- # drops class bodies. The last two feed the --allow-empty verdict (#46).
10
- Discovery = Data.define(:subjects, :scanned_files, :since_candidates, :since_matched_all)
8
+ # `since_matched_all` are the since-covered subjects before --subject and
9
+ # --no-class-level narrow them; `since_filter` is the --since SinceFilter (nil
10
+ # without --since). The last three feed the --allow-empty verdict (#46).
11
+ Discovery = Data.define(:subjects, :scanned_files, :since_candidates, :since_matched_all, :since_filter)
11
12
 
12
- def initialize(config, reporter: nil)
13
+ SIGNALS = { "INT" => :sigint, "TERM" => :sigterm }.freeze
14
+ # An aborted run never passes, whatever --fail-at says.
15
+ EXIT_CODES = { sigint: 130, sigterm: 143, memory_ceiling: 3 }.freeze
16
+
17
+ def initialize(config, reporter: nil, events: nil)
13
18
  @config = config
14
19
  @reporter = reporter || build_reporter
20
+ @events = events || build_events
21
+ @abort = AbortFlag.new
15
22
  end
16
23
 
24
+ # The traps go in first so boot, planning, and the baseline are covered
25
+ # too, and come out last so the host gets its own handlers back.
17
26
  def call
18
- ENV["ACTIVE_MUTATOR"] = "1"
19
- load_operators
20
- ClosureReload.cap = @config.class_level_closure_cap
21
- preload!
22
- preload_spec_helper!
23
- discovery = discover
24
- subjects = discovery.subjects
25
- analyses = subjects.map { |s| Engine.new.analyze(s) }
26
- mutations = analyses.flat_map(&:mutations)
27
- mutations = mutations.first(@config.max_mutants) if @config.max_mutants
28
- invalid_count = analyses.sum(&:invalid_count)
29
- # Decide emptiness before the baseline: a scoped run that plans nothing
30
- # has no use for a coverage map, and building one spawns the whole spec
31
- # suite (#47).
32
- if mutations.empty? && (@config.since || @config.subject_filter)
33
- return debug_plan([], []) if @config.debug_plan
34
-
35
- return empty_plan_exit(invalid_count, discovery)
36
- end
37
-
38
- map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
39
- .coverage_map(force: @config.force_baseline)
40
- @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
41
-
42
- fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
43
- ledger = AcceptedLedger.load(@config.root)
44
- scanned_files = prune_scope(subjects)
45
- warn_stale(ledger, fingerprints.values, scanned_files)
46
-
47
- items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
48
- return debug_plan(items, pre_results) if @config.debug_plan
49
-
50
- pre_results.each { |r| @reporter.on_result(r) }
51
- calibrators = if @config.adaptive_timeout
52
- { parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
53
- end
54
- scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
55
- calibrators: calibrators)
56
- results = scheduler.run(items) + pre_results
57
- # Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
58
- results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)
59
-
60
- accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
61
-
62
- @reporter.summary(results, invalid_count: invalid_count)
63
- exit_code(results)
27
+ previous_traps = trap_signals
28
+ events_file = open_events_file
29
+ sampler = start_sampler
30
+ run
31
+ rescue Aborted => e
32
+ aborted_exit(e)
33
+ ensure
34
+ sampler&.stop
35
+ events_file&.close
36
+ restore_traps(previous_traps)
64
37
  end
65
38
 
66
39
  # Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each
@@ -121,8 +94,16 @@ module ActiveMutator
121
94
  end
122
95
  return results if items.empty?
123
96
 
124
- scheduler ||= Scheduler.new(jobs: @config.jobs)
125
- escalated = scheduler.run(items.values).to_h { |res| [res.mutation, res] }
97
+ # Numbered after phase 1's mutants, so seq stays unique across the run.
98
+ scheduler ||= Scheduler.new(jobs: @config.jobs, events: @events, first_seq: phase1_ids.size + 1, abort: @abort)
99
+ escalated = begin
100
+ @events.phase(:escalating, mutants: items.size) do
101
+ scheduler.run(items.values).to_h { |res| [res.mutation, res] }
102
+ end
103
+ rescue Aborted => e
104
+ # Every phase-1 verdict is final; a half-done escalation proves nothing.
105
+ raise e.with_results(results)
106
+ end
126
107
  results.map do |r|
127
108
  # A replacement only ever exists for a survived candidate (items is
128
109
  # built solely from those), so no redundant status re-check is needed.
@@ -157,12 +138,116 @@ module ActiveMutator
157
138
 
158
139
  private
159
140
 
141
+ def run
142
+ @events.phase(:boot) { boot! }
143
+ discovery, analyses = @events.phase(:planning) do
144
+ found = discover
145
+ [found, found.subjects.map { |s| Engine.new.analyze(s) }]
146
+ end
147
+ subjects = discovery.subjects
148
+ mutations = analyses.flat_map(&:mutations)
149
+ mutations = mutations.first(@config.max_mutants) if @config.max_mutants
150
+ @planned = mutations.size
151
+ @invalid_count = analyses.sum(&:invalid_count)
152
+ # Decide emptiness before the baseline: a scoped run that plans nothing
153
+ # has no use for a coverage map, and building one spawns the whole spec
154
+ # suite (#47).
155
+ if mutations.empty? && (@config.since || @config.subject_filter)
156
+ return debug_plan([], []) if @config.debug_plan
157
+
158
+ return empty_plan_exit(@invalid_count, discovery)
159
+ end
160
+
161
+ map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths, events: @events, abort: @abort)
162
+ .coverage_map(force: @config.force_baseline)
163
+ @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
164
+
165
+ fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
166
+ ledger = AcceptedLedger.load(@config.root)
167
+ scanned_files = prune_scope(subjects)
168
+ warn_stale(ledger, fingerprints.values, scanned_files)
169
+
170
+ items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
171
+ return debug_plan(items, pre_results) if @config.debug_plan
172
+
173
+ # From here on a trip only records its reason. Raised at once, it could
174
+ # land between two steps (the sample as mutating ends, the spec reads
175
+ # before escalation) and drop every finished verdict. Each step checks
176
+ # the flag instead and raises with the results it has.
177
+ results = @abort.deferred do
178
+ done = @events.phase(:mutating, mutants: items.size) { mutate(items, pre_results) }
179
+ stop_if_tripped!(done)
180
+ # Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
181
+ done = escalate_class_body_survivors(done, nil, map, phase1_ids: phase1_ids)
182
+ stop_if_tripped!(done)
183
+
184
+ reporting(done) do
185
+ accept_survivors!(ledger, done, fingerprints, scanned_files) if @config.accept_survivors
186
+ @reporter.summary(done, invalid_count: @invalid_count)
187
+ end
188
+ done
189
+ end
190
+ exit_code(results)
191
+ end
192
+
193
+ def mutate(items, pre_results)
194
+ pre_results.each { |r| @reporter.on_result(r) }
195
+ calibrators = if @config.adaptive_timeout
196
+ { parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
197
+ end
198
+ scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
199
+ calibrators: calibrators, events: @events, abort: @abort)
200
+ scheduler.run(items) + pre_results
201
+ rescue Aborted => e
202
+ raise e.with_results(e.results + pre_results)
203
+ end
204
+
205
+ # The trap only trips the flag: no I/O is safe inside a handler. The
206
+ # main loop does the killing and raises Aborted.
207
+ def trap_signals
208
+ SIGNALS.to_h { |sig, reason| [sig, trap(sig) { @abort.trip!(reason) }] }
209
+ end
210
+
211
+ # Passed back as is: a nil handler means the host ignored the signal.
212
+ def restore_traps(previous)
213
+ previous.each { |sig, handler| trap(sig, handler) }
214
+ end
215
+
216
+ # No --accept-survivors here: a partial run must not rewrite the ledger.
217
+ # Once the report starts, a signal only records its reason: the report
218
+ # finishes, and `--format json` stays one document. The run still exits
219
+ # as aborted, so a memory breach or a CI cancel never passes.
220
+ def reporting(results = [], &)
221
+ @abort.deferred do
222
+ @reported = true
223
+ @events.phase(:reporting, &)
224
+ end
225
+ stop_if_tripped!(results)
226
+ end
227
+
228
+ def stop_if_tripped!(results)
229
+ raise Aborted.new(@abort.reason, results: results) if @abort.tripped?
230
+ end
231
+
232
+ # No summary if the full one already went out: a signal landing after
233
+ # the report would otherwise print a second one.
234
+ def aborted_exit(error)
235
+ counts = Reporter::Terminal.counts(error.results)
236
+ @events.emit(:abort, reason: error.reason, in_flight: error.in_flight, planned: @planned,
237
+ counts: counts, score: error.results.empty? ? nil : Reporter::Terminal.score(counts))
238
+ unless @reported
239
+ @reporter.summary(error.results, invalid_count: @invalid_count || 0,
240
+ aborted: { reason: error.reason, in_flight: error.in_flight, planned: @planned })
241
+ end
242
+ EXIT_CODES.fetch(error.reason)
243
+ end
244
+
160
245
  # A scoped run that plans nothing must not report "100%" and pass --fail-at:
161
246
  # the usual cause is a --since range or --subject filter that matched no
162
247
  # mutable code, or class-body code dropped by --no-class-level (#23 covers
163
248
  # the zero-subject case for explicit paths).
164
249
  def empty_plan_exit(invalid_count, discovery)
165
- @reporter.summary([], invalid_count: invalid_count, empty_plan: true)
250
+ reporting { @reporter.summary([], invalid_count: invalid_count, empty_plan: true) }
166
251
  causes = []
167
252
  causes << "--since #{@config.since} matched no mutable code" if @config.since
168
253
  causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
@@ -177,15 +262,31 @@ module ActiveMutator
177
262
  end
178
263
 
179
264
  # --allow-empty forgives an empty plan only when the --since diff touched no
180
- # candidate source file (docs-only, spec-only, excluded paths). A changed
181
- # candidate that planned nothing is the case worth failing on, unless the
182
- # only code it touched is class-body code that --no-class-level dropped.
183
- # --subject alone has no diff to judge, so it stays an unconditional 0.
265
+ # candidate source file (docs-only, spec-only, excluded paths), changed
266
+ # only comments in them, or deleted code no subject spans (a removed
267
+ # method). A candidate whose code changed but planned
268
+ # nothing is the case worth failing on, unless the only code it touched
269
+ # is class-body code that --no-class-level dropped. --subject alone has no
270
+ # diff to judge, so it stays an unconditional 0.
184
271
  def allow_empty_exit(discovery)
185
272
  return 0 unless @config.since
186
273
 
187
- candidates = discovery.since_candidates
188
- return 0 if candidates.empty?
274
+ filter = discovery.since_filter
275
+ matched_files = discovery.since_matched_all.map { |s| relative(s.file) }
276
+ # A deletion no subject spans leaves nothing to mutate. One inside a
277
+ # subject that still planned nothing falls through to the checks below.
278
+ deleted = discovery.since_candidates.select do |file|
279
+ filter.deletion_only?(file) && !matched_files.include?(file) &&
280
+ Prism.parse_file(File.join(@config.root, file)).success?
281
+ end
282
+ # Checked only here, on the empty-plan path: it runs `git show` per file.
283
+ commented = (discovery.since_candidates - deleted).select { |f| filter.comment_only?(f) }
284
+ candidates = discovery.since_candidates - deleted - commented
285
+ if candidates.empty?
286
+ warn "active_mutator: forgiving empty plan: deletions in #{deleted.join(", ")} left no method to mutate" if deleted.any?
287
+ warn "active_mutator: forgiving empty plan: only comments changed in #{commented.join(", ")}" if commented.any?
288
+ return 0
289
+ end
189
290
 
190
291
  if !@config.class_level && class_body_only?(discovery.since_matched_all, candidates)
191
292
  warn "active_mutator: forgiving empty plan: changed lines are class-body code and --no-class-level is set"
@@ -239,6 +340,14 @@ module ActiveMutator
239
340
  end.flatten.uniq.sort
240
341
  end
241
342
 
343
+ def boot!
344
+ ENV["ACTIVE_MUTATOR"] = "1"
345
+ load_operators
346
+ ClosureReload.cap = @config.class_level_closure_cap
347
+ preload!
348
+ preload_spec_helper!
349
+ end
350
+
242
351
  # Custom operators must exist in the PARENT before Engine analysis:
243
352
  # subclassing Operators::Base self-registers, and forks inherit the
244
353
  # loaded class. `requires` can't serve — those load inside the fork's
@@ -278,6 +387,41 @@ module ActiveMutator
278
387
  @config.spec_paths.map { |sp| "#{sp}/#{rest}_spec.rb" }
279
388
  end
280
389
 
390
+ # Only when something reads the samples. Subscribed last, so each phase
391
+ # line prints before the sample taken at it.
392
+ def start_sampler
393
+ watch_memory if @config.max_rss
394
+ return unless @events.listening?
395
+
396
+ sampler = Sampler.new(events: @events, interval: @config.sample_interval)
397
+ @events.subscribe(sampler)
398
+ sampler.start
399
+ end
400
+
401
+ # Without --diagnostics, the 90% warning and the breach still reach stderr.
402
+ def watch_memory
403
+ notices = %i[memory_warning memory_ceiling]
404
+ @events.subscribe(Diagnostics::Text.new(root: @config.root, only: notices)) unless @config.diagnostics
405
+ @events.subscribe(MemoryCeiling.new(max_rss_kb: @config.max_rss, events: @events, abort: @abort))
406
+ end
407
+
408
+ # Opened per call, not per Runner, so the file is closed on every exit.
409
+ def open_events_file
410
+ return unless @config.events_file
411
+
412
+ file = File.open(File.expand_path(@config.events_file, @config.root), "w")
413
+ @events.subscribe(Diagnostics::Ndjson.new(file))
414
+ file
415
+ rescue SystemCallError => e
416
+ raise Error, "cannot write --events file: #{e.message}"
417
+ end
418
+
419
+ def build_events
420
+ events = Events.new
421
+ events.subscribe(Diagnostics::Text.new(root: @config.root)) if @config.diagnostics
422
+ events
423
+ end
424
+
281
425
  def build_reporter
282
426
  case @config.format
283
427
  when :json then Reporter::Json.new
@@ -308,21 +452,21 @@ module ActiveMutator
308
452
  .sort
309
453
  scanned_files = files.map { |f| relative(f) }.reject { |rel| under_spec_paths?(rel) }
310
454
  subjects = files.flat_map { |file| SubjectFinder.call(file) }
311
- if @config.subject_filter
312
- matcher = SubjectMatcher.new(@config.subject_filter)
313
- subjects = subjects.select { |s| matcher.match?(s.name) }
314
- end
315
455
  since_candidates = []
316
456
  if @config.since
317
457
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
318
458
  subjects = subjects.select { |s| filter.cover?(s) }
319
459
  since_candidates = filter.changed_files & scanned_files
320
460
  end
321
- # Class bodies drop out LAST so since_matched_all still knows about them.
322
461
  since_matched_all = subjects
462
+ if @config.subject_filter
463
+ matcher = SubjectMatcher.new(@config.subject_filter)
464
+ subjects = subjects.select { |s| matcher.match?(s.name) }
465
+ end
323
466
  subjects = subjects.reject(&:class_body?) unless @config.class_level
324
467
  Discovery.new(subjects: subjects, scanned_files: scanned_files,
325
- since_candidates: since_candidates, since_matched_all: since_matched_all)
468
+ since_candidates: since_candidates, since_matched_all: since_matched_all,
469
+ since_filter: filter)
326
470
  end
327
471
 
328
472
  def relative(file) = file.delete_prefix(@config.root.chomp("/") + "/")
@@ -0,0 +1,69 @@
1
+ module ActiveMutator
2
+ # Samples the gem's own memory: the parent, every live worker, and the
3
+ # baseline child. Either side alone tells half the story (0.6.0 died in
4
+ # the parent, right after the child). It learns worker and child pids from
5
+ # the event stream, samples at every phase boundary, and, once started,
6
+ # every `interval` seconds on its own thread. That thread only reads files
7
+ # and emits, so forks from the main thread stay safe: a fork copies only
8
+ # the calling thread.
9
+ class Sampler
10
+ def initialize(events:, interval:, probe: MemoryProbe.new, parent_pid: Process.pid)
11
+ @events = events
12
+ @interval = interval
13
+ @probe = probe
14
+ @parent_pid = parent_pid
15
+ @workers = {} # pid => seq
16
+ @lock = Mutex.new
17
+ end
18
+
19
+ # Events listener: follows pids, and samples at each phase boundary.
20
+ def call(event)
21
+ fields = event.fields
22
+ case event.type
23
+ when :mutant_start then @lock.synchronize { @workers[fields[:pid]] = fields[:seq] }
24
+ when :mutant_end then @lock.synchronize { @workers.delete(fields[:pid]) }
25
+ when :phase_start, :phase_end
26
+ follow_baseline(fields) if fields[:phase] == :baseline
27
+ tick
28
+ end
29
+ end
30
+
31
+ # One sample, emitted as a `memory` event. Returns the total in KB (Pss
32
+ # where the platform has it, else RSS, which overcounts shared pages),
33
+ # nil when nothing could be read. Public so specs call it directly.
34
+ def tick
35
+ workers, baseline_pid = @lock.synchronize { [@workers.dup, @baseline_pid] }
36
+ readings = @probe.processes([@parent_pid, *workers.keys, baseline_pid].compact)
37
+ live = workers.filter_map { |pid, seq| readings[pid]&.then { |r| { pid: pid, seq: seq, **sizes(r) } } }
38
+ baseline = readings[baseline_pid]&.then { |r| { pid: baseline_pid, **sizes(r) } }
39
+ total = readings.empty? ? nil : readings.each_value.sum { |r| r[:pss_kb] || r[:rss_kb] }
40
+ @events.emit(:memory, parent: readings[@parent_pid]&.then { |r| sizes(r) }, workers: live,
41
+ baseline: baseline, total_pss_kb: total, system: @probe.system)
42
+ total
43
+ end
44
+
45
+ def start
46
+ @thread = Thread.new do
47
+ loop do
48
+ sleep @interval
49
+ tick
50
+ end
51
+ end
52
+ self
53
+ end
54
+
55
+ def stop
56
+ @thread&.kill&.join
57
+ end
58
+
59
+ private
60
+
61
+ # phase_start carries the child's pid; phase_end carries none, so it
62
+ # clears it.
63
+ def follow_baseline(fields)
64
+ @lock.synchronize { @baseline_pid = fields[:pid] }
65
+ end
66
+
67
+ def sizes(reading) = reading.slice(:rss_kb, :pss_kb)
68
+ end
69
+ end