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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +166 -40
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +49 -4
  8. data/lib/saboteur/cli/help_options.rb +16 -0
  9. data/lib/saboteur/cli/presets.rb +36 -0
  10. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  11. data/lib/saboteur/cli/run_reporting.rb +74 -5
  12. data/lib/saboteur/cli.rb +67 -46
  13. data/lib/saboteur/config.rb +66 -11
  14. data/lib/saboteur/crash_report.rb +154 -0
  15. data/lib/saboteur/custom_map.rb +3 -1
  16. data/lib/saboteur/doctor.rb +40 -1
  17. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  18. data/lib/saboteur/engine/boot.rb +12 -1
  19. data/lib/saboteur/engine/canary.rb +41 -10
  20. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  21. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  22. data/lib/saboteur/engine/coverage_store.rb +48 -6
  23. data/lib/saboteur/engine/diagnostics.rb +23 -2
  24. data/lib/saboteur/engine/diff_scope.rb +24 -5
  25. data/lib/saboteur/engine/distribution.rb +124 -8
  26. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  27. data/lib/saboteur/engine/result_cache.rb +2 -1
  28. data/lib/saboteur/engine/run_budget.rb +63 -0
  29. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  30. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  31. data/lib/saboteur/engine/scheduler.rb +114 -18
  32. data/lib/saboteur/engine/subject_selection.rb +84 -5
  33. data/lib/saboteur/engine/suite_files.rb +3 -1
  34. data/lib/saboteur/engine/test_guess.rb +145 -22
  35. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  36. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  37. data/lib/saboteur/engine/verdict_check.rb +54 -18
  38. data/lib/saboteur/engine.rb +166 -95
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/runner.rb +179 -17
  41. data/lib/saboteur/execution/sandbox.rb +6 -1
  42. data/lib/saboteur/execution/supervisor.rb +18 -3
  43. data/lib/saboteur/execution/worker_pool.rb +87 -7
  44. data/lib/saboteur/help.rb +55 -11
  45. data/lib/saboteur/ignore_list.rb +11 -1
  46. data/lib/saboteur/install.rb +4 -3
  47. data/lib/saboteur/integration/base.rb +7 -2
  48. data/lib/saboteur/integration/rspec.rb +40 -4
  49. data/lib/saboteur/mutation_source/builtin.rb +20 -11
  50. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  51. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  52. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  53. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  54. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  55. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  56. data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
  57. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  58. data/lib/saboteur/preview.rb +2 -1
  59. data/lib/saboteur/reporter/agent_commands.rb +38 -13
  60. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  61. data/lib/saboteur/reporter/next_steps.rb +14 -4
  62. data/lib/saboteur/reporter/score_history.rb +5 -1
  63. data/lib/saboteur/reporter/stryker.rb +13 -4
  64. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  65. data/lib/saboteur/reporter/terminal.rb +34 -10
  66. data/lib/saboteur/result.rb +22 -7
  67. data/lib/saboteur/selection/coverage_map.rb +2 -1
  68. data/lib/saboteur/selection/git_diff.rb +15 -5
  69. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  70. data/lib/saboteur/subject.rb +11 -0
  71. data/lib/saboteur/text.rb +42 -0
  72. data/lib/saboteur/version.rb +1 -1
  73. data/lib/saboteur.rb +16 -1
  74. 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
- result = @supervisor.run(timeout: timeout) { |writer| child(writer, job) }
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
- File.delete(job.log_path) if outcome.status != :error && File.exist?(job.log_path)
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 log lives under the granted cache dir, so it survives.
87
- redirect_output(job.log_path)
88
- @sandbox&.call([writer.fileno])
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(log_path)
161
- $stdout.reopen(log_path, "w")
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 Outcome.new(status: :killed, runtime: runtime, failed_ids: failed, executed_ids: executed,
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
- return if writable.any? { |dir| granted == dir || granted.start_with?("#{dir}/") }
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
- Process.setpgid(0, 0)
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
- ready = IO.select(workers.map(&:results), nil, nil, 1)&.first || []
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
- give_up(reason) if worker.done.zero?
142
- return if queue.empty?
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
- Process.setpgid(0, 0)
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; --test limits the specs that run; " \
44
- "--threshold PERCENT replaces the survivor check: exit 0 when the score is at or above " \
45
- "PERCENT, exit 1 when it is below. All operators run unless you select a smaller set; " \
46
- "exclusions always remove operators from that set. Inline comment syntax: " \
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", "saboteur run --since origin/main"],
52
- ["Whole project, four workers, repeatable seed", "saboteur run --jobs 4 --seed 42"],
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
- @commands.select(&:exit_codes).map { |command| exit_codes_section(command.exit_codes, command.name) }
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
 
@@ -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
- def apply(results)
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