henitai 0.5.3 → 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 +222 -1
- data/README.md +100 -13
- 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 +36 -10
- 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 +7 -5
- 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 +225 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
|
@@ -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
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# Forks one mutant process from the booted server. The process then does
|
|
6
|
+
# what a per-mutant child does after its own boot: process-group and trap
|
|
7
|
+
# setup, output capture, activation, the tests, the child report.
|
|
8
|
+
class Launcher
|
|
9
|
+
# @param mutants [Hash{String => Mutant}] the run's mutants by id
|
|
10
|
+
def initialize(integration:, mutants:, socket:, server_pid: Process.pid)
|
|
11
|
+
@integration = integration
|
|
12
|
+
@mutants = mutants
|
|
13
|
+
@socket = socket
|
|
14
|
+
@server_pid = server_pid
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param request [Hash] a `run` request
|
|
18
|
+
# @return [Integer] pid of the forked process
|
|
19
|
+
def call(request)
|
|
20
|
+
mutant = @mutants.fetch(request["mutant_id"]) { raise KeyError, "unknown mutant #{request['mutant_id']}" }
|
|
21
|
+
test_files = Array(request["test_files"])
|
|
22
|
+
log_paths = request.fetch("log_paths").transform_keys(&:to_sym)
|
|
23
|
+
env = request["env"] || {}
|
|
24
|
+
Process.fork { run_forked(mutant, test_files, log_paths, env) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def run_forked(mutant, test_files, log_paths, env)
|
|
30
|
+
@socket.close
|
|
31
|
+
Integration::ChildBootstrap.after_fork!(parent_pid: @server_pid)
|
|
32
|
+
env.each { |key, value| ENV[key] = value }
|
|
33
|
+
ENV["HENITAI_MUTANT_ID"] = mutant.id
|
|
34
|
+
Process.exit(@integration.run_in_child(mutant:, test_files:, log_paths:))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module ForkServer
|
|
7
|
+
# Runs mutants through the fork server instead of booting the test
|
|
8
|
+
# environment in every mutant process. Offers the integration's
|
|
9
|
+
# #run_mutant, so the engine's retry and timeout logic is unchanged.
|
|
10
|
+
#
|
|
11
|
+
# Also stands in for the integration of a parallel run: #spawn_mutant hands
|
|
12
|
+
# the scheduler a handle for a process the server forked, and #runtime lets
|
|
13
|
+
# it learn of that process's exit from the server's events.
|
|
14
|
+
#
|
|
15
|
+
# Falls back to the integration's own per-mutant boot when the server is
|
|
16
|
+
# lost, or when it cannot fork one particular mutant (for example the
|
|
17
|
+
# control run's mutant, which the server never saw).
|
|
18
|
+
class MutantRunner
|
|
19
|
+
extend Forwardable
|
|
20
|
+
|
|
21
|
+
EXIT_GRACE_SECONDS = 5.0
|
|
22
|
+
|
|
23
|
+
def_delegators :@integration, :build_result, :select_tests, :test_files
|
|
24
|
+
|
|
25
|
+
# @param fallback_timeout [#call, nil] test files -> timeout for a mutant
|
|
26
|
+
# the parent boots itself; the timeout handed to #run_mutant excludes
|
|
27
|
+
# boot while the server is active and would be far too short for it.
|
|
28
|
+
# rubocop:disable Metrics/ParameterLists
|
|
29
|
+
def initialize(integration:, client:, env: {}, warner: nil, fallback_timeout: nil,
|
|
30
|
+
report_store: Integration::ChildReportStore.new, terminator: GroupTerminator.new)
|
|
31
|
+
# rubocop:enable Metrics/ParameterLists
|
|
32
|
+
@integration = integration
|
|
33
|
+
@client = client
|
|
34
|
+
@env = env
|
|
35
|
+
@warner = warner || ->(message) { Kernel.warn(message) }
|
|
36
|
+
@fallback_timeout = fallback_timeout
|
|
37
|
+
@report_store = report_store
|
|
38
|
+
@terminator = terminator
|
|
39
|
+
@active = true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# False once the server is gone; a lost connection is noticed here even
|
|
43
|
+
# when no request is pending.
|
|
44
|
+
def active?
|
|
45
|
+
deactivate("the fork server closed the connection") if @active && @client.lost?
|
|
46
|
+
@active
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def runtime
|
|
50
|
+
@runtime ||= Runtime.new(client: @client)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Integration::ChildHandle]
|
|
54
|
+
def spawn_mutant(mutant:, test_files:)
|
|
55
|
+
return spawn_locally(mutant, test_files) unless active?
|
|
56
|
+
|
|
57
|
+
spawn_through_server(mutant, test_files)
|
|
58
|
+
rescue Client::SpawnFailed
|
|
59
|
+
spawn_locally(mutant, test_files)
|
|
60
|
+
rescue Client::ServerLost => e
|
|
61
|
+
deactivate(e.message)
|
|
62
|
+
spawn_locally(mutant, test_files)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run_mutant(mutant:, test_files:, timeout:)
|
|
66
|
+
return fallback(mutant, test_files, timeout) unless active?
|
|
67
|
+
|
|
68
|
+
run_through_server(mutant, test_files, timeout)
|
|
69
|
+
rescue Client::SpawnFailed
|
|
70
|
+
fallback(mutant, test_files, timeout)
|
|
71
|
+
rescue Client::ServerLost => e
|
|
72
|
+
deactivate(e.message)
|
|
73
|
+
fallback(mutant, test_files, timeout)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stop
|
|
77
|
+
@active = false
|
|
78
|
+
@client.shutdown
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def spawn_through_server(mutant, test_files)
|
|
84
|
+
log_paths = @integration.mutant_log_paths(mutant)
|
|
85
|
+
@report_store.clear(log_paths[:report_path])
|
|
86
|
+
pid = @client.spawn_mutant(mutant_id: mutant.id, test_files:, log_paths:, env: spawn_env)
|
|
87
|
+
runtime.served(pid)
|
|
88
|
+
Integration::ChildHandle.new(pid:, log_paths:)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def spawn_locally(mutant, test_files)
|
|
92
|
+
handle = @integration.spawn_mutant(mutant:, test_files:)
|
|
93
|
+
runtime.local(handle.pid)
|
|
94
|
+
Integration::ChildHandle.new(pid: handle.pid, log_paths: handle.log_paths,
|
|
95
|
+
timeout: fallback_timeout(test_files))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The scheduler exports the slot of the process it is about to spawn.
|
|
99
|
+
def spawn_env
|
|
100
|
+
slot = ENV.fetch(SlotScheduler::WORKER_SLOT_ENV, nil)
|
|
101
|
+
slot ? @env.merge(SlotScheduler::WORKER_SLOT_ENV => slot) : @env
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def run_through_server(mutant, test_files, timeout)
|
|
105
|
+
log_paths = @integration.mutant_log_paths(mutant)
|
|
106
|
+
@report_store.clear(log_paths[:report_path])
|
|
107
|
+
pid = @client.spawn_mutant(mutant_id: mutant.id, test_files:, log_paths:, env: @env)
|
|
108
|
+
wait_result = wait_for(pid, timeout)
|
|
109
|
+
@integration.build_result(wait_result, log_paths)
|
|
110
|
+
ensure
|
|
111
|
+
@terminator.terminate(pid, leader: false) if pid && wait_result != :timeout
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def wait_for(pid, timeout)
|
|
115
|
+
status = @client.wait_exit(pid, timeout)
|
|
116
|
+
return status if status
|
|
117
|
+
|
|
118
|
+
@terminator.terminate(pid)
|
|
119
|
+
@client.wait_exit(pid, EXIT_GRACE_SECONDS)
|
|
120
|
+
@client.forget(pid)
|
|
121
|
+
:timeout
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fallback(mutant, test_files, timeout)
|
|
125
|
+
@integration.run_mutant(mutant:, test_files:, timeout: fallback_timeout(test_files) || timeout)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fallback_timeout(test_files)
|
|
129
|
+
@fallback_timeout&.call(test_files)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def deactivate(reason)
|
|
133
|
+
return unless @active
|
|
134
|
+
|
|
135
|
+
@active = false
|
|
136
|
+
@client.abandon
|
|
137
|
+
@warner.call("henitai: the fork server stopped (#{reason}); falling back to per-mutant boot")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module ForkServer
|
|
7
|
+
# Line-delimited JSON messages between the henitai parent and its fork
|
|
8
|
+
# server. One message per line keeps both ends independent of how the
|
|
9
|
+
# socket chunks the stream.
|
|
10
|
+
#
|
|
11
|
+
# Requests (parent -> server):
|
|
12
|
+
# run: request_id, mutant_id, test_files, log_paths, env
|
|
13
|
+
# shutdown
|
|
14
|
+
# Events (server -> parent):
|
|
15
|
+
# ready | boot_failed(reason)
|
|
16
|
+
# spawned(request_id, pid) | spawn_failed(request_id, reason)
|
|
17
|
+
# exited(pid, exitstatus | termsig)
|
|
18
|
+
module Protocol
|
|
19
|
+
MAX_READ_BYTES = 65_536
|
|
20
|
+
|
|
21
|
+
def self.encode(message)
|
|
22
|
+
"#{JSON.generate(message)}\n"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Buffers a stream and yields whole messages. Returns nil at end of
|
|
26
|
+
# stream, so a dead peer is distinguishable from a quiet one.
|
|
27
|
+
class Reader
|
|
28
|
+
TIMEOUT = :timeout
|
|
29
|
+
|
|
30
|
+
def initialize(io)
|
|
31
|
+
@io = io
|
|
32
|
+
@buffer = +""
|
|
33
|
+
@eof = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param timeout [Float, nil] seconds to wait for a complete message
|
|
37
|
+
# @return [Hash, Symbol, nil] the message, TIMEOUT, or nil at end of stream
|
|
38
|
+
def next_message(timeout)
|
|
39
|
+
deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
|
|
40
|
+
loop do
|
|
41
|
+
line = @buffer.slice!(/\A.*\n/)
|
|
42
|
+
return JSON.parse(line) if line
|
|
43
|
+
return nil if @eof
|
|
44
|
+
|
|
45
|
+
fill(deadline) or return TIMEOUT
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True when a whole message is already read off the socket, so the
|
|
50
|
+
# socket itself will not signal it as readable.
|
|
51
|
+
def buffered_message?
|
|
52
|
+
@buffer.include?("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fill(deadline)
|
|
58
|
+
remaining = deadline && [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
|
|
59
|
+
return false unless @io.wait_readable(remaining)
|
|
60
|
+
|
|
61
|
+
chunk = @io.read_nonblock(MAX_READ_BYTES, exception: false)
|
|
62
|
+
return true if chunk == :wait_readable
|
|
63
|
+
|
|
64
|
+
@eof = true if chunk.nil?
|
|
65
|
+
@buffer << chunk unless chunk.nil?
|
|
66
|
+
true
|
|
67
|
+
rescue EOFError, Errno::ECONNRESET
|
|
68
|
+
@eof = true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# Collects the processes that have ended since the last call, without
|
|
6
|
+
# blocking. The fork server is the only process that can wait on its
|
|
7
|
+
# mutant processes.
|
|
8
|
+
module Reaper
|
|
9
|
+
# @return [Array<Array(Integer, Status)>] pid and status per ended child
|
|
10
|
+
def self.reap
|
|
11
|
+
exits = [] # : Array[[Integer, Status]]
|
|
12
|
+
loop do
|
|
13
|
+
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
14
|
+
break unless pid
|
|
15
|
+
|
|
16
|
+
exits << [pid, Status.from_process_status(status)]
|
|
17
|
+
end
|
|
18
|
+
exits
|
|
19
|
+
rescue Errno::ECHILD
|
|
20
|
+
exits
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|