saboteur 0.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +145 -0
  5. data/exe/saboteur +12 -0
  6. data/lib/saboteur/cli.rb +112 -0
  7. data/lib/saboteur/config.rb +133 -0
  8. data/lib/saboteur/engine/boot.rb +83 -0
  9. data/lib/saboteur/engine/candidates.rb +63 -0
  10. data/lib/saboteur/engine/coverage_store.rb +99 -0
  11. data/lib/saboteur/engine/diagnostics.rb +37 -0
  12. data/lib/saboteur/engine/diff_scope.rb +32 -0
  13. data/lib/saboteur/engine/distribution.rb +74 -0
  14. data/lib/saboteur/engine/result_cache.rb +93 -0
  15. data/lib/saboteur/engine/scheduler.rb +215 -0
  16. data/lib/saboteur/engine/subject_selection.rb +63 -0
  17. data/lib/saboteur/engine/suite_files.rb +82 -0
  18. data/lib/saboteur/engine/verdict_check.rb +78 -0
  19. data/lib/saboteur/engine.rb +133 -0
  20. data/lib/saboteur/execution/process_table.rb +177 -0
  21. data/lib/saboteur/execution/process_tree.rb +99 -0
  22. data/lib/saboteur/execution/protocol.rb +41 -0
  23. data/lib/saboteur/execution/reevaluator.rb +144 -0
  24. data/lib/saboteur/execution/runner.rb +191 -0
  25. data/lib/saboteur/execution/supervisor.rb +337 -0
  26. data/lib/saboteur/execution/worker_pool.rb +249 -0
  27. data/lib/saboteur/frameworks/rails.rb +166 -0
  28. data/lib/saboteur/hooks.rb +51 -0
  29. data/lib/saboteur/ignore_list.rb +62 -0
  30. data/lib/saboteur/integration/base.rb +72 -0
  31. data/lib/saboteur/integration/rspec.rb +143 -0
  32. data/lib/saboteur/mutation_source/base.rb +26 -0
  33. data/lib/saboteur/mutation_source/builtin.rb +232 -0
  34. data/lib/saboteur/mutation_source/edit.rb +18 -0
  35. data/lib/saboteur/mutation_source/mutation.rb +28 -0
  36. data/lib/saboteur/reporter/diff.rb +97 -0
  37. data/lib/saboteur/reporter/stryker.rb +116 -0
  38. data/lib/saboteur/reporter/terminal.rb +136 -0
  39. data/lib/saboteur/result.rb +134 -0
  40. data/lib/saboteur/selection/coverage_map.rb +113 -0
  41. data/lib/saboteur/selection/git_diff.rb +64 -0
  42. data/lib/saboteur/selection/recorder.rb +72 -0
  43. data/lib/saboteur/subject.rb +166 -0
  44. data/lib/saboteur/subject_finder.rb +98 -0
  45. data/lib/saboteur/version.rb +6 -0
  46. data/lib/saboteur.rb +51 -0
  47. metadata +130 -0
@@ -0,0 +1,82 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "shellwords"
6
+
7
+ module Saboteur
8
+ class Engine
9
+ # The files every test shares: spec helpers and support code, boot and
10
+ # hook files, .rspec and whatever it (or rspec_args) requires. A change
11
+ # to any of them can change what any test runs, so both the coverage
12
+ # map and the result cache depend on them. Example files (*_spec.rb)
13
+ # are not listed: each is tracked with its own tests.
14
+ class SuiteFiles
15
+ OPTION_FILES = %w[.rspec .rspec-local].freeze
16
+ REQUIRE_FLAGS = %w[--require -r].freeze
17
+ LOAD_PATH = %w[spec lib .].freeze
18
+
19
+ def initialize(config)
20
+ @config = config
21
+ @root = File.realpath(config.root)
22
+ end
23
+
24
+ def files
25
+ (shared_spec_files + boot_and_hook_files + option_files + required_by_options).uniq.sort
26
+ end
27
+
28
+ def digest
29
+ files.each_with_object(Digest::SHA256.new) do |file, sha|
30
+ sha << file << "\0" << Digest::SHA256.file(file).hexdigest << "\0"
31
+ end.hexdigest
32
+ end
33
+
34
+ private
35
+
36
+ def shared_spec_files
37
+ Dir.glob("spec/**/*.rb", base: @root).reject { |file| file.end_with?("_spec.rb") }
38
+ .map { |file| File.join(@root, file) }
39
+ end
40
+
41
+ def boot_and_hook_files
42
+ (@config["require"] + @config["hooks"]).filter_map { |file| existing(File.expand_path(file, @root)) }
43
+ end
44
+
45
+ def option_files
46
+ OPTION_FILES.map { |file| File.join(@root, file) }.select { |file| File.file?(file) }
47
+ end
48
+
49
+ # `--require name` resolves through RSpec's load path: spec/, lib/ and
50
+ # any -I directory.
51
+ def required_by_options
52
+ tokens = option_files.flat_map { |file| Shellwords.split(File.read(file)) } + @config["rspec_args"]
53
+ load_path = LOAD_PATH + option_values(tokens, "-I", "--include")
54
+ option_values(tokens, *REQUIRE_FLAGS).filter_map { |name| resolve(name, load_path) }
55
+ end
56
+
57
+ # Values of the given flags, written "-r x", "-rx", "--require x" or
58
+ # "--require=x".
59
+ def option_values(tokens, *flags)
60
+ tokens.each_with_index.filter_map do |token, index|
61
+ flag = flags.find { |name| token == name || token.start_with?("#{name}=") || glued?(token, name) }
62
+ next unless flag
63
+ next tokens[index + 1] if token == flag
64
+
65
+ token.delete_prefix(flag).delete_prefix("=")
66
+ end
67
+ end
68
+
69
+ def glued?(token, flag)
70
+ flag.length == 2 && token.start_with?(flag) && token.length > 2
71
+ end
72
+
73
+ def resolve(name, load_path)
74
+ load_path.lazy.filter_map { |dir| existing(File.expand_path(name, File.join(@root, dir))) }.first
75
+ end
76
+
77
+ def existing(path)
78
+ [path, "#{path}.rb"].find { |candidate| File.file?(candidate) }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Checks each kill against the method re-evaluated unchanged: a kill
7
+ # stands only if the same tests, in the same order, up to and including
8
+ # the killer, pass without the mutant (so a test that fails only after
9
+ # another test left state behind cannot claim a kill). A kill with no
10
+ # failing test named (an exit, a suite-level failure) is checked on all
11
+ # the mutant's tests. A kill that fails the check becomes an error.
12
+ # (Timeouts are checked by the scheduler as they happen.)
13
+ #
14
+ # This re-runs the evidence, not every covering test: a method that every
15
+ # factory calls can have hundreds of tests, and killer-first ordering
16
+ # makes most kills a single test.
17
+ class VerdictCheck
18
+ # run_unchanged: runs tests against the unchanged method,
19
+ # ->(test_ids, timeout) { Runner::Outcome }.
20
+ def initialize(run_unchanged:, baseline_timeout:)
21
+ @run_unchanged = run_unchanged
22
+ @baseline_timeout = baseline_timeout
23
+ end
24
+
25
+ def call(results)
26
+ fresh = results.reject(&:reused?)
27
+ failing_runs = failing_sequences(fresh.select { |result| named_kill?(result) })
28
+ failing = failing_claims(fresh.select { |result| unnamed_kill?(result) })
29
+ results.map { |result| checked(result, failing_runs, failing) }
30
+ end
31
+
32
+ private
33
+
34
+ def named_kill?(result)
35
+ result.status == :killed && result.killed_by.any?
36
+ end
37
+
38
+ def unnamed_kill?(result)
39
+ result.status == :killed && result.killed_by.empty?
40
+ end
41
+
42
+ # The tests the mutant ran, in order, up to and including the killer.
43
+ def sequence(result)
44
+ killer = result.killed_by.first
45
+ position = result.covered_by.index(killer)
46
+ position ? result.covered_by.first(position + 1) : [killer]
47
+ end
48
+
49
+ # Runs each distinct kill sequence once. Returns those that fail, each
50
+ # with why (the failure message, when there is one).
51
+ def failing_sequences(kills)
52
+ kills.map { |result| sequence(result) }.uniq.each_with_object({}) do |tests, failing|
53
+ outcome = @run_unchanged.call(tests, @baseline_timeout)
54
+ failing[tests] = outcome.message unless outcome.status == :survived
55
+ end
56
+ end
57
+
58
+ # Kills with no failing test named. Returns those that fail unchanged.
59
+ def failing_claims(claims)
60
+ claims.reject do |result|
61
+ @run_unchanged.call(result.covered_by, @baseline_timeout).status == :survived
62
+ end
63
+ end
64
+
65
+ def checked(result, failing_runs, failing)
66
+ if named_kill?(result) && failing_runs.key?(sequence(result))
67
+ why = failing_runs[sequence(result)]
68
+ result.as_error("killing test #{result.killed_by.first} also fails with the method unchanged" \
69
+ "#{": #{why}" if why}")
70
+ elsif failing.include?(result)
71
+ result.as_error("tests also fail with the method unchanged")
72
+ else
73
+ result
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,133 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Saboteur
8
+ # One run, as a pipeline: boot the target, select subjects, fetch the
9
+ # coverage map, then schedule each subject's mutants. The parts live in
10
+ # engine/; this class only wires them together.
11
+ class Engine
12
+ # Everything besides sources and tests that can change a verdict.
13
+ VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources arid timeout_factor timeout_min
14
+ max_memory_mb max_processes seed].freeze
15
+
16
+ attr_reader :config, :hooks
17
+
18
+ def initialize(config, err: $stderr)
19
+ @config = config
20
+ @hooks = Hooks.new
21
+ @diagnostics = Diagnostics.new(err)
22
+ @root = File.realpath(config.root)
23
+ end
24
+
25
+ def call
26
+ Boot.refuse_recursion!
27
+ started = monotonic
28
+ ignore_list = IgnoreList.load(config.path("ignore_file"))
29
+ boot = Boot.new(config, hooks)
30
+ integration = boot.load_target
31
+ @rails = boot.rails
32
+ files = config.included_files
33
+ subjects, scope = select_subjects(files)
34
+ results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
35
+ @diagnostics.warnings.concat(ignore_list.apply(results))
36
+ Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
37
+ invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
38
+ review: !scope.nil?, seed: config["seed"])
39
+ end
40
+
41
+ private
42
+
43
+ # The subjects to mutate and, in diff mode, the scope of changed lines.
44
+ def select_subjects(files)
45
+ config.unmatched_patterns.each { |pattern| @diagnostics.warn("#{pattern} matches no file") }
46
+ selection = SubjectSelection.new(config, @diagnostics)
47
+ subjects = selection.select(files)
48
+ [subjects, selection.changed_lines && DiffScope.new(selection.changed_lines)]
49
+ end
50
+
51
+ def mutate(subjects, files, integration, scope)
52
+ map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
53
+ supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
54
+ @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
55
+ warn_about_threads
56
+ cache = result_cache
57
+ scheduler = scheduler_for(map, integration, cache)
58
+ @rails&.prepare_workers(config["jobs"])
59
+ results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
60
+ cache: cache, on_start: method(:start_worker)).run(subjects, candidate_lists(subjects,
61
+ scope))
62
+ cache&.save
63
+ results
64
+ end
65
+
66
+ def scheduler_for(map, integration, cache)
67
+ reevaluator = Execution::Reevaluator.new(config["reeval"])
68
+ runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
69
+ supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"])
70
+ Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
71
+ cache: cache)
72
+ end
73
+
74
+ def result_cache
75
+ return nil unless config["incremental"]
76
+
77
+ inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] })
78
+ ResultCache.new(path: File.join(config.path("cache_dir"), "results.json"), root: @root, inputs: inputs,
79
+ suite_digest: SuiteFiles.new(config).digest)
80
+ end
81
+
82
+ # In each worker: its own database, then the user's worker_start hooks.
83
+ def start_worker(number)
84
+ @rails&.start_worker(number)
85
+ hooks.run(:worker_start, number)
86
+ end
87
+
88
+ # Each subject's candidates, narrowed to changed lines in diff mode.
89
+ def candidate_lists(subjects, scope)
90
+ candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
91
+ subjects.map do |subject|
92
+ list = candidates.for(subject)
93
+ scope ? scope.restrict(subject, list) : list
94
+ end
95
+ end
96
+
97
+ def supervisor(memory_factor: 1)
98
+ limits = Execution::Supervisor::Limits.new(max_memory_mb: config["max_memory_mb"] * memory_factor,
99
+ max_processes: config["max_processes"])
100
+ Execution::Supervisor.new(limits: limits)
101
+ end
102
+
103
+ # The last run's child logs (only those of errors are kept).
104
+ def fresh_log_dir
105
+ File.join(config.path("cache_dir"), "logs").tap do |dir|
106
+ FileUtils.rm_rf(dir)
107
+ FileUtils.mkdir_p(dir)
108
+ end
109
+ end
110
+
111
+ def warn_about_threads
112
+ others = Thread.list.size - 1
113
+ @diagnostics.warn("#{others} other thread(s) alive at fork time; they do not run in children") if
114
+ others.positive?
115
+ end
116
+
117
+ def monotonic
118
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
+ end
120
+ end
121
+ end
122
+
123
+ require_relative "engine/diagnostics"
124
+ require_relative "engine/boot"
125
+ require_relative "engine/subject_selection"
126
+ require_relative "engine/suite_files"
127
+ require_relative "engine/coverage_store"
128
+ require_relative "engine/candidates"
129
+ require_relative "engine/diff_scope"
130
+ require_relative "engine/result_cache"
131
+ require_relative "engine/verdict_check"
132
+ require_relative "engine/scheduler"
133
+ require_relative "engine/distribution"
@@ -0,0 +1,177 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "fiddle"
5
+
6
+ module Saboteur
7
+ module Execution
8
+ # A snapshot of every process on the machine: { pid => Entry }.
9
+ #
10
+ # The supervisor reads it at least twice per mutant, so it must be cheap.
11
+ # Spawning `ps` costs about 30 ms; reading the kernel directly costs a
12
+ # few. Each backend gives the same facts:
13
+ #
14
+ # ppid, pgid to find descendants, even ones that left the group
15
+ # rss_kb to cap memory (compare only values from one backend)
16
+ # zombie exited but not yet reaped: nothing left to kill
17
+ # started start time, so a recycled pid is never mistaken for one
18
+ # we saw earlier
19
+ module ProcessTable
20
+ Entry = Data.define(:pid, :ppid, :pgid, :rss_kb, :zombie, :started)
21
+
22
+ def self.backends
23
+ [Darwin, Linux, Ps].select(&:available?)
24
+ end
25
+
26
+ def self.backend
27
+ @backend ||= backends.first
28
+ end
29
+
30
+ # Containment must never go blind: if the fast backend fails, ask ps.
31
+ def self.snapshot
32
+ backend.snapshot
33
+ rescue SystemCallError, Fiddle::DLError
34
+ raise if backend == Ps
35
+
36
+ Ps.snapshot
37
+ end
38
+
39
+ # macOS: libproc, through Fiddle.
40
+ module Darwin
41
+ LISTALL_LIMIT = 16_384
42
+ BSDINFO = 3
43
+ TASKINFO = 4
44
+ ZOMBIE = 5 # SZOMB in pbi_status
45
+ # Byte offsets in struct proc_bsdinfo / proc_taskinfo (measured on
46
+ # macOS 14, arm64; the layout is the same on x86_64).
47
+ STATUS = 4
48
+ PID = 12
49
+ PPID = 16
50
+ PGID = 100
51
+ START_SECONDS = 120
52
+ START_MICROS = 128
53
+ RESIDENT = 8
54
+
55
+ module_function
56
+
57
+ def available?
58
+ RUBY_PLATFORM.include?("darwin") && !functions.nil?
59
+ end
60
+
61
+ def snapshot
62
+ list_all, info = functions
63
+ pids = all_pids(list_all)
64
+ bsd = Fiddle::Pointer.malloc(256, Fiddle::RUBY_FREE)
65
+ task = Fiddle::Pointer.malloc(256, Fiddle::RUBY_FREE)
66
+ pids.to_h { |pid| [pid, entry_for(pid, info, bsd, task) || exited(pid)] }
67
+ end
68
+
69
+ # libproc gives no details for a zombie (or a process that just
70
+ # ended): it is listed but has nothing left to kill.
71
+ def exited(pid)
72
+ Entry.new(pid: pid, ppid: 0, pgid: 0, rss_kb: 0, zombie: true, started: nil)
73
+ end
74
+
75
+ # A full buffer may mean more pids than fit: grow it and ask again.
76
+ def all_pids(list_all, capacity = LISTALL_LIMIT)
77
+ loop do
78
+ buffer = Fiddle::Pointer.malloc(4 * capacity, Fiddle::RUBY_FREE)
79
+ count = list_all.call(buffer, 4 * capacity)
80
+ raise SystemCallError.new("proc_listallpids", Fiddle.last_error) if count.negative?
81
+ return buffer[0, 4 * count].unpack("l*").reject(&:zero?) if count < capacity
82
+
83
+ capacity *= 2
84
+ end
85
+ end
86
+
87
+ def entry_for(pid, info, bsd, task)
88
+ size = info.call(pid, BSDINFO, 0, bsd, 256)
89
+ return nil if size < START_MICROS + 8
90
+
91
+ raw = bsd[0, size]
92
+ resident = info.call(pid, TASKINFO, 0, task, 256) >= RESIDENT + 8 ? task[RESIDENT, 8].unpack1("Q") : 0
93
+ Entry.new(pid: pid, ppid: raw[PPID, 4].unpack1("L"), pgid: raw[PGID, 4].unpack1("L"),
94
+ rss_kb: resident / 1024, zombie: raw[STATUS, 4].unpack1("L") == ZOMBIE,
95
+ started: raw[START_SECONDS, 16].unpack("QQ"))
96
+ end
97
+
98
+ def functions
99
+ return @functions if defined?(@functions)
100
+
101
+ @functions = begin
102
+ require "fiddle"
103
+ library = Fiddle.dlopen("/usr/lib/libproc.dylib")
104
+ [Fiddle::Function.new(library["proc_listallpids"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
105
+ Fiddle::TYPE_INT),
106
+ Fiddle::Function.new(library["proc_pidinfo"],
107
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG,
108
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)]
109
+ rescue LoadError, Fiddle::DLError
110
+ nil
111
+ end
112
+ end
113
+ end
114
+
115
+ # Linux: /proc/<pid>/stat.
116
+ module Linux
117
+ PAGE_KB = 4
118
+
119
+ module_function
120
+
121
+ def available?
122
+ File.exist?("/proc/self/stat")
123
+ end
124
+
125
+ def snapshot
126
+ Dir.children("/proc").each_with_object({}) do |name, table|
127
+ next unless name.match?(/\A\d+\z/)
128
+
129
+ entry = entry_for(name.to_i)
130
+ table[entry.pid] = entry if entry
131
+ end
132
+ end
133
+
134
+ # Fields after the command name, which may itself hold spaces or ")".
135
+ def entry_for(pid)
136
+ stat = File.read("/proc/#{pid}/stat")
137
+ fields = stat[(stat.rindex(")") + 2)..].split
138
+ Entry.new(pid: pid, ppid: fields[1].to_i, pgid: fields[2].to_i, rss_kb: fields[21].to_i * page_kb,
139
+ zombie: fields[0] == "Z", started: fields[19].to_i)
140
+ rescue SystemCallError
141
+ nil
142
+ end
143
+
144
+ def page_kb
145
+ @page_kb ||= begin
146
+ require "etc"
147
+ Etc.sysconf(Etc::SC_PAGESIZE) / 1024
148
+ rescue StandardError
149
+ PAGE_KB
150
+ end
151
+ end
152
+ end
153
+
154
+ # Anywhere else: one `ps` call. Slow (it spawns a process) but portable.
155
+ module Ps
156
+ COLUMNS = "pid=,ppid=,pgid=,rss=,stat=,lstart="
157
+
158
+ module_function
159
+
160
+ def available?
161
+ true
162
+ end
163
+
164
+ def snapshot
165
+ out = IO.popen(["ps", "-A", "-o", COLUMNS], err: File::NULL, &:read)
166
+ out.each_line.with_object({}) do |line, table|
167
+ pid, ppid, pgid, rss, state, *started = line.split
168
+ next if started.empty?
169
+
170
+ table[pid.to_i] = Entry.new(pid: pid.to_i, ppid: ppid.to_i, pgid: pgid.to_i, rss_kb: rss.to_i,
171
+ zombie: state.start_with?("Z"), started: started.join(" "))
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "process_table"
5
+
6
+ module Saboteur
7
+ module Execution
8
+ # Every process descended from one forked child, tracked for as long as the
9
+ # child is supervised.
10
+ #
11
+ # A process group is not containment: any descendant can call setpgid or
12
+ # setsid (a nested saboteur run does exactly that) and leave the group.
13
+ # Parent links survive that, so the tree is rebuilt from the process table
14
+ # on every poll, and every pid ever seen in it is remembered, so a
15
+ # descendant whose parent exits (re-parented to init) is still known.
16
+ #
17
+ # Identity is pid plus start time, so a recycled pid is never signalled.
18
+ class ProcessTree
19
+ attr_reader :root
20
+
21
+ def initialize(root)
22
+ @root = root
23
+ @known = {} # pid => start time
24
+ end
25
+
26
+ # The live members: not zombies, same start time as when first seen.
27
+ # Also starts tracking any new descendant (by parent link or by the
28
+ # root's process group), so call it often while the tree runs.
29
+ def live_members(table = ProcessTable.snapshot)
30
+ grow(table)
31
+ @known.filter_map do |pid, started|
32
+ entry = table[pid]
33
+ entry if entry && entry.started == started && !entry.zombie
34
+ end
35
+ end
36
+
37
+ # Count and resident memory of the live members. rss_baseline_kb is the
38
+ # supervisor's own RSS: forked children share its pages, so only growth
39
+ # beyond it is counted per process.
40
+ def usage(rss_baseline_kb, table = ProcessTable.snapshot)
41
+ live = live_members(table)
42
+ growth = live.sum { |entry| [entry.rss_kb - rss_baseline_kb, 0].max }
43
+ [live.size, growth]
44
+ end
45
+
46
+ # Stops every member (a stopped process cannot fork), rescans until no new
47
+ # member appears, then kills them all. Returns the pids it killed.
48
+ def kill_all(rounds: 20)
49
+ killed = []
50
+ rounds.times do
51
+ live = stop_until_stable
52
+ break if live.empty?
53
+
54
+ live.each { |entry| signal("KILL", entry.pid) }
55
+ killed.concat(live.map(&:pid))
56
+ sleep 0.01
57
+ end
58
+ killed.uniq
59
+ end
60
+
61
+ private
62
+
63
+ def grow(table)
64
+ @known[root] = table[root].started if @known.empty? && table[root]
65
+ loop do
66
+ added = table.values.select do |entry|
67
+ next false if @known.key?(entry.pid) || entry.pid == Process.pid
68
+
69
+ @known.key?(entry.ppid) || entry.pgid == root
70
+ end
71
+ break if added.empty?
72
+
73
+ added.each { |entry| @known[entry.pid] = entry.started }
74
+ end
75
+ end
76
+
77
+ def stop_until_stable
78
+ stopped = {}
79
+ 5.times do
80
+ live = live_members
81
+ fresh = live.reject { |entry| stopped.key?(entry.pid) }
82
+ return live if fresh.empty?
83
+
84
+ fresh.each do |entry|
85
+ signal("STOP", entry.pid)
86
+ stopped[entry.pid] = true
87
+ end
88
+ end
89
+ live_members
90
+ end
91
+
92
+ def signal(name, pid)
93
+ Process.kill(name, pid)
94
+ rescue Errno::ESRCH, Errno::EPERM
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module Saboteur
7
+ module Execution
8
+ # What a mutant child tells its supervisor, one JSON object per line on
9
+ # a pipe. Every message carries the protocol version.
10
+ #
11
+ # started the mutated method is in place; tests are about to run
12
+ # result passed, failed (ids), executed (ids), runtime, exited (status
13
+ # when a test called exit)
14
+ # error message, backtrace: the harness failed, not the tests
15
+ #
16
+ # A child that dies after "started" without "result" crashed while its
17
+ # tests ran; one that dies before "started" crashed while loading.
18
+ module Protocol
19
+ VERSION = 1
20
+
21
+ module_function
22
+
23
+ def emit(io, event, **fields)
24
+ io.write("#{JSON.generate(v: VERSION, event: event, **fields)}\n")
25
+ io.flush
26
+ rescue IOError, SystemCallError
27
+ nil
28
+ end
29
+
30
+ # Events in order; lines that are not this protocol's are ignored.
31
+ def parse(payload)
32
+ payload.each_line.filter_map do |line|
33
+ message = JSON.parse(line)
34
+ message if message.is_a?(Hash) && message["v"] == VERSION
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end