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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -1
- data/README.md +1 -1
- data/lib/henitai/cli.rb +1 -1
- data/lib/henitai/configuration.rb +9 -2
- data/lib/henitai/dirty_source_detector.rb +53 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
- data/lib/henitai/equivalence_detector.rb +6 -23
- data/lib/henitai/excluded_test_filter.rb +47 -0
- data/lib/henitai/execution_engine.rb +5 -11
- data/lib/henitai/inherited_fd_registry.rb +66 -0
- data/lib/henitai/integration/base.rb +7 -2
- data/lib/henitai/integration/child_bootstrap.rb +27 -0
- data/lib/henitai/integration/child_debug_log.rb +135 -0
- data/lib/henitai/integration/child_runtime_control.rb +6 -18
- data/lib/henitai/integration/loaded_features.rb +38 -0
- data/lib/henitai/integration/mutant_run_support.rb +5 -5
- data/lib/henitai/integration/rspec_child_runner.rb +16 -15
- data/lib/henitai/integration/rspec_process_runner.rb +7 -2
- data/lib/henitai/integration.rb +10 -7
- data/lib/henitai/mutation_skip_directives.rb +7 -1
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/orphan_watchdog.rb +93 -0
- data/lib/henitai/process_liveness.rb +41 -0
- data/lib/henitai/reports_directory_lock.rb +12 -11
- data/lib/henitai/result.rb +30 -3
- data/lib/henitai/runner.rb +41 -123
- data/lib/henitai/runner_dependencies.rb +75 -0
- data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
- data/lib/henitai/slot_scheduler/draining.rb +7 -17
- data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
- data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
- data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
- data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
- data/lib/henitai/slot_scheduler.rb +68 -80
- data/lib/henitai/source_file_selection.rb +76 -0
- data/lib/henitai/subject_selection.rb +33 -0
- data/lib/henitai/survivor_rerun_strategy.rb +7 -19
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +92 -38
- metadata +31 -9
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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 =
|
|
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
|
-
|
|
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? &&
|
|
73
|
+
pending.empty? && slot_table.empty?
|
|
64
74
|
end
|
|
65
75
|
|
|
66
76
|
def fill_idle_slots
|
|
67
|
-
while
|
|
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 =
|
|
87
|
-
|
|
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, :
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
mutant
|
|
107
|
-
return record_no_coverage(mutant) if
|
|
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
|
-
|
|
120
|
-
slot
|
|
121
|
-
|
|
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 =
|
|
158
|
+
slot_id = slot_table.release_pid(pid)
|
|
151
159
|
return unless slot_id
|
|
152
160
|
|
|
153
|
-
slot =
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
174
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|