saboteur 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/CHANGELOG.md +367 -0
- data/README.md +166 -40
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- data/lib/saboteur/cli.rb +67 -46
- data/lib/saboteur/config.rb +66 -11
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +40 -1
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +145 -22
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +166 -95
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/help.rb +55 -11
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +20 -11
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +2 -1
- data/lib/saboteur/reporter/agent_commands.rb +38 -13
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +13 -4
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +34 -10
- data/lib/saboteur/result.rb +22 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- metadata +17 -2
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "stale_captures"
|
|
6
|
+
require_relative "../crash_report"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
9
|
module Execution
|
|
@@ -39,17 +40,42 @@ module Saboteur
|
|
|
39
40
|
end
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# The child log (or its whole directory) vanished mid-job (#234).
|
|
44
|
+
# Carries the digest of the mutant whose run just ended when the job
|
|
45
|
+
# had one, so a parallel parent can prefer this report over a
|
|
46
|
+
# bystander's bare "unknown mutant" report (#285).
|
|
47
|
+
class LogVanished < Error
|
|
48
|
+
attr_reader :culprit_digest
|
|
49
|
+
|
|
50
|
+
def initialize(message, culprit_digest: nil)
|
|
51
|
+
@culprit_digest = culprit_digest
|
|
52
|
+
super(message)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
42
56
|
LOG_TAIL_BYTES = 2000
|
|
57
|
+
CRASH_LOG_BYTES = 2_000
|
|
58
|
+
CRASH_LOG_LINES = 8
|
|
59
|
+
# File mtimes follow the filesystem's clock, which can trail the
|
|
60
|
+
# process clock; a log written during this run can stamp a little
|
|
61
|
+
# before its start, so the freshness check allows that skew.
|
|
62
|
+
CRASH_LOG_CLOCK_SLACK = 2
|
|
63
|
+
# Freshness matching can attach another run's log (clock slack, two
|
|
64
|
+
# nested runs in one child), so the label must not claim exact
|
|
65
|
+
# attribution (#296).
|
|
66
|
+
CRASH_LOG_LABEL = "recent saboteur crash log (freshness-matched, may be another run's):"
|
|
43
67
|
|
|
44
68
|
# log_dir: where children write their output; a child's log is kept
|
|
45
|
-
# only when it ends in an error.
|
|
69
|
+
# only when it ends in an error. crash_log: the crash log of a saboteur
|
|
70
|
+
# run nested inside a test; by default it sits next to the logs dir,
|
|
71
|
+
# where the engine's cache dir keeps it.
|
|
46
72
|
# seed: every child seeds Ruby's random numbers with it before its
|
|
47
73
|
# tests, so a test that uses rand sees the same values in every run.
|
|
48
74
|
# sandbox: when given, a callable run first in every child (Landlock
|
|
49
75
|
# makes the project tree read-only there, #152). It raises rather than
|
|
50
76
|
# run half-restricted, which the child reports as an error.
|
|
51
77
|
def initialize(integration:, reevaluator:, log_dir:, hooks: Hooks.new, supervisor: Supervisor.new, seed: 1,
|
|
52
|
-
sandbox: nil)
|
|
78
|
+
sandbox: nil, canary: nil, crash_log: nil)
|
|
53
79
|
@integration = integration
|
|
54
80
|
@reevaluator = reevaluator
|
|
55
81
|
@hooks = hooks
|
|
@@ -57,6 +83,8 @@ module Saboteur
|
|
|
57
83
|
@supervisor = supervisor
|
|
58
84
|
@seed = seed
|
|
59
85
|
@sandbox = sandbox
|
|
86
|
+
@canary = canary
|
|
87
|
+
@crash_log = crash_log || File.join(File.dirname(log_dir), CrashReport::LOG_NAME)
|
|
60
88
|
end
|
|
61
89
|
|
|
62
90
|
# text: method source to evaluate in the child (the unchanged source for
|
|
@@ -69,23 +97,33 @@ module Saboteur
|
|
|
69
97
|
@sequence = @sequence.to_i + 1
|
|
70
98
|
job = Job.new(subject:, text:, test_ids:, timeout:, mutation:, eval_prefix:,
|
|
71
99
|
log_path: File.join(@log_dir, "child-#{Process.pid}-#{@sequence}.log"))
|
|
72
|
-
|
|
100
|
+
log_file = open_log(job)
|
|
101
|
+
result = @supervisor.run(timeout: timeout) { |writer| child(writer, job, log_file) }
|
|
73
102
|
outcome = outcome_for(result, job)
|
|
74
|
-
|
|
103
|
+
check_tree_still_there(job)
|
|
104
|
+
check_log_still_there(job)
|
|
105
|
+
File.delete(job.log_path) if outcome.status != :error
|
|
75
106
|
outcome
|
|
107
|
+
ensure
|
|
108
|
+
log_file&.close
|
|
76
109
|
end
|
|
77
110
|
|
|
78
111
|
private
|
|
79
112
|
|
|
80
113
|
# --- child ----------------------------------------------------------
|
|
81
114
|
|
|
82
|
-
def child(writer, job)
|
|
115
|
+
def child(writer, job, log_file)
|
|
83
116
|
# Redirect before restricting: the sweep closes inherited stdio
|
|
84
117
|
# redirected to a regular file outside the grants (#178), and a
|
|
85
118
|
# reopened-afterwards $stdout would start from a closed stream.
|
|
86
|
-
# The
|
|
87
|
-
|
|
88
|
-
|
|
119
|
+
# The parent opened the log before the fork, so the inherited
|
|
120
|
+
# descriptor survives on the keep list - the cache dir itself
|
|
121
|
+
# stays read-only inside the sandbox (#234). stdio was just
|
|
122
|
+
# reopened onto that log, so 1 and 2 point at a regular file
|
|
123
|
+
# outside the grants and the sweep would close them; keep them
|
|
124
|
+
# explicitly - keeping only the log's own fd is not enough (#283).
|
|
125
|
+
redirect_output(log_file)
|
|
126
|
+
@sandbox&.call([writer.fileno, log_file.fileno, $stdout.fileno, $stderr.fileno])
|
|
89
127
|
Process.setproctitle(title(job))
|
|
90
128
|
@hooks.run(:after_fork)
|
|
91
129
|
# Before the evaluation: a whole-file subject runs the file's own
|
|
@@ -120,6 +158,7 @@ module Saboteur
|
|
|
120
158
|
method = replacement_method(job) if has_captures && job.mutation
|
|
121
159
|
called = method ? false : nil
|
|
122
160
|
trace = TracePoint.new(:call) { called = true } if method
|
|
161
|
+
started_at = Time.now
|
|
123
162
|
result = if trace
|
|
124
163
|
trace.enable(target: method) { @hooks.around(job.mutation) { run_tests(job.test_ids) } }
|
|
125
164
|
else
|
|
@@ -128,10 +167,72 @@ module Saboteur
|
|
|
128
167
|
if result.nil?
|
|
129
168
|
Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
|
|
130
169
|
else
|
|
131
|
-
Protocol.emit(writer, "result", **result, replacement_called: called)
|
|
170
|
+
Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called)
|
|
132
171
|
end
|
|
133
172
|
end
|
|
134
173
|
|
|
174
|
+
# A test can run saboteur itself (the dogfood's cli_spec): the nested
|
|
175
|
+
# CLI reports its own crashes as exit 3 and logs them to
|
|
176
|
+
# internal-error.log. A log written during this run explains the
|
|
177
|
+
# failure better than the bare expectation diff, so its first lines
|
|
178
|
+
# join each failure detail; an older log is another run's (#296).
|
|
179
|
+
def with_crash_detail(result, started_at)
|
|
180
|
+
excerpt = crash_log_excerpt(started_at)
|
|
181
|
+
details = result[:failure_details]
|
|
182
|
+
return result unless excerpt && details && !details.empty?
|
|
183
|
+
|
|
184
|
+
result.merge(failure_details: details.transform_values { |d| [d, CRASH_LOG_LABEL, excerpt].join("\n") })
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The nested CLI writes its log to the cache dir, or to the pid-keyed
|
|
188
|
+
# temp fallback when the sandbox holds the cache dir read-only (#296).
|
|
189
|
+
def crash_log_excerpt(started_at)
|
|
190
|
+
excerpt = tmp_log_candidates.filter_map { |path| fresh_log_excerpt(path, started_at) }
|
|
191
|
+
.max_by { |mtime, _text| mtime }
|
|
192
|
+
excerpt&.last
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The log paths are predictable and live in shared dirs, so read the
|
|
198
|
+
# log the way CrashReport writes it: through a no-follow, non-blocking
|
|
199
|
+
# descriptor,
|
|
200
|
+
# and validate on that same fd - a regular file, owned by this uid,
|
|
201
|
+
# with no group/other permission bits, fresh by the fd's own mtime.
|
|
202
|
+
# One open, no check-then-reopen race; a symlink, a foreign, wide or
|
|
203
|
+
# non-regular file, or any IO failure is no excerpt, never an
|
|
204
|
+
# exception (#296).
|
|
205
|
+
def fresh_log_excerpt(path, started_at)
|
|
206
|
+
# No safe open, no excerpt: without O_NOFOLLOW a symlink redirects
|
|
207
|
+
# the read, without O_NONBLOCK a no-writer FIFO blocks it (#296).
|
|
208
|
+
return unless CrashReport.safe_log_io?
|
|
209
|
+
|
|
210
|
+
flags = File::RDONLY | CrashReport.nofollow_flag | CrashReport.nonblock_flag
|
|
211
|
+
File.open(path, flags) do |file|
|
|
212
|
+
stat = file.stat
|
|
213
|
+
next unless stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
214
|
+
next unless stat.mtime >= started_at - CRASH_LOG_CLOCK_SLACK
|
|
215
|
+
|
|
216
|
+
text = file.read(CRASH_LOG_BYTES)
|
|
217
|
+
# Scrub before the excerpt travels as JSON: the bounded read can
|
|
218
|
+
# end mid-character and the log can hold locale-invalid bytes, and
|
|
219
|
+
# a JSON::GeneratorError here would turn a kill into a child error
|
|
220
|
+
# (#296).
|
|
221
|
+
[stat.mtime, text ? CrashReport.scrub(text).lines.first(CRASH_LOG_LINES).join : nil]
|
|
222
|
+
end
|
|
223
|
+
rescue SystemCallError, IOError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The excerpt is diagnostic enrichment: when CrashReport itself is the
|
|
228
|
+
# mutated file, tmp_log_path can raise or return garbage - keep the
|
|
229
|
+
# primary candidate and never let the lookup change a verdict (#296).
|
|
230
|
+
def tmp_log_candidates
|
|
231
|
+
[@crash_log, CrashReport.tmp_log_path].compact.uniq.grep(String)
|
|
232
|
+
rescue StandardError
|
|
233
|
+
[@crash_log].compact
|
|
234
|
+
end
|
|
235
|
+
|
|
135
236
|
# Targeted tracing distinguishes the new Ruby body from an old Method
|
|
136
237
|
# capture, even though both have the same owner, name and source line.
|
|
137
238
|
def replacement_method(job)
|
|
@@ -151,19 +252,69 @@ module Saboteur
|
|
|
151
252
|
def run_tests(test_ids)
|
|
152
253
|
result = @integration.run(test_ids)
|
|
153
254
|
{ passed: result.passed, failed: result.failed_ids, executed: result.executed_ids, runtime: result.runtime,
|
|
154
|
-
failure_classes: result.failure_classes, failure_messages: result.failure_messages
|
|
255
|
+
failure_classes: result.failure_classes, failure_messages: result.failure_messages,
|
|
256
|
+
failure_details: result.failure_details }
|
|
155
257
|
rescue SystemExit => e
|
|
156
258
|
# A test called `exit`. The tests noticed something; count it as a failure.
|
|
157
259
|
{ passed: false, failed: [], executed: [], runtime: 0.0, exited: e.status }
|
|
158
260
|
end
|
|
159
261
|
|
|
160
|
-
def redirect_output(
|
|
161
|
-
$stdout.reopen(
|
|
262
|
+
def redirect_output(log_file)
|
|
263
|
+
$stdout.reopen(log_file)
|
|
162
264
|
$stderr.reopen($stdout)
|
|
163
265
|
$stdout.sync = true
|
|
164
266
|
$stderr.sync = true
|
|
165
267
|
end
|
|
166
268
|
|
|
269
|
+
# Names the just-run mutant when there is one (a verdict-check rerun
|
|
270
|
+
# has none): the most likely culprit, worth naming for the report.
|
|
271
|
+
def culprit(job)
|
|
272
|
+
culprit_digest(job) || "an unknown mutant"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The 16-hex digest of the just-run mutant, or nil when the job ran
|
|
276
|
+
# none (a neutral or verdict-check rerun): a parallel parent prefers
|
|
277
|
+
# the vanished-log report that carries one (#285).
|
|
278
|
+
def culprit_digest(job)
|
|
279
|
+
return unless job.mutation && job.subject.respond_to?(:start_byte)
|
|
280
|
+
|
|
281
|
+
job.mutation.digest(subject_start: job.subject.start_byte,
|
|
282
|
+
original_source: job.subject.file_source)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Opened in the parent, not the sandboxed child: the cache dir is
|
|
286
|
+
# not writable inside the sandbox (#234), and a log directory that
|
|
287
|
+
# is already gone is a tool problem to report, not a mutant result.
|
|
288
|
+
def open_log(job)
|
|
289
|
+
File.open(job.log_path, "w")
|
|
290
|
+
rescue SystemCallError => e
|
|
291
|
+
raise Error, "cannot open the child log #{job.log_path} (#{e.class}: #{e.message})"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# A mutant's tests can delete saboteur's own files when the sandbox
|
|
295
|
+
# is off or degraded (#234): a vanished log makes the verdict
|
|
296
|
+
# worthless, so fail as a tool error instead of trusting the run.
|
|
297
|
+
# The mutant that just ran is the likely - not proven - cause: a
|
|
298
|
+
# leftover process from an earlier mutant could have done it.
|
|
299
|
+
# The per-mutant probe (#235): names the mutant whose run just wiped
|
|
300
|
+
# tracked files, instead of blaming the subject's last mutant when the
|
|
301
|
+
# loss surfaces at subject end. Runs before the log check, so the
|
|
302
|
+
# hazard report still wins when a mutant took both (#283).
|
|
303
|
+
def check_tree_still_there(job)
|
|
304
|
+
# culprit_digest, not the display fallback: the digest doubles as
|
|
305
|
+
# trip metadata, and "an unknown mutant" must not rank as one (#285).
|
|
306
|
+
@canary&.probe!(subject: job.subject, digest: culprit_digest(job))
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def check_log_still_there(job)
|
|
310
|
+
return if File.exist?(job.log_path)
|
|
311
|
+
|
|
312
|
+
cause = "a mutant's tests may have hit saboteur's files"
|
|
313
|
+
raise LogVanished.new("the child log #{job.log_path} vanished mid-run - the mutant that just ran, " \
|
|
314
|
+
"#{culprit(job)}, is the likely cause: #{cause}",
|
|
315
|
+
culprit_digest: culprit_digest(job))
|
|
316
|
+
end
|
|
317
|
+
|
|
167
318
|
# --- parent ---------------------------------------------------------
|
|
168
319
|
|
|
169
320
|
def outcome_for(result, job)
|
|
@@ -197,7 +348,7 @@ module Saboteur
|
|
|
197
348
|
end
|
|
198
349
|
end
|
|
199
350
|
|
|
200
|
-
def classify_result(result, runtime, test_ids)
|
|
351
|
+
def classify_result(result, runtime, test_ids, verbose_failure: false)
|
|
201
352
|
failed = Array(result["failed"])
|
|
202
353
|
executed = Array(result["executed"])
|
|
203
354
|
return Outcome.new(status: :killed, runtime: runtime, message: "test called exit") if result.key?("exited")
|
|
@@ -206,9 +357,7 @@ module Saboteur
|
|
|
206
357
|
unexpected = failed - test_ids
|
|
207
358
|
return mismatch(runtime, "failed tests not requested: #{unexpected.first(3).join(', ')}") if unexpected.any?
|
|
208
359
|
|
|
209
|
-
return
|
|
210
|
-
message: result.fetch("failure_messages", {})[failed.first],
|
|
211
|
-
kill_cause: kill_cause(result.fetch("failure_classes", {})[failed.first]))
|
|
360
|
+
return killed_outcome(result, runtime, failed, executed, verbose_failure)
|
|
212
361
|
end
|
|
213
362
|
unless result["passed"]
|
|
214
363
|
return Outcome.new(status: :killed, runtime: runtime, executed_ids: executed,
|
|
@@ -222,8 +371,21 @@ module Saboteur
|
|
|
222
371
|
Outcome.new(status: :survived, runtime: runtime, executed_ids: executed)
|
|
223
372
|
end
|
|
224
373
|
|
|
374
|
+
def killed_outcome(result, runtime, failed, executed, verbose_failure)
|
|
375
|
+
Outcome.new(status: :killed, runtime: runtime, failed_ids: failed, executed_ids: executed,
|
|
376
|
+
message: failure_message(result, failed.first, verbose_failure),
|
|
377
|
+
kill_cause: kill_cause(result.fetch("failure_classes", {})[failed.first]))
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# A neutral check (the verdict re-run, no mutation) needs the full
|
|
381
|
+
# failure to explain why the killer fails unchanged (#296).
|
|
382
|
+
def failure_message(result, test_id, verbose_failure)
|
|
383
|
+
message = result.fetch("failure_messages", {})[test_id]
|
|
384
|
+
verbose_failure ? result.fetch("failure_details", {})[test_id] || message : message
|
|
385
|
+
end
|
|
386
|
+
|
|
225
387
|
def classify_events(result, events, runtime, job)
|
|
226
|
-
outcome = classify_result(result, runtime, job.test_ids)
|
|
388
|
+
outcome = classify_result(result, runtime, job.test_ids, verbose_failure: job.mutation.nil?)
|
|
227
389
|
replaced = events.find { |event| event["event"] == "replaced" }
|
|
228
390
|
return replaced_by_later_definition(outcome, replaced) if never_ran?(outcome, replaced, job)
|
|
229
391
|
|
|
@@ -160,7 +160,12 @@ module Saboteur
|
|
|
160
160
|
return unless stat.file?
|
|
161
161
|
|
|
162
162
|
granted = target.delete_suffix(" (deleted)")
|
|
163
|
-
|
|
163
|
+
# readlink targets arrive tagged with default_external, so under
|
|
164
|
+
# LANG=C a non-ASCII target compared against a UTF-8 grant pattern
|
|
165
|
+
# raises Encoding::CompatibilityError before any test runs (CI #272).
|
|
166
|
+
# Compare bytes.
|
|
167
|
+
target_bytes = granted.b
|
|
168
|
+
return if writable.any? { |dir| target_bytes == dir.b || target_bytes.start_with?(dir.b + "/") } # rubocop:disable Style/StringConcatenation -- interpolation with a BINARY part raises on high bytes
|
|
164
169
|
|
|
165
170
|
flags = LibC.fcntl_fn.call(descriptor, F_GETFL, 0)
|
|
166
171
|
return close_fd(descriptor) if flags.negative?
|
|
@@ -119,7 +119,7 @@ module Saboteur
|
|
|
119
119
|
# on_tick is called from the parent's wait loop after the fork, about
|
|
120
120
|
# every poll, so callers can report progress without a thread alive
|
|
121
121
|
# at fork time.
|
|
122
|
-
def run(timeout:, cpu_seconds: nil, on_tick: nil, &block)
|
|
122
|
+
def run(timeout:, cpu_seconds: nil, on_tick: nil, label: "supervised child died", &block)
|
|
123
123
|
depth = ENV.fetch(CHILD_ENV, "0").to_i
|
|
124
124
|
raise Error, "refusing to fork: saboteur children nested #{depth} deep (limit #{MAX_DEPTH})" if
|
|
125
125
|
depth >= MAX_DEPTH
|
|
@@ -131,6 +131,7 @@ module Saboteur
|
|
|
131
131
|
started = monotonic
|
|
132
132
|
@peak_growth_kb = 0
|
|
133
133
|
supervisor = Process.pid
|
|
134
|
+
@child_label = label
|
|
134
135
|
pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
|
|
135
136
|
writer.close
|
|
136
137
|
contain(pid, reader, started, timeout, on_tick)
|
|
@@ -146,13 +147,27 @@ module Saboteur
|
|
|
146
147
|
reader.close
|
|
147
148
|
self.class.close_in_child.each { |io| io.close unless io.closed? }
|
|
148
149
|
ENV[CHILD_ENV] = depth.to_s
|
|
149
|
-
|
|
150
|
+
# Best-effort like the parent-side own_group: macOS can refuse the
|
|
151
|
+
# group change (EPERM), and the child must still run (#296).
|
|
152
|
+
own_group(0)
|
|
150
153
|
limit_cpu(cpu_seconds)
|
|
151
154
|
trap("TERM") { exit!(143) }
|
|
152
155
|
watch_parent(supervisor)
|
|
153
156
|
yield writer
|
|
154
157
|
exit!(0)
|
|
155
|
-
rescue Exception # rubocop:disable Lint/RescueException
|
|
158
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
159
|
+
# The pipe stays empty when the child dies before answering, so this
|
|
160
|
+
# is the only place the cause survives (#255). The warning is
|
|
161
|
+
# best-effort and the exit is ensured: a broken stderr must not let
|
|
162
|
+
# the exception unwind past the fork block into CLI code in the
|
|
163
|
+
# child, where exit handlers could emit duplicate output (#289).
|
|
164
|
+
begin
|
|
165
|
+
frames = e.backtrace ? e.backtrace.first(8).join("\n from ") : "(no backtrace)"
|
|
166
|
+
warn("saboteur: #{@child_label}: #{e.class}: #{e.message}\n from #{frames}")
|
|
167
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
ensure
|
|
156
171
|
exit!(1)
|
|
157
172
|
end
|
|
158
173
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "../text"
|
|
5
6
|
require_relative "process_tree"
|
|
6
7
|
require_relative "supervisor"
|
|
7
8
|
|
|
@@ -30,11 +31,44 @@ module Saboteur
|
|
|
30
31
|
|
|
31
32
|
# on_start: called in each worker with its number before its first
|
|
32
33
|
# item (per-worker database connections, for example).
|
|
33
|
-
def initialize(jobs:, on_start: nil, on_result: nil)
|
|
34
|
+
def initialize(jobs:, on_start: nil, on_result: nil, drain_timeout: 30)
|
|
34
35
|
@jobs = jobs
|
|
35
36
|
@on_start = on_start
|
|
36
37
|
@on_result = on_result
|
|
37
38
|
@start_failures = 0
|
|
39
|
+
@halted = false
|
|
40
|
+
@drain_timeout = drain_timeout
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Stop handing queued items out while letting in-flight items finish
|
|
44
|
+
# and report: a worker that merely noticed the damage can report
|
|
45
|
+
# before the worker whose mutant caused it, and the later report is
|
|
46
|
+
# the one that names the culprit (#285). Unlike raising from
|
|
47
|
+
# on_result, nothing in flight is killed. Unstarted items come back
|
|
48
|
+
# as nil holes in the result array.
|
|
49
|
+
#
|
|
50
|
+
# drain: true bounds the wait for a safety abort (tool error, canary):
|
|
51
|
+
# a wedged worker must not stall it, so drain_timeout seconds after
|
|
52
|
+
# the first halt the pool stops waiting, kills whatever is still
|
|
53
|
+
# running and leaves nil holes (#285 review). drain: false is the
|
|
54
|
+
# ordinary --max-duration halt: in-flight subjects always finish and
|
|
55
|
+
# report, so a nil hole only ever means "never started"; the
|
|
56
|
+
# overshoot stays bounded by each worker's own mutant deadlines.
|
|
57
|
+
def halt!(drain: true)
|
|
58
|
+
@halted = true
|
|
59
|
+
return if @drain_deadline || !drain
|
|
60
|
+
|
|
61
|
+
@drain_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @drain_timeout
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The serialized shape of a tool error row: the message plus the
|
|
65
|
+
# attribution flags the parent needs to rank reports - a per-mutant
|
|
66
|
+
# culprit digest first, then a canary trip (#283, #285).
|
|
67
|
+
def self.tool_error_row(error)
|
|
68
|
+
row = { "tool_error" => error.message }
|
|
69
|
+
row["canary"] = true if error.is_a?(CanaryTripped)
|
|
70
|
+
row["culprit"] = error.culprit_digest if error.respond_to?(:culprit_digest) && error.culprit_digest
|
|
71
|
+
row
|
|
38
72
|
end
|
|
39
73
|
|
|
40
74
|
def map(items, weights: nil, &work)
|
|
@@ -64,8 +98,20 @@ module Saboteur
|
|
|
64
98
|
|
|
65
99
|
# Waits up to a second for results, then checks for workers that died
|
|
66
100
|
# without closing their pipe (a stray grandchild may still hold it).
|
|
101
|
+
# While halted the wait is bounded by the drain deadline: past it the
|
|
102
|
+
# remaining workers are retired (stop kills a wedged one) and their
|
|
103
|
+
# items come back as nil holes.
|
|
67
104
|
def collect(workers, queue, results, items, &work)
|
|
68
|
-
|
|
105
|
+
timeout = 1
|
|
106
|
+
if @drain_deadline
|
|
107
|
+
remaining = @drain_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
if remaining <= 0
|
|
109
|
+
abandon_all(workers)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
timeout = [timeout, remaining].min
|
|
113
|
+
end
|
|
114
|
+
ready = IO.select(workers.map(&:results), nil, nil, timeout)&.first || []
|
|
69
115
|
ready.each do |io|
|
|
70
116
|
worker = workers.find { |candidate| candidate.results == io }
|
|
71
117
|
receive(worker, workers, queue, results, items, &work) if worker
|
|
@@ -82,7 +128,7 @@ module Saboteur
|
|
|
82
128
|
take_and_report(worker, line, results) if line
|
|
83
129
|
return replace(worker, workers, queue, results, items, &) if line.nil? || worker.last_words
|
|
84
130
|
|
|
85
|
-
assign(worker, queue)
|
|
131
|
+
assign(worker, queue) unless @halted
|
|
86
132
|
retire(worker, workers) unless worker.current
|
|
87
133
|
end
|
|
88
134
|
|
|
@@ -96,7 +142,7 @@ module Saboteur
|
|
|
96
142
|
end
|
|
97
143
|
|
|
98
144
|
def parse(line)
|
|
99
|
-
JSON.parse(line)
|
|
145
|
+
JSON.parse(Text.utf8_strict(line))
|
|
100
146
|
rescue JSON::ParserError => e
|
|
101
147
|
{ "error" => "unreadable result from worker: #{e.message}" }
|
|
102
148
|
end
|
|
@@ -127,6 +173,28 @@ module Saboteur
|
|
|
127
173
|
nil
|
|
128
174
|
end
|
|
129
175
|
|
|
176
|
+
# Past the drain deadline nothing more is coming: kill every wedged
|
|
177
|
+
# worker's tree first, then reap, so the cleanup costs one round
|
|
178
|
+
# instead of one stop grace per worker (#285 review). Items in
|
|
179
|
+
# flight come back as nil holes.
|
|
180
|
+
def abandon_all(workers)
|
|
181
|
+
wedged = workers.dup
|
|
182
|
+
wedged.each do |worker|
|
|
183
|
+
worker.commands.close unless worker.commands.closed?
|
|
184
|
+
Supervisor.live.delete(worker.pid)&.kill_all
|
|
185
|
+
end
|
|
186
|
+
# Every wedged tree dies before any wait: one cleanup round.
|
|
187
|
+
# rubocop:disable-next Style/CombinableLoops
|
|
188
|
+
wedged.each do |worker|
|
|
189
|
+
worker.status ||= Process.wait2(worker.pid).last unless reaped?(worker)
|
|
190
|
+
worker.results.close unless worker.results.closed?
|
|
191
|
+
rescue Errno::ECHILD
|
|
192
|
+
nil
|
|
193
|
+
ensure
|
|
194
|
+
workers.delete(worker)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
130
198
|
def replace(worker, workers, queue, results, items, &)
|
|
131
199
|
drain(worker, results)
|
|
132
200
|
retire(worker, workers)
|
|
@@ -138,8 +206,11 @@ module Saboteur
|
|
|
138
206
|
results[worker.current] = { "error" => "#{reason}, running this item" }
|
|
139
207
|
@on_result&.call(worker.current, results[worker.current])
|
|
140
208
|
end
|
|
141
|
-
|
|
142
|
-
|
|
209
|
+
# Halted pools start nothing, so a start-failure storm cannot be the
|
|
210
|
+
# run's problem then; raising it would mask the rows that carry the
|
|
211
|
+
# real abort cause (#286).
|
|
212
|
+
give_up(reason) if worker.done.zero? && !@halted
|
|
213
|
+
return if @halted || queue.empty?
|
|
143
214
|
|
|
144
215
|
fresh = start(worker.number, items, workers, &)
|
|
145
216
|
workers << fresh
|
|
@@ -201,7 +272,10 @@ module Saboteur
|
|
|
201
272
|
# Runs in the worker until the parent closes its command pipe. If it
|
|
202
273
|
# crashes, its last line says why.
|
|
203
274
|
def serve(number, items, commands, results, &)
|
|
204
|
-
|
|
275
|
+
# Grouping helps tree cleanup find the worker's descendants; macOS
|
|
276
|
+
# can refuse it (EPERM), and a refused setpgid must not crash the
|
|
277
|
+
# worker - parent-link tracking still finds the tree (#296).
|
|
278
|
+
own_group(0)
|
|
205
279
|
Supervisor.reset_in_worker!
|
|
206
280
|
Supervisor.close_in_child.push(commands, results)
|
|
207
281
|
ENV["SABOTEUR_WORKER"] = number.to_s
|
|
@@ -226,6 +300,12 @@ module Saboteur
|
|
|
226
300
|
|
|
227
301
|
def run_one(item, &work)
|
|
228
302
|
work.call(item)
|
|
303
|
+
rescue Error => e
|
|
304
|
+
# A tool error (a vanished cache dir, ...) aborts the run with
|
|
305
|
+
# exit 3; it is not a per-mutant outcome. Serialize it so the
|
|
306
|
+
# parent can re-raise it as itself (#283), with the flags the
|
|
307
|
+
# parent needs to rank reports by attribution quality (#285).
|
|
308
|
+
self.class.tool_error_row(e)
|
|
229
309
|
rescue StandardError => e
|
|
230
310
|
{ "error" => "#{e.class}: #{e.message}" }
|
|
231
311
|
end
|
data/lib/saboteur/help.rb
CHANGED
|
@@ -25,7 +25,10 @@ module Saboteur
|
|
|
25
25
|
2 => "Usage or configuration error: fix the command or config/saboteur.rb. A replay --id that matches no " \
|
|
26
26
|
"mutant is only found after the target has booted. A named source or test file that does not exist.",
|
|
27
27
|
3 => "Tool error: saboteur could not finish, or more than half of the executed mutants errored (such " \
|
|
28
|
-
"a run tested almost nothing). With --threshold, also any errored or resource-limited mutant."
|
|
28
|
+
"a run tested almost nothing). With --threshold, also any errored or resource-limited mutant.",
|
|
29
|
+
4 => "Time budget spent: --max-duration stopped the run before the next subject, so the score is " \
|
|
30
|
+
"partial. --max-duration-ok leaves the exit to the plain verdict: 0, 1, or 3 when the budget " \
|
|
31
|
+
"stopped the run before any mutant was tested."
|
|
29
32
|
}.freeze
|
|
30
33
|
|
|
31
34
|
DOCTOR_EXIT_CODE_MEANINGS = {
|
|
@@ -40,16 +43,20 @@ module Saboteur
|
|
|
40
43
|
summary: "Mutate your code and report the mutants your tests do not catch",
|
|
41
44
|
description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
|
|
42
45
|
"tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
|
|
43
|
-
"directories after run replace include and exclude
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
46
|
+
"directories after run replace include and exclude. Without --test, the run infers the " \
|
|
47
|
+
"specs named after the mutated files and runs only those; a mutated file with no " \
|
|
48
|
+
"conventional spec stops the run with exit 2 before anything boots, and --all keeps " \
|
|
49
|
+
"the whole configured suite. --threshold PERCENT replaces the survivor check: exit 0 " \
|
|
50
|
+
"when the score is at or above PERCENT, exit 1 when it is below. All operators run " \
|
|
51
|
+
"unless you select a smaller set; exclusions always remove operators from that set. " \
|
|
52
|
+
"Inline comment syntax: " \
|
|
47
53
|
'# saboteur:disable-line [operator ...] reason="...". A reason is required.',
|
|
48
54
|
takes_options: true,
|
|
49
55
|
exit_codes: EXIT_CODE_MEANINGS,
|
|
50
56
|
examples: [
|
|
51
|
-
["Pull request: only methods changed since the target branch
|
|
52
|
-
|
|
57
|
+
["Pull request: only methods changed since the target branch, with the inferred specs",
|
|
58
|
+
"saboteur run --since origin/main"],
|
|
59
|
+
["Whole project and whole suite, four workers, repeatable seed", "saboteur run --all --jobs 4 --seed 42"],
|
|
53
60
|
["One class or method", "saboteur run --subject 'Person#adult?'"],
|
|
54
61
|
["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
|
|
55
62
|
["Mutate named files against named specs, skipping the whole-suite coverage run",
|
|
@@ -58,6 +65,35 @@ module Saboteur
|
|
|
58
65
|
["AI agent: bounded JSON summary and strict exit codes", "saboteur run --agent --since origin/main"]
|
|
59
66
|
]
|
|
60
67
|
),
|
|
68
|
+
Command.new(
|
|
69
|
+
name: "pr",
|
|
70
|
+
summary: "Preset: the fast pull-request loop over the lines changed since a ref",
|
|
71
|
+
description: "Expands to saboteur run --baseline-passes 1 --max-duration 10m --max-duration-ok " \
|
|
72
|
+
"--since REF, with REF the first operand (default main): one baseline check, a " \
|
|
73
|
+
"ten-minute wall-clock budget, and mutants limited to the changed methods. The " \
|
|
74
|
+
"preset opts out of the budget-specific exit 4; the normal and incomplete " \
|
|
75
|
+
"verdicts still fail the run (survivors exit 1, a budget stop before any mutant " \
|
|
76
|
+
"ran exits 3). Every run option still works; an explicit flag after the preset " \
|
|
77
|
+
"wins (#244).",
|
|
78
|
+
takes_options: true,
|
|
79
|
+
exit_codes: EXIT_CODE_MEANINGS,
|
|
80
|
+
examples: [["Changed lines since main", "saboteur pr"],
|
|
81
|
+
["Changed lines since another target", "saboteur pr origin/develop"],
|
|
82
|
+
["The same loop with two baseline checks", "saboteur pr --baseline-passes 2"]]
|
|
83
|
+
),
|
|
84
|
+
Command.new(
|
|
85
|
+
name: "audit",
|
|
86
|
+
summary: "Preset: the whole project, all operators, both reports",
|
|
87
|
+
description: "Expands to saboteur run --all --operators all --baseline-passes 2 " \
|
|
88
|
+
"--json .saboteur/audit-report.json --html .saboteur/audit-report.html: every " \
|
|
89
|
+
"subject against the whole suite, all operators, both baseline checks, writing " \
|
|
90
|
+
"the Stryker JSON and HTML reports. Configured selection limits (since, " \
|
|
91
|
+
"subjects, exclude_operators, max_duration) still apply; an explicit flag after " \
|
|
92
|
+
"the preset wins (#244), and an explicit --test replaces the injected --all.",
|
|
93
|
+
takes_options: true,
|
|
94
|
+
exit_codes: EXIT_CODE_MEANINGS,
|
|
95
|
+
examples: [["Full project audit with JSON and HTML reports", "saboteur audit"]]
|
|
96
|
+
),
|
|
61
97
|
Command.new(
|
|
62
98
|
name: "doctor",
|
|
63
99
|
summary: "Check the environment, and with --suite run the baseline twice, without mutating anything",
|
|
@@ -122,7 +158,7 @@ module Saboteur
|
|
|
122
158
|
|
|
123
159
|
AGENT_EXIT_CODES = " With --agent the codes are stricter: 0 only when the run was complete and clean; " \
|
|
124
160
|
"1 survivors to review;\n 2 usage or config error; 3 incomplete (no coverage, caps, errors, " \
|
|
125
|
-
"nothing selected) or tool failure."
|
|
161
|
+
"a budget stop before any mutant ran, nothing selected) or tool failure."
|
|
126
162
|
|
|
127
163
|
FILES = [
|
|
128
164
|
["config/saboteur.rb", "Settings as Ruby (Saboteur.configure). Command line options override it."],
|
|
@@ -166,8 +202,16 @@ module Saboteur
|
|
|
166
202
|
@commands.select(&:takes_options).map { |command| options_section(command) }
|
|
167
203
|
end
|
|
168
204
|
|
|
205
|
+
# run, audit and pr share one exit-code table; the overview keeps every
|
|
206
|
+
# command's section but carries the agent note only on the first table
|
|
207
|
+
# that shares it, so the phrase appears exactly once.
|
|
169
208
|
def exit_code_sections
|
|
170
|
-
|
|
209
|
+
noted = []
|
|
210
|
+
@commands.select(&:exit_codes).map do |command|
|
|
211
|
+
first = noted.none? { |codes| codes.equal?(command.exit_codes) }
|
|
212
|
+
noted << command.exit_codes
|
|
213
|
+
exit_codes_section(command.exit_codes, command.name, agent_note: first)
|
|
214
|
+
end
|
|
171
215
|
end
|
|
172
216
|
|
|
173
217
|
def title
|
|
@@ -204,10 +248,10 @@ module Saboteur
|
|
|
204
248
|
"EXAMPLES\n#{examples.map { |note, line| " # #{note}\n #{line}" }.join("\n")}\n"
|
|
205
249
|
end
|
|
206
250
|
|
|
207
|
-
def exit_codes_section(codes, command_name = nil)
|
|
251
|
+
def exit_codes_section(codes, command_name = nil, agent_note: true)
|
|
208
252
|
rows = codes.map { |code, meaning| " #{code} #{meaning}" }
|
|
209
253
|
title = command_name ? "EXIT CODES (saboteur #{command_name})" : "EXIT CODES"
|
|
210
|
-
note = codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
|
|
254
|
+
note = agent_note && codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
|
|
211
255
|
"#{title}\n#{rows.join("\n")}\n#{note}"
|
|
212
256
|
end
|
|
213
257
|
|
data/lib/saboteur/ignore_list.rb
CHANGED
|
@@ -103,7 +103,10 @@ module Saboteur
|
|
|
103
103
|
|
|
104
104
|
# Marks matching survivors as ignored. Returns warnings for stale entries:
|
|
105
105
|
# entries whose subject was examined but whose mutation no longer exists.
|
|
106
|
-
|
|
106
|
+
# unscoped_digests (subject id => every digest generated before a diff
|
|
107
|
+
# scope cut) keeps a scoped run from calling a scoped-out entry stale
|
|
108
|
+
# (#284).
|
|
109
|
+
def apply(results, unscoped_digests: nil)
|
|
107
110
|
seen = {}
|
|
108
111
|
results.each do |result|
|
|
109
112
|
key = [result.subject.id, result.digest]
|
|
@@ -113,8 +116,15 @@ module Saboteur
|
|
|
113
116
|
seen[key] = true
|
|
114
117
|
result.ignored = entry.reason if result.survived?
|
|
115
118
|
end
|
|
119
|
+
stale_warnings(results, seen, unscoped_digests)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def stale_warnings(results, seen, unscoped_digests)
|
|
116
125
|
examined = results.to_set { |result| result.subject.id }
|
|
117
126
|
entries.select { |entry| examined.include?(entry.subject) && !seen[[entry.subject, entry.mutation]] }
|
|
127
|
+
.reject { |entry| unscoped_digests&.fetch(entry.subject, nil)&.include?(entry.mutation) }
|
|
118
128
|
.map { |entry| "stale ignore entry #{entry.subject} #{entry.mutation}: no such mutation" }
|
|
119
129
|
end
|
|
120
130
|
end
|