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
|
@@ -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 --
|
|
9
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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)
|
|
182
|
-
# only comments in them
|
|
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
|
-
|
|
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:
|
|
194
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
127
|
-
|
|
179
|
+
Result.new(mutation: entry[:item].mutation, status: :error,
|
|
180
|
+
details: "worker emitted unparseable payload", seconds: seconds)
|
|
128
181
|
else
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|