saboteur 0.7.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +657 -0
- data/README.md +198 -1077
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +6 -2
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +53 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +71 -54
- data/lib/saboteur/config.rb +98 -5
- data/lib/saboteur/config_file.rb +8 -0
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +9 -0
- data/lib/saboteur/engine/baseline_guard.rb +62 -14
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +19 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +32 -10
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +35 -0
- data/lib/saboteur/engine/load_coverage.rb +45 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +107 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +27 -0
- data/lib/saboteur/engine/sandbox_policy.rb +38 -0
- data/lib/saboteur/engine/scheduler.rb +109 -3
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +295 -20
- data/lib/saboteur/engine/test_guess.rb +315 -25
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +100 -127
- data/lib/saboteur/execution/external_runner.rb +471 -0
- data/lib/saboteur/execution/marker_sweep.rb +79 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +12 -0
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +15 -2
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +27 -10
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +135 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +26 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +120 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +40 -7
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +105 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +13 -0
- metadata +35 -2
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "find"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
module Saboteur
|
|
10
|
+
module Execution
|
|
11
|
+
# Runs mutants through a user-supplied shell command (#140): each
|
|
12
|
+
# execution gets a FRESH disposable copy of the project tree, so every
|
|
13
|
+
# mutant and every neutral check starts from a pristine snapshot and a
|
|
14
|
+
# destructive command cannot poison later runs. The mutant is written
|
|
15
|
+
# into the copy - never the real file; the runner's own writes never
|
|
16
|
+
# follow symlinks and never leave the copy - and the command runs there
|
|
17
|
+
# with the caller's permissions: unsandboxed, with network and side
|
|
18
|
+
# effects exactly as if typed into a shell, including writing anywhere
|
|
19
|
+
# it chooses by absolute path. Its exit status decides the verdict: 0
|
|
20
|
+
# survived, non-zero killed (except the shell's 126/127 and signals,
|
|
21
|
+
# which read as command/setup errors), deadline timeout. It is the mode
|
|
22
|
+
# for target apps that cannot load inside this Ruby (apps on older
|
|
23
|
+
# Rubies); it is explicitly slower, now also one tree copy per
|
|
24
|
+
# execution.
|
|
25
|
+
#
|
|
26
|
+
# Safety shape, stated narrowly: the pristine reference is the real
|
|
27
|
+
# tree itself, read per subject as subject.file_source; the copy is
|
|
28
|
+
# not a canary and proves nothing by itself. Because only the copy is
|
|
29
|
+
# mutated, a failed write or a command that keeps writing in the
|
|
30
|
+
# background hits the copy, not the checkout; the command can still
|
|
31
|
+
# write anywhere else by absolute path, so only trusted commands
|
|
32
|
+
# belong here. The command's whole process group is TERM/KILLed and
|
|
33
|
+
# reaped in an ensure on every exit path (verdict, timeout, error,
|
|
34
|
+
# interrupt); a SIGKILL of this process can leave the group alive.
|
|
35
|
+
# Output capture is bounded as it is written (a ring buffer), so a
|
|
36
|
+
# chatty command cannot fill memory or disk. A neutral preflight runs
|
|
37
|
+
# the command once on an untouched copy before any mutant exists, so a
|
|
38
|
+
# typo command fails immediately with its own output.
|
|
39
|
+
class ExternalRunner
|
|
40
|
+
# The command is one test in the report's eyes: a kill names it as
|
|
41
|
+
# the killer, a run lists it as executed.
|
|
42
|
+
PSEUDO_TEST = "(test command)"
|
|
43
|
+
|
|
44
|
+
# Shell "cannot execute" / "command not found" read as setup errors,
|
|
45
|
+
# never as kills: the suite did not run.
|
|
46
|
+
SETUP_EXIT_CODES = [126, 127].freeze
|
|
47
|
+
|
|
48
|
+
# The child inherits this marker and Boot.refuse_recursion! refuses
|
|
49
|
+
# any saboteur run inside it, so a test command that itself invokes
|
|
50
|
+
# saboteur cannot mutate the copy mid-run.
|
|
51
|
+
CHILD_MARKER = "SABOTEUR_EXTERNAL_CHILD"
|
|
52
|
+
|
|
53
|
+
# Bundler env anchors a child command must never inherit (#435).
|
|
54
|
+
BUNDLER_ANCHOR_KEYS = %w[
|
|
55
|
+
BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_SETUP BUNDLER_VERSION BUNDLE_APP_CONFIG
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# with_unbundled_env deletes the whole BUNDLE_* prefix, which also
|
|
59
|
+
# drops settings the SHELL exported - CI commonly exports
|
|
60
|
+
# BUNDLE_PATH (plus resolution knobs) without writing .bundle/config,
|
|
61
|
+
# and the child's bundler then cannot find the app's gems (#449).
|
|
62
|
+
# Re-add these only when they came from the pre-bundler environment
|
|
63
|
+
# (Bundler.original_env), never from bundler's own activation, and
|
|
64
|
+
# never the anchor keys above.
|
|
65
|
+
BUNDLER_PASSTHROUGH_KEYS = %w[
|
|
66
|
+
BUNDLE_PATH BUNDLE_WITHOUT BUNDLE_WITH BUNDLE_FROZEN BUNDLE_DEPLOYMENT
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
# Bounded capture: only the last bytes of combined output are kept,
|
|
70
|
+
# for preflight and error messages.
|
|
71
|
+
OUTPUT_LIMIT = 64 * 1024
|
|
72
|
+
|
|
73
|
+
# Grace for the capture drain after the command exits: a descendant
|
|
74
|
+
# holding the pipe past this no longer blocks the run.
|
|
75
|
+
DRAIN_GRACE = 0.5
|
|
76
|
+
|
|
77
|
+
def initialize(command:, root:)
|
|
78
|
+
@command = command
|
|
79
|
+
# realpath, not expand_path: the tree must be judged by where it
|
|
80
|
+
# really is, so a subject path can be checked against the same
|
|
81
|
+
# canonical root without a symlink making it look outside (#305 QA).
|
|
82
|
+
@root = File.realpath(root)
|
|
83
|
+
@copy_root = nil
|
|
84
|
+
at_exit { close }
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
raise Error, "the test command root does not exist: #{root}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Runs the command once on an untouched copy before any mutant
|
|
90
|
+
# exists. A failing command can never claim kills, so fail the whole
|
|
91
|
+
# run here with the command's own output (#140, #305).
|
|
92
|
+
# deadline is absolute (CLOCK_MONOTONIC): the copy is made first and
|
|
93
|
+
# the command is admitted only with the time then remaining, so the
|
|
94
|
+
# copy can never eat the budget while the command still gets its full
|
|
95
|
+
# slice (#305 Architect). An already-spent deadline is a budget stop,
|
|
96
|
+
# not a command failure - and so is a wait cut short with the overall
|
|
97
|
+
# run budget also spent: the run is out of time, the command is not
|
|
98
|
+
# at fault (#305 QA).
|
|
99
|
+
# The budget only owns the outcome when it is the LIMITING deadline
|
|
100
|
+
# (budget_deadline <= deadline): an own-slice timeout stays the
|
|
101
|
+
# command's error even when the run budget is spent too, and the
|
|
102
|
+
# cause is fixed at the moment of admission or timeout, before
|
|
103
|
+
# TERM/KILL and the drain can cross the budget and relabel it (#305
|
|
104
|
+
# Principal).
|
|
105
|
+
def preflight!(deadline:, budget_deadline: nil)
|
|
106
|
+
budget_limited = !budget_deadline.nil? && budget_deadline <= deadline
|
|
107
|
+
result = with_fresh_copy do
|
|
108
|
+
remaining = deadline - monotonic
|
|
109
|
+
if remaining <= 0
|
|
110
|
+
raise BudgetPhaseTimeout, "the --max-duration budget stopped the external preflight" if budget_limited
|
|
111
|
+
|
|
112
|
+
raise Error, "the test command's preflight deadline was spent before the command could start"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
spawn_and_wait(remaining, timeout_cause: budget_limited ? :budget : :own, budget_deadline: budget_deadline)
|
|
116
|
+
end
|
|
117
|
+
if result == :budget_timeout
|
|
118
|
+
raise BudgetPhaseTimeout, "the --max-duration budget stopped the external preflight"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
case result
|
|
122
|
+
when 0 then true
|
|
123
|
+
when Exception
|
|
124
|
+
raise Error, "the test command could not be started: #{result.message}"
|
|
125
|
+
else
|
|
126
|
+
raise Error, "the test command fails on the unchanged tree " \
|
|
127
|
+
"(#{describe_exit(result)}):\n#{last_output}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Mutation-visibility check (#441): the command must FAIL with the
|
|
132
|
+
# subject file replaced by a load-time error. A pass means the
|
|
133
|
+
# command never sees the copy's file - a RUBYLIB/-I path or an
|
|
134
|
+
# installed gem anchors it to the real tree, or the suite never
|
|
135
|
+
# loads the file - and every mutant on it "survives" unseen, so the
|
|
136
|
+
# score would read 0.0 % for nothing. Warn loudly and let the run
|
|
137
|
+
# continue: the file may legitimately be uncovered. The first
|
|
138
|
+
# runnable subject stands for the whole scope: anchoring breaks
|
|
139
|
+
# every file alike. Returns the warning, or nil when the command
|
|
140
|
+
# saw the broken file. Budget and deadline faults still raise.
|
|
141
|
+
def visibility_check(subject:, deadline:, budget_deadline: nil)
|
|
142
|
+
budget_limited = !budget_deadline.nil? && budget_deadline <= deadline
|
|
143
|
+
result = with_fresh_copy do
|
|
144
|
+
remaining = deadline - monotonic
|
|
145
|
+
if remaining <= 0
|
|
146
|
+
if budget_limited
|
|
147
|
+
raise BudgetPhaseTimeout,
|
|
148
|
+
"the --max-duration budget stopped the mutation visibility check"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
raise Error, "the mutation visibility check's deadline was spent before the command could start"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
write_subject(subject, visibility_mutation(subject))
|
|
155
|
+
spawn_and_wait(remaining, timeout_cause: budget_limited ? :budget : :own,
|
|
156
|
+
budget_deadline: budget_deadline)
|
|
157
|
+
end
|
|
158
|
+
if result == :budget_timeout
|
|
159
|
+
raise BudgetPhaseTimeout,
|
|
160
|
+
"the --max-duration budget stopped the mutation visibility check"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
visibility_warning(subject, result)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Same contract as Runner#run. text, test_ids and eval_prefix belong
|
|
167
|
+
# to the in-process machinery and are ignored here. The neutral run
|
|
168
|
+
# (mutation nil) writes the unchanged bytes explicitly: every
|
|
169
|
+
# execution proves the write path against pristine content (#305).
|
|
170
|
+
def run(subject:, text:, test_ids:, timeout:, mutation: nil, eval_prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
171
|
+
started = monotonic
|
|
172
|
+
message = nil
|
|
173
|
+
status = begin
|
|
174
|
+
with_fresh_copy do
|
|
175
|
+
write_subject(subject, mutation)
|
|
176
|
+
verdict_for(spawn_and_wait(timeout))
|
|
177
|
+
end
|
|
178
|
+
# A copy or write failure is this execution's outcome, never a crash
|
|
179
|
+
# of the run: report it with bounded context like any command error
|
|
180
|
+
# (#305 QA).
|
|
181
|
+
rescue Error, SystemCallError => e
|
|
182
|
+
message = bounded(e.message)
|
|
183
|
+
:error
|
|
184
|
+
end
|
|
185
|
+
Runner::Outcome.new(status: status, runtime: monotonic - started,
|
|
186
|
+
failed_ids: status == :killed ? [PSEUDO_TEST] : [],
|
|
187
|
+
executed_ids: status == :error ? [] : [PSEUDO_TEST],
|
|
188
|
+
message: message || (status == :error ? error_context : nil))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Drops the current disposable copy. Public so the engine can release
|
|
192
|
+
# the tree as soon as the run ends; idempotent, and the at_exit
|
|
193
|
+
# backstop calls it again.
|
|
194
|
+
def close
|
|
195
|
+
FileUtils.rm_rf(@copy_root) if @copy_root
|
|
196
|
+
@copy_root = nil
|
|
197
|
+
rescue SystemCallError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
# A pristine copy of the tree for one execution (#305 QA/DevRel): the
|
|
204
|
+
# previous execution's copy - mutated file, command side effects and
|
|
205
|
+
# all - is dropped before the new one is made.
|
|
206
|
+
def with_fresh_copy
|
|
207
|
+
close
|
|
208
|
+
# realpath the copy root too: macOS tmp dirs are /var symlinked to
|
|
209
|
+
# /private/var, and the containment check compares canonical link
|
|
210
|
+
# targets against this root - both sides must be canonical (#305).
|
|
211
|
+
@copy_root = File.realpath(Dir.mktmpdir("saboteur-external-"))
|
|
212
|
+
FileUtils.cp_r(File.join(@root, "."), @copy_root, preserve: true)
|
|
213
|
+
rebuild_symlinks
|
|
214
|
+
yield
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# cp_r keeps symlinks, and a write through a link that escapes the
|
|
218
|
+
# copy would land in the real tree. Links resolving inside the tree
|
|
219
|
+
# are REBUILT as relative links inside the copy: alias identity is
|
|
220
|
+
# preserved, so requiring the alias sees the same (possibly mutated)
|
|
221
|
+
# content as the real path, and the link can never point out (#305
|
|
222
|
+
# QA). Chains collapse to the final target, which is equivalent.
|
|
223
|
+
# Anything escaping the tree or unresolvable refuses the run.
|
|
224
|
+
def rebuild_symlinks
|
|
225
|
+
Find.find(@copy_root) do |path|
|
|
226
|
+
next unless File.symlink?(path)
|
|
227
|
+
|
|
228
|
+
target = File.realpath(path)
|
|
229
|
+
unless target.start_with?("#{@copy_root}/")
|
|
230
|
+
raise Error, "the tree links outside itself: #{path.sub("#{@copy_root}/", '')} -> #{target}; " \
|
|
231
|
+
"external test commands need a self-contained tree"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
relative = Pathname.new(target).relative_path_from(Pathname.new(File.dirname(path))).to_s
|
|
235
|
+
File.unlink(path)
|
|
236
|
+
File.symlink(relative, path)
|
|
237
|
+
rescue SystemCallError
|
|
238
|
+
raise Error, "unresolvable symlink in the tree: #{path.sub("#{@copy_root}/", '')}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The mutation's edits apply at absolute byte offsets in the file;
|
|
243
|
+
# the neutral run writes the original bytes back. The reference text
|
|
244
|
+
# is the real tree's, never the copy's (#305 DevRel canary).
|
|
245
|
+
def write_subject(subject, mutation)
|
|
246
|
+
path = File.join(@copy_root, relative(subject))
|
|
247
|
+
File.binwrite(path, mutation ? mutated_source(subject, mutation) : subject.file_source)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The subject must live under the canonical root; anything else -
|
|
251
|
+
# absolute or `..` traversal - is refused, never joined onto the copy
|
|
252
|
+
# (#305 QA).
|
|
253
|
+
def relative(subject)
|
|
254
|
+
# realpath like the root: macOS tmp is /var symlinked to /private/var,
|
|
255
|
+
# so a spelled subject path must canonicalize before the containment
|
|
256
|
+
# check or every run is refused as outside the root (#442).
|
|
257
|
+
expanded = File.realpath(subject.file)
|
|
258
|
+
prefix = "#{@root}/"
|
|
259
|
+
raise Error, "the subject file is outside the project root: #{subject.file}" unless expanded.start_with?(prefix)
|
|
260
|
+
|
|
261
|
+
expanded[prefix.length..]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def mutated_source(subject, mutation)
|
|
265
|
+
source = subject.file_source.dup.b
|
|
266
|
+
mutation.edits.sort_by(&:start_byte).reverse_each do |edit|
|
|
267
|
+
source = source.byteslice(0, edit.start_byte) + edit.replacement.b + source.byteslice(edit.end_byte..)
|
|
268
|
+
end
|
|
269
|
+
source.force_encoding(Encoding::UTF_8)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def verdict_for(result)
|
|
273
|
+
case result
|
|
274
|
+
when :timeout then :timeout
|
|
275
|
+
when Exception, :signal, *SETUP_EXIT_CODES then :error
|
|
276
|
+
when 0 then :survived
|
|
277
|
+
else :killed
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def error_context
|
|
282
|
+
"#{describe_exit(@last_result)}: #{last_output}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# timeout_cause fixes who owns a timeout at the moment the wait ends,
|
|
286
|
+
# BEFORE the ensure's TERM/KILL grace and capture drain run: that
|
|
287
|
+
# cleanup can cross the run budget, and a budget crossed there must
|
|
288
|
+
# not relabel the command's own timeout as a budget stop (#305
|
|
289
|
+
# Principal). :budget_timeout is decided here, never after.
|
|
290
|
+
def spawn_and_wait(timeout, timeout_cause: :own, budget_deadline: nil)
|
|
291
|
+
# Always through the shell: users write pipelines and redirects,
|
|
292
|
+
# and a bare unknown word must exit 127 instead of raising ENOENT.
|
|
293
|
+
start_capture
|
|
294
|
+
pid = spawn_shell
|
|
295
|
+
# The parent's copy of the write end closes right after the spawn:
|
|
296
|
+
# while it stays open the reader never sees EOF and the drain below
|
|
297
|
+
# would always wait out its grace (#305 Architect).
|
|
298
|
+
@capture_writer.close
|
|
299
|
+
@capture_writer = nil
|
|
300
|
+
@last_result = await_exit(pid, timeout)
|
|
301
|
+
if @last_result == :timeout && timeout_cause == :budget && budget_deadline && monotonic >= budget_deadline
|
|
302
|
+
@last_result = :budget_timeout
|
|
303
|
+
end
|
|
304
|
+
@last_result
|
|
305
|
+
rescue SystemCallError => e
|
|
306
|
+
@last_result = e
|
|
307
|
+
ensure
|
|
308
|
+
# The whole process group goes down on every exit path - verdict,
|
|
309
|
+
# timeout, error and interrupt alike - so background descendants
|
|
310
|
+
# cannot write after the verdict is in (#305).
|
|
311
|
+
reap_tree(pid) if pid
|
|
312
|
+
finish_capture
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def spawn_shell
|
|
316
|
+
Process.spawn(child_env, ["/bin/sh", "sh"], "-c", @command, chdir: @copy_root,
|
|
317
|
+
pgroup: true,
|
|
318
|
+
out: @capture_writer,
|
|
319
|
+
err: @capture_writer,
|
|
320
|
+
unsetenv_others: true)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# A `bundle exec saboteur` parent - or a shell with BUNDLE_GEMFILE
|
|
324
|
+
# exported - anchors the child command's bundler to the REAL tree:
|
|
325
|
+
# the child resolves the project gem from the unmutated checkout, the
|
|
326
|
+
# mutant is never exercised and every mutant survives (#435). The
|
|
327
|
+
# copy carries its own Gemfile, so the child's bundler must resolve
|
|
328
|
+
# from @copy_root instead: hand the child the unbundled environment
|
|
329
|
+
# and drop the anchor keys outright (they can also sit in Bundler's
|
|
330
|
+
# ORIGINAL_ENV and survive with_unbundled_env). RUBYOPT/RUBYLIB keep
|
|
331
|
+
# their non-bundler parts; everything else passes through unchanged.
|
|
332
|
+
def child_env
|
|
333
|
+
env = defined?(Bundler) ? Bundler.with_unbundled_env { ENV.to_h } : ENV.to_h
|
|
334
|
+
BUNDLER_ANCHOR_KEYS.each { |key| env.delete(key) }
|
|
335
|
+
if defined?(Bundler)
|
|
336
|
+
original = Bundler.original_env
|
|
337
|
+
BUNDLER_PASSTHROUGH_KEYS.each { |key| env[key] = original[key] if original.key?(key) }
|
|
338
|
+
end
|
|
339
|
+
if (rubyopt = env["RUBYOPT"])
|
|
340
|
+
rubyopt = rubyopt.gsub(%r{\s*-rbundler/setup}, "").strip
|
|
341
|
+
rubyopt.empty? ? env.delete("RUBYOPT") : env["RUBYOPT"] = rubyopt
|
|
342
|
+
end
|
|
343
|
+
env.merge(CHILD_MARKER => "1")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Only a failing exit proves the command saw the broken file; a
|
|
347
|
+
# pass is the anchoring or no-coverage case, and a timeout or setup
|
|
348
|
+
# failure leaves visibility unknown. Both warn and name the file.
|
|
349
|
+
def visibility_warning(subject, result)
|
|
350
|
+
return nil if result.is_a?(Integer) && !result.zero? && !SETUP_EXIT_CODES.include?(result)
|
|
351
|
+
|
|
352
|
+
detail = case result
|
|
353
|
+
when 0 then "the test command still passed"
|
|
354
|
+
when :timeout then "the test command timed out"
|
|
355
|
+
when :signal, *SETUP_EXIT_CODES then "the test command could not run (#{describe_exit(result)})"
|
|
356
|
+
when Exception then "the test command could not be started: #{result.message}"
|
|
357
|
+
end
|
|
358
|
+
"mutation visibility check failed for #{relative(subject)}: #{detail} with the file replaced by a " \
|
|
359
|
+
"load-time error, so mutants in it may all survive unseen and the score would read 0.0 %. The " \
|
|
360
|
+
"command must load the project's code from the mutated copy; check for anchors to the real tree " \
|
|
361
|
+
"(RUBYLIB, -I paths, an installed gem) or narrow the scope to files the suite loads."
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# The whole file becomes a load-time error: any load of it from the
|
|
365
|
+
# copy fails the suite, while a command anchored elsewhere is
|
|
366
|
+
# untouched. Valid syntax, so only an actual load trips it.
|
|
367
|
+
def visibility_mutation(subject)
|
|
368
|
+
source = subject.file_source
|
|
369
|
+
edit = Saboteur::MutationSource::Edit.new(file: subject.file, start_byte: 0, end_byte: source.bytesize,
|
|
370
|
+
replacement: 'raise "saboteur mutation visibility check: ' \
|
|
371
|
+
'this file must not load during the check"')
|
|
372
|
+
Saboteur::MutationSource::Mutation.new(subject_id: subject.id, operator: "visibility_check", edits: [edit],
|
|
373
|
+
coarse: false)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def await_exit(pid, timeout)
|
|
377
|
+
deadline = monotonic + timeout
|
|
378
|
+
loop do
|
|
379
|
+
done, status = Process.wait2(pid, Process::WNOHANG)
|
|
380
|
+
return status.exitstatus || :signal if done
|
|
381
|
+
return :timeout if monotonic >= deadline
|
|
382
|
+
|
|
383
|
+
sleep 0.01
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def reap_tree(pid)
|
|
388
|
+
return unless tree_alive?(pid)
|
|
389
|
+
|
|
390
|
+
Process.kill("TERM", -pid)
|
|
391
|
+
deadline = monotonic + 2
|
|
392
|
+
sleep 0.02 while monotonic < deadline && tree_alive?(pid)
|
|
393
|
+
Process.kill("KILL", -pid) if tree_alive?(pid)
|
|
394
|
+
Process.wait2(pid)
|
|
395
|
+
rescue SystemCallError
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def tree_alive?(pid)
|
|
400
|
+
Process.kill(0, -pid)
|
|
401
|
+
true
|
|
402
|
+
rescue SystemCallError
|
|
403
|
+
false
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# A ring buffer on a pipe: output is bounded as it is written, never
|
|
407
|
+
# spooled to disk (Principal, #305). The buffer lives outside the
|
|
408
|
+
# thread so a bounded drain still keeps what was captured.
|
|
409
|
+
def start_capture
|
|
410
|
+
@capture_reader, @capture_writer = IO.pipe
|
|
411
|
+
@capture_buffer = +""
|
|
412
|
+
@capture_thread = Thread.new do
|
|
413
|
+
loop do
|
|
414
|
+
@capture_buffer << @capture_reader.readpartial(16_384)
|
|
415
|
+
if @capture_buffer.bytesize > OUTPUT_LIMIT
|
|
416
|
+
@capture_buffer = @capture_buffer.byteslice(-OUTPUT_LIMIT, OUTPUT_LIMIT)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
rescue IOError # EOFError included: the command closed its output
|
|
420
|
+
nil
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# The command is done, but a descendant that escaped the process
|
|
425
|
+
# group (setsid/daemon) can hold the write end open forever, so the
|
|
426
|
+
# drain is bounded: the reader gets a short grace to see EOF, then it
|
|
427
|
+
# is killed and its captured bytes kept. Escaped sessions are not
|
|
428
|
+
# contained - they only cannot block the run or leak the fd (#305
|
|
429
|
+
# Architect). Both parent ends are always closed.
|
|
430
|
+
def finish_capture
|
|
431
|
+
@capture_writer&.close
|
|
432
|
+
if @capture_thread
|
|
433
|
+
@capture_thread.join(DRAIN_GRACE) || @capture_thread.kill
|
|
434
|
+
@capture_thread.join
|
|
435
|
+
end
|
|
436
|
+
@capture_reader&.close
|
|
437
|
+
@captured = @capture_buffer
|
|
438
|
+
@capture_reader = @capture_writer = @capture_thread = @capture_buffer = nil
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Captured bytes become report JSON: force UTF-8 and drop invalid
|
|
442
|
+
# sequences - the ring buffer can start mid-multibyte after a
|
|
443
|
+
# truncation (#305 Principal).
|
|
444
|
+
def last_output
|
|
445
|
+
bounded(@captured.to_s).strip.then { |text| text.empty? ? "(no output)" : text }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def bounded(text)
|
|
449
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub("")[0, OUTPUT_LIMIT]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def describe_exit(result)
|
|
453
|
+
if SETUP_EXIT_CODES.include?(result)
|
|
454
|
+
"exit #{result} - the shell could not run the command"
|
|
455
|
+
elsif result == :signal
|
|
456
|
+
"killed by a signal"
|
|
457
|
+
elsif result == :timeout
|
|
458
|
+
"timed out"
|
|
459
|
+
elsif result.is_a?(Exception)
|
|
460
|
+
"could not start: #{result.message}"
|
|
461
|
+
else
|
|
462
|
+
"exit #{result}"
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def monotonic
|
|
467
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fiddle"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Execution
|
|
8
|
+
# Finds the processes carrying a run's supervision marker (#365). A
|
|
9
|
+
# descendant that leaves the child's process group and loses its
|
|
10
|
+
# intermediate parent between two tree polls is invisible to
|
|
11
|
+
# ProcessTree; the marker survives both escapes, in one of two
|
|
12
|
+
# channels:
|
|
13
|
+
#
|
|
14
|
+
# comm - the child sets its /proc/<pid>/comm at start; fork copies it
|
|
15
|
+
# and setsid keeps it, so a fork-only descendant (Process.daemon)
|
|
16
|
+
# stays named. An exec or a self-rename (puma, sidekiq) drops it.
|
|
17
|
+
# environ - /proc/<pid>/environ is frozen at each exec, so an exec'd
|
|
18
|
+
# descendant shows the environment it was started with, marker
|
|
19
|
+
# included; a fork-only descendant shows the top process's exec
|
|
20
|
+
# environment instead, which never had the marker - that path is
|
|
21
|
+
# what the comm channel covers.
|
|
22
|
+
#
|
|
23
|
+
# Linux only. Other platforms keep the gap from #365 rather than ship
|
|
24
|
+
# an unverified parser.
|
|
25
|
+
module MarkerSweep
|
|
26
|
+
PR_SET_NAME = 15
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Best-effort: set this process's comm to the run's marker. comm - not
|
|
31
|
+
# $0, which project code can read - survives fork and setsid; an exec
|
|
32
|
+
# resets it, which the environment channel covers instead. Without a
|
|
33
|
+
# usable prctl (macOS) the environment marker still names exec'd
|
|
34
|
+
# descendants and the tree still tracks the rest.
|
|
35
|
+
def write_comm(marker)
|
|
36
|
+
prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
|
|
37
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP] + ([Fiddle::TYPE_LONG] * 3),
|
|
38
|
+
Fiddle::TYPE_INT)
|
|
39
|
+
prctl.call(PR_SET_NAME, "#{Supervisor::COMM_PREFIX}#{marker}", 0, 0, 0)
|
|
40
|
+
nil
|
|
41
|
+
rescue StandardError, Fiddle::DLError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def available?
|
|
46
|
+
File.exist?("/proc/self/environ")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Live candidate pids marked in either channel. A process that exits
|
|
50
|
+
# or turns unreadable mid-sweep is skipped; the caller's rounds see
|
|
51
|
+
# what remains. The environment value is a colon-joined list so a
|
|
52
|
+
# nested run keeps its ancestors' markers reachable.
|
|
53
|
+
def marked_pids(marker, env_var)
|
|
54
|
+
comm_name = "#{Supervisor::COMM_PREFIX}#{marker}"
|
|
55
|
+
env_prefix = "#{env_var}="
|
|
56
|
+
Dir.children("/proc").filter_map do |name|
|
|
57
|
+
next unless name.match?(/\A\d+\z/)
|
|
58
|
+
|
|
59
|
+
pid = name.to_i
|
|
60
|
+
next if pid == Process.pid
|
|
61
|
+
|
|
62
|
+
pid if comm_marked?(pid, comm_name) || environ_marked?(pid, env_prefix, marker)
|
|
63
|
+
rescue SystemCallError, IOError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def comm_marked?(pid, comm_name)
|
|
69
|
+
File.read(File.join("/proc", pid.to_s, "comm")).chomp == comm_name
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def environ_marked?(pid, env_prefix, marker)
|
|
73
|
+
entries = File.binread(File.join("/proc", pid.to_s, "environ")).split("\0")
|
|
74
|
+
value = entries.find { |entry| entry.start_with?(env_prefix) }&.[](env_prefix.length..)
|
|
75
|
+
value&.split(":")&.include?(marker) || false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Execution
|
|
6
|
+
# Peak resident memory of a process from its proc status (#202). VmHWM is
|
|
7
|
+
# the kernel's exact high-water mark, so no sampling interval can miss
|
|
8
|
+
# it. Nil wherever procfs is absent (macOS), so reports omit the section
|
|
9
|
+
# instead of printing a fake zero.
|
|
10
|
+
module PeakMemory
|
|
11
|
+
PROC_STATUS = "/proc/self/status"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# This process's peak RSS in MB, or nil when procfs is unavailable.
|
|
16
|
+
def peak_mb(path = PROC_STATUS)
|
|
17
|
+
rss_mb(File.read(path))
|
|
18
|
+
rescue SystemCallError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rss_mb(status)
|
|
23
|
+
kb = status[/^VmHWM:\s*(\d+) kB/, 1]
|
|
24
|
+
kb && (kb.to_i / 1024.0).round(1)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -17,6 +17,18 @@ module Saboteur
|
|
|
17
17
|
# started start time, so a recycled pid is never mistaken for one
|
|
18
18
|
# we saw earlier
|
|
19
19
|
module ProcessTable
|
|
20
|
+
# A pid's command line for messages, /proc first and ps elsewhere;
|
|
21
|
+
# nil when the process is already gone. Capped for one-line display.
|
|
22
|
+
def self.command(pid)
|
|
23
|
+
command = if File.readable?("/proc/#{pid}/cmdline")
|
|
24
|
+
File.binread("/proc/#{pid}/cmdline").tr("\0", " ").strip
|
|
25
|
+
else
|
|
26
|
+
`ps -o command= -p #{pid}`.strip
|
|
27
|
+
end
|
|
28
|
+
command.empty? ? nil : command.slice(0, 120)
|
|
29
|
+
rescue SystemCallError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
20
32
|
Entry = Data.define(:pid, :ppid, :pgid, :rss_kb, :zombie, :started)
|
|
21
33
|
|
|
22
34
|
def self.backends
|