saboteur 0.3.0 → 0.4.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +113 -1
  3. data/README.md +248 -63
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/cli.rb +150 -22
  7. data/lib/saboteur/config.rb +27 -15
  8. data/lib/saboteur/config_file.rb +97 -0
  9. data/lib/saboteur/custom_map.rb +170 -0
  10. data/lib/saboteur/doctor.rb +17 -1
  11. data/lib/saboteur/engine/baseline_guard.rb +20 -3
  12. data/lib/saboteur/engine/boot.rb +9 -0
  13. data/lib/saboteur/engine/coverage_store.rb +31 -4
  14. data/lib/saboteur/engine/data_inputs.rb +43 -0
  15. data/lib/saboteur/engine/diagnostics.rb +28 -0
  16. data/lib/saboteur/engine/diff_scope.rb +8 -0
  17. data/lib/saboteur/engine/run_fingerprint.rb +34 -6
  18. data/lib/saboteur/engine/scheduler.rb +7 -16
  19. data/lib/saboteur/engine/suite_files.rb +8 -1
  20. data/lib/saboteur/engine.rb +74 -13
  21. data/lib/saboteur/execution/reevaluator.rb +7 -4
  22. data/lib/saboteur/execution/supervisor.rb +23 -11
  23. data/lib/saboteur/frameworks/rails.rb +329 -13
  24. data/lib/saboteur/help.rb +33 -17
  25. data/lib/saboteur/ignore_list.rb +71 -17
  26. data/lib/saboteur/install.rb +132 -0
  27. data/lib/saboteur/integration/rspec.rb +47 -3
  28. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  29. data/lib/saboteur/mutation_source/builtin.rb +8 -2
  30. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  31. data/lib/saboteur/replay.rb +1 -1
  32. data/lib/saboteur/reporter/agent.rb +87 -0
  33. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  34. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  35. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  36. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  37. data/lib/saboteur/reporter/findings.rb +8 -0
  38. data/lib/saboteur/reporter/html.rb +49 -1
  39. data/lib/saboteur/reporter/stryker.rb +11 -4
  40. data/lib/saboteur/reporter/terminal.rb +65 -2
  41. data/lib/saboteur/result.rb +46 -5
  42. data/lib/saboteur/score.rb +89 -0
  43. data/lib/saboteur/version.rb +1 -1
  44. data/lib/saboteur/working_tree.rb +48 -0
  45. data/lib/saboteur.rb +4 -0
  46. metadata +17 -2
@@ -45,15 +45,32 @@ module Saboteur
45
45
 
46
46
  def check(number)
47
47
  Execution::ForkReadiness.prepare_heap
48
- result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, number) }
48
+ heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}")
49
+ result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
50
+ record(writer, number)
51
+ end
49
52
  refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
53
+ # Before the payload checks: a child killed by a signal (the OOM
54
+ # killer, say) writes nothing, and the signal is the explanation.
55
+ if result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
56
+ refuse(number, unsuccessful_exit(result.exit_status))
57
+ end
50
58
  refuse(number, "the child did not report a result") if result.payload.empty?
51
- refuse(number, "the child exited unsuccessfully") if
52
- result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
53
59
  refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
54
60
  parse_result(number, result.payload)
55
61
  end
56
62
 
63
+ # A signaled child (the OOM killer, say) can leave an empty log,
64
+ # which reads like a suite failure; name the signal and hint the
65
+ # likely cause instead.
66
+ def unsuccessful_exit(status)
67
+ return "the child exited with status #{status.exitstatus}" unless status.signaled?
68
+
69
+ signal = "SIG#{Signal.signame(status.termsig)}"
70
+ hint = signal == "SIGKILL" ? " - possible OOM, check memory" : ""
71
+ "the child was killed by #{signal}#{hint}"
72
+ end
73
+
57
74
  def parse_result(number, payload)
58
75
  data = JSON.parse(payload)
59
76
  refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
@@ -62,6 +62,7 @@ module Saboteur
62
62
  @rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
63
63
  rails&.before_boot
64
64
  @hooks.run(:before_boot)
65
+ add_spec_to_load_path if @config["rails"]
65
66
  @config["require"].each { |file| require File.expand_path(file, @root) }
66
67
  integration = Integration.fetch(@config["integration"]).new(@config)
67
68
  integration.setup
@@ -72,6 +73,14 @@ module Saboteur
72
73
 
73
74
  private
74
75
 
76
+ # rspec puts spec/ on the load path, so a boot file under spec/ (a
77
+ # dummy app's rails_helper, say) can require its siblings. Match
78
+ # that, or the same require list fails to load.
79
+ def add_spec_to_load_path
80
+ path = File.join(@root, "spec")
81
+ $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
82
+ end
83
+
75
84
  def load_hooks
76
85
  Saboteur.current_hooks = @hooks
77
86
  @config["hooks"].each { |file| load File.expand_path(file, @root) }
@@ -27,9 +27,9 @@ module Saboteur
27
27
  def fetch(files)
28
28
  path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
29
29
  cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
30
- return cached if cached
30
+ return refuse_empty_map(cached, files) if cached
31
31
 
32
- map = build(files)
32
+ map = refuse_empty_map(build(files), files)
33
33
  map.save(path)
34
34
  map
35
35
  end
@@ -52,11 +52,15 @@ module Saboteur
52
52
  "Run through the saboteur executable and disable SimpleCov for the run."
53
53
  end
54
54
 
55
- @diagnostics.inform("building coverage map (runs the whole suite once)")
55
+ scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
56
+ @diagnostics.inform("building coverage map (runs #{scope} once)")
56
57
  # The suite child forked next inherits this heap; pack it first, to
57
58
  # reduce later copying of shared pages.
58
59
  Execution::ForkReadiness.prepare_heap
59
- result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, files) }
60
+ heartbeat = @diagnostics.progress_heartbeat("building coverage map")
61
+ result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
62
+ record(writer, files)
63
+ end
60
64
  raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
61
65
  raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
62
66
 
@@ -66,6 +70,29 @@ module Saboteur
66
70
  Selection::CoverageMap.from_json(result.payload)
67
71
  end
68
72
 
73
+ # A whole-suite build that records no line in any included file fails
74
+ # the run (exit 3): usually the setup hid the app from Coverage
75
+ # (Bootsnap's compile cache is the default Rails case); it can also
76
+ # mean no test in the suite exercises any included file. Either way a
77
+ # run on this map would test nothing, and exit 0 would lie. With
78
+ # explicit tests (--test) an empty map is a legitimate outcome
79
+ # instead: the user narrowed the suite to specs that may not touch
80
+ # these sources, so the map passes with a warning. Applies to a cached
81
+ # map too, so a map cached before this check cannot keep a run green,
82
+ # and the guard runs before saving, so an empty map is never cached.
83
+ def refuse_empty_map(map, files)
84
+ return map if files.empty? || map.lines.any? { |_file, by_line| by_line.any? }
85
+
86
+ if @config["tests"].empty?
87
+ raise Error, "Coverage recorded no lines in #{files.size} included files during a whole-suite " \
88
+ "build. Is Bootsnap's compile cache on? If not, no test may exercise these files. " \
89
+ "Run `saboteur doctor`."
90
+ end
91
+ @diagnostics.warn("Coverage recorded no lines in #{files.size} included files; " \
92
+ "the selected specs may not exercise them.")
93
+ map
94
+ end
95
+
69
96
  def died(status)
70
97
  how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
71
98
  else "exited with status #{status&.exitstatus}"
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Non-Ruby files a test run can read and that change its outcome: Rails
7
+ # fixtures, schema dumps and database/environment configuration, plus
8
+ # anything listed under `fingerprint_extra`. Ruby files are tracked
9
+ # elsewhere; these have no .rb extension, so the Ruby walk misses them.
10
+ # An absent pattern simply matches nothing, so plain gems are unaffected.
11
+ module DataInputs
12
+ GLOBS = %w[
13
+ spec/fixtures/**/*
14
+ spec/**/fixtures/**/*
15
+ test/fixtures/**/*
16
+ db/structure.sql
17
+ db/schema.rb
18
+ config/database.yml
19
+ config/database.yml.erb
20
+ config/*.yml
21
+ config/environments/*.yml
22
+ config/credentials/*.yml.enc
23
+ ].freeze
24
+
25
+ # True when the absolute path is one of the declared inputs, whether or
26
+ # not the file exists yet. A run must never write there.
27
+ def self.match?(root, path, extra: [])
28
+ return false unless path.start_with?("#{root}/")
29
+
30
+ relative = path.delete_prefix("#{root}/")
31
+ flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
32
+ (GLOBS + extra).any? { |pattern| File.fnmatch?(pattern, relative, flags) }
33
+ end
34
+
35
+ # Sorted absolute paths of regular files (symlinks followed) under root.
36
+ def self.files(root, extra: [])
37
+ (GLOBS + extra).flat_map { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: root) }
38
+ .map { |relative| File.join(root, relative) }
39
+ .select { |path| File.file?(path) }.uniq.sort
40
+ end
41
+ end
42
+ end
43
+ end
@@ -6,6 +6,26 @@ module Saboteur
6
6
  # What a run has to say besides results: warnings for the report, the
7
7
  # count of mutations dropped for not parsing, and progress marks.
8
8
  class Diagnostics
9
+ # See progress_heartbeat. Time-gated itself, so the supervisor can
10
+ # call tick on every poll.
11
+ class ProgressHeartbeat
12
+ def initialize(diagnostics, label, interval: 30)
13
+ @diagnostics = diagnostics
14
+ @label = label
15
+ @interval = interval
16
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
+ @last_print = @started
18
+ end
19
+
20
+ def tick
21
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ return if now - @last_print < @interval
23
+
24
+ @last_print = now
25
+ @diagnostics.inform("#{@label}: #{(now - @started).round} s elapsed")
26
+ end
27
+ end
28
+
9
29
  MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
10
30
 
11
31
  attr_reader :warnings, :invalid_mutations
@@ -28,6 +48,14 @@ module Saboteur
28
48
  @err.puts("saboteur: #{message}")
29
49
  end
30
50
 
51
+ # A heartbeat for a long phase: the supervisor's wait loop calls tick
52
+ # in the parent after the fork, so no extra thread is ever alive at
53
+ # fork time. Prints "<label>: N s elapsed" about every interval
54
+ # seconds, so a long phase does not look stuck.
55
+ def progress_heartbeat(label, interval: 30)
56
+ ProgressHeartbeat.new(self, label, interval: interval)
57
+ end
58
+
31
59
  def progress(result)
32
60
  @err.print(MARKS.fetch(result.status, "?"))
33
61
  @err.flush
@@ -20,6 +20,14 @@ module Saboteur
20
20
  end
21
21
  end
22
22
 
23
+ # The changed lines a mutant touches, sorted. A statement that spans
24
+ # several lines is mutated as one piece and reported at its first line,
25
+ # which can sit above the line that changed; this names the changed line.
26
+ def changed_lines_for(subject, mutation)
27
+ changed = @changed_lines.fetch(subject.file, Set.new)
28
+ mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
29
+ end
30
+
23
31
  private
24
32
 
25
33
  def edited_lines(subject, edit)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "digest"
5
5
  require "json"
6
+ require_relative "data_inputs"
6
7
 
7
8
  module Saboteur
8
9
  class Engine
@@ -12,7 +13,7 @@ module Saboteur
12
13
  FORMAT_VERSION = 1
13
14
  MANIFESTS = %w[Gemfile gems.rb Gemfile.lock gems.locked .rspec .rspec-local].freeze
14
15
  LOCATION_SETTINGS = %w[cache_dir json_report html_report].freeze
15
- ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra].freeze
16
+ ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra data].freeze
16
17
  EXCLUDED_DIRECTORIES = %w[.git vendor node_modules].freeze
17
18
  Entry = Data.define(:path, :kind, :state, :sha256, :required)
18
19
  Change = Data.define(:path, :reason)
@@ -178,11 +179,12 @@ module Saboteur
178
179
  end
179
180
 
180
181
  def output_overlap_issues
181
- paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) }
182
+ paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) } +
183
+ DataInputs.files(@root, extra: @config["fingerprint_extra"])
182
184
  outputs = %w[json_report html_report].filter_map { |key| @config.fetch(key) }
183
185
  .map { |path| canonical_path(path) }
184
186
  issues = outputs.filter_map do |path|
185
- if path.end_with?(".rb") || paths.include?(path) || File.directory?(path)
187
+ if path.end_with?(".rb") || paths.include?(path) || File.directory?(path) || data_input?(path)
186
188
  "output overlaps input: #{display_path(path)}"
187
189
  end
188
190
  end
@@ -192,24 +194,50 @@ module Saboteur
192
194
 
193
195
  def cache_overlap_issues(paths)
194
196
  cache = canonical_path(@config["cache_dir"])
195
- protected = [@root] + %w[app lib spec].map { |name| File.join(@root, name) } + paths
196
- overlaps = protected.any? do |path|
197
+ protected = [@root] + %w[app lib spec test/fixtures].map { |name| File.join(@root, name) } + paths
198
+ inside = protected.any? do |path|
197
199
  path == cache || path.start_with?("#{cache}/") || (path != @root && cache.start_with?("#{path}/"))
198
200
  end
201
+ overlaps = inside || data_input?(cache)
199
202
  overlaps ? ["cache directory overlaps inputs: #{display_path(cache)}"] : []
200
203
  end
201
204
 
205
+ # Declared fixture, schema or config input, or a path inside a fixture tree.
206
+ def data_input?(path)
207
+ DataInputs.match?(@root, path, extra: @config["fingerprint_extra"])
208
+ end
209
+
202
210
  def inventory(issues)
203
211
  paths = project_files(issues).to_h { |path| [path, "ruby"] }
204
212
  MANIFESTS.each { |name| paths[File.join(@root, name)] = "manifest" }
213
+ data_files(issues).each { |path| paths[path] ||= "data" }
205
214
  required = merged_required_files
206
215
  required.each { |path, (kind, _)| paths[path] = kind }
207
216
  paths.map do |path, kind|
208
- needed = required.key?(path) ? required[path].last : kind != "manifest"
217
+ needed = required.key?(path) ? required[path].last : !%w[manifest data].include?(kind)
209
218
  read_entry(path, kind, needed, issues)
210
219
  end
211
220
  end
212
221
 
222
+ # Fixtures, schema dumps and database config. A pattern with no match is
223
+ # fine; a listed file that vanishes later shows up as "removed". Like Ruby
224
+ # files, data reached through a symlink (the file or a directory above it)
225
+ # is an undeclared boundary: its real path could be an output or lie outside
226
+ # the project, so the snapshot is incomplete instead of guessing.
227
+ def data_files(issues)
228
+ DataInputs.files(@root, extra: @config["fingerprint_extra"]).reject do |path|
229
+ next false unless symlinked?(path)
230
+
231
+ issues << "symlink input: #{display_path(path)}".freeze
232
+ true
233
+ end
234
+ end
235
+
236
+ def symlinked?(path)
237
+ current = @root
238
+ path.delete_prefix("#{@root}/").split("/").any? { |part| File.symlink?(current = File.join(current, part)) }
239
+ end
240
+
213
241
  def merged_required_files
214
242
  required_files.group_by(&:first).transform_values do |entries|
215
243
  roles = entries.map { |_, kind, _| kind }
@@ -7,11 +7,10 @@ module Saboteur
7
7
  # unit of work parallel workers will share out.
8
8
  #
9
9
  # Order of business: skip what cannot be run (refinements, not loaded,
10
- # shadowed, no covering test); coarse mutants; fine mutants only if no
11
- # coarse mutant survived; then check every kill and timeout against the
12
- # unchanged method (VerdictCheck). Each mutant runs only the tests that
13
- # executed its edit, likeliest killers first, with a timeout from those
14
- # tests' recorded durations.
10
+ # shadowed, no covering test); coarse mutants; all eligible fine mutants;
11
+ # then check every kill and timeout against the unchanged method
12
+ # (VerdictCheck). Each mutant runs only the tests that executed its edit,
13
+ # likeliest killers first, with a timeout from their recorded durations.
15
14
  class Scheduler
16
15
  # A subject to run, with what its mutants need. killers counts the
17
16
  # tests that killed earlier mutants of this subject; slowdown is how
@@ -45,7 +44,7 @@ module Saboteur
45
44
  reused = all_reused(plan, candidates)
46
45
  return reused if reused
47
46
 
48
- results = run_candidates(plan, candidates) do |mutation, text|
47
+ results = run_candidates(candidates) do |mutation, text|
49
48
  reuse(plan, mutation, text) || execute(plan, mutation, text)
50
49
  end
51
50
  checked(subject, results)
@@ -86,21 +85,13 @@ module Saboteur
86
85
  return nil unless @cache
87
86
 
88
87
  catch(:must_run) do
89
- run_candidates(plan, candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
88
+ run_candidates(candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
90
89
  end
91
90
  end
92
91
 
93
- def run_candidates(plan, candidates, &run_one)
92
+ def run_candidates(candidates, &run_one)
94
93
  coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
95
94
  results = coarse.map { |mutation, text| run_one.call(mutation, text) }
96
- # A surviving coarse mutant means nothing checks the body, so the fine
97
- # mutants would only add noise. Not so when a rescue or else can
98
- # return a value of its own: fine mutants that make the body raise
99
- # can still be caught.
100
- if results.any?(&:survived?) && !plan.subject.recovers?
101
- return results + skip_all(fine, plan.subject, "coarse mutant survived", covered_by: plan.tests)
102
- end
103
-
104
95
  results + fine.map { |mutation, text| run_one.call(mutation, text) }
105
96
  end
106
97
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "digest"
5
5
  require "shellwords"
6
+ require_relative "data_inputs"
6
7
 
7
8
  module Saboteur
8
9
  class Engine
@@ -22,7 +23,7 @@ module Saboteur
22
23
  end
23
24
 
24
25
  def files
25
- (shared_spec_files + boot_and_hook_files + option_files + required_by_options).uniq.sort
26
+ (shared_spec_files + boot_and_hook_files + option_files + required_by_options + data_files).uniq.sort
26
27
  end
27
28
 
28
29
  def digest
@@ -33,6 +34,12 @@ module Saboteur
33
34
 
34
35
  private
35
36
 
37
+ # Fixtures, schema dumps and database config: not Ruby, but they change
38
+ # what the tests see, so a stale cache must not survive an edit to them.
39
+ def data_files
40
+ DataInputs.files(@root, extra: @config["fingerprint_extra"])
41
+ end
42
+
36
43
  def shared_spec_files
37
44
  Dir.glob("spec/**/*.rb", base: @root).reject { |file| file.end_with?("_spec.rb") }
38
45
  .map { |file| File.join(@root, file) }
@@ -29,20 +29,52 @@ module Saboteur
29
29
  Boot.refuse_recursion!
30
30
  started = monotonic
31
31
  ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
32
+ @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
32
33
  boot = Boot.new(config, hooks)
33
- integration = boot.load_target
34
- @rails = boot.rails
35
- files = config.included_files
36
- subjects, scope = select_subjects(files)
37
- results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
38
- @diagnostics.warnings.concat(ignore_list.apply(results))
39
- Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
40
- invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
41
- review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects)
34
+ begin
35
+ integration = boot.load_target
36
+ @rails = boot.rails
37
+ files = config.included_files
38
+ subjects, scope = select_subjects(files)
39
+ results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
40
+ @diagnostics.warnings.concat(ignore_list.apply(results))
41
+ build_report(results, subjects, scope, started)
42
+ ensure
43
+ # The run boundary for the per-worker databases: cleanup runs however
44
+ # the call ends - a boot that raised partway through, a baseline or
45
+ # coverage failure, no subjects, no runnable work - so a host that
46
+ # embeds Engine and calls it repeatedly never holds copies or
47
+ # snapshots until process exit. cleanup_workers is idempotent; the
48
+ # Rails profile's at_exit handler stays as the fallback for an
49
+ # abrupt process exit.
50
+ safely_cleanup_workers(boot.rails)
51
+ end
52
+ end
53
+
54
+ # Stale entries and duplicate literals never fail a run; they warn so the
55
+ # map does not rot silently. doctor fails on stale keys.
56
+ def load_custom_map
57
+ @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
58
+ end
59
+
60
+ def custom_map_warnings(map)
61
+ path = config.path("custom_map")
62
+ warnings = map.stale_keys.map { |key| "stale custom map key #{key}: matches no file" }
63
+ return warnings unless path && File.file?(path)
64
+
65
+ warnings + map.duplicate_keys(path).map { |key| "duplicate custom map key #{key}: Ruby keeps the last" }
42
66
  end
43
67
 
44
68
  private
45
69
 
70
+ # The run's immutable report, built once the last result is in.
71
+ def build_report(results, subjects, scope, started)
72
+ Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
73
+ invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
74
+ review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
75
+ diff_scope: scope)
76
+ end
77
+
46
78
  # The subjects to mutate and, in diff mode, the scope of changed lines.
47
79
  def select_subjects(files)
48
80
  config.unmatched_patterns.each { |pattern| @diagnostics.warn("#{pattern} matches no file") }
@@ -55,6 +87,7 @@ module Saboteur
55
87
  end
56
88
 
57
89
  def mutate(subjects, files, integration, scope)
90
+ warn_about_unscoped_suite(integration)
58
91
  check_baseline(integration)
59
92
  map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
60
93
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
@@ -72,7 +105,8 @@ module Saboteur
72
105
  # are retained, to reduce later copying of shared pages.
73
106
  Execution::ForkReadiness.prepare_heap
74
107
  results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
75
- cache: cache, on_start: method(:start_worker)).run(runnable_subjects, runnable_lists)
108
+ cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
109
+ runnable_lists)
76
110
  cache&.save
77
111
  results
78
112
  end
@@ -82,6 +116,18 @@ module Saboteur
82
116
  warn_about_threads
83
117
  end
84
118
 
119
+ # A --since run without --test runs the loaded suite twice for the
120
+ # baselines; a cold coverage cache adds one more pass. Say so
121
+ # before those hours pass, with the way to scope it.
122
+ def warn_about_unscoped_suite(integration)
123
+ return unless config["since"] && config["tests"].empty?
124
+
125
+ message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
126
+ "(#{integration.test_files.size} spec files). Pass --test to scope it."
127
+ @diagnostics.warn(message)
128
+ @diagnostics.inform(message)
129
+ end
130
+
85
131
  def check_baseline(integration)
86
132
  BaselineGuard.new(config: config, integration: integration, hooks: hooks,
87
133
  supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
@@ -104,6 +150,14 @@ module Saboteur
104
150
  suite_digest: SuiteFiles.new(config).digest)
105
151
  end
106
152
 
153
+ # A cleanup failure must never mask the run's own outcome - not even
154
+ # one cleanup_workers did not anticipate.
155
+ def safely_cleanup_workers(rails)
156
+ rails&.cleanup_workers
157
+ rescue StandardError => e
158
+ @diagnostics.warn(e.message)
159
+ end
160
+
107
161
  # In each worker: its own database, then the user's worker_start hooks.
108
162
  def start_worker(number)
109
163
  @rails&.start_worker(number)
@@ -143,10 +197,17 @@ module Saboteur
143
197
  end
144
198
  end
145
199
 
200
+ # Threads do not survive fork, so name them: with the name the owner
201
+ # gave the thread, else the first frame of its backtrace, so the user
202
+ # can tell whether they matter (a connection pool reaper, Sidekiq, an
203
+ # SDK's background flusher).
146
204
  def warn_about_threads
147
- others = Thread.list.size - 1
148
- @diagnostics.warn("#{others} other thread(s) alive at fork time; they do not run in children") if
149
- others.positive?
205
+ others = Thread.list - [Thread.current]
206
+ return if others.empty?
207
+
208
+ names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
209
+ list = names.join(", ")
210
+ @diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
150
211
  end
151
212
 
152
213
  def monotonic
@@ -29,11 +29,12 @@ module Saboteur
29
29
  target = subject.singleton ? owner.singleton_class : owner
30
30
  visibility = visibility_of(target, subject.name)
31
31
  module_function = module_function?(owner, subject)
32
+ copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
32
33
  aliases = aliases_of(target, subject.name)
33
34
 
34
35
  @strategy == "nesting" ? eval_nested(subject, text) : eval_in_owner(owner, subject, text)
35
36
 
36
- restore(owner, target, subject.name, visibility, module_function)
37
+ restore(owner, target, subject.name, visibility, copy_visibility)
37
38
  repoint(target, subject.name, aliases)
38
39
  end
39
40
 
@@ -82,7 +83,8 @@ module Saboteur
82
83
  def module_function?(owner, subject)
83
84
  return false if subject.singleton || owner.is_a?(Class)
84
85
  return false unless owner.private_method_defined?(subject.name, false)
85
- return false unless owner.singleton_class.public_method_defined?(subject.name, false)
86
+ return false unless owner.singleton_class.method_defined?(subject.name, false) ||
87
+ owner.singleton_class.private_method_defined?(subject.name, false)
86
88
 
87
89
  owner.instance_method(subject.name).source_location ==
88
90
  owner.singleton_class.instance_method(subject.name).source_location
@@ -132,9 +134,10 @@ module Saboteur
132
134
  end
133
135
  end
134
136
 
135
- def restore(owner, target, name, visibility, module_function)
136
- if module_function
137
+ def restore(owner, target, name, visibility, copy_visibility)
138
+ if copy_visibility
137
139
  owner.send(:module_function, name)
140
+ owner.singleton_class.send(copy_visibility, name)
138
141
  elsif visibility != :public
139
142
  target.send(visibility, name)
140
143
  end
@@ -94,7 +94,10 @@ module Saboteur
94
94
  # Runs the block in a forked child with a pipe writer; the child exits
95
95
  # when the block returns. Returns a Result. cpu_seconds bounds every
96
96
  # descendant's CPU time (default: twice the deadline, at least 10 s).
97
- def run(timeout:, cpu_seconds: nil, &block)
97
+ # on_tick is called from the parent's wait loop after the fork, about
98
+ # every poll, so callers can report progress without a thread alive
99
+ # at fork time.
100
+ def run(timeout:, cpu_seconds: nil, on_tick: nil, &block)
98
101
  depth = ENV.fetch(CHILD_ENV, "0").to_i
99
102
  raise Error, "refusing to fork: saboteur children nested #{depth} deep (limit #{MAX_DEPTH})" if
100
103
  depth >= MAX_DEPTH
@@ -107,7 +110,7 @@ module Saboteur
107
110
  supervisor = Process.pid
108
111
  pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
109
112
  writer.close
110
- contain(pid, reader, started, timeout)
113
+ contain(pid, reader, started, timeout, on_tick)
111
114
  ensure
112
115
  reader&.close unless reader&.closed?
113
116
  end
@@ -168,24 +171,31 @@ module Saboteur
168
171
 
169
172
  # --- parent ---------------------------------------------------------
170
173
 
171
- def contain(pid, reader, started, timeout)
174
+ def contain(pid, reader, started, timeout, on_tick)
172
175
  tree = ProcessTree.new(pid)
173
176
  self.class.live[pid] = tree
174
177
  own_group(pid)
175
- payload, stop, detail = watch(tree, reader, started + timeout)
176
- exit_status = stop == :timeout ? terminate(pid) : nil
177
- leftover_pids = tree.kill_all - [pid]
178
- exit_status ||= reap(pid)
179
- self.class.live.delete(pid)
180
- reap_adopted(leftover_pids)
181
- reap_group(pid)
178
+ begin
179
+ payload, stop, detail = watch(tree, reader, started + timeout, on_tick)
180
+ ensure
181
+ # Containment holds on every path out of watch: a raising on_tick
182
+ # (progress IO failed) or any other error must not skip the
183
+ # kill/reap, or the child and its tree would run unsupervised
184
+ # until this process exits.
185
+ exit_status = stop == :timeout ? terminate(pid) : nil
186
+ leftover_pids = tree.kill_all - [pid]
187
+ exit_status ||= reap(pid)
188
+ self.class.live.delete(pid)
189
+ reap_adopted(leftover_pids)
190
+ reap_group(pid)
191
+ end
182
192
  Result.new(payload: payload, exit_status: exit_status, stopped_by: stop, detail: detail,
183
193
  runtime: monotonic - started, leftover_pids: leftover_pids)
184
194
  end
185
195
 
186
196
  # Reads until EOF, polling the tree's size and memory. Returns
187
197
  # [payload, stop, detail].
188
- def watch(tree, reader, deadline)
198
+ def watch(tree, reader, deadline, on_tick)
189
199
  payload = +""
190
200
  next_poll = monotonic + POLL_INTERVAL
191
201
  tree.live_members
@@ -193,6 +203,8 @@ module Saboteur
193
203
  now = monotonic
194
204
  return [payload, :timeout, "exceeded deadline"] if now >= deadline
195
205
 
206
+ on_tick&.call
207
+
196
208
  if now >= next_poll
197
209
  stop = over_limit(tree)
198
210
  return [payload, *stop] if stop