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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -1
  3. data/README.md +100 -13
  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 +36 -10
  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 +7 -5
  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 +225 -45
  121. metadata +46 -3
  122. 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