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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +145 -0
- data/exe/saboteur +12 -0
- data/lib/saboteur/cli.rb +112 -0
- data/lib/saboteur/config.rb +133 -0
- data/lib/saboteur/engine/boot.rb +83 -0
- data/lib/saboteur/engine/candidates.rb +63 -0
- data/lib/saboteur/engine/coverage_store.rb +99 -0
- data/lib/saboteur/engine/diagnostics.rb +37 -0
- data/lib/saboteur/engine/diff_scope.rb +32 -0
- data/lib/saboteur/engine/distribution.rb +74 -0
- data/lib/saboteur/engine/result_cache.rb +93 -0
- data/lib/saboteur/engine/scheduler.rb +215 -0
- data/lib/saboteur/engine/subject_selection.rb +63 -0
- data/lib/saboteur/engine/suite_files.rb +82 -0
- data/lib/saboteur/engine/verdict_check.rb +78 -0
- data/lib/saboteur/engine.rb +133 -0
- data/lib/saboteur/execution/process_table.rb +177 -0
- data/lib/saboteur/execution/process_tree.rb +99 -0
- data/lib/saboteur/execution/protocol.rb +41 -0
- data/lib/saboteur/execution/reevaluator.rb +144 -0
- data/lib/saboteur/execution/runner.rb +191 -0
- data/lib/saboteur/execution/supervisor.rb +337 -0
- data/lib/saboteur/execution/worker_pool.rb +249 -0
- data/lib/saboteur/frameworks/rails.rb +166 -0
- data/lib/saboteur/hooks.rb +51 -0
- data/lib/saboteur/ignore_list.rb +62 -0
- data/lib/saboteur/integration/base.rb +72 -0
- data/lib/saboteur/integration/rspec.rb +143 -0
- data/lib/saboteur/mutation_source/base.rb +26 -0
- data/lib/saboteur/mutation_source/builtin.rb +232 -0
- data/lib/saboteur/mutation_source/edit.rb +18 -0
- data/lib/saboteur/mutation_source/mutation.rb +28 -0
- data/lib/saboteur/reporter/diff.rb +97 -0
- data/lib/saboteur/reporter/stryker.rb +116 -0
- data/lib/saboteur/reporter/terminal.rb +136 -0
- data/lib/saboteur/result.rb +134 -0
- data/lib/saboteur/selection/coverage_map.rb +113 -0
- data/lib/saboteur/selection/git_diff.rb +64 -0
- data/lib/saboteur/selection/recorder.rb +72 -0
- data/lib/saboteur/subject.rb +166 -0
- data/lib/saboteur/subject_finder.rb +98 -0
- data/lib/saboteur/version.rb +6 -0
- data/lib/saboteur.rb +51 -0
- 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
|