henitai 0.5.1 → 0.6.0

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