henitai 0.5.1 → 0.6.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 +296 -1
- data/README.md +109 -16
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +57 -15
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +17 -7
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +227 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "parser_current"
|
|
5
|
+
require_relative "source_parser"
|
|
6
|
+
|
|
7
|
+
module Henitai
|
|
8
|
+
# Verifies, before a mutant runs, that its activation source differs from
|
|
9
|
+
# the original method by exactly the reported mutation and nothing else.
|
|
10
|
+
#
|
|
11
|
+
# The checks run in order:
|
|
12
|
+
#
|
|
13
|
+
# 1. Site: the mutated node must lie inside the method body that the
|
|
14
|
+
# activation replaces.
|
|
15
|
+
# 2. Syntax: the activation source must be valid Ruby in context. Prism's
|
|
16
|
+
# error list is used because the parser translation layer recovers from
|
|
17
|
+
# errors silently.
|
|
18
|
+
# 3. Structure: the activated method body, re-parsed, must equal the
|
|
19
|
+
# original body with the original node replaced by the mutated node.
|
|
20
|
+
# Both sides are normalised first: parentheses around one expression
|
|
21
|
+
# are dropped, an interpolation-free +dstr+ is the same value as one
|
|
22
|
+
# +str+, nested statement sequences flatten, an empty kwargs node is no
|
|
23
|
+
# argument, and +__FILE__+ compares as an absolute path.
|
|
24
|
+
#
|
|
25
|
+
# A violation is a harness defect, not a test verdict: the mutant must not
|
|
26
|
+
# run, and the reason must be reported.
|
|
27
|
+
class FidelityCheck
|
|
28
|
+
# @return [String, nil] the reason the mutant is unfaithful, or nil
|
|
29
|
+
def violation(mutant)
|
|
30
|
+
return unless checkable?(mutant)
|
|
31
|
+
|
|
32
|
+
activation = Mutant::Activator.activation_for(mutant)
|
|
33
|
+
return "activation source could not be built" unless activation
|
|
34
|
+
|
|
35
|
+
whole_def = activation.mode == :toplevel
|
|
36
|
+
expected = expected_tree(mutant, whole_def)
|
|
37
|
+
return "mutation site is outside the executed method body" unless expected
|
|
38
|
+
|
|
39
|
+
syntax_violation(activation.source) || structure_violation(mutant, expected, activation, whole_def)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
"fidelity could not be verified (#{e.class}: #{e.message})"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def checkable?(mutant)
|
|
47
|
+
return false unless mutant.original_node
|
|
48
|
+
|
|
49
|
+
subject = mutant.subject
|
|
50
|
+
!subject.method_name.nil? && !subject.ast_node.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def syntax_violation(source)
|
|
54
|
+
error = Prism.parse(source).errors.first
|
|
55
|
+
"activation source is not valid Ruby: #{error.message}" if error
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def structure_violation(mutant, expected, activation, whole_def)
|
|
59
|
+
source = activation.source
|
|
60
|
+
tree = SourceParser.parse(source, path: activation.file || Mutant::Activator::EVAL_FILE,
|
|
61
|
+
line: activation.line || 1)
|
|
62
|
+
actual = normalize(activated_tree(tree, mutant.subject.method_name, whole_def))
|
|
63
|
+
expected = normalize(expected)
|
|
64
|
+
"activated code differs from the reported mutation" unless actual == expected
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A re-evaluated def (ADR-13) is compared as a whole, parameters
|
|
68
|
+
# included; the define_method fallback only by body.
|
|
69
|
+
def expected_tree(mutant, whole_def)
|
|
70
|
+
definition = mutant.subject.ast_node
|
|
71
|
+
scope = whole_def ? definition : method_body(definition)
|
|
72
|
+
replaced, found = substitute(scope, mutant.original_node, mutant.mutated_node)
|
|
73
|
+
replaced if found
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def method_body(node)
|
|
77
|
+
case node.type
|
|
78
|
+
when :defs then node.children[3]
|
|
79
|
+
when :def, :block then node.children[2]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def substitute(node, target, replacement)
|
|
84
|
+
return [replacement, true] if node.equal?(target)
|
|
85
|
+
return [node, false] unless node.is_a?(Parser::AST::Node)
|
|
86
|
+
|
|
87
|
+
found = false
|
|
88
|
+
children = node.children.map do |child|
|
|
89
|
+
child, child_found = substitute(child, target, replacement)
|
|
90
|
+
found ||= child_found
|
|
91
|
+
child
|
|
92
|
+
end
|
|
93
|
+
[found ? node.updated(nil, children) : node, found]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The method as activated: a re-evaluated +def+ (ADR-13) or the
|
|
97
|
+
# +define_method+ block of the fallback.
|
|
98
|
+
def activated_tree(tree, method_name, whole_def)
|
|
99
|
+
name = method_name.to_sym
|
|
100
|
+
definition = find_node(tree) do |node|
|
|
101
|
+
activated_def?(node, name) || (node.type == :block && define_method_call?(node.children[0], name))
|
|
102
|
+
end
|
|
103
|
+
whole_def ? definition : definition && method_body(definition)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def activated_def?(node, name)
|
|
107
|
+
(node.type == :def && node.children[0] == name) || (node.type == :defs && node.children[1] == name)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def define_method_call?(call, method_name)
|
|
111
|
+
call.type == :send && call.children[0].nil? && call.children[1] == :define_method &&
|
|
112
|
+
call.children[2]&.type == :sym && call.children[2].children[0] == method_name
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def find_node(node, &)
|
|
116
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
117
|
+
return node if yield(node)
|
|
118
|
+
|
|
119
|
+
node.children.each do |child|
|
|
120
|
+
found = find_node(child, &)
|
|
121
|
+
return found if found
|
|
122
|
+
end
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize(node)
|
|
127
|
+
return node unless node.is_a?(Parser::AST::Node)
|
|
128
|
+
return normalize(node.children[0]) if parenthesised?(node)
|
|
129
|
+
return node.updated(:str, [literal_text(node)]) if literal_dstr?(node)
|
|
130
|
+
return node.updated(nil, [File.expand_path(node.children[0])]) if file_keyword?(node)
|
|
131
|
+
|
|
132
|
+
node.updated(nil, normalized_children(node))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# `foo(a, )` -- left behind when all keyword arguments are removed --
|
|
136
|
+
# calls `foo(a)`, so an empty kwargs node is no argument at all.
|
|
137
|
+
def normalized_children(node)
|
|
138
|
+
children = node.children.map { |child| normalize(child) }.reject { |child| empty_kwargs?(child) }
|
|
139
|
+
node.type == :begin ? children.flat_map { |child| statements(child) } : children
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Parentheses around one expression carry no meaning once the tree
|
|
143
|
+
# encodes precedence; Mutant::Replacement adds them around operators.
|
|
144
|
+
def parenthesised?(node)
|
|
145
|
+
node.type == :begin && node.children.size == 1
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def empty_kwargs?(node)
|
|
149
|
+
node.is_a?(Parser::AST::Node) && node.type == :kwargs && node.children.empty?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A statement sequence spliced into another one parses as one flat
|
|
153
|
+
# sequence.
|
|
154
|
+
def statements(node)
|
|
155
|
+
node.is_a?(Parser::AST::Node) && node.type == :begin ? node.children : [node]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def file_keyword?(node)
|
|
159
|
+
node.type == :str && node.location&.expression&.source == "__FILE__"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# An interpolation-free heredoc parses into a +dstr+ of line segments.
|
|
163
|
+
def literal_dstr?(node)
|
|
164
|
+
node.type == :dstr && node.children.all? { |child| child.type == :str || literal_dstr?(child) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def literal_text(node)
|
|
168
|
+
return node.children[0] if node.type == :str
|
|
169
|
+
|
|
170
|
+
node.children.map { |child| literal_text(child) }.join
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Runs the suite twice and persists the test files whose outcome differed.
|
|
7
|
+
#
|
|
8
|
+
# Only used when `mutation.flaky_detection` is enabled. Each run records
|
|
9
|
+
# per-file outcomes via the integration's coverage hooks; the files that
|
|
10
|
+
# disagree between the two runs are written to `henitai_flaky_tests.json`.
|
|
11
|
+
class FlakyDetectionBaseline
|
|
12
|
+
def initialize(reports_dir:)
|
|
13
|
+
@reports_dir = reports_dir
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run(integration:, test_files:)
|
|
17
|
+
FileUtils.mkdir_p(@reports_dir)
|
|
18
|
+
first, first_result = run_once(integration, test_files)
|
|
19
|
+
second, second_result = run_once(integration, test_files)
|
|
20
|
+
registry = FlakyTestRegistry.from_outcomes(first.outcomes, second.outcomes)
|
|
21
|
+
ensure_usable_suite(registry, [[first, first_result], [second, second_result]])
|
|
22
|
+
registry.write(File.join(@reports_dir, FlakyTestRegistry::FILE_NAME))
|
|
23
|
+
warn_flaky(registry)
|
|
24
|
+
registry
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def run_once(integration, test_files)
|
|
30
|
+
outcome_path = TestFileOutcomes.report_path(@reports_dir)
|
|
31
|
+
FileUtils.rm_f(outcome_path)
|
|
32
|
+
result = integration.run_suite(test_files)
|
|
33
|
+
raise CoverageError, "could not record suite outcomes" unless File.exist?(outcome_path)
|
|
34
|
+
|
|
35
|
+
[TestFileOutcomes.load(outcome_path), result]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A failing run is only acceptable as evidence of flakiness: the suite
|
|
39
|
+
# must pass at least once and every file that failed in a failing run must
|
|
40
|
+
# be a flaky file. A run that failed without a file to blame (a load error,
|
|
41
|
+
# a suite-level hook) is never explained.
|
|
42
|
+
def ensure_usable_suite(registry, runs)
|
|
43
|
+
failing = runs.reject { |_outcomes, result| result.survived? }
|
|
44
|
+
return if failing.empty?
|
|
45
|
+
return if failing.size < runs.size && failing.all? { |outcomes, _result| explained?(registry, outcomes) }
|
|
46
|
+
|
|
47
|
+
raise CoverageError, BootstrapErrorMessage.build(failing.first.last)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def explained?(registry, outcomes)
|
|
51
|
+
failed = outcomes.failed_files
|
|
52
|
+
!outcomes.unattributed_failures? && !failed.empty? && failed.all? { |file| registry.flaky?(file) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def warn_flaky(registry)
|
|
56
|
+
return if registry.empty?
|
|
57
|
+
|
|
58
|
+
warn("Flaky tests detected (#{registry.all.size}): #{registry.all.join(', ')}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Re-runs a mutant whose kill is attributable only to known-flaky test files.
|
|
5
|
+
#
|
|
6
|
+
# Survivors are never retried: a retry could only turn a survivor into a
|
|
7
|
+
# kill, which would inflate the score. A kill is retried only when every
|
|
8
|
+
# killing test file is in the flaky set, up to `max_retries` times.
|
|
9
|
+
class FlakyMutantRetrier
|
|
10
|
+
attr_reader :retried_count
|
|
11
|
+
|
|
12
|
+
def initialize(flaky_tests:, max_retries:)
|
|
13
|
+
@decision = FlakyRetryDecision.new(flaky_tests: flaky_tests, max_retries: max_retries)
|
|
14
|
+
@retried_count = 0
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(mutant:, integration:, test_files:, timeout:)
|
|
19
|
+
result = integration.run_mutant(mutant: mutant, test_files: test_files, timeout: timeout)
|
|
20
|
+
attempts = 0
|
|
21
|
+
while @decision.retry?(**decision_input(result, attempts))
|
|
22
|
+
attempts += 1
|
|
23
|
+
result = integration.run_mutant(mutant: mutant, test_files: test_files, timeout: timeout)
|
|
24
|
+
end
|
|
25
|
+
@mutex.synchronize { @retried_count += 1 } if attempts.positive?
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def decision_input(result, attempts)
|
|
32
|
+
{
|
|
33
|
+
status: status_of(result),
|
|
34
|
+
killing_tests: result.respond_to?(:killing_tests) ? result.killing_tests : [],
|
|
35
|
+
attempts: attempts
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def status_of(result)
|
|
40
|
+
result.is_a?(Symbol) ? result : result.status
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Decides whether a mutant verdict earns another attempt.
|
|
5
|
+
#
|
|
6
|
+
# Survivors are never retried: flakiness can only turn a survivor into a
|
|
7
|
+
# kill, which would inflate the score. A kill is retried only when every test
|
|
8
|
+
# that killed the mutant is a known flaky test, because flakiness can fake a
|
|
9
|
+
# kill but not a survivor. Without a flaky set nothing is retried.
|
|
10
|
+
class FlakyRetryDecision
|
|
11
|
+
def initialize(flaky_tests:, max_retries:)
|
|
12
|
+
@flaky_tests = flaky_tests
|
|
13
|
+
@max_retries = max_retries.to_i
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def retry?(status:, killing_tests:, attempts:)
|
|
17
|
+
status == :killed &&
|
|
18
|
+
attempts < @max_retries &&
|
|
19
|
+
killing_tests.any? &&
|
|
20
|
+
killing_tests.all? { |file| @flaky_tests.flaky?(file) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# The set of test files whose outcome differed between two baseline runs.
|
|
7
|
+
#
|
|
8
|
+
# Produced by comparing per-file pass/fail maps from two runs of the same
|
|
9
|
+
# suite, and persisted as `henitai_flaky_tests.json` so that later runs can
|
|
10
|
+
# tell a kill caused by a flaky test from one caused by a stable test.
|
|
11
|
+
class FlakyTestRegistry
|
|
12
|
+
FILE_NAME = "henitai_flaky_tests.json"
|
|
13
|
+
|
|
14
|
+
# A file is flaky only when both runs ran it and disagreed. A file seen in
|
|
15
|
+
# one run only has no second observation, so it is never called flaky;
|
|
16
|
+
# FlakyDetectionBaseline rejects a failing run that such a file leaves
|
|
17
|
+
# unexplained.
|
|
18
|
+
def self.from_outcomes(first, second)
|
|
19
|
+
shared = first.keys & second.keys
|
|
20
|
+
flaky = shared.reject { |file| first[file] == second[file] }
|
|
21
|
+
new(flaky)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.load(path)
|
|
25
|
+
return new([]) unless File.file?(path)
|
|
26
|
+
|
|
27
|
+
new(Array(JSON.parse(File.read(path))["flaky_tests"]))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.for_config(config)
|
|
31
|
+
return new([]) unless config.respond_to?(:flaky_detection) && config.flaky_detection
|
|
32
|
+
|
|
33
|
+
for_reports_dir(config.respond_to?(:reports_dir) ? config.reports_dir : nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.for_reports_dir(dir)
|
|
37
|
+
return new([]) if dir.nil?
|
|
38
|
+
|
|
39
|
+
load(File.join(dir, FILE_NAME))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(files)
|
|
43
|
+
@files = files.map { |file| File.expand_path(file) }.uniq.sort.freeze
|
|
44
|
+
@lookup = @files.to_set.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def all
|
|
48
|
+
@files
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def flaky?(path)
|
|
52
|
+
@lookup.include?(File.expand_path(path))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty?
|
|
56
|
+
@files.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write(path)
|
|
60
|
+
File.write(path, JSON.pretty_generate("flaky_tests" => @files))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# The parent's end of the connection to the fork server. It sends `run`
|
|
6
|
+
# requests and collects the server's events; the parent cannot wait on the
|
|
7
|
+
# mutant processes (they are the server's children), so their ends arrive
|
|
8
|
+
# as `exited` events.
|
|
9
|
+
class Client
|
|
10
|
+
class ServerLost < StandardError; end
|
|
11
|
+
|
|
12
|
+
class BootFailed < StandardError; end
|
|
13
|
+
|
|
14
|
+
class SpawnFailed < StandardError; end
|
|
15
|
+
|
|
16
|
+
SHUTDOWN_GRACE = 5.0
|
|
17
|
+
SPAWN_TIMEOUT = 30.0
|
|
18
|
+
REAP_POLL_SECONDS = 0.05
|
|
19
|
+
|
|
20
|
+
# @param socket [IO] the parent end of the connection
|
|
21
|
+
# @param pid [Integer, nil] the server process, reaped on #shutdown
|
|
22
|
+
# @param shutdown_grace [Float] seconds the server may take to exit before it is killed
|
|
23
|
+
def initialize(socket:, pid:, shutdown_grace: SHUTDOWN_GRACE)
|
|
24
|
+
@socket = socket
|
|
25
|
+
@pid = pid
|
|
26
|
+
@shutdown_grace = shutdown_grace
|
|
27
|
+
@reader = Protocol::Reader.new(socket)
|
|
28
|
+
@next_request_id = 0
|
|
29
|
+
@boot = nil
|
|
30
|
+
@spawned = {}
|
|
31
|
+
@exits = {}
|
|
32
|
+
@outstanding = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lost?
|
|
36
|
+
@lost == true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The connection the parent can select on to learn of relayed events.
|
|
40
|
+
def io
|
|
41
|
+
@socket
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @raise [BootFailed] when the server reports failure or stays silent
|
|
45
|
+
# @raise [ServerLost] when the server dies while booting
|
|
46
|
+
def wait_until_ready(timeout)
|
|
47
|
+
outcome = await(timeout) { @boot }
|
|
48
|
+
raise BootFailed, "the fork server did not become ready within #{timeout}s" unless outcome
|
|
49
|
+
raise BootFailed, outcome.last if outcome.first == :failed
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Integer] pid of the forked mutant process
|
|
55
|
+
# @raise [SpawnFailed] when the server could not fork this mutant
|
|
56
|
+
# @raise [ServerLost]
|
|
57
|
+
def spawn_mutant(mutant_id:, test_files:, log_paths:, env: {})
|
|
58
|
+
request_id = (@next_request_id += 1)
|
|
59
|
+
send_message("type" => "run", "request_id" => request_id, "mutant_id" => mutant_id,
|
|
60
|
+
"test_files" => test_files, "log_paths" => log_paths, "env" => env)
|
|
61
|
+
outcome = await(SPAWN_TIMEOUT) { @spawned.delete(request_id) }
|
|
62
|
+
lose_server("the fork server did not answer a run request") unless outcome
|
|
63
|
+
raise SpawnFailed, outcome.last if outcome.first == :failed
|
|
64
|
+
|
|
65
|
+
@outstanding << outcome.last
|
|
66
|
+
outcome.last
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Status, nil] how the process ended; nil when it is still
|
|
70
|
+
# running after +timeout+ seconds
|
|
71
|
+
def wait_exit(pid, timeout)
|
|
72
|
+
await(timeout) { @exits.delete(pid) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Non-blocking variants for the parallel scheduler, which reaps
|
|
76
|
+
# completions from relayed events. A lost server completes every
|
|
77
|
+
# outstanding process with a {Status.lost} instead of raising, so no
|
|
78
|
+
# slot waits for an event that cannot arrive and no verdict is invented.
|
|
79
|
+
#
|
|
80
|
+
# @return [Status, nil]
|
|
81
|
+
def poll_exit(pid)
|
|
82
|
+
drain_available
|
|
83
|
+
@exits.delete(pid)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Array(Integer, Status), nil]
|
|
87
|
+
def poll_any_exit
|
|
88
|
+
drain_available
|
|
89
|
+
pid, status = @exits.first
|
|
90
|
+
return nil unless pid
|
|
91
|
+
|
|
92
|
+
@exits.delete(pid)
|
|
93
|
+
[pid, status]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Whether an event is already in user space (read off the socket but not
|
|
97
|
+
# yet handed out), which the socket's readability no longer signals.
|
|
98
|
+
def pending_events?
|
|
99
|
+
!@exits.empty? || @reader.buffered_message?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Stops tracking a pid whose result is no longer wanted, such as a child
|
|
103
|
+
# the caller gave up on after a timeout. Its exit, if one already
|
|
104
|
+
# arrived or arrives later, is dropped rather than left for a recycled pid.
|
|
105
|
+
def forget(pid)
|
|
106
|
+
drain_available
|
|
107
|
+
@outstanding.delete(pid)
|
|
108
|
+
@exits.delete(pid)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Gives up on a server that is lost or stalled: it must not fork another
|
|
112
|
+
# mutant that a local run is about to repeat.
|
|
113
|
+
def abandon
|
|
114
|
+
@lost = true
|
|
115
|
+
kill_outstanding
|
|
116
|
+
close_socket
|
|
117
|
+
reap_server
|
|
118
|
+
@pid = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def shutdown
|
|
122
|
+
send_message("type" => "shutdown")
|
|
123
|
+
rescue ServerLost
|
|
124
|
+
nil
|
|
125
|
+
ensure
|
|
126
|
+
close_socket
|
|
127
|
+
reap_server
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# The registry keeps fallback children from inheriting this connection.
|
|
133
|
+
def close_socket
|
|
134
|
+
InheritedFdRegistry.unregister(@socket)
|
|
135
|
+
@socket.close unless @socket.closed?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Without its server a mutant process has no one to reap it, and a local
|
|
139
|
+
# rerun of the same mutant must not race it.
|
|
140
|
+
def kill_outstanding
|
|
141
|
+
@outstanding.each { |pid| GroupKiller.kill(pid) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def await(timeout)
|
|
145
|
+
deadline = now + timeout
|
|
146
|
+
until (result = yield)
|
|
147
|
+
remaining = deadline - now
|
|
148
|
+
return nil unless remaining.positive?
|
|
149
|
+
|
|
150
|
+
pump(remaining)
|
|
151
|
+
end
|
|
152
|
+
result
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def pump(timeout)
|
|
156
|
+
message = @reader.next_message(timeout)
|
|
157
|
+
return if message == Protocol::Reader::TIMEOUT
|
|
158
|
+
return lose_server if message.nil?
|
|
159
|
+
|
|
160
|
+
record(message)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def drain_available
|
|
164
|
+
loop do
|
|
165
|
+
message = @reader.next_message(0)
|
|
166
|
+
break if message == Protocol::Reader::TIMEOUT
|
|
167
|
+
|
|
168
|
+
message.nil? ? lose_server : record(message)
|
|
169
|
+
end
|
|
170
|
+
rescue ServerLost
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def lose_server(reason = "the fork server closed the connection")
|
|
175
|
+
@lost = true
|
|
176
|
+
kill_outstanding
|
|
177
|
+
@outstanding.each { |pid| @exits[pid] ||= Status.lost }
|
|
178
|
+
@outstanding.clear
|
|
179
|
+
raise ServerLost, reason
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def record(message)
|
|
183
|
+
case message["type"]
|
|
184
|
+
when "ready" then @boot = [:ready]
|
|
185
|
+
when "boot_failed" then @boot = [:failed, message["reason"]]
|
|
186
|
+
when "spawned" then @spawned[message["request_id"]] = [:ok, message["pid"]]
|
|
187
|
+
when "spawn_failed" then @spawned[message["request_id"]] = [:failed, message["reason"]]
|
|
188
|
+
when "exited" then record_exit(message)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def record_exit(message)
|
|
193
|
+
pid = message["pid"]
|
|
194
|
+
return unless @outstanding.delete(pid)
|
|
195
|
+
|
|
196
|
+
@exits[pid] = Status.from_event(message)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def send_message(message)
|
|
200
|
+
@socket.write(Protocol.encode(message))
|
|
201
|
+
rescue IOError, SystemCallError => e
|
|
202
|
+
raise ServerLost, "the fork server connection is gone: #{e.message}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def reap_server
|
|
206
|
+
return if @pid.nil? || exited_within?(@shutdown_grace)
|
|
207
|
+
|
|
208
|
+
Process.kill(:SIGKILL, @pid)
|
|
209
|
+
Process.wait(@pid)
|
|
210
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def exited_within?(grace)
|
|
215
|
+
deadline = now + grace
|
|
216
|
+
loop do
|
|
217
|
+
return true if Process.wait(@pid, Process::WNOHANG)
|
|
218
|
+
return false if now > deadline
|
|
219
|
+
|
|
220
|
+
sleep(REAP_POLL_SECONDS)
|
|
221
|
+
end
|
|
222
|
+
rescue Errno::ECHILD
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def now
|
|
227
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# Signals a mutant process the fork server forked. Such a process leads
|
|
6
|
+
# its own process group only once its bootstrap ran +setpgid+, so until
|
|
7
|
+
# then the group does not exist and the process itself is signalled.
|
|
8
|
+
# Its parent is the server, so it is never ours to wait on.
|
|
9
|
+
module GroupKiller
|
|
10
|
+
# @param leader [Boolean] whether the pid itself may still be alive;
|
|
11
|
+
# false once it was reaped, when only stragglers in its group remain
|
|
12
|
+
def self.kill(pid, signal = :SIGKILL, leader: true)
|
|
13
|
+
Process.kill(signal, -pid)
|
|
14
|
+
rescue Errno::ESRCH
|
|
15
|
+
kill_leader(pid, signal) if leader
|
|
16
|
+
rescue Errno::EPERM
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.kill_leader(pid, signal)
|
|
21
|
+
Process.kill(signal, pid)
|
|
22
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
private_class_method :kill_leader
|
|
26
|
+
|
|
27
|
+
# Signal 0 probes existence only; a missing process or group is not alive.
|
|
28
|
+
def self.alive?(pid, leader: true)
|
|
29
|
+
Process.kill(0, -pid)
|
|
30
|
+
true
|
|
31
|
+
rescue Errno::ESRCH
|
|
32
|
+
leader && probe(pid)
|
|
33
|
+
rescue Errno::EPERM
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.probe(pid)
|
|
38
|
+
Process.kill(0, pid)
|
|
39
|
+
true
|
|
40
|
+
rescue Errno::ESRCH
|
|
41
|
+
false
|
|
42
|
+
rescue Errno::EPERM
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
private_class_method :probe
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# Stops a mutant process of the fork server and everything it started.
|
|
6
|
+
#
|
|
7
|
+
# The process is the server's child, not ours: waiting on it raises
|
|
8
|
+
# ECHILD, so liveness is probed with signal 0 instead.
|
|
9
|
+
class GroupTerminator
|
|
10
|
+
GRACE_SECONDS = 2.0
|
|
11
|
+
KILL_SECONDS = 1.0
|
|
12
|
+
POLL_SECONDS = 0.02
|
|
13
|
+
|
|
14
|
+
def initialize(killer: GroupKiller, clock: Process.method(:clock_gettime), pauser: Kernel.method(:sleep))
|
|
15
|
+
@killer = killer
|
|
16
|
+
@clock = clock
|
|
17
|
+
@pauser = pauser
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param leader [Boolean] false when the process already ended and only
|
|
21
|
+
# members of its group are left
|
|
22
|
+
def terminate(pid, leader: true)
|
|
23
|
+
@killer.kill(pid, :SIGTERM, leader:)
|
|
24
|
+
return if gone_within?(pid, GRACE_SECONDS, leader)
|
|
25
|
+
|
|
26
|
+
@killer.kill(pid, :SIGKILL, leader:)
|
|
27
|
+
gone_within?(pid, KILL_SECONDS, leader)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def gone_within?(pid, seconds, leader)
|
|
33
|
+
deadline = now + seconds
|
|
34
|
+
while @killer.alive?(pid, leader:)
|
|
35
|
+
return false if now >= deadline
|
|
36
|
+
|
|
37
|
+
@pauser.call(POLL_SECONDS)
|
|
38
|
+
end
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def now
|
|
43
|
+
@clock.call(Process::CLOCK_MONOTONIC)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|