henitai 0.2.0 → 0.3.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 +105 -1
- data/README.md +138 -5
- data/assets/schema/henitai.schema.json +41 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +54 -0
- data/lib/henitai/cli/command_support.rb +52 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +96 -0
- data/lib/henitai/cli/options.rb +84 -0
- data/lib/henitai/cli/run_command.rb +136 -0
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +27 -407
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +42 -9
- data/lib/henitai/configuration_validator/rules.rb +161 -0
- data/lib/henitai/configuration_validator/scalars.rb +169 -0
- data/lib/henitai/configuration_validator.rb +20 -240
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +115 -53
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +119 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +52 -0
- data/lib/henitai/integration/minitest.rb +101 -0
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +138 -0
- data/lib/henitai/integration/scenario_log_support.rb +160 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +15 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -6
- data/lib/henitai/mutant_history_store/sql.rb +90 -0
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +67 -81
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +23 -6
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +258 -125
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +88 -23
- data/lib/henitai/runner.rb +137 -218
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +146 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +272 -0
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +16 -1
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +386 -96
- metadata +45 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -0,0 +1,146 @@
|
|
|
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)
|
|
110
|
+
draining.each_value { |slot| reap_and_finalize_slot(slot) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# One last WNOHANG before blocking: catches processes that exited
|
|
114
|
+
# between SIGKILL and here.
|
|
115
|
+
def reap_and_finalize_slot(slot)
|
|
116
|
+
_, final_status = wnohang_reap(slot.pid)
|
|
117
|
+
reap_pid(slot.pid) unless final_status
|
|
118
|
+
|
|
119
|
+
pid_to_slot.delete(slot.pid)
|
|
120
|
+
slots.delete(slot.slot_id)
|
|
121
|
+
Integration::SchedulerDiagnostics.child_ended(slot.pid)
|
|
122
|
+
|
|
123
|
+
return if slot.forced_outcome == :interrupted
|
|
124
|
+
|
|
125
|
+
record_drain_result(slot, final_status)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def record_drain_result(slot, final_status)
|
|
129
|
+
result = build_drain_result(slot, final_status)
|
|
130
|
+
slot.mutant.status = result.status
|
|
131
|
+
results << result
|
|
132
|
+
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
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
|
+
end
|
|
145
|
+
end
|
|
146
|
+
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,272 @@
|
|
|
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
|
+
# Environment variable exposing a stable worker-slot index (0..jobs-1) to
|
|
27
|
+
# each forked child so test suites can isolate shared resources per slot
|
|
28
|
+
# (e.g. "myapp_test_#{ENV['HENITAI_WORKER_SLOT']}"). A flaky-retry respawn
|
|
29
|
+
# keeps the original attempt's value.
|
|
30
|
+
WORKER_SLOT_ENV = "HENITAI_WORKER_SLOT"
|
|
31
|
+
|
|
32
|
+
# Tracks one in-flight mutant child process.
|
|
33
|
+
Slot = Struct.new(
|
|
34
|
+
:slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
|
|
35
|
+
:log_paths, :retry_count, :draining, :term_sent_at_monotonic,
|
|
36
|
+
:forced_outcome, :worker_index
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# @return [Integer] mutants that required at least one retry during the run.
|
|
40
|
+
# @return [Array<ScenarioExecutionResult>] verdicts accumulated so far.
|
|
41
|
+
attr_reader :flaky_retry_count, :results
|
|
42
|
+
|
|
43
|
+
def initialize(integration:, config:, progress_reporter:, options:, host:)
|
|
44
|
+
@integration = integration
|
|
45
|
+
@config = config
|
|
46
|
+
@progress_reporter = progress_reporter
|
|
47
|
+
@options = options
|
|
48
|
+
@host = host
|
|
49
|
+
@pending = []
|
|
50
|
+
@slots = {}
|
|
51
|
+
@pid_to_slot = {}
|
|
52
|
+
@results = []
|
|
53
|
+
@flaky_retry_count = 0
|
|
54
|
+
@next_slot_id = 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Queues the mutants to be scheduled into worker slots.
|
|
58
|
+
def enqueue(mutants)
|
|
59
|
+
@pending = mutants.dup
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def done?
|
|
63
|
+
pending.empty? && slots.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fill_idle_slots
|
|
67
|
+
while slots.size < worker_count && !pending.empty?
|
|
68
|
+
mutant = pending.shift
|
|
69
|
+
spawn_into_slot(mutant)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reap_all_completed_children
|
|
74
|
+
loop do
|
|
75
|
+
pid, status = runtime.wait2(-1, Process::WNOHANG)
|
|
76
|
+
break unless pid
|
|
77
|
+
|
|
78
|
+
complete_slot(pid, status)
|
|
79
|
+
end
|
|
80
|
+
rescue Errno::ECHILD
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def next_event_timeout
|
|
85
|
+
now = monotonic_time
|
|
86
|
+
slot_timeouts = slots.each_value.filter_map do |slot|
|
|
87
|
+
remaining_slot_timeout(slot, now)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
slot_timeouts.min
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
attr_reader :pending, :slots, :pid_to_slot, :integration, :config,
|
|
96
|
+
:progress_reporter, :options, :host
|
|
97
|
+
|
|
98
|
+
def worker_count = host.worker_count
|
|
99
|
+
def runtime = host.runtime
|
|
100
|
+
def wakeup = host.wakeup
|
|
101
|
+
def shutdown? = host.shutdown_requested?
|
|
102
|
+
|
|
103
|
+
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)
|
|
108
|
+
|
|
109
|
+
worker_index = next_free_worker_index
|
|
110
|
+
with_worker_slot(worker_index) do
|
|
111
|
+
handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
|
|
112
|
+
register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
|
|
113
|
+
end
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
record_spawn_failure(mutant, e)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
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
|
|
123
|
+
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_slot(slot_id, mutant, handle, worker_index, timeout)
|
|
127
|
+
Slot.new(
|
|
128
|
+
slot_id, mutant, handle.pid,
|
|
129
|
+
monotonic_time,
|
|
130
|
+
timeout, handle.log_paths, 0, false, nil, nil,
|
|
131
|
+
worker_index
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def slot_timeout(mutant, test_files)
|
|
136
|
+
resolver = options[:timeout_resolver]
|
|
137
|
+
resolver ? resolver.call(mutant, test_files) : config.timeout
|
|
138
|
+
end
|
|
139
|
+
|
|
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
|
+
def complete_slot(pid, wait_result)
|
|
150
|
+
slot_id = pid_to_slot.delete(pid)
|
|
151
|
+
return unless slot_id
|
|
152
|
+
|
|
153
|
+
slot = slots[slot_id]
|
|
154
|
+
return unless slot
|
|
155
|
+
|
|
156
|
+
Integration::SchedulerDiagnostics.child_ended(pid)
|
|
157
|
+
result = integration.build_result(wait_result, slot.log_paths)
|
|
158
|
+
dispatch_slot_result(slot, result)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
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
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def should_retry?(slot, result)
|
|
174
|
+
!shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def retry_slot(slot)
|
|
178
|
+
test_files = resolve_test_files(slot.mutant)
|
|
179
|
+
with_worker_slot(slot.worker_index) do
|
|
180
|
+
handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
|
|
181
|
+
finish_retry(slot, handle)
|
|
182
|
+
end
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
slots.delete(slot.slot_id)
|
|
185
|
+
record_spawn_failure(slot.mutant, e)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def finish_retry(slot, handle)
|
|
189
|
+
@flaky_retry_count += 1 if slot.retry_count.zero?
|
|
190
|
+
slot.retry_count += 1
|
|
191
|
+
reset_slot_for_retry(slot, handle)
|
|
192
|
+
pid_to_slot[handle.pid] = slot.slot_id
|
|
193
|
+
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def reset_slot_for_retry(slot, handle)
|
|
197
|
+
slot.pid = handle.pid
|
|
198
|
+
slot.log_paths = handle.log_paths
|
|
199
|
+
slot.started_at_monotonic = monotonic_time
|
|
200
|
+
slot.draining = false
|
|
201
|
+
slot.term_sent_at_monotonic = nil
|
|
202
|
+
slot.forced_outcome = nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def record_spawn_failure(mutant, error)
|
|
206
|
+
result = ScenarioExecutionResult.new(
|
|
207
|
+
status: :compile_error,
|
|
208
|
+
stdout: "",
|
|
209
|
+
stderr: "spawn failed: #{error.message}",
|
|
210
|
+
log_path: "/dev/null",
|
|
211
|
+
exit_status: nil
|
|
212
|
+
)
|
|
213
|
+
mutant.status = result.status
|
|
214
|
+
results << result
|
|
215
|
+
progress_reporter&.progress(mutant, scenario_result: result)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def record_no_coverage(mutant)
|
|
219
|
+
mutant.status = :no_coverage
|
|
220
|
+
progress_reporter&.progress(mutant, scenario_result: nil)
|
|
221
|
+
end
|
|
222
|
+
|
|
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
|
+
def with_worker_slot(worker_index)
|
|
261
|
+
previous = ENV.fetch(WORKER_SLOT_ENV, nil)
|
|
262
|
+
ENV[WORKER_SLOT_ENV] = worker_index.to_s
|
|
263
|
+
yield
|
|
264
|
+
ensure
|
|
265
|
+
if previous.nil?
|
|
266
|
+
ENV.delete(WORKER_SLOT_ENV)
|
|
267
|
+
else
|
|
268
|
+
ENV[WORKER_SLOT_ENV] = previous
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "coverage_report_reader"
|
|
4
|
+
require_relative "mutation_skip_directives"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
# Applies static, pre-execution filtering to generated mutants.
|
|
@@ -8,8 +9,10 @@ module Henitai
|
|
|
8
9
|
DEFAULT_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_COVERAGE_REPORT_PATH
|
|
9
10
|
DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
|
|
10
11
|
|
|
11
|
-
def initialize(coverage_report_reader: CoverageReportReader.new
|
|
12
|
+
def initialize(coverage_report_reader: CoverageReportReader.new,
|
|
13
|
+
skip_directives: MutationSkipDirectives.new)
|
|
12
14
|
@coverage_report_reader = coverage_report_reader
|
|
15
|
+
@skip_directives = skip_directives
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
# This method is the gate-level filter orchestrator.
|
|
@@ -18,7 +21,7 @@ module Henitai
|
|
|
18
21
|
coverage_report_present = coverage_report_present?(config)
|
|
19
22
|
|
|
20
23
|
Array(mutants).each do |mutant|
|
|
21
|
-
next if ignored_mutant?(mutant, config)
|
|
24
|
+
next if ignored_mutant?(mutant, config) || skip_directive_mutant?(mutant)
|
|
22
25
|
|
|
23
26
|
mark_equivalent_mutant(mutant)
|
|
24
27
|
mark_no_coverage_mutant(
|
|
@@ -61,7 +64,7 @@ module Henitai
|
|
|
61
64
|
|
|
62
65
|
private
|
|
63
66
|
|
|
64
|
-
attr_reader :coverage_report_reader
|
|
67
|
+
attr_reader :coverage_report_reader, :skip_directives
|
|
65
68
|
|
|
66
69
|
def ignored?(mutant, config)
|
|
67
70
|
source = source_for(mutant)
|
|
@@ -79,6 +82,15 @@ module Henitai
|
|
|
79
82
|
true
|
|
80
83
|
end
|
|
81
84
|
|
|
85
|
+
def skip_directive_mutant?(mutant)
|
|
86
|
+
directive = skip_directives.directive_for(mutant)
|
|
87
|
+
return false unless directive
|
|
88
|
+
|
|
89
|
+
mutant.status = :ignored
|
|
90
|
+
mutant.ignore_reason = directive.reason if mutant.respond_to?(:ignore_reason=)
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
|
|
82
94
|
def mark_equivalent_mutant(mutant)
|
|
83
95
|
return unless mutant.pending?
|
|
84
96
|
|
|
@@ -96,9 +108,7 @@ module Henitai
|
|
|
96
108
|
def covered?(mutant, coverage_lines)
|
|
97
109
|
file = normalize_path(mutant.location[:file])
|
|
98
110
|
covered = Array(coverage_lines[file])
|
|
99
|
-
(mutant.location[:start_line]..mutant.location[:end_line]).
|
|
100
|
-
covered.include?(line)
|
|
101
|
-
end
|
|
111
|
+
covered.any? { |line| (mutant.location[:start_line]..mutant.location[:end_line]).cover?(line) }
|
|
102
112
|
end
|
|
103
113
|
|
|
104
114
|
def source_for(mutant)
|