henitai 0.2.0 → 0.2.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/README.md +15 -3
  4. data/assets/schema/henitai.schema.json +6 -0
  5. data/lib/henitai/cli/clean_command.rb +48 -0
  6. data/lib/henitai/cli/command_support.rb +51 -0
  7. data/lib/henitai/cli/init_command.rb +64 -0
  8. data/lib/henitai/cli/operator_command.rb +95 -0
  9. data/lib/henitai/cli/options.rb +120 -0
  10. data/lib/henitai/cli/run_command.rb +103 -0
  11. data/lib/henitai/cli.rb +16 -404
  12. data/lib/henitai/configuration.rb +2 -1
  13. data/lib/henitai/configuration_validator/rules.rb +143 -0
  14. data/lib/henitai/configuration_validator/scalars.rb +123 -0
  15. data/lib/henitai/configuration_validator.rb +12 -239
  16. data/lib/henitai/eager_load.rb +36 -5
  17. data/lib/henitai/execution_engine.rb +4 -3
  18. data/lib/henitai/integration/base.rb +171 -0
  19. data/lib/henitai/integration/child_debug_support.rb +115 -0
  20. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  21. data/lib/henitai/integration/coverage_suppression.rb +43 -0
  22. data/lib/henitai/integration/minitest.rb +133 -0
  23. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  24. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  25. data/lib/henitai/integration/rspec_test_selection.rb +135 -0
  26. data/lib/henitai/integration/scenario_log_support.rb +116 -0
  27. data/lib/henitai/integration.rb +22 -846
  28. data/lib/henitai/mutant/activator.rb +1 -79
  29. data/lib/henitai/mutant/parameter_source.rb +98 -0
  30. data/lib/henitai/mutant.rb +1 -0
  31. data/lib/henitai/mutant_history_store/sql.rb +72 -0
  32. data/lib/henitai/mutant_history_store.rb +5 -69
  33. data/lib/henitai/per_test_coverage_collector.rb +3 -1
  34. data/lib/henitai/process_worker_runner.rb +48 -334
  35. data/lib/henitai/reporter.rb +20 -8
  36. data/lib/henitai/result.rb +17 -15
  37. data/lib/henitai/runner.rb +59 -182
  38. data/lib/henitai/slot_scheduler/draining.rb +140 -0
  39. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  40. data/lib/henitai/slot_scheduler.rb +214 -0
  41. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  42. data/lib/henitai/unparse_helper.rb +5 -2
  43. data/lib/henitai/version.rb +1 -1
  44. data/lib/henitai.rb +2 -0
  45. data/sig/configuration_validator.rbs +46 -22
  46. data/sig/henitai.rbs +158 -73
  47. metadata +25 -2
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- # Flat, single-threaded process-slot scheduler for parallel mutation runs.
4
+ # Flat, single-threaded driver for parallel mutation runs.
5
5
  #
6
- # Owns the process table: it is the sole caller of Process.wait* so there
7
- # are no race conditions between threads reaping the same child.
8
- class ProcessWorkerRunner # rubocop:disable Metrics/ClassLength
9
- PROCESS_DRAIN_WINDOW = 0.2
10
-
6
+ # Owns the event loop and OS signal handling, and delegates the slot
7
+ # lifecycle (spawning, reaping, timeout detection and the drain/broadcast
8
+ # state machine) to a {SlotScheduler}. Because the scheduler is the sole
9
+ # caller of Process.wait*, there are no races between threads reaping the
10
+ # same child.
11
+ class ProcessWorkerRunner
11
12
  # Default bridge to process and signal primitives used by the scheduler.
12
13
  class Runtime
13
14
  def clock_gettime(clock_id)
@@ -31,12 +32,8 @@ module Henitai
31
32
  end
32
33
  end
33
34
 
34
- # Tracks one in-flight mutant child process.
35
- Slot = Struct.new(
36
- :slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
37
- :log_paths, :retry_count, :draining, :term_sent_at_monotonic,
38
- :forced_outcome
39
- )
35
+ # Loop primitives the {SlotScheduler} reads back from its host.
36
+ attr_reader :worker_count, :runtime, :wakeup
40
37
 
41
38
  def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
42
39
  @worker_count = worker_count
@@ -45,6 +42,18 @@ module Henitai
45
42
  @shutdown_requested = false
46
43
  end
47
44
 
45
+ # Number of mutants that required at least one retry during the run.
46
+ # Mirrors the linear path's per-mutant flaky semantics so the engine can
47
+ # report a single, mode-agnostic flaky statistic.
48
+ def flaky_retry_count
49
+ @scheduler ? @scheduler.flaky_retry_count : 0
50
+ end
51
+
52
+ # True once a graceful shutdown has been requested; read by the scheduler.
53
+ def shutdown_requested?
54
+ @shutdown_requested
55
+ end
56
+
48
57
  # Trigger a graceful shutdown from outside the event loop.
49
58
  # Safe to call from any thread. The loop observes the flag on its next tick.
50
59
  def request_shutdown
@@ -65,7 +74,7 @@ module Henitai
65
74
  prepare_run(mutants, integration, config, progress_reporter, options)
66
75
 
67
76
  event_loop
68
- @results
77
+ @scheduler.results
69
78
  ensure
70
79
  @wakeup&.close
71
80
  @wakeup = nil
@@ -73,13 +82,23 @@ module Henitai
73
82
 
74
83
  private
75
84
 
76
- attr_reader :worker_count, :pending, :slots, :pid_to_slot, :results,
77
- :integration, :config, :progress_reporter, :runtime
85
+ def prepare_run(mutants, integration, config, progress_reporter, options)
86
+ @shutdown_requested = false
87
+ @wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
88
+ @scheduler = SlotScheduler.new(
89
+ integration: integration,
90
+ config: config,
91
+ progress_reporter: progress_reporter,
92
+ options: options,
93
+ host: self
94
+ )
95
+ @scheduler.enqueue(mutants)
96
+ end
78
97
 
79
98
  def event_loop
80
99
  saved_traps = install_signal_traps
81
100
  loop do
82
- break if done?
101
+ break if @scheduler.done?
83
102
 
84
103
  break if process_cycle == :shutdown
85
104
  end
@@ -89,346 +108,41 @@ module Henitai
89
108
  end
90
109
 
91
110
  def process_cycle
92
- fill_idle_slots unless @shutdown_requested
93
- reap_all_completed_children
94
- check_timeouts
95
- fill_idle_slots unless @shutdown_requested
111
+ @scheduler.fill_idle_slots unless @shutdown_requested
112
+ @scheduler.reap_all_completed_children
113
+ @scheduler.check_timeouts
114
+ @scheduler.fill_idle_slots unless @shutdown_requested
96
115
  return handle_shutdown if @shutdown_requested
97
116
 
98
- drain_draining_slots if draining_slots?
99
- fill_idle_slots unless @shutdown_requested
100
- return :done if done?
117
+ @scheduler.drain_draining_slots if @scheduler.draining_slots?
118
+ @scheduler.fill_idle_slots unless @shutdown_requested
119
+ return :done if @scheduler.done?
101
120
 
102
121
  wait_for_next_event
103
122
  nil
104
123
  end
105
124
 
106
125
  def handle_shutdown
107
- interrupt_active_slots
108
- drain_draining_slots
126
+ @scheduler.interrupt_active_slots
127
+ @scheduler.drain_draining_slots
109
128
  :shutdown
110
129
  end
111
130
 
112
- def done?
113
- pending.empty? && slots.empty?
114
- end
115
-
116
- def fill_idle_slots
117
- while slots.size < worker_count && !pending.empty?
118
- mutant = pending.shift
119
- spawn_into_slot(mutant)
120
- end
121
- end
122
-
123
- def spawn_into_slot(mutant)
124
- test_files = resolve_test_files(mutant)
125
- mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
126
- mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
127
- handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
128
- register_slot(handle, mutant)
129
- rescue StandardError => e
130
- record_spawn_failure(mutant, e)
131
- end
132
-
133
- def register_slot(handle, mutant)
134
- slot_id = next_slot_id!
135
- slot = build_slot(slot_id, mutant, handle)
136
- slots[slot_id] = slot
137
- pid_to_slot[handle.pid] = slot_id
138
- Integration::SchedulerDiagnostics.child_started(handle.pid)
139
- end
140
-
141
- def build_slot(slot_id, mutant, handle)
142
- Slot.new(
143
- slot_id, mutant, handle.pid,
144
- monotonic_time,
145
- config.timeout, handle.log_paths, 0, false, nil, nil
146
- )
147
- end
148
-
149
- def reap_all_completed_children
150
- loop do
151
- pid, status = runtime.wait2(-1, Process::WNOHANG)
152
- break unless pid
153
-
154
- complete_slot(pid, status)
155
- end
156
- rescue Errno::ECHILD
157
- nil
158
- end
159
-
160
- def complete_slot(pid, wait_result)
161
- slot_id = pid_to_slot.delete(pid)
162
- return unless slot_id
163
-
164
- slot = slots[slot_id]
165
- return unless slot
166
-
167
- Integration::SchedulerDiagnostics.child_ended(pid)
168
- result = integration.build_result(wait_result, slot.log_paths)
169
- dispatch_slot_result(slot, result)
170
- end
171
-
172
- def dispatch_slot_result(slot, result)
173
- if should_retry?(slot, result)
174
- retry_slot(slot)
175
- else
176
- slots.delete(slot.slot_id)
177
- slot.mutant.status = result.status
178
- results << result
179
- progress_reporter&.progress(slot.mutant, scenario_result: result)
180
- end
181
- end
182
-
183
- # Per-slot timeout check. Must be called after reap_all_completed_children
184
- # so that naturally-exited processes are already removed from slots.
185
- def check_timeouts
186
- now = monotonic_time
187
- slots.each_value do |slot|
188
- next if slot.draining
189
- next unless now >= slot.started_at_monotonic + slot.timeout
190
-
191
- # Final targeted reap: if the child already exited, classify it normally.
192
- pid, status = runtime.wait2(slot.pid, Process::WNOHANG)
193
- if pid
194
- complete_slot(pid, status)
195
- else
196
- slot.forced_outcome = :timeout
197
- slot.draining = true
198
- end
199
- end
200
- end
201
-
202
- def draining_slots?
203
- slots.any? { |_, slot| slot.draining }
204
- end
205
-
206
- # Two-phase broadcast cleanup for all slots that are in draining state.
207
- #
208
- # Precision rule: before signalling, do one final WNOHANG pass to catch
209
- # processes that exited naturally in the window between check_timeouts and
210
- # now. If SIGTERM gets ESRCH, the process is already gone — we must not
211
- # force-label those as :timeout.
212
- def drain_draining_slots
213
- draining = draining_slots
214
- return if draining.empty?
215
-
216
- prune_raced_draining_slots(draining)
217
-
218
- return if draining.empty?
219
-
220
- broadcast_term(draining)
221
- wait_for_drain_window
222
- signal_draining_slots(draining)
223
- reap_and_remove_draining(draining)
224
- end
225
-
226
- def draining_slots
227
- slots.select { |_, slot| slot.draining }
228
- end
229
-
230
- def prune_raced_draining_slots(draining)
231
- draining.reject! do |_, slot|
232
- pid, status = wnohang_reap(slot.pid)
233
- next false unless pid
234
-
235
- complete_slot(pid, status)
236
- true
237
- end
238
- end
239
-
240
- def wait_for_drain_window
241
- @wakeup&.wait(PROCESS_DRAIN_WINDOW)
131
+ def wait_for_next_event
132
+ @wakeup&.wait(@scheduler.next_event_timeout)
242
133
  @wakeup&.drain
243
134
  end
244
135
 
245
- def signal_draining_slots(draining)
246
- draining.each_value { |slot| signal_process_group(slot.pid, :SIGKILL) }
247
- end
248
-
249
- def broadcast_term(draining)
250
- now = monotonic_time
251
- draining.each_value do |slot|
252
- slot.term_sent_at_monotonic = now
253
- signal_process_group(slot.pid, :SIGTERM)
254
- end
255
- end
256
-
257
- # After SIGKILL window: blocking reap each slot, then build its result.
258
- #
259
- # Interrupted slots are cleaned up but produce no result — the scheduler
260
- # is shutting down and does not emit verdicts for in-flight mutants.
261
- #
262
- # For timeout slots: a real exit status only wins if observed before any
263
- # parent signal was sent. Once SIGTERM has been dispatched, the forced
264
- # outcome is authoritative — a child handling SIGTERM and exiting 0 must
265
- # not be misclassified as :survived.
266
- def reap_and_remove_draining(draining) # rubocop:disable Metrics/AbcSize
267
- draining.each_value do |slot|
268
- # One last WNOHANG before blocking: catches processes that exited
269
- # between SIGKILL and here.
270
- _, final_status = wnohang_reap(slot.pid)
271
- reap_pid(slot.pid) unless final_status
272
-
273
- pid_to_slot.delete(slot.pid)
274
- slots.delete(slot.slot_id)
275
- Integration::SchedulerDiagnostics.child_ended(slot.pid)
276
-
277
- next if slot.forced_outcome == :interrupted
278
-
279
- result = build_drain_result(slot, final_status)
280
- slot.mutant.status = result.status
281
- results << result
282
- progress_reporter&.progress(slot.mutant, scenario_result: result)
283
- end
284
- end
285
-
286
- # Choose result: use real exit status only if observed before any parent
287
- # signal was sent. After SIGTERM, the forced outcome is authoritative.
288
- def build_drain_result(slot, final_status)
289
- if final_status&.exited? && slot.term_sent_at_monotonic.nil?
290
- integration.build_result(final_status, slot.log_paths)
291
- else
292
- integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
293
- end
294
- end
295
-
296
136
  def install_signal_traps
297
137
  saved = {}
298
138
  %w[INT TERM HUP].each do |sig|
299
- saved[sig] = runtime.trap(sig) { @shutdown_requested = true }
139
+ saved[sig] = @runtime.trap(sig) { @shutdown_requested = true }
300
140
  end
301
141
  saved
302
142
  end
303
143
 
304
144
  def restore_signal_traps(saved)
305
- saved&.each { |sig, handler| runtime.trap(sig, handler) }
306
- end
307
-
308
- def interrupt_active_slots
309
- slots.each_value do |slot|
310
- next if slot.draining
311
-
312
- slot.forced_outcome = :interrupted
313
- slot.draining = true
314
- end
315
- end
316
-
317
- def should_retry?(slot, result)
318
- !@shutdown_requested && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
319
- end
320
-
321
- def prepare_run(mutants, integration, config, progress_reporter, options)
322
- @pending = mutants.dup
323
- @slots = {}
324
- @pid_to_slot = {}
325
- @results = []
326
- @next_slot_id = 0
327
- @integration = integration
328
- @config = config
329
- @progress_reporter = progress_reporter
330
- @options = options
331
- @wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
332
- end
333
-
334
- def next_event_timeout
335
- now = monotonic_time
336
- slot_timeouts = slots.each_value.filter_map do |slot|
337
- remaining_slot_timeout(slot, now)
338
- end
339
-
340
- slot_timeouts.min
341
- end
342
-
343
- def remaining_slot_timeout(slot, now)
344
- deadline =
345
- if slot.draining
346
- slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
347
- else
348
- slot.started_at_monotonic + slot.timeout
349
- end
350
- remaining = deadline - now
351
- remaining.positive? ? remaining : 0.0
352
- end
353
-
354
- def wait_for_next_event
355
- @wakeup&.wait(next_event_timeout)
356
- @wakeup&.drain
357
- end
358
-
359
- def retry_slot(slot) # rubocop:disable Metrics/AbcSize
360
- slot.retry_count += 1
361
- test_files = resolve_test_files(slot.mutant)
362
- handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
363
- slot.pid = handle.pid
364
- slot.log_paths = handle.log_paths
365
- slot.started_at_monotonic = monotonic_time
366
- slot.draining = false
367
- slot.term_sent_at_monotonic = nil
368
- slot.forced_outcome = nil
369
- pid_to_slot[handle.pid] = slot.slot_id
370
- Integration::SchedulerDiagnostics.child_started(handle.pid)
371
- rescue StandardError => e
372
- slots.delete(slot.slot_id)
373
- record_spawn_failure(slot.mutant, e)
374
- end
375
-
376
- def record_spawn_failure(mutant, error)
377
- result = ScenarioExecutionResult.new(
378
- status: :compile_error,
379
- stdout: "",
380
- stderr: "spawn failed: #{error.message}",
381
- log_path: "/dev/null",
382
- exit_status: nil
383
- )
384
- mutant.status = result.status
385
- results << result
386
- progress_reporter&.progress(mutant, scenario_result: result)
387
- end
388
-
389
- def wnohang_reap(pid)
390
- runtime.wait2(pid, Process::WNOHANG)
391
- rescue Errno::ECHILD, Errno::ESRCH
392
- nil
393
- end
394
-
395
- def signal_process_group(pid, signal)
396
- runtime.kill(signal, -pid)
397
- rescue Errno::ESRCH
398
- nil
399
- rescue Errno::EPERM
400
- # Process group not yet established; fall back to signalling the pid.
401
- begin
402
- runtime.kill(signal, pid)
403
- rescue Errno::ESRCH
404
- nil
405
- end
406
- end
407
-
408
- def reap_pid(pid)
409
- runtime.wait(pid)
410
- rescue Errno::ECHILD, Errno::ESRCH
411
- nil
412
- end
413
-
414
- def monotonic_time
415
- runtime.clock_gettime(Process::CLOCK_MONOTONIC)
416
- end
417
-
418
- def resolve_test_files(mutant)
419
- if @options.key?(:test_file_resolver)
420
- @options[:test_file_resolver].call(mutant)
421
- elsif @options.key?(:test_files)
422
- @options[:test_files]
423
- else
424
- integration.select_tests(mutant.subject)
425
- end
426
- end
427
-
428
- def next_slot_id!
429
- id = @next_slot_id
430
- @next_slot_id += 1
431
- id
145
+ saved&.each { |sig, handler| @runtime.trap(sig, handler) }
432
146
  end
433
147
  end
434
148
  end
@@ -22,9 +22,12 @@ module Henitai
22
22
  # @param names [Array<String>] reporter names from configuration
23
23
  # @param result [Result]
24
24
  # @param config [Configuration]
25
- def self.run_all(names:, result:, config:)
25
+ # @param history_store [MutantHistoryStore, nil] persistence store the JSON
26
+ # reporter reads trend data from; supplied by the composition root so the
27
+ # reporter does not build infrastructure itself.
28
+ def self.run_all(names:, result:, config:, history_store: nil)
26
29
  names.each do |name|
27
- reporter_class(name).new(config:).report(result)
30
+ reporter_class(name).new(config:, history_store:).report(result)
28
31
  end
29
32
  end
30
33
 
@@ -36,8 +39,12 @@ module Henitai
36
39
 
37
40
  # Base class for all reporters.
38
41
  class Base
39
- def initialize(config:)
42
+ # @param history_store [MutantHistoryStore, nil] accepted by every
43
+ # reporter for a uniform factory signature; only the JSON reporter uses
44
+ # it. Ignored elsewhere.
45
+ def initialize(config:, history_store: nil)
40
46
  @config = config
47
+ @history_store = history_store
41
48
  end
42
49
 
43
50
  # @param result [Result]
@@ -47,7 +54,7 @@ module Henitai
47
54
 
48
55
  private
49
56
 
50
- attr_reader :config
57
+ attr_reader :config, :history_store
51
58
  end
52
59
 
53
60
  # Terminal reporter.
@@ -297,12 +304,17 @@ module Henitai
297
304
  end
298
305
 
299
306
  def write_history_report
300
- path = File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
301
- history_store = MutantHistoryStore.new(path:)
302
- return unless File.exist?(path)
307
+ store = history_store || default_history_store
308
+ return unless File.exist?(store.path)
303
309
 
304
310
  FileUtils.mkdir_p(File.dirname(history_report_path))
305
- File.write(history_report_path, JSON.pretty_generate(history_store.trend_report))
311
+ File.write(history_report_path, JSON.pretty_generate(store.trend_report))
312
+ end
313
+
314
+ def default_history_store
315
+ MutantHistoryStore.new(
316
+ path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
317
+ )
306
318
  end
307
319
 
308
320
  def history_report_path
@@ -17,18 +17,25 @@ module Henitai
17
17
  attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
18
18
  :session_id, :git_sha
19
19
 
20
+ # @param source_provider [#call] maps a file path to its source string.
21
+ # Injected so the domain object performs no disk IO; the caller (which
22
+ # already knows the file paths) supplies the contents. The default
23
+ # provider returns "" for every file — callers that need real source in
24
+ # the schema (e.g. the runner) inject a provider primed with file content.
20
25
  # rubocop:disable Metrics/ParameterLists
21
26
  def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
22
27
  partial_rerun: false, survivor_stats: nil,
23
- session_id: SecureRandom.uuid, git_sha: nil)
24
- @mutants = mutants
25
- @started_at = started_at
26
- @finished_at = finished_at
27
- @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
28
- @partial_rerun = partial_rerun
29
- @survivor_stats = survivor_stats
30
- @session_id = session_id
31
- @git_sha = git_sha
28
+ session_id: SecureRandom.uuid, git_sha: nil,
29
+ source_provider: ->(_file) { "" })
30
+ @mutants = mutants
31
+ @started_at = started_at
32
+ @finished_at = finished_at
33
+ @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
34
+ @partial_rerun = partial_rerun
35
+ @survivor_stats = survivor_stats
36
+ @session_id = session_id
37
+ @git_sha = git_sha
38
+ @source_provider = source_provider
32
39
  end
33
40
  # rubocop:enable Metrics/ParameterLists
34
41
 
@@ -129,14 +136,9 @@ module Henitai
129
136
 
130
137
  def build_files_section
131
138
  mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
132
- source = begin
133
- File.read(file_mutants.first.location[:file])
134
- rescue StandardError
135
- ""
136
- end
137
139
  {
138
140
  language: "ruby",
139
- source:,
141
+ source: @source_provider.call(file_mutants.first.location[:file]),
140
142
  mutants: file_mutants.map { |m| mutant_to_schema(m) }
141
143
  }
142
144
  end