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,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
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# The process primitives {SlotScheduler} drives, for a parallel run whose
|
|
6
|
+
# mutant processes belong to the fork server (ADR-15).
|
|
7
|
+
#
|
|
8
|
+
# The parent cannot wait on a grandchild, so a served pid completes when
|
|
9
|
+
# the server relays its +exited+ event. A pid forked locally (the per-mutant
|
|
10
|
+
# fallback) is waited on as usual. Signals go straight to the OS: every
|
|
11
|
+
# mutant process leads its own process group.
|
|
12
|
+
class Runtime
|
|
13
|
+
WAIT_SECONDS = 5.0
|
|
14
|
+
ANY_PID = -1
|
|
15
|
+
|
|
16
|
+
# @param client [Client]
|
|
17
|
+
# @param base [#clock_gettime, #wait2, #kill, #wait, #pause, #trap]
|
|
18
|
+
def initialize(client:, base: ProcessWorkerRunner::Runtime.new)
|
|
19
|
+
@client = client
|
|
20
|
+
@base = base
|
|
21
|
+
@served = []
|
|
22
|
+
@local = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def served(pid)
|
|
26
|
+
@served << pid
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def local(pid)
|
|
30
|
+
@local << pid
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Sockets the event loop must wake on besides its signal pipe.
|
|
34
|
+
def wakeup_ios
|
|
35
|
+
@client.lost? ? [] : [@client.io]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# An event already read off the socket does not wake a select on it.
|
|
39
|
+
def pending_events?
|
|
40
|
+
@client.pending_events?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wait2(pid, flags = nil)
|
|
44
|
+
return any_exit(flags) if pid == ANY_PID
|
|
45
|
+
return served_exit(pid) if @served.include?(pid)
|
|
46
|
+
|
|
47
|
+
local_exit(pid, flags)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wait(pid)
|
|
51
|
+
return @base.wait(pid) unless @served.include?(pid)
|
|
52
|
+
|
|
53
|
+
status = await_exit(pid)
|
|
54
|
+
@served.delete(pid) if status
|
|
55
|
+
status
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A served process leads no group until its bootstrap ran +setpgid+;
|
|
59
|
+
# until then the signal goes to the process itself.
|
|
60
|
+
def kill(signal, pid)
|
|
61
|
+
@base.kill(signal, pid)
|
|
62
|
+
rescue Errno::ESRCH
|
|
63
|
+
raise unless pid.negative? && @served.include?(-pid)
|
|
64
|
+
|
|
65
|
+
@base.kill(signal, -pid)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def clock_gettime(clock_id) = @base.clock_gettime(clock_id)
|
|
69
|
+
def pause(seconds) = @base.pause(seconds)
|
|
70
|
+
def trap(signal, handler = nil, &) = @base.trap(signal, handler, &)
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def any_exit(flags)
|
|
75
|
+
finished = @client.poll_any_exit
|
|
76
|
+
if finished
|
|
77
|
+
@served.delete(finished.first)
|
|
78
|
+
return finished
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@local.each do |pid|
|
|
82
|
+
result = local_exit(pid, flags)
|
|
83
|
+
return result if result
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def served_exit(pid)
|
|
89
|
+
status = @client.poll_exit(pid)
|
|
90
|
+
return nil unless status
|
|
91
|
+
|
|
92
|
+
@served.delete(pid)
|
|
93
|
+
[pid, status]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def local_exit(pid, flags)
|
|
97
|
+
result = @base.wait2(pid, flags)
|
|
98
|
+
@local.delete(pid) if result
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def await_exit(pid)
|
|
103
|
+
@client.wait_exit(pid, WAIT_SECONDS)
|
|
104
|
+
rescue Client::ServerLost
|
|
105
|
+
@client.poll_exit(pid)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# The request loop of the fork server process. It boots once (the boot
|
|
6
|
+
# callable loads the integration environment), then forks one mutant
|
|
7
|
+
# process per `run` request through the launcher and relays how each one
|
|
8
|
+
# ended, because only this process can wait on them.
|
|
9
|
+
class Server
|
|
10
|
+
POLL_SECONDS = 0.5
|
|
11
|
+
|
|
12
|
+
# @param socket [IO] the server end of the connection to the parent
|
|
13
|
+
# @param boot [#call] loads the environment; raising reports `boot_failed`
|
|
14
|
+
# @param launcher [#call] request Hash -> pid of the forked mutant process
|
|
15
|
+
# @param reaper [#call] -> Array of [pid, Status] for ended children
|
|
16
|
+
# @param wake_reader [IO, nil] readable when a child ended (CHLD wakeup)
|
|
17
|
+
# @param killer [#call] pid -> kills that pid's process group
|
|
18
|
+
# rubocop:disable Metrics/ParameterLists
|
|
19
|
+
def initialize(socket:, boot:, launcher:, reaper: Reaper.method(:reap), wake_reader: nil,
|
|
20
|
+
killer: GroupKiller.method(:kill))
|
|
21
|
+
# rubocop:enable Metrics/ParameterLists
|
|
22
|
+
@socket = socket
|
|
23
|
+
@reader = Protocol::Reader.new(socket)
|
|
24
|
+
@boot = boot
|
|
25
|
+
@launcher = launcher
|
|
26
|
+
@reaper = reaper
|
|
27
|
+
@wake_reader = wake_reader
|
|
28
|
+
@killer = killer
|
|
29
|
+
@live = []
|
|
30
|
+
@shutdown = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def serve
|
|
34
|
+
return unless announce_boot
|
|
35
|
+
|
|
36
|
+
until @shutdown
|
|
37
|
+
wait_for_activity
|
|
38
|
+
relay_exits
|
|
39
|
+
handle_requests
|
|
40
|
+
end
|
|
41
|
+
ensure
|
|
42
|
+
@live.each { |pid| @killer.call(pid) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def announce_boot
|
|
48
|
+
@boot.call
|
|
49
|
+
send_event("type" => "ready")
|
|
50
|
+
true
|
|
51
|
+
rescue StandardError, ScriptError, SystemExit => e
|
|
52
|
+
send_event("type" => "boot_failed", "reason" => "#{e.class}: #{e.message}")
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wait_for_activity
|
|
57
|
+
ready = IO.select([@socket, @wake_reader].compact, nil, nil, POLL_SECONDS)
|
|
58
|
+
drain_wake_reader if @wake_reader && ready&.first&.include?(@wake_reader)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def drain_wake_reader
|
|
62
|
+
loop { @wake_reader.read_nonblock(4096) }
|
|
63
|
+
rescue IO::WaitReadable, EOFError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def relay_exits
|
|
68
|
+
@reaper.call.each do |pid, status|
|
|
69
|
+
next unless @live.delete(pid)
|
|
70
|
+
|
|
71
|
+
send_event({ "type" => "exited", "pid" => pid }.merge(status.to_event))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handle_requests
|
|
76
|
+
loop do
|
|
77
|
+
message = @reader.next_message(0)
|
|
78
|
+
return @shutdown = true if message.nil?
|
|
79
|
+
return unless message.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
dispatch(message)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def dispatch(message)
|
|
86
|
+
case message["type"]
|
|
87
|
+
when "run" then launch(message)
|
|
88
|
+
when "shutdown" then @shutdown = true
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def launch(request)
|
|
93
|
+
pid = @launcher.call(request)
|
|
94
|
+
@live << pid
|
|
95
|
+
send_event("type" => "spawned", "request_id" => request["request_id"], "pid" => pid)
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
send_event("type" => "spawn_failed", "request_id" => request["request_id"],
|
|
98
|
+
"reason" => "#{e.class}: #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def send_event(event)
|
|
102
|
+
@socket.write(Protocol.encode(event))
|
|
103
|
+
rescue IOError, SystemCallError
|
|
104
|
+
@shutdown = true
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
module ForkServer
|
|
8
|
+
# Forks the fork server from the parent, after the mutants exist, and
|
|
9
|
+
# returns the parent's Client for it. In the server process: set up like
|
|
10
|
+
# any mutant child, send boot output to a log file, boot the integration
|
|
11
|
+
# environment once, then serve requests until told to stop.
|
|
12
|
+
class ServerProcess
|
|
13
|
+
def self.start(integration:, mutants:, test_files:, log_path:)
|
|
14
|
+
new(integration:, mutants:, test_files:, log_path:).start
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(integration:, mutants:, test_files:, log_path:)
|
|
18
|
+
@integration = integration
|
|
19
|
+
@mutants = mutants.to_h { |mutant| [mutant.id, mutant] }
|
|
20
|
+
@test_files = test_files
|
|
21
|
+
@log_path = log_path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Client]
|
|
25
|
+
def start
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(@log_path))
|
|
27
|
+
parent_socket, server_socket = UNIXSocket.pair
|
|
28
|
+
InheritedFdRegistry.register(parent_socket)
|
|
29
|
+
parent_pid = Process.pid
|
|
30
|
+
pid = Process.fork do
|
|
31
|
+
parent_socket.close
|
|
32
|
+
serve(server_socket, parent_pid)
|
|
33
|
+
end
|
|
34
|
+
server_socket.close
|
|
35
|
+
Client.new(socket: parent_socket, pid:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def serve(socket, parent_pid)
|
|
41
|
+
Integration::ChildBootstrap.after_fork!(parent_pid:)
|
|
42
|
+
redirect_output
|
|
43
|
+
wake_reader, wake_writer = IO.pipe
|
|
44
|
+
[wake_reader, wake_writer].each { |io| InheritedFdRegistry.register(io) }
|
|
45
|
+
launcher = Launcher.new(integration: @integration, mutants: @mutants, socket:)
|
|
46
|
+
boot = -> { boot_environment(wake_writer) }
|
|
47
|
+
Server.new(socket:, boot:, launcher:, wake_reader:).serve
|
|
48
|
+
Process.exit!(0)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def boot_environment(wake_writer)
|
|
52
|
+
@integration.preload_environment(@test_files)
|
|
53
|
+
Signal.trap("CHLD") { wake(wake_writer) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wake(writer)
|
|
57
|
+
writer.write_nonblock(".")
|
|
58
|
+
rescue IO::WaitWritable, IOError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def redirect_output
|
|
63
|
+
File.open(@log_path, "w") do |log|
|
|
64
|
+
$stdout.reopen(log)
|
|
65
|
+
$stderr.reopen(log)
|
|
66
|
+
end
|
|
67
|
+
$stdin.reopen(File::NULL)
|
|
68
|
+
$stdout.sync = true
|
|
69
|
+
$stderr.sync = true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ForkServer
|
|
5
|
+
# Stands in for Process::Status of a mutant process that the parent cannot
|
|
6
|
+
# wait on itself: the fork server reaps it and relays how it ended.
|
|
7
|
+
# Answers the subset MutantVerdict and ScenarioExecutionResult read.
|
|
8
|
+
class Status
|
|
9
|
+
attr_reader :exitstatus, :termsig
|
|
10
|
+
|
|
11
|
+
def self.from_event(event)
|
|
12
|
+
new(exitstatus: event["exitstatus"], termsig: event["termsig"])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_process_status(status)
|
|
16
|
+
new(exitstatus: status.exitstatus, termsig: status.termsig)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Stands in for a process whose end the lost server never relayed. It
|
|
20
|
+
# says nothing about the mutant, so it must never become a verdict.
|
|
21
|
+
def self.lost
|
|
22
|
+
new(exitstatus: nil, termsig: 9, lost: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(exitstatus:, termsig:, lost: false)
|
|
26
|
+
@exitstatus = exitstatus
|
|
27
|
+
@termsig = termsig
|
|
28
|
+
@lost = lost
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def lost?
|
|
32
|
+
@lost
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def exited?
|
|
36
|
+
!signaled?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def signaled?
|
|
40
|
+
!termsig.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def success?
|
|
44
|
+
!signaled? && exitstatus.to_i.zero?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_event
|
|
48
|
+
{ "exitstatus" => exitstatus, "termsig" => termsig }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fork_server/protocol"
|
|
4
|
+
require_relative "fork_server/status"
|
|
5
|
+
require_relative "fork_server/reaper"
|
|
6
|
+
require_relative "fork_server/group_killer"
|
|
7
|
+
require_relative "fork_server/group_terminator"
|
|
8
|
+
require_relative "fork_server/server"
|
|
9
|
+
require_relative "fork_server/launcher"
|
|
10
|
+
require_relative "fork_server/client"
|
|
11
|
+
require_relative "fork_server/runtime"
|
|
12
|
+
require_relative "fork_server/server_process"
|
|
13
|
+
require_relative "fork_server/mutant_runner"
|
|
14
|
+
|
|
15
|
+
module Henitai
|
|
16
|
+
# Opt-in execution model (ADR-15): the test environment boots once in a
|
|
17
|
+
# long-lived server process and every mutant process is forked from it.
|
|
18
|
+
module ForkServer
|
|
19
|
+
BOOT_TIMEOUT = 300.0
|
|
20
|
+
|
|
21
|
+
# Starts a server for the run and returns a runner that sends mutants to
|
|
22
|
+
# it, or nil, after a warning, when the server cannot be booted. The
|
|
23
|
+
# caller then keeps the per-mutant boot.
|
|
24
|
+
#
|
|
25
|
+
# @return [MutantRunner, nil]
|
|
26
|
+
# rubocop:disable Metrics/ParameterLists
|
|
27
|
+
def self.start(integration:, mutants:, test_files:, log_path:, warner: nil, boot_timeout: BOOT_TIMEOUT,
|
|
28
|
+
fallback_timeout: nil)
|
|
29
|
+
warner ||= ->(message) { Kernel.warn(message) }
|
|
30
|
+
client = ServerProcess.start(integration:, mutants:, test_files:, log_path:)
|
|
31
|
+
begin
|
|
32
|
+
client.wait_until_ready(boot_timeout)
|
|
33
|
+
rescue Client::BootFailed, Client::ServerLost => e
|
|
34
|
+
client.shutdown
|
|
35
|
+
warner.call("henitai: the fork server failed to boot (#{e.message}); see #{log_path}; " \
|
|
36
|
+
"falling back to per-mutant boot")
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
MutantRunner.new(integration:, client:, warner:, fallback_timeout:)
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Metrics/ParameterLists
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -18,7 +18,10 @@ module Henitai
|
|
|
18
18
|
mutation-history.sqlite3
|
|
19
19
|
mutation-history.json
|
|
20
20
|
henitai_per_test.json
|
|
21
|
+
henitai_suite_boot.json
|
|
21
22
|
henitai_dependency_manifest.json
|
|
23
|
+
henitai_flaky_tests.json
|
|
24
|
+
henitai_suite_outcomes.json
|
|
22
25
|
.henitai-run.lock
|
|
23
26
|
mutation-logs
|
|
24
27
|
mutation-coverage
|