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