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.
@@ -5,63 +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; `since_filter` is the --since SinceFilter (nil
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
10
  # without --since). The last three feed the --allow-empty verdict (#46).
11
11
  Discovery = Data.define(:subjects, :scanned_files, :since_candidates, :since_matched_all, :since_filter)
12
12
 
13
- 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)
14
18
  @config = config
15
19
  @reporter = reporter || build_reporter
20
+ @events = events || build_events
21
+ @abort = AbortFlag.new
16
22
  end
17
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.
18
26
  def call
19
- ENV["ACTIVE_MUTATOR"] = "1"
20
- load_operators
21
- ClosureReload.cap = @config.class_level_closure_cap
22
- preload!
23
- preload_spec_helper!
24
- discovery = discover
25
- subjects = discovery.subjects
26
- analyses = subjects.map { |s| Engine.new.analyze(s) }
27
- mutations = analyses.flat_map(&:mutations)
28
- mutations = mutations.first(@config.max_mutants) if @config.max_mutants
29
- invalid_count = analyses.sum(&:invalid_count)
30
- # Decide emptiness before the baseline: a scoped run that plans nothing
31
- # has no use for a coverage map, and building one spawns the whole spec
32
- # suite (#47).
33
- if mutations.empty? && (@config.since || @config.subject_filter)
34
- return debug_plan([], []) if @config.debug_plan
35
-
36
- return empty_plan_exit(invalid_count, discovery)
37
- end
38
-
39
- map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
40
- .coverage_map(force: @config.force_baseline)
41
- @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
42
-
43
- fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
44
- ledger = AcceptedLedger.load(@config.root)
45
- scanned_files = prune_scope(subjects)
46
- warn_stale(ledger, fingerprints.values, scanned_files)
47
-
48
- items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
49
- return debug_plan(items, pre_results) if @config.debug_plan
50
-
51
- pre_results.each { |r| @reporter.on_result(r) }
52
- calibrators = if @config.adaptive_timeout
53
- { parallel: TimeoutCalibrator.new, serial: TimeoutCalibrator.new }
54
- end
55
- scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
56
- calibrators: calibrators)
57
- results = scheduler.run(items) + pre_results
58
- # Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
59
- results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)
60
-
61
- accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
62
-
63
- @reporter.summary(results, invalid_count: invalid_count)
64
- 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)
65
37
  end
66
38
 
67
39
  # Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each
@@ -122,8 +94,16 @@ module ActiveMutator
122
94
  end
123
95
  return results if items.empty?
124
96
 
125
- scheduler ||= Scheduler.new(jobs: @config.jobs)
126
- 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
127
107
  results.map do |r|
128
108
  # A replacement only ever exists for a survived candidate (items is
129
109
  # built solely from those), so no redundant status re-check is needed.
@@ -158,12 +138,116 @@ module ActiveMutator
158
138
 
159
139
  private
160
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
+
161
245
  # A scoped run that plans nothing must not report "100%" and pass --fail-at:
162
246
  # the usual cause is a --since range or --subject filter that matched no
163
247
  # mutable code, or class-body code dropped by --no-class-level (#23 covers
164
248
  # the zero-subject case for explicit paths).
165
249
  def empty_plan_exit(invalid_count, discovery)
166
- @reporter.summary([], invalid_count: invalid_count, empty_plan: true)
250
+ reporting { @reporter.summary([], invalid_count: invalid_count, empty_plan: true) }
167
251
  causes = []
168
252
  causes << "--since #{@config.since} matched no mutable code" if @config.since
169
253
  causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
@@ -178,20 +262,29 @@ module ActiveMutator
178
262
  end
179
263
 
180
264
  # --allow-empty forgives an empty plan only when the --since diff touched no
181
- # candidate source file (docs-only, spec-only, excluded paths) or changed
182
- # only comments in them. A candidate whose code changed but planned
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
183
268
  # nothing is the case worth failing on, unless the only code it touched
184
269
  # is class-body code that --no-class-level dropped. --subject alone has no
185
270
  # diff to judge, so it stays an unconditional 0.
186
271
  def allow_empty_exit(discovery)
187
272
  return 0 unless @config.since
188
- return 0 if discovery.since_candidates.empty?
189
273
 
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
190
282
  # Checked only here, on the empty-plan path: it runs `git show` per file.
191
- candidates = discovery.since_candidates.reject { |file| discovery.since_filter.comment_only?(file) }
283
+ commented = (discovery.since_candidates - deleted).select { |f| filter.comment_only?(f) }
284
+ candidates = discovery.since_candidates - deleted - commented
192
285
  if candidates.empty?
193
- warn "active_mutator: forgiving empty plan: only comments changed in " \
194
- "#{discovery.since_candidates.join(", ")}"
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?
195
288
  return 0
196
289
  end
197
290
 
@@ -247,6 +340,14 @@ module ActiveMutator
247
340
  end.flatten.uniq.sort
248
341
  end
249
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
+
250
351
  # Custom operators must exist in the PARENT before Engine analysis:
251
352
  # subclassing Operators::Base self-registers, and forks inherit the
252
353
  # loaded class. `requires` can't serve — those load inside the fork's
@@ -286,6 +387,41 @@ module ActiveMutator
286
387
  @config.spec_paths.map { |sp| "#{sp}/#{rest}_spec.rb" }
287
388
  end
288
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
+
289
425
  def build_reporter
290
426
  case @config.format
291
427
  when :json then Reporter::Json.new
@@ -316,18 +452,17 @@ module ActiveMutator
316
452
  .sort
317
453
  scanned_files = files.map { |f| relative(f) }.reject { |rel| under_spec_paths?(rel) }
318
454
  subjects = files.flat_map { |file| SubjectFinder.call(file) }
319
- if @config.subject_filter
320
- matcher = SubjectMatcher.new(@config.subject_filter)
321
- subjects = subjects.select { |s| matcher.match?(s.name) }
322
- end
323
455
  since_candidates = []
324
456
  if @config.since
325
457
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
326
458
  subjects = subjects.select { |s| filter.cover?(s) }
327
459
  since_candidates = filter.changed_files & scanned_files
328
460
  end
329
- # Class bodies drop out LAST so since_matched_all still knows about them.
330
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
331
466
  subjects = subjects.reject(&:class_body?) unless @config.class_level
332
467
  Discovery.new(subjects: subjects, scanned_files: scanned_files,
333
468
  since_candidates: since_candidates, since_matched_all: since_matched_all,
@@ -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
@@ -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