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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -1
  3. data/README.md +109 -16
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +57 -15
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +17 -7
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +227 -45
  121. metadata +46 -3
  122. 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