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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +657 -0
  3. data/README.md +198 -1077
  4. data/lib/saboteur/cli/exit_status.rb +32 -1
  5. data/lib/saboteur/cli/help_options.rb +2 -1
  6. data/lib/saboteur/cli/history_command.rb +135 -0
  7. data/lib/saboteur/cli/presets.rb +6 -2
  8. data/lib/saboteur/cli/selection_options.rb +31 -0
  9. data/lib/saboteur/cli/shard_options.rb +17 -0
  10. data/lib/saboteur/cli/source_set.rb +53 -0
  11. data/lib/saboteur/cli/survivors_command.rb +157 -0
  12. data/lib/saboteur/cli.rb +71 -54
  13. data/lib/saboteur/config.rb +98 -5
  14. data/lib/saboteur/config_file.rb +8 -0
  15. data/lib/saboteur/constant_reads.rb +182 -0
  16. data/lib/saboteur/constant_subject.rb +70 -0
  17. data/lib/saboteur/constant_subject_finder.rb +281 -0
  18. data/lib/saboteur/coverage_reads.rb +29 -0
  19. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  20. data/lib/saboteur/doctor.rb +9 -0
  21. data/lib/saboteur/engine/baseline_guard.rb +62 -14
  22. data/lib/saboteur/engine/boot.rb +4 -0
  23. data/lib/saboteur/engine/candidate_selection.rb +19 -0
  24. data/lib/saboteur/engine/candidates.rb +43 -3
  25. data/lib/saboteur/engine/coverage_store.rb +32 -10
  26. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  27. data/lib/saboteur/engine/diagnostics.rb +18 -0
  28. data/lib/saboteur/engine/distribution.rb +10 -0
  29. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  30. data/lib/saboteur/engine/gated_run.rb +35 -0
  31. data/lib/saboteur/engine/load_coverage.rb +45 -0
  32. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  33. data/lib/saboteur/engine/pipelines.rb +107 -0
  34. data/lib/saboteur/engine/result_cache.rb +65 -8
  35. data/lib/saboteur/engine/run_budget.rb +27 -0
  36. data/lib/saboteur/engine/sandbox_policy.rb +38 -0
  37. data/lib/saboteur/engine/scheduler.rb +109 -3
  38. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  39. data/lib/saboteur/engine/shard.rb +69 -0
  40. data/lib/saboteur/engine/subject_selection.rb +295 -20
  41. data/lib/saboteur/engine/test_guess.rb +315 -25
  42. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  43. data/lib/saboteur/engine.rb +100 -127
  44. data/lib/saboteur/execution/external_runner.rb +471 -0
  45. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  46. data/lib/saboteur/execution/peak_memory.rb +28 -0
  47. data/lib/saboteur/execution/process_table.rb +12 -0
  48. data/lib/saboteur/execution/process_tree.rb +65 -5
  49. data/lib/saboteur/execution/reevaluator.rb +68 -12
  50. data/lib/saboteur/execution/runner.rb +46 -7
  51. data/lib/saboteur/execution/sandbox.rb +15 -2
  52. data/lib/saboteur/execution/seatbelt.rb +226 -0
  53. data/lib/saboteur/execution/supervisor.rb +27 -10
  54. data/lib/saboteur/execution/worker_pool.rb +23 -1
  55. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  56. data/lib/saboteur/gates.rb +43 -2
  57. data/lib/saboteur/help.rb +60 -9
  58. data/lib/saboteur/ignore_list.rb +29 -6
  59. data/lib/saboteur/integration/rspec.rb +135 -5
  60. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  61. data/lib/saboteur/mutation_source/builtin.rb +26 -5
  62. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  63. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  64. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  65. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  67. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  68. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  69. data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
  70. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  71. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  72. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  73. data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
  74. data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
  75. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  76. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  77. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  79. data/lib/saboteur/preview.rb +120 -12
  80. data/lib/saboteur/refinement_activations.rb +60 -0
  81. data/lib/saboteur/reporter/agent.rb +9 -1
  82. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  83. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  84. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  85. data/lib/saboteur/reporter/findings.rb +5 -2
  86. data/lib/saboteur/reporter/history_policy.rb +11 -0
  87. data/lib/saboteur/reporter/history_reader.rb +47 -0
  88. data/lib/saboteur/reporter/html.rb +26 -4
  89. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  90. data/lib/saboteur/reporter/stryker.rb +27 -3
  91. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  92. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  93. data/lib/saboteur/reporter/terminal.rb +40 -7
  94. data/lib/saboteur/result.rb +19 -3
  95. data/lib/saboteur/selection/coverage_map.rb +105 -10
  96. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  97. data/lib/saboteur/selection/external_test_map.rb +27 -0
  98. data/lib/saboteur/selection/git_diff.rb +93 -11
  99. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  100. data/lib/saboteur/selection/recorder.rb +22 -4
  101. data/lib/saboteur/selection/source_scope.rb +22 -0
  102. data/lib/saboteur/subject.rb +42 -7
  103. data/lib/saboteur/subject_finder.rb +173 -29
  104. data/lib/saboteur/version.rb +1 -1
  105. data/lib/saboteur/working_tree.rb +38 -15
  106. data/lib/saboteur.rb +13 -0
  107. 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