saboteur 0.5.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -0
  3. data/README.md +202 -42
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/comparison.rb +1 -0
  6. data/lib/saboteur/baseline/evidence.rb +11 -1
  7. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  8. data/lib/saboteur/baseline/workflow.rb +2 -1
  9. data/lib/saboteur/baseline_health.rb +40 -6
  10. data/lib/saboteur/cli/exit_status.rb +49 -4
  11. data/lib/saboteur/cli/help_options.rb +16 -0
  12. data/lib/saboteur/cli/presets.rb +36 -0
  13. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  14. data/lib/saboteur/cli/run_reporting.rb +75 -6
  15. data/lib/saboteur/cli.rb +80 -48
  16. data/lib/saboteur/config.rb +88 -13
  17. data/lib/saboteur/crash_report.rb +154 -0
  18. data/lib/saboteur/custom_map.rb +3 -1
  19. data/lib/saboteur/doctor.rb +64 -2
  20. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  21. data/lib/saboteur/engine/boot.rb +12 -1
  22. data/lib/saboteur/engine/canary.rb +41 -10
  23. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  24. data/lib/saboteur/engine/candidates.rb +4 -1
  25. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  26. data/lib/saboteur/engine/coverage_store.rb +48 -6
  27. data/lib/saboteur/engine/diagnostics.rb +23 -2
  28. data/lib/saboteur/engine/diff_scope.rb +24 -5
  29. data/lib/saboteur/engine/distribution.rb +124 -8
  30. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  31. data/lib/saboteur/engine/result_cache.rb +2 -1
  32. data/lib/saboteur/engine/run_budget.rb +63 -0
  33. data/lib/saboteur/engine/run_fingerprint.rb +5 -1
  34. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  35. data/lib/saboteur/engine/scheduler.rb +114 -18
  36. data/lib/saboteur/engine/subject_selection.rb +84 -5
  37. data/lib/saboteur/engine/suite_files.rb +3 -1
  38. data/lib/saboteur/engine/test_guess.rb +183 -23
  39. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  40. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  41. data/lib/saboteur/engine/verdict_check.rb +54 -18
  42. data/lib/saboteur/engine.rb +175 -96
  43. data/lib/saboteur/execution/protocol.rb +2 -1
  44. data/lib/saboteur/execution/runner.rb +179 -17
  45. data/lib/saboteur/execution/sandbox.rb +6 -1
  46. data/lib/saboteur/execution/supervisor.rb +18 -3
  47. data/lib/saboteur/execution/worker_pool.rb +87 -7
  48. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  49. data/lib/saboteur/frameworks/rails.rb +8 -10
  50. data/lib/saboteur/help.rb +55 -10
  51. data/lib/saboteur/ignore_list.rb +11 -1
  52. data/lib/saboteur/install.rb +4 -3
  53. data/lib/saboteur/integration/base.rb +7 -2
  54. data/lib/saboteur/integration/rspec.rb +40 -4
  55. data/lib/saboteur/mutation_source/builtin.rb +22 -13
  56. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  57. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  58. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  59. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  60. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  61. data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
  62. data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
  63. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  64. data/lib/saboteur/preview.rb +12 -2
  65. data/lib/saboteur/reporter/agent_commands.rb +49 -16
  66. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  67. data/lib/saboteur/reporter/agent_summary.rb +1 -1
  68. data/lib/saboteur/reporter/history_policy.rb +35 -0
  69. data/lib/saboteur/reporter/history_record.rb +4 -5
  70. data/lib/saboteur/reporter/next_steps.rb +14 -4
  71. data/lib/saboteur/reporter/score_history.rb +19 -3
  72. data/lib/saboteur/reporter/stryker.rb +19 -8
  73. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  74. data/lib/saboteur/reporter/terminal.rb +43 -10
  75. data/lib/saboteur/result.rb +25 -7
  76. data/lib/saboteur/selection/coverage_map.rb +2 -1
  77. data/lib/saboteur/selection/git_diff.rb +15 -5
  78. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  79. data/lib/saboteur/subject.rb +11 -0
  80. data/lib/saboteur/text.rb +42 -0
  81. data/lib/saboteur/version.rb +1 -1
  82. data/lib/saboteur.rb +16 -1
  83. metadata +20 -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
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Frameworks
6
+ # Which database hosts count as local for the Rails integration's refusal
7
+ # to mutate a remote test database (issue #258).
8
+ module DatabaseHosts
9
+ # 0.0.0.0: a listen address, but in a URL it connects to this machine (issue #258).
10
+ LOCAL_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"].freeze
11
+
12
+ module_function
13
+
14
+ def local?(host)
15
+ host = host.to_s
16
+ host.empty? || host.start_with?("/") || LOCAL_HOSTS.include?(host.downcase)
17
+ end
18
+
19
+ # "name (host host)" per config whose host is not local and not in
20
+ # extra_allowed. Shared by the boot refusal and doctor.
21
+ def remote(configs, extra_allowed = [])
22
+ allowed = extra_allowed.map(&:downcase)
23
+ configs.filter_map do |db|
24
+ host = db.configuration_hash[:host]
25
+ "#{db.name} (host #{host})" unless local?(host) || allowed.include?(host.downcase)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -7,6 +7,8 @@ require "pathname"
7
7
  require "English"
8
8
  require "tmpdir"
9
9
 
10
+ require_relative "database_hosts"
11
+
10
12
  module Saboteur
11
13
  module Frameworks
12
14
  # Opt-in Rails support (`c.rails = true` in config/saboteur.rb).
@@ -16,8 +18,6 @@ module Saboteur
16
18
  # forking, and drops database connections in the parent so each child opens
17
19
  # its own. Prints host names only, never credentials.
18
20
  class Rails
19
- LOCAL_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"].freeze
20
-
21
21
  # How many blocking session names the template-copy error lists.
22
22
  MAX_BLOCKER_NAMES = 5
23
23
 
@@ -170,9 +170,9 @@ module Saboteur
170
170
  db.adapter.to_s.match?(/sqlite/) && db.database.to_s == ":memory:"
171
171
  end
172
172
 
173
- def self.local_host?(host)
174
- host.nil? || host.to_s.empty? || host.to_s.start_with?("/") || LOCAL_HOSTS.include?(host.to_s.downcase)
175
- end
173
+ def self.local_host?(host) = DatabaseHosts.local?(host)
174
+
175
+ def self.remote_databases(configs, extra_allowed = []) = DatabaseHosts.remote(configs, extra_allowed)
176
176
 
177
177
  private
178
178
 
@@ -197,13 +197,11 @@ module Saboteur
197
197
  def check_databases
198
198
  return unless defined?(::ActiveRecord::Base)
199
199
 
200
- remote = ::ActiveRecord::Base.configurations.configs_for(env_name: "test").filter_map do |db|
201
- host = db.configuration_hash[:host]
202
- "#{db.name} (host #{host})" unless self.class.local_host?(host)
203
- end
200
+ remote = self.class.remote_databases(test_configs, @config["allow_db_hosts"])
204
201
  return if remote.empty?
205
202
 
206
- raise Error, "rails integration refuses to run against non-local databases: #{remote.join(', ')}"
203
+ raise Error, "rails integration refuses to run against non-local databases: #{remote.join(', ')}; " \
204
+ "if they are disposable test databases, allow them with c.allow_db_hosts in #{Config::FILE}"
207
205
  end
208
206
 
209
207
  def test_configs