henitai 0.4.0 → 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +1 -1
  4. data/lib/henitai/cli.rb +1 -1
  5. data/lib/henitai/configuration.rb +9 -2
  6. data/lib/henitai/dirty_source_detector.rb +53 -0
  7. data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
  8. data/lib/henitai/equivalence_detector.rb +6 -23
  9. data/lib/henitai/excluded_test_filter.rb +47 -0
  10. data/lib/henitai/execution_engine.rb +5 -11
  11. data/lib/henitai/inherited_fd_registry.rb +66 -0
  12. data/lib/henitai/integration/base.rb +7 -2
  13. data/lib/henitai/integration/child_bootstrap.rb +27 -0
  14. data/lib/henitai/integration/child_debug_log.rb +135 -0
  15. data/lib/henitai/integration/child_runtime_control.rb +6 -18
  16. data/lib/henitai/integration/loaded_features.rb +38 -0
  17. data/lib/henitai/integration/mutant_run_support.rb +5 -5
  18. data/lib/henitai/integration/rspec_child_runner.rb +16 -15
  19. data/lib/henitai/integration/rspec_process_runner.rb +7 -2
  20. data/lib/henitai/integration.rb +10 -7
  21. data/lib/henitai/mutation_skip_directives.rb +7 -1
  22. data/lib/henitai/operators/return_value.rb +1 -1
  23. data/lib/henitai/orphan_watchdog.rb +93 -0
  24. data/lib/henitai/process_liveness.rb +41 -0
  25. data/lib/henitai/reports_directory_lock.rb +12 -11
  26. data/lib/henitai/result.rb +30 -3
  27. data/lib/henitai/runner.rb +41 -123
  28. data/lib/henitai/runner_dependencies.rb +75 -0
  29. data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
  30. data/lib/henitai/slot_scheduler/draining.rb +7 -17
  31. data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
  32. data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
  33. data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
  34. data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
  35. data/lib/henitai/slot_scheduler.rb +68 -80
  36. data/lib/henitai/source_file_selection.rb +76 -0
  37. data/lib/henitai/subject_selection.rb +33 -0
  38. data/lib/henitai/survivor_rerun_strategy.rb +7 -19
  39. data/lib/henitai/version.rb +1 -1
  40. data/lib/henitai.rb +8 -0
  41. data/sig/henitai.rbs +92 -38
  42. metadata +31 -9
  43. data/lib/henitai/integration/child_debug_support.rb +0 -119
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # The collaborators {Runner} drives, built on first use.
5
+ #
6
+ # Extracted so each one can be asserted on directly — several are chosen by
7
+ # configuration (the integration adapter, the operator set, which progress
8
+ # reporters are composed) and that choice had no test seam while it lived
9
+ # behind private readers on `Runner`.
10
+ #
11
+ # Memoization is not an optimisation here, it is a correctness requirement for
12
+ # {#per_test_coverage} and {#history_store}: the incremental filter proves
13
+ # survivor reuse against the same live coverage map the history store records
14
+ # its intersection set from. Two instances would be two snapshots.
15
+ class RunnerDependencies
16
+ def initialize(config:)
17
+ @config = config
18
+ end
19
+
20
+ def subject_resolver = @subject_resolver ||= SubjectResolver.new
21
+ def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
22
+ def mutant_generator = @mutant_generator ||= MutantGenerator.new
23
+ def static_filter = @static_filter ||= StaticFilter.new
24
+ def execution_engine = @execution_engine ||= ExecutionEngine.new
25
+ def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
26
+
27
+ def integration
28
+ @integration ||= Integration.for(@config.integration).new
29
+ end
30
+
31
+ def operators
32
+ @operators ||= Operator.for_set(@config.operators)
33
+ end
34
+
35
+ def per_test_coverage
36
+ @per_test_coverage ||= PerTestCoverage.new(reports_dir: @config.reports_dir)
37
+ end
38
+
39
+ def history_store
40
+ @history_store ||= MutantHistoryStore.new(
41
+ path: File.join(@config.reports_dir, Henitai::HISTORY_STORE_FILENAME),
42
+ per_test_coverage: per_test_coverage
43
+ )
44
+ end
45
+
46
+ # Fans progress out to the terminal reporter (when enabled) and the
47
+ # checkpoint writer (when enabled and a file report is configured), so a
48
+ # long run persists partial results incrementally.
49
+ #
50
+ # Deliberately *not* memoized: `full_run?` is a property of the invocation,
51
+ # not of the dependency set, and a cached reporter would silently keep the
52
+ # first answer.
53
+ def progress_reporter(full_run:)
54
+ CompositeProgressReporter.for(config: @config, source_provider: source_provider, full_run: full_run)
55
+ end
56
+
57
+ # Reads each source file once and caches it per path, so Result consumes
58
+ # source content while performing no disk IO of its own. Unreadable files
59
+ # (recipe stubs with synthetic locations, say) answer "" rather than
60
+ # aborting the run.
61
+ #
62
+ # Not memoized: each call gets its own cache, scoped to one reporter's
63
+ # lifetime rather than shared across the process.
64
+ def source_provider
65
+ cache = {} # : Hash[String, String]
66
+ lambda do |file|
67
+ cache[file] ||= begin
68
+ File.read(file)
69
+ rescue StandardError
70
+ ""
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Chooses the verdict for a slot that went through the drain path.
6
+ #
7
+ # The rule: a real exit status only wins if the process exited *before* any
8
+ # parent signal was sent. Once SIGTERM has been dispatched, the forced
9
+ # outcome is authoritative — a child that traps SIGTERM and exits 0 would
10
+ # otherwise be recorded as `:survived`, turning a timeout into a false
11
+ # survivor and inflating the survivor list.
12
+ #
13
+ # `:timeout` is the fallback when nothing forced the outcome, because the
14
+ # only way into this path without a forced outcome is a deadline breach.
15
+ class DrainVerdict
16
+ def initialize(integration:)
17
+ @integration = integration
18
+ end
19
+
20
+ def build(slot, final_status)
21
+ if final_status&.exited? && slot.term_sent_at_monotonic.nil?
22
+ @integration.build_result(final_status, slot.log_paths)
23
+ else
24
+ @integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -16,7 +16,7 @@ module Henitai
16
16
  # so that naturally-exited processes are already removed from slots.
17
17
  def check_timeouts
18
18
  now = monotonic_time
19
- slots.each_value do |slot|
19
+ slot_table.each_value do |slot|
20
20
  next if slot.draining
21
21
  next unless now >= slot.started_at_monotonic + slot.timeout
22
22
 
@@ -32,7 +32,7 @@ module Henitai
32
32
  end
33
33
 
34
34
  def draining_slots?
35
- slots.any? { |_, slot| slot.draining }
35
+ slot_table.any_draining?
36
36
  end
37
37
 
38
38
  # Two-phase broadcast cleanup for all slots that are in draining state.
@@ -56,7 +56,7 @@ module Henitai
56
56
  end
57
57
 
58
58
  def interrupt_active_slots
59
- slots.each_value do |slot|
59
+ slot_table.each_value do |slot|
60
60
  next if slot.draining
61
61
 
62
62
  slot.forced_outcome = :interrupted
@@ -67,7 +67,7 @@ module Henitai
67
67
  private
68
68
 
69
69
  def draining_slots
70
- slots.select { |_, slot| slot.draining }
70
+ slot_table.draining
71
71
  end
72
72
 
73
73
  def prune_raced_draining_slots(draining)
@@ -116,8 +116,8 @@ module Henitai
116
116
  _, final_status = wnohang_reap(slot.pid)
117
117
  reap_pid(slot.pid) unless final_status
118
118
 
119
- pid_to_slot.delete(slot.pid)
120
- slots.delete(slot.slot_id)
119
+ slot_table.release_pid(slot.pid)
120
+ slot_table.delete(slot.slot_id)
121
121
  Integration::SchedulerDiagnostics.child_ended(slot.pid)
122
122
 
123
123
  return if slot.forced_outcome == :interrupted
@@ -126,21 +126,11 @@ module Henitai
126
126
  end
127
127
 
128
128
  def record_drain_result(slot, final_status)
129
- result = build_drain_result(slot, final_status)
129
+ result = drain_verdict.build(slot, final_status)
130
130
  slot.mutant.status = result.status
131
131
  results << result
132
132
  progress_reporter&.progress(slot.mutant, scenario_result: result)
133
133
  end
134
-
135
- # Choose result: use real exit status only if observed before any parent
136
- # signal was sent. After SIGTERM, the forced outcome is authoritative.
137
- def build_drain_result(slot, final_status)
138
- if final_status&.exited? && slot.term_sent_at_monotonic.nil?
139
- integration.build_result(final_status, slot.log_paths)
140
- else
141
- integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
142
- end
143
- end
144
134
  end
145
135
  end
146
136
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Decides whether a finished slot earns another attempt.
6
+ #
7
+ # A survived verdict is the only retryable one: it is the verdict a flaky
8
+ # test can fake, whereas a kill cannot be faked by flakiness. A requested
9
+ # shutdown vetoes retries outright — respawning children while tearing the
10
+ # run down would leak processes past the drain window.
11
+ class RetryPolicy
12
+ def initialize(max_retries:)
13
+ @max_retries = max_retries.to_i
14
+ end
15
+
16
+ def retry?(slot:, result:, shutdown:)
17
+ !shutdown && result.survived? && slot.retry_count < @max_retries
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Seconds left before a slot is due, for the scheduler's event-wait budget.
6
+ #
7
+ # A live slot is due at `started_at + timeout`. A draining slot is due at
8
+ # `term_sent_at + drain_window` instead: once SIGTERM has gone out, the only
9
+ # remaining question is how long to wait before escalating to SIGKILL.
10
+ class SlotDeadline
11
+ def initialize(drain_window:)
12
+ @drain_window = drain_window
13
+ end
14
+
15
+ def remaining(slot, now)
16
+ # Invariant: drain_draining_slots runs (and removes draining slots)
17
+ # before the event wait, so this never observes a draining slot whose
18
+ # SIGTERM has not been sent. Guarded defensively against a future
19
+ # ordering change: an unsignalled draining slot is due now.
20
+ return 0.0 if slot.draining && slot.term_sent_at_monotonic.nil?
21
+
22
+ remaining = deadline_for(slot) - now
23
+ remaining.positive? ? remaining : 0.0
24
+ end
25
+
26
+ private
27
+
28
+ def deadline_for(slot)
29
+ if slot.draining
30
+ slot.term_sent_at_monotonic + @drain_window
31
+ else
32
+ slot.started_at_monotonic + slot.timeout
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # The slot table for one parallel run: live slots by id, the pid -> slot_id
6
+ # reverse index, and the slot-id sequence.
7
+ #
8
+ # Two indexes rather than one because the two lookups have different
9
+ # lifetimes. A slot survives a flaky retry and keeps its id, but its pid
10
+ # changes with every respawn, so the reverse index is rebuilt while the
11
+ # forward entry stays put.
12
+ class SlotTable
13
+ def initialize
14
+ @slots = {}
15
+ @pid_to_slot = {}
16
+ @next_slot_id = 0
17
+ end
18
+
19
+ def add(slot)
20
+ @slots[slot.slot_id] = slot
21
+ end
22
+
23
+ def delete(slot_id)
24
+ @slots.delete(slot_id)
25
+ end
26
+
27
+ def fetch(slot_id)
28
+ @slots[slot_id]
29
+ end
30
+
31
+ def empty? = @slots.empty?
32
+ def size = @slots.size
33
+ def each_value(&) = @slots.each_value(&)
34
+
35
+ def register_pid(pid, slot_id)
36
+ @pid_to_slot[pid] = slot_id
37
+ end
38
+
39
+ # Removes the mapping and answers the slot id it held, so a reap is a
40
+ # single operation: a pid can only be claimed once.
41
+ def release_pid(pid)
42
+ @pid_to_slot.delete(pid)
43
+ end
44
+
45
+ # Monotonic and never reused. Freed worker indices are recycled; slot ids
46
+ # are not, which is what makes them safe as hash keys across retries.
47
+ def next_slot_id!
48
+ id = @next_slot_id
49
+ @next_slot_id += 1
50
+ id
51
+ end
52
+
53
+ # Smallest index in 0...worker_count not held by a live slot, so
54
+ # concurrently-running children always see distinct values and freed
55
+ # indices are reused. Slot ids themselves grow monotonically and are
56
+ # unsuitable as a resource token.
57
+ #
58
+ # The `|| used.size` fallback covers a table already holding at least
59
+ # worker_count slots — reachable when a retry respawns into a table that
60
+ # a concurrent fill has since topped up.
61
+ def next_free_worker_index(worker_count)
62
+ used = @slots.each_value.map(&:worker_index)
63
+ (0...worker_count).find { |index| !used.include?(index) } || used.size
64
+ end
65
+
66
+ def draining
67
+ @slots.select { |_, slot| slot.draining }
68
+ end
69
+
70
+ def any_draining?
71
+ @slots.any? { |_, slot| slot.draining }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Resolves the test files a mutant should be run against.
6
+ #
7
+ # Three sources, in precedence order: a caller-supplied resolver lambda, a
8
+ # fixed list, or the integration's own per-subject selection. The first two
9
+ # exist so a survivor rerun or a per-test-coverage run can narrow the
10
+ # selection without the integration knowing about it.
11
+ #
12
+ # Precedence is by key *presence*, not truthiness: an explicit empty list
13
+ # means "no tests cover this", which the scheduler turns into
14
+ # `:no_coverage`. Falling through to the integration there would silently
15
+ # widen the selection back out.
16
+ class TestFileSelection
17
+ def initialize(options:, integration:)
18
+ @options = options
19
+ @integration = integration
20
+ end
21
+
22
+ def for(mutant)
23
+ if @options.key?(:test_file_resolver)
24
+ @options[:test_file_resolver].call(mutant)
25
+ elsif @options.key?(:test_files)
26
+ @options[:test_files]
27
+ else
28
+ @integration.select_tests(mutant.subject)
29
+ end
30
+ end
31
+
32
+ # True when the resolver was asked and came back empty. Only meaningful
33
+ # for a resolver-driven run: a fixed empty `test_files` list is a
34
+ # deliberate "run nothing", not an absence of coverage.
35
+ def resolved_empty?(test_files)
36
+ @options.key?(:test_file_resolver) && test_files.empty?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -2,6 +2,11 @@
2
2
 
3
3
  require_relative "slot_scheduler/process_control"
4
4
  require_relative "slot_scheduler/draining"
5
+ require_relative "slot_scheduler/drain_verdict"
6
+ require_relative "slot_scheduler/retry_policy"
7
+ require_relative "slot_scheduler/slot_deadline"
8
+ require_relative "slot_scheduler/slot_table"
9
+ require_relative "slot_scheduler/test_file_selection"
5
10
 
6
11
  module Henitai
7
12
  # Owns the process-slot table for a single parallel mutation run.
@@ -40,18 +45,23 @@ module Henitai
40
45
  # @return [Array<ScenarioExecutionResult>] verdicts accumulated so far.
41
46
  attr_reader :flaky_retry_count, :results
42
47
 
43
- def initialize(integration:, config:, progress_reporter:, options:, host:)
48
+ # +slot_table+ is injectable so a spec can seed mid-run state, or assert
49
+ # against the table through its own public interface, without reaching a
50
+ # private reader here.
51
+ # rubocop:disable Metrics/ParameterLists -- every one of these is a distinct
52
+ # collaborator supplied by ProcessWorkerRunner; bundling them into a context
53
+ # object would only move the list.
54
+ def initialize(integration:, config:, progress_reporter:, options:, host:, slot_table: SlotTable.new)
55
+ # rubocop:enable Metrics/ParameterLists
44
56
  @integration = integration
45
57
  @config = config
46
58
  @progress_reporter = progress_reporter
47
59
  @options = options
48
60
  @host = host
61
+ @slot_table = slot_table
49
62
  @pending = []
50
- @slots = {}
51
- @pid_to_slot = {}
52
63
  @results = []
53
64
  @flaky_retry_count = 0
54
- @next_slot_id = 0
55
65
  end
56
66
 
57
67
  # Queues the mutants to be scheduled into worker slots.
@@ -60,11 +70,11 @@ module Henitai
60
70
  end
61
71
 
62
72
  def done?
63
- pending.empty? && slots.empty?
73
+ pending.empty? && slot_table.empty?
64
74
  end
65
75
 
66
76
  def fill_idle_slots
67
- while slots.size < worker_count && !pending.empty?
77
+ while slot_table.size < worker_count && !pending.empty?
68
78
  mutant = pending.shift
69
79
  spawn_into_slot(mutant)
70
80
  end
@@ -83,8 +93,8 @@ module Henitai
83
93
 
84
94
  def next_event_timeout
85
95
  now = monotonic_time
86
- slot_timeouts = slots.each_value.filter_map do |slot|
87
- remaining_slot_timeout(slot, now)
96
+ slot_timeouts = slot_table.each_value.filter_map do |slot|
97
+ slot_deadline.remaining(slot, now)
88
98
  end
89
99
 
90
100
  slot_timeouts.min
@@ -92,7 +102,7 @@ module Henitai
92
102
 
93
103
  private
94
104
 
95
- attr_reader :pending, :slots, :pid_to_slot, :integration, :config,
105
+ attr_reader :pending, :slot_table, :integration, :config,
96
106
  :progress_reporter, :options, :host
97
107
 
98
108
  def worker_count = host.worker_count
@@ -101,12 +111,12 @@ module Henitai
101
111
  def shutdown? = host.shutdown_requested?
102
112
 
103
113
  def spawn_into_slot(mutant)
104
- test_files = resolve_test_files(mutant)
105
- mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
106
- mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
107
- return record_no_coverage(mutant) if resolved_selection_empty?(test_files)
114
+ selection = test_file_selection
115
+ test_files = selection.for(mutant)
116
+ annotate_selection(mutant, test_files)
117
+ return record_no_coverage(mutant) if selection.resolved_empty?(test_files)
108
118
 
109
- worker_index = next_free_worker_index
119
+ worker_index = slot_table.next_free_worker_index(worker_count)
110
120
  with_worker_slot(worker_index) do
111
121
  handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
112
122
  register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
@@ -115,11 +125,18 @@ module Henitai
115
125
  record_spawn_failure(mutant, e)
116
126
  end
117
127
 
128
+ # Reported back onto the mutant so the report can show which tests were
129
+ # considered, whether or not any of them ran. Guarded by respond_to? because
130
+ # specs pass Struct stand-ins without these members.
131
+ def annotate_selection(mutant, test_files)
132
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
133
+ mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
134
+ end
135
+
118
136
  def register_slot(handle, mutant, worker_index, timeout)
119
- slot_id = next_slot_id!
120
- slot = build_slot(slot_id, mutant, handle, worker_index, timeout)
121
- slots[slot_id] = slot
122
- pid_to_slot[handle.pid] = slot_id
137
+ slot = build_slot(slot_table.next_slot_id!, mutant, handle, worker_index, timeout)
138
+ slot_table.add(slot)
139
+ slot_table.register_pid(handle.pid, slot.slot_id)
123
140
  Integration::SchedulerDiagnostics.child_started(handle.pid)
124
141
  end
125
142
 
@@ -137,20 +154,11 @@ module Henitai
137
154
  resolver ? resolver.call(mutant, test_files) : config.timeout
138
155
  end
139
156
 
140
- # Smallest index in 0...worker_count not held by a live slot, so
141
- # concurrently-running children always see distinct values and freed
142
- # indices are reused. Slot ids themselves grow monotonically and are
143
- # unsuitable as a resource token.
144
- def next_free_worker_index
145
- used = slots.each_value.map(&:worker_index)
146
- (0...worker_count).find { |index| !used.include?(index) } || used.size
147
- end
148
-
149
157
  def complete_slot(pid, wait_result)
150
- slot_id = pid_to_slot.delete(pid)
158
+ slot_id = slot_table.release_pid(pid)
151
159
  return unless slot_id
152
160
 
153
- slot = slots[slot_id]
161
+ slot = slot_table.fetch(slot_id)
154
162
  return unless slot
155
163
 
156
164
  Integration::SchedulerDiagnostics.child_ended(pid)
@@ -159,29 +167,46 @@ module Henitai
159
167
  end
160
168
 
161
169
  def dispatch_slot_result(slot, result)
162
- if should_retry?(slot, result)
163
- retry_slot(slot)
164
- else
165
- slots.delete(slot.slot_id)
166
- slot.mutant.status = result.status
167
- results << result
168
- progress_reporter&.progress(slot.mutant, scenario_result: result)
169
- result.release_output! if result.respond_to?(:release_output!)
170
- end
170
+ return retry_slot(slot) if retry_policy.retry?(slot: slot, result: result, shutdown: shutdown?)
171
+
172
+ finalize_slot(slot, result)
173
+ end
174
+
175
+ def finalize_slot(slot, result)
176
+ slot_table.delete(slot.slot_id)
177
+ slot.mutant.status = result.status
178
+ results << result
179
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
180
+ result.release_output! if result.respond_to?(:release_output!)
181
+ end
182
+
183
+ # Memoized lazily, not built in #initialize: specs (and the no-op scheduler
184
+ # the runner builds when nothing is pending) pass `config: nil`, and this
185
+ # is only reached once a slot has actually finished.
186
+ def retry_policy
187
+ @retry_policy ||= RetryPolicy.new(max_retries: config.max_flaky_retries)
188
+ end
189
+
190
+ def drain_verdict
191
+ @drain_verdict ||= DrainVerdict.new(integration: integration)
192
+ end
193
+
194
+ def slot_deadline
195
+ @slot_deadline ||= SlotDeadline.new(drain_window: PROCESS_DRAIN_WINDOW)
171
196
  end
172
197
 
173
- def should_retry?(slot, result)
174
- !shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
198
+ def test_file_selection
199
+ @test_file_selection ||= TestFileSelection.new(options: options, integration: integration)
175
200
  end
176
201
 
177
202
  def retry_slot(slot)
178
- test_files = resolve_test_files(slot.mutant)
203
+ test_files = test_file_selection.for(slot.mutant)
179
204
  with_worker_slot(slot.worker_index) do
180
205
  handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
181
206
  finish_retry(slot, handle)
182
207
  end
183
208
  rescue StandardError => e
184
- slots.delete(slot.slot_id)
209
+ slot_table.delete(slot.slot_id)
185
210
  record_spawn_failure(slot.mutant, e)
186
211
  end
187
212
 
@@ -189,7 +214,7 @@ module Henitai
189
214
  @flaky_retry_count += 1 if slot.retry_count.zero?
190
215
  slot.retry_count += 1
191
216
  reset_slot_for_retry(slot, handle)
192
- pid_to_slot[handle.pid] = slot.slot_id
217
+ slot_table.register_pid(handle.pid, slot.slot_id)
193
218
  Integration::SchedulerDiagnostics.child_started(handle.pid)
194
219
  end
195
220
 
@@ -220,43 +245,6 @@ module Henitai
220
245
  progress_reporter&.progress(mutant, scenario_result: nil)
221
246
  end
222
247
 
223
- def resolved_selection_empty?(test_files)
224
- options.key?(:test_file_resolver) && test_files.empty?
225
- end
226
-
227
- def remaining_slot_timeout(slot, now)
228
- # Invariant: drain_draining_slots runs (and removes draining slots) before
229
- # the event wait, so next_event_timeout never observes a draining slot
230
- # whose SIGTERM has not been sent. Guard term_sent_at_monotonic defensively
231
- # against a future ordering change: an unsignalled draining slot is due now.
232
- return 0.0 if slot.draining && slot.term_sent_at_monotonic.nil?
233
-
234
- deadline =
235
- if slot.draining
236
- slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
237
- else
238
- slot.started_at_monotonic + slot.timeout
239
- end
240
- remaining = deadline - now
241
- remaining.positive? ? remaining : 0.0
242
- end
243
-
244
- def resolve_test_files(mutant)
245
- if @options.key?(:test_file_resolver)
246
- @options[:test_file_resolver].call(mutant)
247
- elsif @options.key?(:test_files)
248
- @options[:test_files]
249
- else
250
- integration.select_tests(mutant.subject)
251
- end
252
- end
253
-
254
- def next_slot_id!
255
- id = @next_slot_id
256
- @next_slot_id += 1
257
- id
258
- end
259
-
260
248
  def with_worker_slot(worker_index)
261
249
  previous = ENV.fetch(WORKER_SLOT_ENV, nil)
262
250
  ENV[WORKER_SLOT_ENV] = worker_index.to_s
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Resolves which source files a run mutates: everything under `includes:`,
5
+ # minus anything matched by `excludes:`, optionally narrowed to what changed
6
+ # since a git ref.
7
+ #
8
+ # Excludes are absolute, not a tiebreak: an excluded file (a standalone entry
9
+ # point that cannot be mutation-tested in-process, say) stays excluded even
10
+ # when it is the only thing that changed. Both stages are filters over the
11
+ # same list, so they commute — excludes run first only to keep the
12
+ # per-test-coverage lookups in `filter_changed` off files that are already
13
+ # out of scope.
14
+ class SourceFileSelection
15
+ def initialize(config:, since:, git_diff_analyzer:, per_test_coverage:)
16
+ @config = config
17
+ @since = since
18
+ @git_diff_analyzer = git_diff_analyzer
19
+ @per_test_coverage = per_test_coverage
20
+ end
21
+
22
+ def call
23
+ filter_changed(reject_excluded(included_source_files))
24
+ end
25
+
26
+ def included_source_files
27
+ Array(@config.includes).flat_map do |include_path|
28
+ Dir.glob(File.join(include_path, "**", "*.rb"))
29
+ end.uniq
30
+ end
31
+
32
+ # Drops files matched by any `excludes:` glob. Compared as expanded paths so
33
+ # a relative glob and an absolute candidate still match.
34
+ def reject_excluded(files)
35
+ excluded = excluded_source_files
36
+ return files if excluded.empty?
37
+
38
+ files.reject { |path| excluded.include?(normalize_path(path)) }
39
+ end
40
+
41
+ def filter_changed(files)
42
+ return files unless @since
43
+
44
+ changed = changed_paths_since.map { |path| normalize_path(path) }
45
+ changed += covered_sources_for_changed_tests(changed)
46
+ files.select { |path| changed.include?(normalize_path(path)) }
47
+ end
48
+
49
+ private
50
+
51
+ def excluded_source_files
52
+ Array(@config.excludes)
53
+ .flat_map { |pattern| Dir.glob(pattern) }
54
+ .map { |path| normalize_path(path) }
55
+ end
56
+
57
+ # Committed changes since the ref plus the current working tree (tracked
58
+ # dirty and untracked files): the working tree is what gets tested, so it is
59
+ # always part of "changed since REF".
60
+ def changed_paths_since
61
+ @git_diff_analyzer.changed_files(from: @since, to: "HEAD") +
62
+ @git_diff_analyzer.working_tree_changed_files
63
+ end
64
+
65
+ # Changed test files select the source files they cover, so an edited test
66
+ # re-tests the subjects it can kill. Uses the per-test map from the previous
67
+ # run — Gate 1 runs before this run's bootstrap finishes.
68
+ def covered_sources_for_changed_tests(changed_paths)
69
+ changed_paths
70
+ .flat_map { |path| @per_test_coverage.source_files_covered_by(path) }
71
+ .map { |path| normalize_path(path) }
72
+ end
73
+
74
+ def normalize_path(path) = File.expand_path(path)
75
+ end
76
+ end