saboteur 0.2.0 → 0.4.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/CHANGELOG.md +153 -1
- data/README.md +526 -73
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +305 -30
- data/lib/saboteur/config.rb +65 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +88 -0
- data/lib/saboteur/engine/baseline_guard.rb +116 -0
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +40 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +344 -0
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +125 -25
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +210 -0
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +69 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +14 -3
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +131 -0
- data/lib/saboteur/reporter/html.rb +213 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +21 -4
- data/lib/saboteur/reporter/terminal.rb +76 -2
- data/lib/saboteur/result.rb +61 -9
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +7 -0
- metadata +30 -2
data/lib/saboteur/engine.rb
CHANGED
|
@@ -3,66 +3,136 @@
|
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
|
+
require_relative "replay"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
|
-
#
|
|
9
|
+
# A single-use instance runs one pipeline: boot the target, select subjects, fetch the
|
|
9
10
|
# coverage map, then schedule each subject's mutants. The parts live in
|
|
10
11
|
# engine/; this class only wires them together.
|
|
11
12
|
class Engine
|
|
12
13
|
# Everything besides sources and tests that can change a verdict.
|
|
13
14
|
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources arid timeout_factor timeout_min
|
|
14
|
-
max_memory_mb max_processes seed].freeze
|
|
15
|
+
max_memory_mb max_processes seed tests].freeze
|
|
15
16
|
|
|
16
17
|
attr_reader :config, :hooks
|
|
17
18
|
|
|
18
|
-
def initialize(config, err: $stderr)
|
|
19
|
+
def initialize(config, err: $stderr, digest: nil)
|
|
19
20
|
@config = config
|
|
20
21
|
@hooks = Hooks.new
|
|
21
22
|
@diagnostics = Diagnostics.new(err)
|
|
22
23
|
@root = File.realpath(config.root)
|
|
24
|
+
@skipped_subjects = {}
|
|
25
|
+
@replay = Replay.new(digest: digest) if digest
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def call
|
|
26
29
|
Boot.refuse_recursion!
|
|
27
30
|
started = monotonic
|
|
28
|
-
ignore_list = IgnoreList.load(config.path("ignore_file"))
|
|
31
|
+
ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
|
|
32
|
+
@diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
|
|
29
33
|
boot = Boot.new(config, hooks)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
begin
|
|
35
|
+
integration = boot.load_target
|
|
36
|
+
@rails = boot.rails
|
|
37
|
+
files = config.included_files
|
|
38
|
+
subjects, scope = select_subjects(files)
|
|
39
|
+
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
|
|
40
|
+
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
41
|
+
build_report(results, subjects, scope, started)
|
|
42
|
+
ensure
|
|
43
|
+
# The run boundary for the per-worker databases: cleanup runs however
|
|
44
|
+
# the call ends - a boot that raised partway through, a baseline or
|
|
45
|
+
# coverage failure, no subjects, no runnable work - so a host that
|
|
46
|
+
# embeds Engine and calls it repeatedly never holds copies or
|
|
47
|
+
# snapshots until process exit. cleanup_workers is idempotent; the
|
|
48
|
+
# Rails profile's at_exit handler stays as the fallback for an
|
|
49
|
+
# abrupt process exit.
|
|
50
|
+
safely_cleanup_workers(boot.rails)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
55
|
+
# map does not rot silently. doctor fails on stale keys.
|
|
56
|
+
def load_custom_map
|
|
57
|
+
@replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def custom_map_warnings(map)
|
|
61
|
+
path = config.path("custom_map")
|
|
62
|
+
warnings = map.stale_keys.map { |key| "stale custom map key #{key}: matches no file" }
|
|
63
|
+
return warnings unless path && File.file?(path)
|
|
64
|
+
|
|
65
|
+
warnings + map.duplicate_keys(path).map { |key| "duplicate custom map key #{key}: Ruby keeps the last" }
|
|
39
66
|
end
|
|
40
67
|
|
|
41
68
|
private
|
|
42
69
|
|
|
70
|
+
# The run's immutable report, built once the last result is in.
|
|
71
|
+
def build_report(results, subjects, scope, started)
|
|
72
|
+
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
73
|
+
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
74
|
+
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
|
|
75
|
+
diff_scope: scope)
|
|
76
|
+
end
|
|
77
|
+
|
|
43
78
|
# The subjects to mutate and, in diff mode, the scope of changed lines.
|
|
44
79
|
def select_subjects(files)
|
|
45
80
|
config.unmatched_patterns.each { |pattern| @diagnostics.warn("#{pattern} matches no file") }
|
|
46
81
|
selection = SubjectSelection.new(config, @diagnostics)
|
|
47
82
|
subjects = selection.select(files)
|
|
48
|
-
|
|
83
|
+
scope = selection.changed_lines && DiffScope.new(selection.changed_lines)
|
|
84
|
+
return replay_subject(subjects, scope) if @replay
|
|
85
|
+
|
|
86
|
+
[subjects, scope]
|
|
49
87
|
end
|
|
50
88
|
|
|
51
89
|
def mutate(subjects, files, integration, scope)
|
|
90
|
+
warn_about_unscoped_suite(integration)
|
|
91
|
+
check_baseline(integration)
|
|
52
92
|
map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
|
|
53
93
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
|
|
54
|
-
|
|
55
|
-
warn_about_threads
|
|
94
|
+
inform_about_run(subjects, map)
|
|
56
95
|
cache = result_cache
|
|
57
96
|
scheduler = scheduler_for(map, integration, cache)
|
|
97
|
+
lists = candidate_lists(subjects, scope, map)
|
|
98
|
+
work = subjects.zip(lists).reject { |_, list| list.empty? }
|
|
99
|
+
return [] if work.empty?
|
|
100
|
+
|
|
101
|
+
runnable_subjects, runnable_lists = work.transpose
|
|
58
102
|
@rails&.prepare_workers(config["jobs"])
|
|
103
|
+
# The execution phase forks next: the worker batch, or one child per
|
|
104
|
+
# mutant when serial. Pack the heap now that the map and candidates
|
|
105
|
+
# are retained, to reduce later copying of shared pages.
|
|
106
|
+
Execution::ForkReadiness.prepare_heap
|
|
59
107
|
results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
|
|
60
|
-
cache: cache, on_start: method(:start_worker)).run(
|
|
61
|
-
|
|
108
|
+
cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
|
|
109
|
+
runnable_lists)
|
|
62
110
|
cache&.save
|
|
63
111
|
results
|
|
64
112
|
end
|
|
65
113
|
|
|
114
|
+
def inform_about_run(subjects, map)
|
|
115
|
+
@diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
|
|
116
|
+
warn_about_threads
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A --since run without --test runs the loaded suite twice for the
|
|
120
|
+
# baselines; a cold coverage cache adds one more pass. Say so
|
|
121
|
+
# before those hours pass, with the way to scope it.
|
|
122
|
+
def warn_about_unscoped_suite(integration)
|
|
123
|
+
return unless config["since"] && config["tests"].empty?
|
|
124
|
+
|
|
125
|
+
message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
|
|
126
|
+
"(#{integration.test_files.size} spec files). Pass --test to scope it."
|
|
127
|
+
@diagnostics.warn(message)
|
|
128
|
+
@diagnostics.inform(message)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def check_baseline(integration)
|
|
132
|
+
BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
133
|
+
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
|
|
134
|
+
end
|
|
135
|
+
|
|
66
136
|
def scheduler_for(map, integration, cache)
|
|
67
137
|
reevaluator = Execution::Reevaluator.new(config["reeval"])
|
|
68
138
|
runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
|
|
@@ -72,6 +142,7 @@ module Saboteur
|
|
|
72
142
|
end
|
|
73
143
|
|
|
74
144
|
def result_cache
|
|
145
|
+
return nil if @replay
|
|
75
146
|
return nil unless config["incremental"]
|
|
76
147
|
|
|
77
148
|
inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] })
|
|
@@ -79,6 +150,14 @@ module Saboteur
|
|
|
79
150
|
suite_digest: SuiteFiles.new(config).digest)
|
|
80
151
|
end
|
|
81
152
|
|
|
153
|
+
# A cleanup failure must never mask the run's own outcome - not even
|
|
154
|
+
# one cleanup_workers did not anticipate.
|
|
155
|
+
def safely_cleanup_workers(rails)
|
|
156
|
+
rails&.cleanup_workers
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
@diagnostics.warn(e.message)
|
|
159
|
+
end
|
|
160
|
+
|
|
82
161
|
# In each worker: its own database, then the user's worker_start hooks.
|
|
83
162
|
def start_worker(number)
|
|
84
163
|
@rails&.start_worker(number)
|
|
@@ -86,14 +165,24 @@ module Saboteur
|
|
|
86
165
|
end
|
|
87
166
|
|
|
88
167
|
# Each subject's candidates, narrowed to changed lines in diff mode.
|
|
89
|
-
def candidate_lists(subjects, scope)
|
|
168
|
+
def candidate_lists(subjects, scope, map)
|
|
169
|
+
return [@replay_candidate] if @replay
|
|
170
|
+
|
|
90
171
|
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
172
|
+
selection = CandidateSelection.new(candidates: candidates, map: map,
|
|
173
|
+
limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
|
|
174
|
+
subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
|
|
175
|
+
@skipped_subjects = selection.skipped_subjects
|
|
94
176
|
end
|
|
95
177
|
end
|
|
96
178
|
|
|
179
|
+
def replay_subject(subjects, scope)
|
|
180
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
181
|
+
subject, candidate = @replay.select(subjects, candidates, scope: scope)
|
|
182
|
+
@replay_candidate = [candidate]
|
|
183
|
+
[[subject], nil]
|
|
184
|
+
end
|
|
185
|
+
|
|
97
186
|
def supervisor(memory_factor: 1)
|
|
98
187
|
limits = Execution::Supervisor::Limits.new(max_memory_mb: config["max_memory_mb"] * memory_factor,
|
|
99
188
|
max_processes: config["max_processes"])
|
|
@@ -108,10 +197,17 @@ module Saboteur
|
|
|
108
197
|
end
|
|
109
198
|
end
|
|
110
199
|
|
|
200
|
+
# Threads do not survive fork, so name them: with the name the owner
|
|
201
|
+
# gave the thread, else the first frame of its backtrace, so the user
|
|
202
|
+
# can tell whether they matter (a connection pool reaper, Sidekiq, an
|
|
203
|
+
# SDK's background flusher).
|
|
111
204
|
def warn_about_threads
|
|
112
|
-
others = Thread.list
|
|
113
|
-
|
|
114
|
-
|
|
205
|
+
others = Thread.list - [Thread.current]
|
|
206
|
+
return if others.empty?
|
|
207
|
+
|
|
208
|
+
names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
|
|
209
|
+
list = names.join(", ")
|
|
210
|
+
@diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
|
|
115
211
|
end
|
|
116
212
|
|
|
117
213
|
def monotonic
|
|
@@ -125,9 +221,13 @@ require_relative "engine/boot"
|
|
|
125
221
|
require_relative "engine/subject_selection"
|
|
126
222
|
require_relative "engine/suite_files"
|
|
127
223
|
require_relative "engine/coverage_store"
|
|
224
|
+
require_relative "engine/baseline_guard"
|
|
128
225
|
require_relative "engine/candidates"
|
|
226
|
+
require_relative "engine/candidate_selection"
|
|
129
227
|
require_relative "engine/diff_scope"
|
|
130
228
|
require_relative "engine/result_cache"
|
|
131
229
|
require_relative "engine/verdict_check"
|
|
132
230
|
require_relative "engine/scheduler"
|
|
133
231
|
require_relative "engine/distribution"
|
|
232
|
+
|
|
233
|
+
require_relative "engine/run_fingerprint"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Execution
|
|
6
|
+
# Gets the heap ready at a fork boundary.
|
|
7
|
+
#
|
|
8
|
+
# Boot and candidate construction can leave garbage, fragmentation and
|
|
9
|
+
# young objects in the heap. Forked children initially share its pages.
|
|
10
|
+
# Later collection and object writes can make pages private. Preparing the
|
|
11
|
+
# heap before a fork batch can reduce that copying. Call it once per phase
|
|
12
|
+
# boundary, not per fork: preparing again while siblings are alive can
|
|
13
|
+
# break sharing with those siblings.
|
|
14
|
+
module ForkReadiness
|
|
15
|
+
# Process.warmup (CRuby 3.3+) is made for this: a full collection that
|
|
16
|
+
# also promotes survivors to the old generation and compacts. Without
|
|
17
|
+
# it, a lone GC.compact still packs the heap (it runs a full collection
|
|
18
|
+
# itself first) but does not promise old-generation promotion. Without
|
|
19
|
+
# either, a full collection is the best that Ruby can do.
|
|
20
|
+
def self.prepare_heap
|
|
21
|
+
return warmup_heap if Process.respond_to?(:warmup)
|
|
22
|
+
return compact_heap if GC.respond_to?(:compact)
|
|
23
|
+
|
|
24
|
+
full_collection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.warmup_heap
|
|
28
|
+
Process.warmup
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
private_class_method :warmup_heap
|
|
32
|
+
|
|
33
|
+
def self.compact_heap
|
|
34
|
+
GC.compact
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
private_class_method :compact_heap
|
|
38
|
+
|
|
39
|
+
def self.full_collection
|
|
40
|
+
GC.start(full_mark: true, immediate_sweep: true)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
private_class_method :full_collection
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -29,11 +29,12 @@ module Saboteur
|
|
|
29
29
|
target = subject.singleton ? owner.singleton_class : owner
|
|
30
30
|
visibility = visibility_of(target, subject.name)
|
|
31
31
|
module_function = module_function?(owner, subject)
|
|
32
|
+
copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
|
|
32
33
|
aliases = aliases_of(target, subject.name)
|
|
33
34
|
|
|
34
35
|
@strategy == "nesting" ? eval_nested(subject, text) : eval_in_owner(owner, subject, text)
|
|
35
36
|
|
|
36
|
-
restore(owner, target, subject.name, visibility,
|
|
37
|
+
restore(owner, target, subject.name, visibility, copy_visibility)
|
|
37
38
|
repoint(target, subject.name, aliases)
|
|
38
39
|
end
|
|
39
40
|
|
|
@@ -82,7 +83,8 @@ module Saboteur
|
|
|
82
83
|
def module_function?(owner, subject)
|
|
83
84
|
return false if subject.singleton || owner.is_a?(Class)
|
|
84
85
|
return false unless owner.private_method_defined?(subject.name, false)
|
|
85
|
-
return false unless owner.singleton_class.
|
|
86
|
+
return false unless owner.singleton_class.method_defined?(subject.name, false) ||
|
|
87
|
+
owner.singleton_class.private_method_defined?(subject.name, false)
|
|
86
88
|
|
|
87
89
|
owner.instance_method(subject.name).source_location ==
|
|
88
90
|
owner.singleton_class.instance_method(subject.name).source_location
|
|
@@ -132,9 +134,10 @@ module Saboteur
|
|
|
132
134
|
end
|
|
133
135
|
end
|
|
134
136
|
|
|
135
|
-
def restore(owner, target, name, visibility,
|
|
136
|
-
if
|
|
137
|
+
def restore(owner, target, name, visibility, copy_visibility)
|
|
138
|
+
if copy_visibility
|
|
137
139
|
owner.send(:module_function, name)
|
|
140
|
+
owner.singleton_class.send(copy_visibility, name)
|
|
138
141
|
elsif visibility != :public
|
|
139
142
|
target.send(visibility, name)
|
|
140
143
|
end
|
|
@@ -94,7 +94,10 @@ module Saboteur
|
|
|
94
94
|
# Runs the block in a forked child with a pipe writer; the child exits
|
|
95
95
|
# when the block returns. Returns a Result. cpu_seconds bounds every
|
|
96
96
|
# descendant's CPU time (default: twice the deadline, at least 10 s).
|
|
97
|
-
|
|
97
|
+
# on_tick is called from the parent's wait loop after the fork, about
|
|
98
|
+
# every poll, so callers can report progress without a thread alive
|
|
99
|
+
# at fork time.
|
|
100
|
+
def run(timeout:, cpu_seconds: nil, on_tick: nil, &block)
|
|
98
101
|
depth = ENV.fetch(CHILD_ENV, "0").to_i
|
|
99
102
|
raise Error, "refusing to fork: saboteur children nested #{depth} deep (limit #{MAX_DEPTH})" if
|
|
100
103
|
depth >= MAX_DEPTH
|
|
@@ -107,7 +110,7 @@ module Saboteur
|
|
|
107
110
|
supervisor = Process.pid
|
|
108
111
|
pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
|
|
109
112
|
writer.close
|
|
110
|
-
contain(pid, reader, started, timeout)
|
|
113
|
+
contain(pid, reader, started, timeout, on_tick)
|
|
111
114
|
ensure
|
|
112
115
|
reader&.close unless reader&.closed?
|
|
113
116
|
end
|
|
@@ -168,24 +171,31 @@ module Saboteur
|
|
|
168
171
|
|
|
169
172
|
# --- parent ---------------------------------------------------------
|
|
170
173
|
|
|
171
|
-
def contain(pid, reader, started, timeout)
|
|
174
|
+
def contain(pid, reader, started, timeout, on_tick)
|
|
172
175
|
tree = ProcessTree.new(pid)
|
|
173
176
|
self.class.live[pid] = tree
|
|
174
177
|
own_group(pid)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
begin
|
|
179
|
+
payload, stop, detail = watch(tree, reader, started + timeout, on_tick)
|
|
180
|
+
ensure
|
|
181
|
+
# Containment holds on every path out of watch: a raising on_tick
|
|
182
|
+
# (progress IO failed) or any other error must not skip the
|
|
183
|
+
# kill/reap, or the child and its tree would run unsupervised
|
|
184
|
+
# until this process exits.
|
|
185
|
+
exit_status = stop == :timeout ? terminate(pid) : nil
|
|
186
|
+
leftover_pids = tree.kill_all - [pid]
|
|
187
|
+
exit_status ||= reap(pid)
|
|
188
|
+
self.class.live.delete(pid)
|
|
189
|
+
reap_adopted(leftover_pids)
|
|
190
|
+
reap_group(pid)
|
|
191
|
+
end
|
|
182
192
|
Result.new(payload: payload, exit_status: exit_status, stopped_by: stop, detail: detail,
|
|
183
193
|
runtime: monotonic - started, leftover_pids: leftover_pids)
|
|
184
194
|
end
|
|
185
195
|
|
|
186
196
|
# Reads until EOF, polling the tree's size and memory. Returns
|
|
187
197
|
# [payload, stop, detail].
|
|
188
|
-
def watch(tree, reader, deadline)
|
|
198
|
+
def watch(tree, reader, deadline, on_tick)
|
|
189
199
|
payload = +""
|
|
190
200
|
next_poll = monotonic + POLL_INTERVAL
|
|
191
201
|
tree.live_members
|
|
@@ -193,6 +203,8 @@ module Saboteur
|
|
|
193
203
|
now = monotonic
|
|
194
204
|
return [payload, :timeout, "exceeded deadline"] if now >= deadline
|
|
195
205
|
|
|
206
|
+
on_tick&.call
|
|
207
|
+
|
|
196
208
|
if now >= next_poll
|
|
197
209
|
stop = over_limit(tree)
|
|
198
210
|
return [payload, *stop] if stop
|