henitai 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/README.md +15 -3
  4. data/assets/schema/henitai.schema.json +6 -0
  5. data/lib/henitai/cli/clean_command.rb +48 -0
  6. data/lib/henitai/cli/command_support.rb +51 -0
  7. data/lib/henitai/cli/init_command.rb +64 -0
  8. data/lib/henitai/cli/operator_command.rb +95 -0
  9. data/lib/henitai/cli/options.rb +120 -0
  10. data/lib/henitai/cli/run_command.rb +103 -0
  11. data/lib/henitai/cli.rb +16 -404
  12. data/lib/henitai/configuration.rb +2 -1
  13. data/lib/henitai/configuration_validator/rules.rb +143 -0
  14. data/lib/henitai/configuration_validator/scalars.rb +123 -0
  15. data/lib/henitai/configuration_validator.rb +12 -239
  16. data/lib/henitai/eager_load.rb +36 -5
  17. data/lib/henitai/execution_engine.rb +4 -3
  18. data/lib/henitai/integration/base.rb +171 -0
  19. data/lib/henitai/integration/child_debug_support.rb +115 -0
  20. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  21. data/lib/henitai/integration/coverage_suppression.rb +43 -0
  22. data/lib/henitai/integration/minitest.rb +133 -0
  23. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  24. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  25. data/lib/henitai/integration/rspec_test_selection.rb +135 -0
  26. data/lib/henitai/integration/scenario_log_support.rb +116 -0
  27. data/lib/henitai/integration.rb +22 -846
  28. data/lib/henitai/mutant/activator.rb +1 -79
  29. data/lib/henitai/mutant/parameter_source.rb +98 -0
  30. data/lib/henitai/mutant.rb +1 -0
  31. data/lib/henitai/mutant_history_store/sql.rb +72 -0
  32. data/lib/henitai/mutant_history_store.rb +5 -69
  33. data/lib/henitai/per_test_coverage_collector.rb +3 -1
  34. data/lib/henitai/process_worker_runner.rb +48 -334
  35. data/lib/henitai/reporter.rb +20 -8
  36. data/lib/henitai/result.rb +17 -15
  37. data/lib/henitai/runner.rb +59 -182
  38. data/lib/henitai/slot_scheduler/draining.rb +140 -0
  39. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  40. data/lib/henitai/slot_scheduler.rb +214 -0
  41. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  42. data/lib/henitai/unparse_helper.rb +5 -2
  43. data/lib/henitai/version.rb +1 -1
  44. data/lib/henitai.rb +2 -0
  45. data/sig/configuration_validator.rbs +46 -22
  46. data/sig/henitai.rbs +158 -73
  47. metadata +25 -2
@@ -25,7 +25,6 @@ module Henitai
25
25
  # Gate 5 — Reporting
26
26
  # Write results to configured reporters (terminal, html, json, dashboard).
27
27
  #
28
- # rubocop:disable Metrics/ClassLength
29
28
  class Runner
30
29
  attr_reader :config, :result
31
30
 
@@ -51,7 +50,7 @@ module Henitai
51
50
  def run
52
51
  started_at = Time.now
53
52
 
54
- mutants = if survivor_rerun? && (fast_mutants = try_recipe_run)
53
+ mutants = if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
55
54
  execute_mutants(fast_mutants)
56
55
  else
57
56
  source_files = self.source_files
@@ -87,7 +86,9 @@ module Henitai
87
86
  mutants = generate_mutants(subjects)
88
87
  bootstrap_thread.value
89
88
  filtered = filter_mutants(mutants)
90
- apply_survivor_selection(filtered)
89
+ return filtered unless survivor_rerun?
90
+
91
+ survivor_strategy.apply_selection(filtered)
91
92
  end
92
93
 
93
94
  def bootstrap_mutants(source_files)
@@ -104,7 +105,7 @@ module Henitai
104
105
  end
105
106
 
106
107
  def report(result)
107
- Reporter.run_all(names: config.reporters, result:, config:)
108
+ Reporter.run_all(names: config.reporters, result:, config:, history_store:)
108
109
  end
109
110
 
110
111
  def persist_history(result, recorded_at)
@@ -122,14 +123,29 @@ module Henitai
122
123
  finished_at:,
123
124
  thresholds: result_thresholds,
124
125
  partial_rerun: survivor_rerun?,
125
- survivor_stats: @survivor_stats,
126
- git_sha: safe_head_sha
126
+ survivor_stats: survivor_strategy.survivor_stats,
127
+ git_sha: safe_head_sha,
128
+ source_provider: source_provider
127
129
  )
128
130
  persist_history(@result, finished_at)
129
131
  report(@result)
130
132
  @result
131
133
  end
132
134
 
135
+ # Reads each source file once and caches it, so Result consumes source
136
+ # content while performing no disk IO of its own. Returns "" for files that
137
+ # cannot be read (e.g. recipe stubs with synthetic locations).
138
+ def source_provider
139
+ cache = {} # : Hash[String, String]
140
+ lambda do |file|
141
+ cache[file] ||= begin
142
+ File.read(file)
143
+ rescue StandardError
144
+ ""
145
+ end
146
+ end
147
+ end
148
+
133
149
  def safe_head_sha
134
150
  git_diff_analyzer.head_sha
135
151
  rescue StandardError
@@ -187,22 +203,38 @@ module Henitai
187
203
  end
188
204
 
189
205
  def source_files
190
- @source_files ||= begin
191
- included_files = Array(config.includes).flat_map do |include_path|
192
- Dir.glob(File.join(include_path, "**", "*.rb"))
193
- end.uniq
194
-
195
- if @since
196
- changed_files = git_diff_analyzer.changed_files(from: @since, to: "HEAD")
197
- changed_file_set = changed_files.map { |path| normalize_path(path) }
198
-
199
- included_files.select do |path|
200
- changed_file_set.include?(normalize_path(path))
201
- end
202
- else
203
- included_files
204
- end
205
- end
206
+ @source_files ||= filter_changed(reject_excluded(included_source_files))
207
+ end
208
+
209
+ def included_source_files
210
+ Array(config.includes).flat_map do |include_path|
211
+ Dir.glob(File.join(include_path, "**", "*.rb"))
212
+ end.uniq
213
+ end
214
+
215
+ # Drops files matched by any `excludes:` glob (e.g. standalone entry points
216
+ # that cannot be mutation-tested in-process). Excludes apply regardless of
217
+ # the --since filter.
218
+ def reject_excluded(files)
219
+ excluded = excluded_source_files
220
+ return files if excluded.empty?
221
+
222
+ files.reject { |path| excluded.include?(normalize_path(path)) }
223
+ end
224
+
225
+ def excluded_source_files
226
+ Array(config.excludes)
227
+ .flat_map { |pattern| Dir.glob(pattern) }
228
+ .map { |path| normalize_path(path) }
229
+ end
230
+
231
+ def filter_changed(files)
232
+ return files unless @since
233
+
234
+ changed_file_set = git_diff_analyzer
235
+ .changed_files(from: @since, to: "HEAD")
236
+ .map { |path| normalize_path(path) }
237
+ files.select { |path| changed_file_set.include?(normalize_path(path)) }
206
238
  end
207
239
 
208
240
  def pattern_subjects
@@ -227,167 +259,12 @@ module Henitai
227
259
  !@survivors_from.nil?
228
260
  end
229
261
 
230
- def apply_survivor_selection(mutants)
231
- return mutants unless survivor_rerun?
232
-
233
- dirty_worktree_files = dirty_worktree_changed_files
234
- loaded = SurvivorLoader.new(@survivors_from, include_paths: Array(config.includes)).load
235
- selector = SurvivorSelector.new(survivor_ids: loaded.survivor_ids)
236
- selected = selector.select(mutants)
237
- finalize_survivor_split(
238
- selector,
239
- selected,
240
- test_filter(
241
- loaded,
242
- dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
243
- ).apply(selected)
262
+ def survivor_strategy
263
+ @survivor_strategy ||= SurvivorRerunStrategy.new(
264
+ survivors_from: @survivors_from,
265
+ config:,
266
+ git_diff_analyzer:
244
267
  )
245
268
  end
246
-
247
- # Attempts to run survivors directly from pre-computed activation recipes,
248
- # bypassing source parsing and mutant generation entirely.
249
- # Returns the mutant array on success, or nil if recipes are unavailable.
250
- def try_recipe_run
251
- dirty_worktree_files = dirty_worktree_changed_files
252
- loaded = load_survivor_report
253
- return nil unless recipe_fast_path_safe?(loaded, dirty_worktree_files)
254
-
255
- run_from_recipes(loaded, dirty_worktree_files)
256
- end
257
-
258
- def load_survivor_report
259
- SurvivorLoader.new(@survivors_from, include_paths: Array(config.includes)).load
260
- end
261
-
262
- def run_from_recipes(loaded, dirty_worktree_files)
263
- recipes = load_activation_recipes(loaded.survivor_ids)
264
- return nil if recipes.nil?
265
-
266
- selector, stubs = recipe_selector_and_stubs(loaded.survivor_ids, recipes)
267
- split = test_filter(
268
- loaded,
269
- dirty_source_files: dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
270
- ).apply(stubs)
271
- finalize_survivor_split(selector, stubs, split)
272
- end
273
-
274
- def recipe_fast_path_safe?(loaded, dirty_worktree_files)
275
- !dirty_source_files?(dirty_worktree_files, git_sha: loaded.git_sha)
276
- end
277
-
278
- # Builds stub Mutants from recipes and a SurvivorSelector primed with the
279
- # survivor ID set. The selector is given a synthetic #select call so that
280
- # #drift_warning? / #unmatched_ids are available (all IDs will be matched).
281
- def recipe_selector_and_stubs(survivor_ids, recipes)
282
- stubs = survivor_ids.map { |id| build_stub_mutant(id, recipes[id]) }
283
- selector = SurvivorSelector.new(survivor_ids:)
284
- selector.select(stubs)
285
- [selector, stubs]
286
- end
287
-
288
- # Returns the recipe hash if the file exists and covers every survivor ID;
289
- # otherwise returns nil to trigger the normal generation path.
290
- def load_activation_recipes(survivor_ids)
291
- path = File.join(File.dirname(@survivors_from), SurvivorActivationCache::FILENAME)
292
- recipes = SurvivorActivationCache.load(path)
293
- return nil if recipes.nil?
294
- return nil unless survivor_ids.all? { |id| recipes.key?(id) }
295
-
296
- recipes
297
- end
298
-
299
- def build_stub_mutant(stable_id, recipe)
300
- mutant = Mutant.new(
301
- subject: stub_subject_from_recipe(recipe),
302
- operator: recipe.fetch("operator"),
303
- nodes: { original: nil, mutated: nil },
304
- description: recipe.fetch("description"),
305
- location: recipe_location(recipe["location"]),
306
- precomputed_stable_id: stable_id,
307
- precomputed_activation_source: recipe.fetch("activationSource")
308
- )
309
- mutant.covered_by = recipe["coveredBy"]
310
- mutant
311
- end
312
-
313
- def stub_subject_from_recipe(recipe)
314
- Subject.new(
315
- namespace: recipe["namespace"],
316
- method_name: recipe["methodName"],
317
- method_type: (recipe["methodType"] || "instance").to_sym,
318
- source_location: { file: recipe["sourceFile"], range: nil }
319
- )
320
- end
321
-
322
- def recipe_location(loc)
323
- return {} unless loc.is_a?(Hash)
324
-
325
- {
326
- file: loc["file"],
327
- start_line: loc["startLine"],
328
- end_line: loc["endLine"],
329
- start_col: loc["startCol"],
330
- end_col: loc["endCol"]
331
- }.compact
332
- end
333
-
334
- def finalize_survivor_split(selector, selected, split)
335
- split[:stable].each { |m| m.status = :survived }
336
- warn_survivor_drift(selector) if selector.drift_warning?
337
- @survivor_stats = build_survivor_stats(selector, selected, split)
338
- split[:stable] + split[:pending]
339
- end
340
-
341
- def test_filter(loaded, dirty_source_files: false)
342
- SurvivorTestFilter.new(
343
- coverage_map: loaded.coverage_map,
344
- git_sha: loaded.git_sha,
345
- dirty_source_files:,
346
- worktree_changed_files: Array(dirty_worktree_changed_files),
347
- diff_analyzer: git_diff_analyzer
348
- )
349
- end
350
-
351
- def dirty_worktree_changed_files
352
- @dirty_worktree_changed_files ||= git_diff_analyzer.working_tree_changed_files
353
- rescue StandardError
354
- nil
355
- end
356
-
357
- def dirty_source_files?(dirty_worktree_files, git_sha: nil)
358
- return true if dirty_worktree_files.nil?
359
-
360
- all_changed = dirty_worktree_files + committed_changed_files(git_sha)
361
- include_roots = Array(config.includes).map { |path| normalize_path(path) }
362
- all_changed.any? { |path| in_include_root?(normalize_path(path), include_roots) }
363
- rescue StandardError
364
- true
365
- end
366
-
367
- def committed_changed_files(git_sha)
368
- return [] unless git_sha
369
-
370
- git_diff_analyzer.changed_files(from: git_sha, to: "HEAD")
371
- end
372
-
373
- def in_include_root?(path, include_roots)
374
- include_roots.any? { |root| path == root || path.start_with?("#{root}/") }
375
- end
376
-
377
- def warn_survivor_drift(selector)
378
- warn "henitai: WARNING: #{selector.unmatched_ids.size} prior survivors " \
379
- "could not be matched; the source may have drifted - consider a full run"
380
- end
381
-
382
- def build_survivor_stats(selector, selected, split)
383
- {
384
- matched: selected.size,
385
- unmatched_count: selector.unmatched_ids.size,
386
- unmatched_ids: selector.unmatched_ids,
387
- skipped_count: split[:stable].size,
388
- drift_warning: selector.drift_warning?
389
- }
390
- end
391
269
  end
392
- # rubocop:enable Metrics/ClassLength
393
270
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Drain/timeout state machine for in-flight slots.
6
+ #
7
+ # A slot enters the draining state either when it exceeds its timeout
8
+ # ({#check_timeouts}) or when a shutdown is requested
9
+ # ({#interrupt_active_slots}). {#drain_draining_slots} then performs the
10
+ # two-phase SIGTERM/SIGKILL broadcast and the final blocking reap.
11
+ #
12
+ # Mixed into {SlotScheduler}; relies on its slot table, +integration+,
13
+ # +progress_reporter+, +wakeup+ and the {ProcessControl} primitives.
14
+ module Draining
15
+ # Per-slot timeout check. Must be called after reap_all_completed_children
16
+ # so that naturally-exited processes are already removed from slots.
17
+ def check_timeouts
18
+ now = monotonic_time
19
+ slots.each_value do |slot|
20
+ next if slot.draining
21
+ next unless now >= slot.started_at_monotonic + slot.timeout
22
+
23
+ # Final targeted reap: if the child already exited, classify it normally.
24
+ pid, status = wnohang_reap(slot.pid)
25
+ if pid
26
+ complete_slot(pid, status)
27
+ else
28
+ slot.forced_outcome = :timeout
29
+ slot.draining = true
30
+ end
31
+ end
32
+ end
33
+
34
+ def draining_slots?
35
+ slots.any? { |_, slot| slot.draining }
36
+ end
37
+
38
+ # Two-phase broadcast cleanup for all slots that are in draining state.
39
+ #
40
+ # Precision rule: before signalling, do one final WNOHANG pass to catch
41
+ # processes that exited naturally in the window between check_timeouts and
42
+ # now. If SIGTERM gets ESRCH, the process is already gone — we must not
43
+ # force-label those as :timeout.
44
+ def drain_draining_slots
45
+ draining = draining_slots
46
+ return if draining.empty?
47
+
48
+ prune_raced_draining_slots(draining)
49
+
50
+ return if draining.empty?
51
+
52
+ broadcast_term(draining)
53
+ wait_for_drain_window
54
+ signal_draining_slots(draining)
55
+ reap_and_remove_draining(draining)
56
+ end
57
+
58
+ def interrupt_active_slots
59
+ slots.each_value do |slot|
60
+ next if slot.draining
61
+
62
+ slot.forced_outcome = :interrupted
63
+ slot.draining = true
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def draining_slots
70
+ slots.select { |_, slot| slot.draining }
71
+ end
72
+
73
+ def prune_raced_draining_slots(draining)
74
+ draining.reject! do |_, slot|
75
+ pid, status = wnohang_reap(slot.pid)
76
+ next false unless pid
77
+
78
+ complete_slot(pid, status)
79
+ true
80
+ end
81
+ end
82
+
83
+ def wait_for_drain_window
84
+ wakeup&.wait(PROCESS_DRAIN_WINDOW)
85
+ wakeup&.drain
86
+ end
87
+
88
+ def signal_draining_slots(draining)
89
+ draining.each_value { |slot| signal_process_group(slot.pid, :SIGKILL) }
90
+ end
91
+
92
+ def broadcast_term(draining)
93
+ now = monotonic_time
94
+ draining.each_value do |slot|
95
+ slot.term_sent_at_monotonic = now
96
+ signal_process_group(slot.pid, :SIGTERM)
97
+ end
98
+ end
99
+
100
+ # After SIGKILL window: blocking reap each slot, then build its result.
101
+ #
102
+ # Interrupted slots are cleaned up but produce no result — the scheduler
103
+ # is shutting down and does not emit verdicts for in-flight mutants.
104
+ #
105
+ # For timeout slots: a real exit status only wins if observed before any
106
+ # parent signal was sent. Once SIGTERM has been dispatched, the forced
107
+ # outcome is authoritative — a child handling SIGTERM and exiting 0 must
108
+ # not be misclassified as :survived.
109
+ def reap_and_remove_draining(draining) # rubocop:disable Metrics/AbcSize
110
+ draining.each_value do |slot|
111
+ # One last WNOHANG before blocking: catches processes that exited
112
+ # between SIGKILL and here.
113
+ _, final_status = wnohang_reap(slot.pid)
114
+ reap_pid(slot.pid) unless final_status
115
+
116
+ pid_to_slot.delete(slot.pid)
117
+ slots.delete(slot.slot_id)
118
+ Integration::SchedulerDiagnostics.child_ended(slot.pid)
119
+
120
+ next if slot.forced_outcome == :interrupted
121
+
122
+ result = build_drain_result(slot, final_status)
123
+ slot.mutant.status = result.status
124
+ results << result
125
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
126
+ end
127
+ end
128
+
129
+ # Choose result: use real exit status only if observed before any parent
130
+ # signal was sent. After SIGTERM, the forced outcome is authoritative.
131
+ def build_drain_result(slot, final_status)
132
+ if final_status&.exited? && slot.term_sent_at_monotonic.nil?
133
+ integration.build_result(final_status, slot.log_paths)
134
+ else
135
+ integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Low-level bridge to the OS process and signal primitives.
6
+ #
7
+ # Every Process.wait*/kill call routes through +runtime+ so that the
8
+ # scheduler remains the single caller of the process table. Mixed into
9
+ # {SlotScheduler}; relies on its +runtime+ reader.
10
+ module ProcessControl
11
+ private
12
+
13
+ def monotonic_time
14
+ runtime.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ end
16
+
17
+ def wnohang_reap(pid)
18
+ runtime.wait2(pid, Process::WNOHANG)
19
+ rescue Errno::ECHILD, Errno::ESRCH
20
+ nil
21
+ end
22
+
23
+ def signal_process_group(pid, signal)
24
+ runtime.kill(signal, -pid)
25
+ rescue Errno::ESRCH
26
+ nil
27
+ rescue Errno::EPERM
28
+ # Process group not yet established; fall back to signalling the pid.
29
+ begin
30
+ runtime.kill(signal, pid)
31
+ rescue Errno::ESRCH
32
+ nil
33
+ end
34
+ end
35
+
36
+ def reap_pid(pid)
37
+ runtime.wait(pid)
38
+ rescue Errno::ECHILD, Errno::ESRCH
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slot_scheduler/process_control"
4
+ require_relative "slot_scheduler/draining"
5
+
6
+ module Henitai
7
+ # Owns the process-slot table for a single parallel mutation run.
8
+ #
9
+ # {ProcessWorkerRunner} drives the event loop and OS signal handling and
10
+ # delegates every slot operation here: filling idle slots, reaping completed
11
+ # children, retrying flaky survivors, detecting timeouts and running the
12
+ # drain/broadcast state machine. Keeping the table behind one collaborator
13
+ # means Process.wait* has a single caller, so there are no races between
14
+ # threads reaping the same child.
15
+ #
16
+ # The drain/timeout state machine lives in {Draining}; the low-level process
17
+ # and signal primitives live in {ProcessControl}. +host+ is the owning
18
+ # {ProcessWorkerRunner}, which supplies +runtime+, +wakeup+, +worker_count+
19
+ # and the shutdown flag.
20
+ class SlotScheduler
21
+ include ProcessControl
22
+ include Draining
23
+
24
+ PROCESS_DRAIN_WINDOW = 0.2
25
+
26
+ # Tracks one in-flight mutant child process.
27
+ Slot = Struct.new(
28
+ :slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
29
+ :log_paths, :retry_count, :draining, :term_sent_at_monotonic,
30
+ :forced_outcome
31
+ )
32
+
33
+ # @return [Integer] mutants that required at least one retry during the run.
34
+ # @return [Array<ScenarioExecutionResult>] verdicts accumulated so far.
35
+ attr_reader :flaky_retry_count, :results
36
+
37
+ def initialize(integration:, config:, progress_reporter:, options:, host:)
38
+ @integration = integration
39
+ @config = config
40
+ @progress_reporter = progress_reporter
41
+ @options = options
42
+ @host = host
43
+ @pending = []
44
+ @slots = {}
45
+ @pid_to_slot = {}
46
+ @results = []
47
+ @flaky_retry_count = 0
48
+ @next_slot_id = 0
49
+ end
50
+
51
+ # Queues the mutants to be scheduled into worker slots.
52
+ def enqueue(mutants)
53
+ @pending = mutants.dup
54
+ end
55
+
56
+ def done?
57
+ pending.empty? && slots.empty?
58
+ end
59
+
60
+ def fill_idle_slots
61
+ while slots.size < worker_count && !pending.empty?
62
+ mutant = pending.shift
63
+ spawn_into_slot(mutant)
64
+ end
65
+ end
66
+
67
+ def reap_all_completed_children
68
+ loop do
69
+ pid, status = runtime.wait2(-1, Process::WNOHANG)
70
+ break unless pid
71
+
72
+ complete_slot(pid, status)
73
+ end
74
+ rescue Errno::ECHILD
75
+ nil
76
+ end
77
+
78
+ def next_event_timeout
79
+ now = monotonic_time
80
+ slot_timeouts = slots.each_value.filter_map do |slot|
81
+ remaining_slot_timeout(slot, now)
82
+ end
83
+
84
+ slot_timeouts.min
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :pending, :slots, :pid_to_slot, :integration, :config,
90
+ :progress_reporter, :options, :host
91
+
92
+ def worker_count = host.worker_count
93
+ def runtime = host.runtime
94
+ def wakeup = host.wakeup
95
+ def shutdown? = host.shutdown_requested?
96
+
97
+ def spawn_into_slot(mutant)
98
+ test_files = resolve_test_files(mutant)
99
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
100
+ mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
101
+ handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
102
+ register_slot(handle, mutant)
103
+ rescue StandardError => e
104
+ record_spawn_failure(mutant, e)
105
+ end
106
+
107
+ def register_slot(handle, mutant)
108
+ slot_id = next_slot_id!
109
+ slot = build_slot(slot_id, mutant, handle)
110
+ slots[slot_id] = slot
111
+ pid_to_slot[handle.pid] = slot_id
112
+ Integration::SchedulerDiagnostics.child_started(handle.pid)
113
+ end
114
+
115
+ def build_slot(slot_id, mutant, handle)
116
+ Slot.new(
117
+ slot_id, mutant, handle.pid,
118
+ monotonic_time,
119
+ config.timeout, handle.log_paths, 0, false, nil, nil
120
+ )
121
+ end
122
+
123
+ def complete_slot(pid, wait_result)
124
+ slot_id = pid_to_slot.delete(pid)
125
+ return unless slot_id
126
+
127
+ slot = slots[slot_id]
128
+ return unless slot
129
+
130
+ Integration::SchedulerDiagnostics.child_ended(pid)
131
+ result = integration.build_result(wait_result, slot.log_paths)
132
+ dispatch_slot_result(slot, result)
133
+ end
134
+
135
+ def dispatch_slot_result(slot, result)
136
+ if should_retry?(slot, result)
137
+ retry_slot(slot)
138
+ else
139
+ slots.delete(slot.slot_id)
140
+ slot.mutant.status = result.status
141
+ results << result
142
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
143
+ end
144
+ end
145
+
146
+ def should_retry?(slot, result)
147
+ !shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
148
+ end
149
+
150
+ def retry_slot(slot) # rubocop:disable Metrics/AbcSize
151
+ @flaky_retry_count += 1 if slot.retry_count.zero?
152
+ slot.retry_count += 1
153
+ test_files = resolve_test_files(slot.mutant)
154
+ handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
155
+ slot.pid = handle.pid
156
+ slot.log_paths = handle.log_paths
157
+ slot.started_at_monotonic = monotonic_time
158
+ slot.draining = false
159
+ slot.term_sent_at_monotonic = nil
160
+ slot.forced_outcome = nil
161
+ pid_to_slot[handle.pid] = slot.slot_id
162
+ Integration::SchedulerDiagnostics.child_started(handle.pid)
163
+ rescue StandardError => e
164
+ slots.delete(slot.slot_id)
165
+ record_spawn_failure(slot.mutant, e)
166
+ end
167
+
168
+ def record_spawn_failure(mutant, error)
169
+ result = ScenarioExecutionResult.new(
170
+ status: :compile_error,
171
+ stdout: "",
172
+ stderr: "spawn failed: #{error.message}",
173
+ log_path: "/dev/null",
174
+ exit_status: nil
175
+ )
176
+ mutant.status = result.status
177
+ results << result
178
+ progress_reporter&.progress(mutant, scenario_result: result)
179
+ end
180
+
181
+ def remaining_slot_timeout(slot, now)
182
+ # Invariant: drain_draining_slots runs (and removes draining slots) before
183
+ # the event wait, so next_event_timeout never observes a draining slot
184
+ # whose SIGTERM has not been sent. Guard term_sent_at_monotonic defensively
185
+ # against a future ordering change: an unsignalled draining slot is due now.
186
+ return 0.0 if slot.draining && slot.term_sent_at_monotonic.nil?
187
+
188
+ deadline =
189
+ if slot.draining
190
+ slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
191
+ else
192
+ slot.started_at_monotonic + slot.timeout
193
+ end
194
+ remaining = deadline - now
195
+ remaining.positive? ? remaining : 0.0
196
+ end
197
+
198
+ def resolve_test_files(mutant)
199
+ if @options.key?(:test_file_resolver)
200
+ @options[:test_file_resolver].call(mutant)
201
+ elsif @options.key?(:test_files)
202
+ @options[:test_files]
203
+ else
204
+ integration.select_tests(mutant.subject)
205
+ end
206
+ end
207
+
208
+ def next_slot_id!
209
+ id = @next_slot_id
210
+ @next_slot_id += 1
211
+ id
212
+ end
213
+ end
214
+ end