saboteur 0.3.0 → 0.5.0

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