henitai 0.3.1 → 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 +104 -1
- data/README.md +11 -1
- data/assets/schema/henitai.schema.json +1 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +1 -1
- data/lib/henitai/configuration.rb +9 -2
- data/lib/henitai/configuration_validator.rb +1 -1
- 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/operator.rb +12 -2
- data/lib/henitai/operators/hash_key_type.rb +50 -0
- data/lib/henitai/operators/hash_literal.rb +19 -20
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- 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 +94 -38
- metadata +32 -9
- data/lib/henitai/integration/child_debug_support.rb +0 -119
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Answers whether a process id is still running.
|
|
5
|
+
#
|
|
6
|
+
# Extracted so the one subtle rule here lives in a single place: EPERM means
|
|
7
|
+
# the process exists but belongs to someone else, so it counts as *alive*.
|
|
8
|
+
# Only ESRCH proves it is gone. Getting that backwards would let
|
|
9
|
+
# OrphanWatchdog kill live children, and would make the reports-directory
|
|
10
|
+
# lock report a running owner as dead.
|
|
11
|
+
module ProcessLiveness
|
|
12
|
+
# Captured at load time, before any test double can replace
|
|
13
|
+
# `Process.kill`. A `Method` object keeps pointing at the original
|
|
14
|
+
# definition even after the singleton method is redefined, which is what
|
|
15
|
+
# makes this immune to stubbing.
|
|
16
|
+
#
|
|
17
|
+
# This is not defensiveness for its own sake: a mutant child runs the host
|
|
18
|
+
# project's own suite, and a spec in that suite stubbing `Process.kill` to
|
|
19
|
+
# raise ESRCH made this answer "parent is dead" while the parent was very
|
|
20
|
+
# much alive. OrphanWatchdog then exited the child, which the scheduler
|
|
21
|
+
# recorded as CompileError. Observed on henitai's own dogfood run.
|
|
22
|
+
KILL = Process.method(:kill)
|
|
23
|
+
|
|
24
|
+
# @param pid [Integer, nil] process id to probe
|
|
25
|
+
# @param kill [#call] signalling primitive; injected only by specs, which
|
|
26
|
+
# cannot reach {KILL} by stubbing and must not be able to
|
|
27
|
+
# @return [Boolean] false only when the pid provably does not exist
|
|
28
|
+
def self.alive?(pid, kill: KILL)
|
|
29
|
+
return false unless pid.is_a?(Integer)
|
|
30
|
+
|
|
31
|
+
kill.call(0, pid)
|
|
32
|
+
true
|
|
33
|
+
rescue Errno::ESRCH
|
|
34
|
+
false
|
|
35
|
+
rescue StandardError
|
|
36
|
+
# EPERM and anything unexpected: assume alive, the conservative answer
|
|
37
|
+
# for every caller.
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -18,7 +18,17 @@ module Henitai
|
|
|
18
18
|
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
19
19
|
acquire(file)
|
|
20
20
|
write_owner(file)
|
|
21
|
-
|
|
21
|
+
# Registered so a forked mutant child can close its inherited copy: the
|
|
22
|
+
# flock lives on the shared open file description, so a child that
|
|
23
|
+
# outlives its parent would otherwise keep this lock held. Unregistered
|
|
24
|
+
# inside the File.open block, keeping the registration's lifetime a
|
|
25
|
+
# subset of the handle's.
|
|
26
|
+
InheritedFdRegistry.register(file)
|
|
27
|
+
begin
|
|
28
|
+
yield
|
|
29
|
+
ensure
|
|
30
|
+
InheritedFdRegistry.unregister(file)
|
|
31
|
+
end
|
|
22
32
|
end
|
|
23
33
|
end
|
|
24
34
|
|
|
@@ -55,16 +65,7 @@ module Henitai
|
|
|
55
65
|
|
|
56
66
|
# True only when the recorded owner pid provably no longer exists. EPERM
|
|
57
67
|
# means the process is alive but owned by someone else — treated as alive.
|
|
58
|
-
def dead_owner?(pid)
|
|
59
|
-
return false unless pid.is_a?(Integer)
|
|
60
|
-
|
|
61
|
-
Process.kill(0, pid)
|
|
62
|
-
false
|
|
63
|
-
rescue Errno::ESRCH
|
|
64
|
-
true
|
|
65
|
-
rescue StandardError
|
|
66
|
-
false
|
|
67
|
-
end
|
|
68
|
+
def dead_owner?(pid) = !ProcessLiveness.alive?(pid)
|
|
68
69
|
|
|
69
70
|
def write_owner(file)
|
|
70
71
|
file.rewind
|
data/lib/henitai/result.rb
CHANGED
|
@@ -15,8 +15,28 @@ module Henitai
|
|
|
15
15
|
SCHEMA_VERSION = "1.0"
|
|
16
16
|
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
17
17
|
|
|
18
|
+
# Which outcomes count as "the test suite detected this mutant" for the MS
|
|
19
|
+
# numerator. Duplicated from Configuration rather than read from it: Result
|
|
20
|
+
# is a domain object and must not depend on configuration loading. The same
|
|
21
|
+
# arrangement already exists for DEFAULT_THRESHOLDS, and result_spec asserts
|
|
22
|
+
# the two constants stay equal.
|
|
23
|
+
DEFAULT_COVERAGE_CRITERIA = {
|
|
24
|
+
test_result: true,
|
|
25
|
+
timeout: true,
|
|
26
|
+
process_abort: true
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Maps each criterion to the mutant status it governs. process_abort has no
|
|
30
|
+
# producer in lib/ yet -- nothing classifies a mutant as :runtime_error --
|
|
31
|
+
# so that entry is forward-looking wiring rather than a live code path.
|
|
32
|
+
CRITERION_STATUSES = {
|
|
33
|
+
test_result: :killed,
|
|
34
|
+
timeout: :timeout,
|
|
35
|
+
process_abort: :runtime_error
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
18
38
|
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
19
|
-
:session_id, :git_sha, :since
|
|
39
|
+
:session_id, :git_sha, :since, :coverage_criteria
|
|
20
40
|
|
|
21
41
|
# @param source_provider [#call] maps a file path to its source string.
|
|
22
42
|
# Injected so the domain object performs no disk IO; the caller (which
|
|
@@ -33,7 +53,7 @@ module Henitai
|
|
|
33
53
|
partial_rerun: false, survivor_stats: nil,
|
|
34
54
|
session_id: SecureRandom.uuid, git_sha: nil,
|
|
35
55
|
source_provider: ->(_file) { "" }, authoritative: true,
|
|
36
|
-
since: nil)
|
|
56
|
+
since: nil, coverage_criteria: nil)
|
|
37
57
|
@mutants = mutants
|
|
38
58
|
@started_at = started_at
|
|
39
59
|
@finished_at = finished_at
|
|
@@ -45,6 +65,7 @@ module Henitai
|
|
|
45
65
|
@source_provider = source_provider
|
|
46
66
|
@authoritative = authoritative
|
|
47
67
|
@since = since
|
|
68
|
+
@coverage_criteria = DEFAULT_COVERAGE_CRITERIA.merge(coverage_criteria || {})
|
|
48
69
|
end
|
|
49
70
|
# rubocop:enable Metrics/ParameterLists
|
|
50
71
|
|
|
@@ -140,7 +161,13 @@ module Henitai
|
|
|
140
161
|
private
|
|
141
162
|
|
|
142
163
|
def detected_in(list)
|
|
143
|
-
list.count { |m|
|
|
164
|
+
list.count { |m| detected_statuses.include?(m.status) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def detected_statuses
|
|
168
|
+
@detected_statuses ||= CRITERION_STATUSES.filter_map do |criterion, status|
|
|
169
|
+
status if @coverage_criteria[criterion]
|
|
170
|
+
end
|
|
144
171
|
end
|
|
145
172
|
|
|
146
173
|
def mutation_score_for(list)
|
data/lib/henitai/runner.rb
CHANGED
|
@@ -30,9 +30,16 @@ module Henitai
|
|
|
30
30
|
|
|
31
31
|
# @param mode [Hash] execution-mode flags: +dry_run:+ stops before Gate 4,
|
|
32
32
|
# +incremental:+ reuses still-valid Killed verdicts from history.
|
|
33
|
+
# +deps+ is assigned in the body, not defaulted in the signature: the
|
|
34
|
+
# default needs @config, and `config:` itself defaults to a load.
|
|
35
|
+
#
|
|
36
|
+
# rubocop:disable Metrics/ParameterLists -- these are the CLI's own flags
|
|
37
|
+
# plus the dependency seam; a params object would only move the list.
|
|
33
38
|
def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil,
|
|
34
|
-
mode: {})
|
|
39
|
+
mode: {}, deps: nil)
|
|
40
|
+
# rubocop:enable Metrics/ParameterLists
|
|
35
41
|
@config = config
|
|
42
|
+
@deps = deps || RunnerDependencies.new(config: @config)
|
|
36
43
|
@subjects = subjects
|
|
37
44
|
@since = since
|
|
38
45
|
@survivors_from = survivors_from
|
|
@@ -88,13 +95,13 @@ module Henitai
|
|
|
88
95
|
end
|
|
89
96
|
|
|
90
97
|
def resolve_subjects(source_files = self.source_files)
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
subject_selection.resolve(source_files)
|
|
99
|
+
end
|
|
93
100
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
def subject_selection
|
|
102
|
+
@subject_selection ||= SubjectSelection.new(
|
|
103
|
+
subject_resolver: subject_resolver, patterns: @subjects
|
|
104
|
+
)
|
|
98
105
|
end
|
|
99
106
|
|
|
100
107
|
def generate_mutants(subjects)
|
|
@@ -164,6 +171,7 @@ module Henitai
|
|
|
164
171
|
started_at:,
|
|
165
172
|
finished_at:,
|
|
166
173
|
thresholds: result_thresholds,
|
|
174
|
+
coverage_criteria: result_coverage_criteria,
|
|
167
175
|
partial_rerun: survivor_rerun?,
|
|
168
176
|
survivor_stats: survivor_strategy.survivor_stats,
|
|
169
177
|
git_sha: safe_head_sha,
|
|
@@ -173,20 +181,6 @@ module Henitai
|
|
|
173
181
|
)
|
|
174
182
|
end
|
|
175
183
|
|
|
176
|
-
# Reads each source file once and caches it, so Result consumes source
|
|
177
|
-
# content while performing no disk IO of its own. Returns "" for files that
|
|
178
|
-
# cannot be read (e.g. recipe stubs with synthetic locations).
|
|
179
|
-
def source_provider
|
|
180
|
-
cache = {} # : Hash[String, String]
|
|
181
|
-
lambda do |file|
|
|
182
|
-
cache[file] ||= begin
|
|
183
|
-
File.read(file)
|
|
184
|
-
rescue StandardError
|
|
185
|
-
""
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
184
|
def safe_head_sha
|
|
191
185
|
git_diff_analyzer.head_sha
|
|
192
186
|
rescue StandardError
|
|
@@ -199,123 +193,47 @@ module Henitai
|
|
|
199
193
|
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
200
194
|
end
|
|
201
195
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
|
|
205
|
-
|
|
206
|
-
def mutant_generator = @mutant_generator ||= MutantGenerator.new
|
|
207
|
-
|
|
208
|
-
def static_filter = @static_filter ||= StaticFilter.new
|
|
209
|
-
|
|
210
|
-
def execution_engine = @execution_engine ||= ExecutionEngine.new
|
|
196
|
+
attr_reader :deps
|
|
211
197
|
|
|
212
|
-
def
|
|
198
|
+
def subject_resolver = deps.subject_resolver
|
|
199
|
+
def git_diff_analyzer = deps.git_diff_analyzer
|
|
200
|
+
def mutant_generator = deps.mutant_generator
|
|
201
|
+
def static_filter = deps.static_filter
|
|
202
|
+
def execution_engine = deps.execution_engine
|
|
203
|
+
def coverage_bootstrapper = deps.coverage_bootstrapper
|
|
204
|
+
def integration = deps.integration
|
|
205
|
+
def operators = deps.operators
|
|
206
|
+
def history_store = deps.history_store
|
|
207
|
+
def per_test_coverage = deps.per_test_coverage
|
|
208
|
+
def source_provider = deps.source_provider
|
|
213
209
|
|
|
214
|
-
def
|
|
215
|
-
@integration ||= Integration.for(config.integration).new
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def operators
|
|
219
|
-
@operators ||= Operator.for_set(config.operators)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
# Fans progress out to the terminal reporter (when enabled) and the
|
|
223
|
-
# checkpoint writer (when enabled and a file report is configured), so a
|
|
224
|
-
# long run persists partial results incrementally.
|
|
225
|
-
def progress_reporter
|
|
226
|
-
CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def history_store
|
|
230
|
-
@history_store ||= MutantHistoryStore.new(
|
|
231
|
-
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
|
|
232
|
-
)
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# One shared live view of the per-test coverage map: the incremental
|
|
236
|
-
# filter proves survivor reuse against it and the history store records
|
|
237
|
-
# the same intersection set — one implementation, one snapshot.
|
|
238
|
-
def per_test_coverage
|
|
239
|
-
@per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
|
|
240
|
-
end
|
|
210
|
+
def progress_reporter = deps.progress_reporter(full_run: full_run?)
|
|
241
211
|
|
|
242
212
|
def source_files
|
|
243
|
-
@source_files ||=
|
|
213
|
+
@source_files ||= source_file_selection.call
|
|
244
214
|
end
|
|
245
215
|
|
|
246
|
-
def
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# Drops files matched by any `excludes:` glob (e.g. standalone entry points
|
|
253
|
-
# that cannot be mutation-tested in-process). Excludes apply regardless of
|
|
254
|
-
# the --since filter.
|
|
255
|
-
def reject_excluded(files)
|
|
256
|
-
excluded = excluded_source_files
|
|
257
|
-
return files if excluded.empty?
|
|
258
|
-
|
|
259
|
-
files.reject { |path| excluded.include?(normalize_path(path)) }
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def excluded_source_files
|
|
263
|
-
Array(config.excludes)
|
|
264
|
-
.flat_map { |pattern| Dir.glob(pattern) }
|
|
265
|
-
.map { |path| normalize_path(path) }
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def filter_changed(files)
|
|
269
|
-
return files unless @since
|
|
270
|
-
|
|
271
|
-
changed_file_set = changed_paths_since.map { |path| normalize_path(path) }
|
|
272
|
-
changed_file_set += covered_sources_for_changed_tests(changed_file_set)
|
|
273
|
-
files.select { |path| changed_file_set.include?(normalize_path(path)) }
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Committed changes since the ref plus the current working tree (tracked
|
|
277
|
-
# dirty and untracked files): the working tree is what gets tested, so it
|
|
278
|
-
# is always part of "changed since REF".
|
|
279
|
-
def changed_paths_since
|
|
280
|
-
git_diff_analyzer.changed_files(from: @since, to: "HEAD") +
|
|
281
|
-
git_diff_analyzer.working_tree_changed_files
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Changed test files select the source files they cover, so an edited
|
|
285
|
-
# test re-tests the subjects it can kill. Uses the per-test map from the
|
|
286
|
-
# previous run — Gate 1 runs before this run's bootstrap finishes.
|
|
287
|
-
def covered_sources_for_changed_tests(changed_paths)
|
|
288
|
-
changed_paths
|
|
289
|
-
.flat_map { |path| per_test_coverage.source_files_covered_by(path) }
|
|
290
|
-
.map { |path| normalize_path(path) }
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def pattern_subjects
|
|
294
|
-
Array(@subjects)
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
def unique_subjects(subjects)
|
|
298
|
-
subjects.uniq { |subject| [subject.expression, subject.source_file] }
|
|
216
|
+
def source_file_selection
|
|
217
|
+
@source_file_selection ||= SourceFileSelection.new(
|
|
218
|
+
config: config, since: @since,
|
|
219
|
+
git_diff_analyzer: git_diff_analyzer, per_test_coverage: per_test_coverage
|
|
220
|
+
)
|
|
299
221
|
end
|
|
300
222
|
|
|
301
|
-
def
|
|
302
|
-
File.expand_path(path)
|
|
303
|
-
end
|
|
223
|
+
def result_thresholds = optional_config(:thresholds)
|
|
304
224
|
|
|
305
|
-
def
|
|
306
|
-
return nil unless config.respond_to?(:thresholds)
|
|
225
|
+
def result_coverage_criteria = optional_config(:coverage_criteria)
|
|
307
226
|
|
|
308
|
-
|
|
309
|
-
|
|
227
|
+
# Specs pass bare config doubles that expose only what the example needs, so
|
|
228
|
+
# scoring inputs are read defensively rather than assumed present.
|
|
229
|
+
def optional_config(name) = config.respond_to?(name) ? config.public_send(name) : nil
|
|
310
230
|
|
|
311
|
-
def survivor_rerun?
|
|
312
|
-
!@survivors_from.nil?
|
|
313
|
-
end
|
|
231
|
+
def survivor_rerun? = !@survivors_from.nil?
|
|
314
232
|
|
|
315
233
|
# Mutation-scope full run, controlling Result#authoritative? — distinct
|
|
316
234
|
# from the per-test-coverage plan's test-suite-scope "full run".
|
|
317
235
|
def full_run?
|
|
318
|
-
|
|
236
|
+
Array(@subjects).empty? && @since.nil? && !survivor_rerun?
|
|
319
237
|
end
|
|
320
238
|
|
|
321
239
|
def survivor_strategy
|
|
@@ -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
|