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.
- checksums.yaml +4 -4
- data/README.md +45 -13
- data/lib/active_mutator/abort_flag.rb +53 -0
- data/lib/active_mutator/baseline.rb +132 -58
- data/lib/active_mutator/baseline_hooks.rb +24 -2
- data/lib/active_mutator/cli.rb +18 -3
- 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 +209 -65
- data/lib/active_mutator/sampler.rb +69 -0
- data/lib/active_mutator/scheduler.rb +105 -61
- data/lib/active_mutator/since_filter.rb +68 -7
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator.rb +7 -0
- metadata +8 -1
|
@@ -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 --
|
|
9
|
-
#
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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)
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
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
|
-
|
|
188
|
-
|
|
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
|