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,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
|
|
@@ -9,14 +9,23 @@ module Henitai
|
|
|
9
9
|
# Shells out to git to discover changed files between two refs.
|
|
10
10
|
#
|
|
11
11
|
# By default the analyzer runs in the current working directory. Callers can
|
|
12
|
-
# pass dir: to point it at another
|
|
12
|
+
# pass dir: to point it at another directory without changing cwd.
|
|
13
|
+
#
|
|
14
|
+
# Every path is reported relative to that directory, which need not be the
|
|
15
|
+
# repository root: `git diff` would otherwise print root-relative paths
|
|
16
|
+
# while `git ls-files` prints directory-relative ones. Paths are read
|
|
17
|
+
# NUL-separated so git never quotes non-ASCII names.
|
|
13
18
|
class GitDiffAnalyzer
|
|
14
|
-
|
|
15
|
-
stdout, stderr, status = git_diff(dir, "--name-only", from, to)
|
|
19
|
+
NAME_ONLY = ["--name-only", "--relative", "-z"].freeze
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
def changed_files(from:, to:, dir: Dir.pwd)
|
|
22
|
+
diff_paths(dir, from, to)
|
|
23
|
+
end
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
# Changes on HEAD since its merge base with ref. Commits that landed on
|
|
26
|
+
# ref after the fork point are not part of this diff.
|
|
27
|
+
def changed_files_since(ref, dir: Dir.pwd)
|
|
28
|
+
diff_paths(dir, "#{ref}...HEAD")
|
|
20
29
|
end
|
|
21
30
|
|
|
22
31
|
def working_tree_changed_files(dir: Dir.pwd)
|
|
@@ -55,7 +64,7 @@ module Henitai
|
|
|
55
64
|
end
|
|
56
65
|
|
|
57
66
|
def changed_line_ranges(path, from:, to:, dir:)
|
|
58
|
-
stdout, stderr, status = git_diff(dir, "--unified=0", from, to, "--", path)
|
|
67
|
+
stdout, stderr, status = git_diff(dir, "--unified=0", "--relative", from, to, "--", path)
|
|
59
68
|
|
|
60
69
|
raise GitDiffError, stderr.strip unless status.success?
|
|
61
70
|
|
|
@@ -94,23 +103,31 @@ module Henitai
|
|
|
94
103
|
Open3.capture3(*command)
|
|
95
104
|
end
|
|
96
105
|
|
|
97
|
-
def
|
|
98
|
-
stdout, stderr, status = git_diff(dir,
|
|
106
|
+
def diff_paths(dir, *revisions)
|
|
107
|
+
stdout, stderr, status = git_diff(dir, *NAME_ONLY, *revisions)
|
|
99
108
|
|
|
100
109
|
raise GitDiffError, stderr.strip unless status.success?
|
|
101
110
|
|
|
102
|
-
stdout
|
|
111
|
+
paths(stdout)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def working_tree_tracked_files(dir)
|
|
115
|
+
diff_paths(dir, "HEAD")
|
|
103
116
|
end
|
|
104
117
|
|
|
105
118
|
def untracked_files(dir)
|
|
106
119
|
command = ["git"]
|
|
107
120
|
command += ["-C", dir] if dir
|
|
108
|
-
command += ["ls-files", "--others", "--exclude-standard"]
|
|
121
|
+
command += ["ls-files", "--others", "--exclude-standard", "-z"]
|
|
109
122
|
stdout, stderr, status = Open3.capture3(*command)
|
|
110
123
|
|
|
111
124
|
raise GitDiffError, stderr.strip unless status.success?
|
|
112
125
|
|
|
113
|
-
stdout
|
|
126
|
+
paths(stdout)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def paths(stdout)
|
|
130
|
+
stdout.dup.force_encoding(Encoding::UTF_8).split("\0").reject(&:empty?)
|
|
114
131
|
end
|
|
115
132
|
end
|
|
116
133
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "signature_line"
|
|
3
4
|
require_relative "verdict_fingerprint"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
@@ -8,23 +9,26 @@ module Henitai
|
|
|
8
9
|
#
|
|
9
10
|
# Reuse rules (conservative — any doubt resolves to re-execution):
|
|
10
11
|
#
|
|
12
|
+
# * Both verdicts: the stored {ReuseContext} fingerprint (test
|
|
13
|
+
# dependencies, verdict-relevant configuration, henitai version) must
|
|
14
|
+
# equal the current one. Rows recorded without a context are never
|
|
15
|
+
# reused.
|
|
11
16
|
# * Killed: the stored verdict must carry both fingerprints, and both the
|
|
12
17
|
# subject's source and every recorded covering test file must be
|
|
13
18
|
# byte-identical to what was recorded. Killed is monotone, so no live
|
|
14
19
|
# coverage is needed.
|
|
15
20
|
# * Survived: additionally the LIVE covering set (the full-map
|
|
16
21
|
# intersection computed via {PerTestCoverage}) must equal the recorded
|
|
17
|
-
# set in membership
|
|
18
|
-
#
|
|
19
|
-
# re-
|
|
20
|
-
# fingerprint, survivors always re-execute.
|
|
22
|
+
# set in membership — a new or edited test reaching the mutant always
|
|
23
|
+
# forces a re-run (ADR-11). Without a live per-test map or a context,
|
|
24
|
+
# survivors always re-execute.
|
|
21
25
|
#
|
|
22
26
|
# Timeouts, errors, unknown ids and legacy rows always re-execute.
|
|
23
27
|
class IncrementalFilter
|
|
24
|
-
def initialize(history_store:, per_test_coverage: nil,
|
|
28
|
+
def initialize(history_store:, per_test_coverage: nil, reuse_context: nil)
|
|
25
29
|
@history_store = history_store
|
|
26
30
|
@per_test_coverage = per_test_coverage
|
|
27
|
-
@
|
|
31
|
+
@reuse_context = reuse_context
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
# @return [Array<Mutant>] the same collection; cache hits get the stored
|
|
@@ -48,7 +52,7 @@ module Henitai
|
|
|
48
52
|
|
|
49
53
|
private
|
|
50
54
|
|
|
51
|
-
attr_reader :history_store, :per_test_coverage, :
|
|
55
|
+
attr_reader :history_store, :per_test_coverage, :reuse_context
|
|
52
56
|
|
|
53
57
|
# MutantIdentity deliberately omits source coordinates (line-drift
|
|
54
58
|
# tolerance), so distinct mutants inside one subject can share a stable
|
|
@@ -75,7 +79,9 @@ module Henitai
|
|
|
75
79
|
|
|
76
80
|
def killed_reusable?(mutant, verdict)
|
|
77
81
|
subject_source_unchanged?(mutant, verdict) &&
|
|
78
|
-
VerdictFingerprint.tests_fingerprint_current?(
|
|
82
|
+
VerdictFingerprint.tests_fingerprint_current?(
|
|
83
|
+
verdict.fetch(:covered_tests_fingerprint), context_sha: context_sha
|
|
84
|
+
)
|
|
79
85
|
end
|
|
80
86
|
|
|
81
87
|
# The filter checks per-test map availability itself instead of trusting
|
|
@@ -84,17 +90,21 @@ module Henitai
|
|
|
84
90
|
# unknowable means re-execute.
|
|
85
91
|
def survived_reusable?(mutant, verdict)
|
|
86
92
|
return false unless per_test_coverage&.available?
|
|
93
|
+
return false if SignatureLine.on?(mutant)
|
|
87
94
|
return false unless subject_source_unchanged?(mutant, verdict)
|
|
88
95
|
|
|
89
96
|
VerdictFingerprint.survivor_fingerprint_current?(
|
|
90
97
|
verdict.fetch(:covered_tests_fingerprint),
|
|
91
98
|
live_paths: per_test_coverage.tests_covering(mutant),
|
|
92
|
-
|
|
99
|
+
context_sha: context_sha
|
|
93
100
|
)
|
|
94
101
|
end
|
|
95
102
|
|
|
103
|
+
def context_sha = reuse_context&.fingerprint
|
|
104
|
+
|
|
96
105
|
def subject_source_unchanged?(mutant, verdict)
|
|
97
|
-
VerdictFingerprint.subject_source_hash(mutant)
|
|
106
|
+
current = VerdictFingerprint.subject_source_hash(mutant)
|
|
107
|
+
!current.nil? && current == verdict.fetch(:subject_source_hash)
|
|
98
108
|
end
|
|
99
109
|
end
|
|
100
110
|
end
|
|
@@ -15,6 +15,9 @@ module Henitai
|
|
|
15
15
|
class Base
|
|
16
16
|
include ChildRuntimeControl
|
|
17
17
|
|
|
18
|
+
EXITED_GROUP_POLL_INTERVAL = 0.05
|
|
19
|
+
EXITED_GROUP_POLL_COUNT = 40
|
|
20
|
+
|
|
18
21
|
# Child-run diagnostics. Public so the child-side modules mixed into
|
|
19
22
|
# concrete adapters can reach it without `send`.
|
|
20
23
|
def child_debug_log
|
|
@@ -57,6 +60,20 @@ module Henitai
|
|
|
57
60
|
false
|
|
58
61
|
end
|
|
59
62
|
|
|
63
|
+
# True when the integration can boot once in a fork server (ADR-15) and
|
|
64
|
+
# run mutants from the booted state.
|
|
65
|
+
def fork_server_supported?
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Loads the test environment and the given test files without running
|
|
70
|
+
# anything; only called inside the fork server process.
|
|
71
|
+
#
|
|
72
|
+
# @param test_files [Array<String>]
|
|
73
|
+
def preload_environment(test_files)
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
end
|
|
76
|
+
|
|
60
77
|
def wait_with_timeout(pid, timeout)
|
|
61
78
|
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
62
79
|
return Process.last_status if wait_nonblocking(pid)
|
|
@@ -78,6 +95,29 @@ module Henitai
|
|
|
78
95
|
end
|
|
79
96
|
|
|
80
97
|
def cleanup_process_group(pid)
|
|
98
|
+
if leader_exited?(pid)
|
|
99
|
+
terminate_exited_group(pid)
|
|
100
|
+
else
|
|
101
|
+
terminate_running_group(pid)
|
|
102
|
+
end
|
|
103
|
+
rescue Errno::EPERM
|
|
104
|
+
cleanup_child_process(pid)
|
|
105
|
+
rescue Errno::ESRCH
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# A leader that already exited normally cannot be waited on again, so
|
|
112
|
+
# the group is polled for members instead of burning the grace period.
|
|
113
|
+
def terminate_exited_group(pid)
|
|
114
|
+
Process.kill(:SIGTERM, -pid)
|
|
115
|
+
return unless group_members_remain?(pid)
|
|
116
|
+
|
|
117
|
+
Process.kill(:SIGKILL, -pid)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def terminate_running_group(pid)
|
|
81
121
|
grace_period = 2.0
|
|
82
122
|
wakeup = Henitai.const_get(:ProcessWakeup).new.install
|
|
83
123
|
Process.kill(:SIGTERM, -pid)
|
|
@@ -88,15 +128,34 @@ module Henitai
|
|
|
88
128
|
return if wait_nonblocking(pid)
|
|
89
129
|
|
|
90
130
|
Process.kill(:SIGKILL, -pid)
|
|
91
|
-
rescue Errno::EPERM
|
|
92
|
-
cleanup_child_process(pid)
|
|
93
|
-
rescue Errno::ESRCH
|
|
94
|
-
nil
|
|
95
131
|
ensure
|
|
96
132
|
wakeup&.close
|
|
97
133
|
end
|
|
98
134
|
|
|
99
|
-
|
|
135
|
+
def leader_exited?(pid)
|
|
136
|
+
!Process.wait(pid, Process::WNOHANG).nil?
|
|
137
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def group_members_remain?(pid)
|
|
142
|
+
EXITED_GROUP_POLL_COUNT.times do
|
|
143
|
+
return false unless group_alive?(pid)
|
|
144
|
+
|
|
145
|
+
pause(EXITED_GROUP_POLL_INTERVAL)
|
|
146
|
+
end
|
|
147
|
+
group_alive?(pid)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Signal 0 probes existence only: ESRCH means the group is empty.
|
|
151
|
+
def group_alive?(pid)
|
|
152
|
+
Process.kill(0, -pid)
|
|
153
|
+
true
|
|
154
|
+
rescue Errno::ESRCH
|
|
155
|
+
false
|
|
156
|
+
rescue Errno::EPERM
|
|
157
|
+
true
|
|
158
|
+
end
|
|
100
159
|
|
|
101
160
|
def pause(seconds)
|
|
102
161
|
sleep(seconds)
|
|
@@ -133,6 +192,10 @@ module Henitai
|
|
|
133
192
|
{ "PARALLEL_WORKERS" => "1" }
|
|
134
193
|
end
|
|
135
194
|
|
|
195
|
+
def suite_process_env
|
|
196
|
+
subprocess_env.merge(SuiteBootClock::STARTED_AT_ENV => Time.now.to_f.to_s)
|
|
197
|
+
end
|
|
198
|
+
|
|
136
199
|
def wait_nonblocking(pid)
|
|
137
200
|
Process.wait(pid, Process::WNOHANG)
|
|
138
201
|
rescue Errno::ECHILD, Errno::ESRCH
|
|
@@ -143,6 +206,10 @@ module Henitai
|
|
|
143
206
|
@scenario_log_support ||= ScenarioLogSupport.new
|
|
144
207
|
end
|
|
145
208
|
|
|
209
|
+
def child_report_store
|
|
210
|
+
@child_report_store ||= ChildReportStore.new
|
|
211
|
+
end
|
|
212
|
+
|
|
146
213
|
def with_subprocess_env
|
|
147
214
|
original_env = {} # : Hash[String, String?]
|
|
148
215
|
subprocess_env.each do |key, value|
|