saboteur 0.3.0 → 0.5.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
|
@@ -40,11 +40,33 @@ module Saboteur
|
|
|
40
40
|
|
|
41
41
|
# payload: bytes the child wrote to its pipe. stop: nil when the child
|
|
42
42
|
# finished on its own, else :timeout, :memory or :processes.
|
|
43
|
-
|
|
43
|
+
# supervisor_killed: the child had already closed its pipe (delivered
|
|
44
|
+
# its result) but was still running after POST_EOF_GRACE, so the
|
|
45
|
+
# supervisor killed it. Its signal exit is then ours, not a failure of
|
|
46
|
+
# the child. memory_pressure: the process tree was seen growing to at
|
|
47
|
+
# least half of the memory limit, the evidence that a SIGKILL may be
|
|
48
|
+
# an out-of-memory kill.
|
|
49
|
+
Result = Data.define(:payload, :exit_status, :stopped_by, :detail, :runtime, :leftover_pids,
|
|
50
|
+
:supervisor_killed, :memory_pressure) do
|
|
51
|
+
def initialize(supervisor_killed: false, memory_pressure: false, **fields)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True when the child itself ended unsuccessfully (non-zero exit or
|
|
56
|
+
# a signal the supervisor did not send).
|
|
57
|
+
def child_failed?
|
|
58
|
+
exit_status.is_a?(Process::Status) && !exit_status.success? && !supervisor_killed
|
|
59
|
+
end
|
|
60
|
+
end
|
|
44
61
|
|
|
45
62
|
POLL_INTERVAL = 0.5
|
|
46
63
|
PR_SET_CHILD_SUBREAPER = 36
|
|
47
64
|
TERM_GRACE = 1.0
|
|
65
|
+
# How long a child that closed its pipe may take to exit by itself
|
|
66
|
+
# (at_exit handlers, buffered output) before the supervisor kills it.
|
|
67
|
+
POST_EOF_GRACE = 5.0
|
|
68
|
+
# Share of the memory limit the tree must reach to count as pressure.
|
|
69
|
+
MEMORY_PRESSURE_SHARE = 0.5
|
|
48
70
|
REAP_DEADLINE = 5.0
|
|
49
71
|
|
|
50
72
|
@live = {}
|
|
@@ -94,7 +116,10 @@ module Saboteur
|
|
|
94
116
|
# Runs the block in a forked child with a pipe writer; the child exits
|
|
95
117
|
# when the block returns. Returns a Result. cpu_seconds bounds every
|
|
96
118
|
# descendant's CPU time (default: twice the deadline, at least 10 s).
|
|
97
|
-
|
|
119
|
+
# on_tick is called from the parent's wait loop after the fork, about
|
|
120
|
+
# every poll, so callers can report progress without a thread alive
|
|
121
|
+
# at fork time.
|
|
122
|
+
def run(timeout:, cpu_seconds: nil, on_tick: nil, &block)
|
|
98
123
|
depth = ENV.fetch(CHILD_ENV, "0").to_i
|
|
99
124
|
raise Error, "refusing to fork: saboteur children nested #{depth} deep (limit #{MAX_DEPTH})" if
|
|
100
125
|
depth >= MAX_DEPTH
|
|
@@ -104,10 +129,11 @@ module Saboteur
|
|
|
104
129
|
$stdout.flush
|
|
105
130
|
$stderr.flush
|
|
106
131
|
started = monotonic
|
|
132
|
+
@peak_growth_kb = 0
|
|
107
133
|
supervisor = Process.pid
|
|
108
134
|
pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
|
|
109
135
|
writer.close
|
|
110
|
-
contain(pid, reader, started, timeout)
|
|
136
|
+
contain(pid, reader, started, timeout, on_tick)
|
|
111
137
|
ensure
|
|
112
138
|
reader&.close unless reader&.closed?
|
|
113
139
|
end
|
|
@@ -168,24 +194,99 @@ module Saboteur
|
|
|
168
194
|
|
|
169
195
|
# --- parent ---------------------------------------------------------
|
|
170
196
|
|
|
171
|
-
def contain(pid, reader, started, timeout)
|
|
197
|
+
def contain(pid, reader, started, timeout, on_tick)
|
|
172
198
|
tree = ProcessTree.new(pid)
|
|
173
199
|
self.class.live[pid] = tree
|
|
174
200
|
own_group(pid)
|
|
175
|
-
|
|
176
|
-
exit_status =
|
|
201
|
+
deadline = started + timeout
|
|
202
|
+
exit_status = nil
|
|
203
|
+
grace_expired = false
|
|
204
|
+
begin
|
|
205
|
+
payload, stop, detail = watch(tree, reader, deadline, on_tick)
|
|
206
|
+
# EOF: the child has reported. Keep supervising while it finishes
|
|
207
|
+
# exiting by itself (same deadline, limits and progress ticks).
|
|
208
|
+
exit_status, stop, detail, grace_expired = await_exit(tree, pid, deadline, on_tick) if stop.nil?
|
|
209
|
+
ensure
|
|
210
|
+
# Containment holds on every path out of watch: a raising on_tick
|
|
211
|
+
# (progress IO failed) or any other error must not skip the
|
|
212
|
+
# kill/reap, or the child and its tree would run unsupervised
|
|
213
|
+
# until this process exits.
|
|
214
|
+
exit_status, supervisor_killed, leftover_pids = release(pid, tree, exit_status, stop, grace_expired)
|
|
215
|
+
end
|
|
216
|
+
Result.new(payload: payload, exit_status: exit_status, stopped_by: stop, detail: detail,
|
|
217
|
+
runtime: monotonic - started, leftover_pids: leftover_pids,
|
|
218
|
+
supervisor_killed: supervisor_killed, memory_pressure: memory_pressure?)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Kills and reaps the child and everything it started. Returns
|
|
222
|
+
# [exit_status, supervisor_killed, leftover_pids].
|
|
223
|
+
def release(pid, tree, exit_status, stop, grace_expired)
|
|
224
|
+
exit_status ||= terminate(pid) if stop == :timeout
|
|
225
|
+
supervisor_killed = false
|
|
226
|
+
# The grace ran out with the child seemingly alive. What ends it is
|
|
227
|
+
# only ours to claim if we see it alive: stop it first.
|
|
228
|
+
exit_status, supervisor_killed = freeze_child(pid) if grace_expired
|
|
177
229
|
leftover_pids = tree.kill_all - [pid]
|
|
178
230
|
exit_status ||= reap(pid)
|
|
179
231
|
self.class.live.delete(pid)
|
|
180
232
|
reap_adopted(leftover_pids)
|
|
181
233
|
reap_group(pid)
|
|
182
|
-
|
|
183
|
-
|
|
234
|
+
[exit_status, supervisor_killed, leftover_pids]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# After EOF, waits for the child to exit by itself while still checking
|
|
238
|
+
# the deadline, the memory and process limits and calling on_tick.
|
|
239
|
+
# Returns [exit_status, stop, detail, grace_expired]. exit_status is set
|
|
240
|
+
# only when the child exited on its own; grace_expired is true when it
|
|
241
|
+
# was still running after POST_EOF_GRACE.
|
|
242
|
+
def await_exit(tree, pid, deadline, on_tick)
|
|
243
|
+
grace_deadline = [monotonic + POST_EOF_GRACE, deadline].min
|
|
244
|
+
next_poll = monotonic + POLL_INTERVAL
|
|
245
|
+
loop do
|
|
246
|
+
status = waitpid(pid)
|
|
247
|
+
return [status, nil, nil, false] if status
|
|
248
|
+
|
|
249
|
+
now = monotonic
|
|
250
|
+
return [nil, :timeout, "exceeded deadline", false] if now >= deadline
|
|
251
|
+
return [nil, nil, nil, true] if now >= grace_deadline
|
|
252
|
+
|
|
253
|
+
on_tick&.call
|
|
254
|
+
if now >= next_poll
|
|
255
|
+
stop = over_limit(tree)
|
|
256
|
+
return [nil, *stop, false] if stop
|
|
257
|
+
|
|
258
|
+
next_poll = now + POLL_INTERVAL
|
|
259
|
+
end
|
|
260
|
+
sleep 0.01
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# SIGSTOP cannot be caught, ignored or raced by a signal handler. A
|
|
265
|
+
# child that reports "stopped" was alive when we acted, so the SIGKILL
|
|
266
|
+
# that follows is ours. A child that turns out to have ended by itself
|
|
267
|
+
# keeps its own status. Returns [exit_status or nil, supervisor_killed].
|
|
268
|
+
def freeze_child(pid)
|
|
269
|
+
signal("STOP", pid)
|
|
270
|
+
deadline = monotonic + TERM_GRACE
|
|
271
|
+
until monotonic > deadline
|
|
272
|
+
_, status = Process.waitpid2(pid, Process::WNOHANG | Process::WUNTRACED)
|
|
273
|
+
return [nil, true] if status&.stopped?
|
|
274
|
+
return [status, false] if status
|
|
275
|
+
|
|
276
|
+
sleep 0.01
|
|
277
|
+
end
|
|
278
|
+
[nil, false]
|
|
279
|
+
rescue Errno::ECHILD
|
|
280
|
+
[:reaped, false]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def memory_pressure?
|
|
284
|
+
@peak_growth_kb >= @limits.max_memory_mb * 1024 * MEMORY_PRESSURE_SHARE
|
|
184
285
|
end
|
|
185
286
|
|
|
186
287
|
# Reads until EOF, polling the tree's size and memory. Returns
|
|
187
288
|
# [payload, stop, detail].
|
|
188
|
-
def watch(tree, reader, deadline)
|
|
289
|
+
def watch(tree, reader, deadline, on_tick)
|
|
189
290
|
payload = +""
|
|
190
291
|
next_poll = monotonic + POLL_INTERVAL
|
|
191
292
|
tree.live_members
|
|
@@ -193,6 +294,8 @@ module Saboteur
|
|
|
193
294
|
now = monotonic
|
|
194
295
|
return [payload, :timeout, "exceeded deadline"] if now >= deadline
|
|
195
296
|
|
|
297
|
+
on_tick&.call
|
|
298
|
+
|
|
196
299
|
if now >= next_poll
|
|
197
300
|
stop = over_limit(tree)
|
|
198
301
|
return [payload, *stop] if stop
|
|
@@ -211,6 +314,7 @@ module Saboteur
|
|
|
211
314
|
|
|
212
315
|
def over_limit(tree)
|
|
213
316
|
count, growth_kb = tree.usage(baseline_rss_kb)
|
|
317
|
+
@peak_growth_kb = [@peak_growth_kb, growth_kb].max
|
|
214
318
|
if growth_kb > @limits.max_memory_mb * 1024
|
|
215
319
|
[:memory, "process tree grew by #{growth_kb / 1024} MB (limit #{@limits.max_memory_mb} MB)"]
|
|
216
320
|
elsif count > @limits.max_processes
|
|
@@ -30,9 +30,10 @@ module Saboteur
|
|
|
30
30
|
|
|
31
31
|
# on_start: called in each worker with its number before its first
|
|
32
32
|
# item (per-worker database connections, for example).
|
|
33
|
-
def initialize(jobs:, on_start: nil)
|
|
33
|
+
def initialize(jobs:, on_start: nil, on_result: nil)
|
|
34
34
|
@jobs = jobs
|
|
35
35
|
@on_start = on_start
|
|
36
|
+
@on_result = on_result
|
|
36
37
|
@start_failures = 0
|
|
37
38
|
end
|
|
38
39
|
|
|
@@ -78,13 +79,22 @@ module Saboteur
|
|
|
78
79
|
# next item, retires it when none is left, or replaces it if it died.
|
|
79
80
|
def receive(worker, workers, queue, results, items, &)
|
|
80
81
|
line = worker.results.gets
|
|
81
|
-
|
|
82
|
+
take_and_report(worker, line, results) if line
|
|
82
83
|
return replace(worker, workers, queue, results, items, &) if line.nil? || worker.last_words
|
|
83
84
|
|
|
84
85
|
assign(worker, queue)
|
|
85
86
|
retire(worker, workers) unless worker.current
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
# Records one result line, then reports the arrival before the worker
|
|
90
|
+
# gets more work: a watcher (the canary) can raise here and stop every
|
|
91
|
+
# queued item from starting (issue #102).
|
|
92
|
+
def take_and_report(worker, line, results)
|
|
93
|
+
current = worker.current
|
|
94
|
+
take(worker, line, results)
|
|
95
|
+
@on_result&.call(current, results[current]) if current && results[current]
|
|
96
|
+
end
|
|
97
|
+
|
|
88
98
|
def parse(line)
|
|
89
99
|
JSON.parse(line)
|
|
90
100
|
rescue JSON::ParserError => e
|
|
@@ -112,7 +122,7 @@ module Saboteur
|
|
|
112
122
|
pending = worker.results.read_nonblock(1 << 20, exception: false)
|
|
113
123
|
return unless pending.is_a?(String)
|
|
114
124
|
|
|
115
|
-
pending.each_line { |line|
|
|
125
|
+
pending.each_line { |line| take_and_report(worker, line, results) if line.end_with?("\n") }
|
|
116
126
|
rescue IOError
|
|
117
127
|
nil
|
|
118
128
|
end
|
|
@@ -121,7 +131,13 @@ module Saboteur
|
|
|
121
131
|
drain(worker, results)
|
|
122
132
|
retire(worker, workers)
|
|
123
133
|
reason = worker.last_words || "worker #{worker.number} died#{how(worker.status)}"
|
|
124
|
-
|
|
134
|
+
if worker.current
|
|
135
|
+
# The synthetic error row takes the same arrival path as a normal
|
|
136
|
+
# result, so the canary still trips when the dead worker damaged
|
|
137
|
+
# the tree before dying (issue #102).
|
|
138
|
+
results[worker.current] = { "error" => "#{reason}, running this item" }
|
|
139
|
+
@on_result&.call(worker.current, results[worker.current])
|
|
140
|
+
end
|
|
125
141
|
give_up(reason) if worker.done.zero?
|
|
126
142
|
return if queue.empty?
|
|
127
143
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Execution
|
|
6
|
+
# Registry of gems that wrap a method at definition time. Their wrapper
|
|
7
|
+
# is what Ruby runs, so the shadow guard would call every wrapped method
|
|
8
|
+
# "shadowed" and skip it. A registered wrapper is instead recognized and
|
|
9
|
+
# re-evaluated together with the def (see Scheduler#eval_prefix). An
|
|
10
|
+
# unknown wrapper still hits the generic guard, which is the safe side.
|
|
11
|
+
module Wrapper
|
|
12
|
+
# :sorbet when the runtime method is sorbet-runtime's sig wrapper:
|
|
13
|
+
# its source lives inside the gem and a signature is registered for it.
|
|
14
|
+
def self.around(method, file)
|
|
15
|
+
return nil unless file&.include?("sorbet-runtime")
|
|
16
|
+
return nil unless defined?(::T::Utils) && ::T::Utils.respond_to?(:signature_for_method)
|
|
17
|
+
return nil unless ::T::Utils.signature_for_method(method)
|
|
18
|
+
|
|
19
|
+
:sorbet
|
|
20
|
+
rescue StandardError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The def a wrapper dispatches to: sorbet's runtime method is a
|
|
25
|
+
# forwarding wrapper, while the registered signature holds the real
|
|
26
|
+
# def. Callers comparing definitions (StaleCaptures) must compare
|
|
27
|
+
# against this, or the freshly re-registered def reads as stale.
|
|
28
|
+
# Real sorbet returns a Signature object (its #method is the def);
|
|
29
|
+
# minimal registries return the method itself. Nil when unknown.
|
|
30
|
+
def self.original_def(method)
|
|
31
|
+
return nil unless around(method, method.source_location&.first) == :sorbet
|
|
32
|
+
|
|
33
|
+
registered = ::T::Utils.signature_for_method(method)
|
|
34
|
+
candidate = if registered.is_a?(Method) || registered.is_a?(UnboundMethod)
|
|
35
|
+
registered
|
|
36
|
+
elsif registered.respond_to?(:method)
|
|
37
|
+
registered.method
|
|
38
|
+
end
|
|
39
|
+
candidate.is_a?(Method) || candidate.is_a?(UnboundMethod) ? candidate : nil
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|