henitai 0.1.8 → 0.2.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.
@@ -0,0 +1,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Flat, single-threaded process-slot scheduler for parallel mutation runs.
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
+
11
+ # Default bridge to process and signal primitives used by the scheduler.
12
+ class Runtime
13
+ def clock_gettime(clock_id)
14
+ Process.clock_gettime(clock_id)
15
+ end
16
+
17
+ def wait2(pid, flags = nil)
18
+ Process.wait2(pid, flags)
19
+ end
20
+
21
+ def kill(signal, pid)
22
+ Process.kill(signal, pid)
23
+ end
24
+
25
+ def wait(pid)
26
+ Process.wait(pid)
27
+ end
28
+
29
+ def trap(signal, handler = nil, &block)
30
+ Kernel.trap(signal, handler || block)
31
+ end
32
+ end
33
+
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
+ )
40
+
41
+ def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
42
+ @worker_count = worker_count
43
+ @runtime = runtime
44
+ @wakeup = wakeup
45
+ @shutdown_requested = false
46
+ end
47
+
48
+ # Trigger a graceful shutdown from outside the event loop.
49
+ # Safe to call from any thread. The loop observes the flag on its next tick.
50
+ def request_shutdown
51
+ @shutdown_requested = true
52
+ @wakeup&.signal
53
+ end
54
+
55
+ # Runs all mutants and returns an array of ScenarioExecutionResult.
56
+ #
57
+ # @param mutants [Array<Mutant>]
58
+ # @param integration [Integration::Base]
59
+ # @param config [Configuration]
60
+ # @param progress_reporter [#progress, nil]
61
+ # @param options [Hash]
62
+ # @return [Array<ScenarioExecutionResult>]
63
+ def run(mutants, integration, config, progress_reporter, options = {})
64
+ Integration::SchedulerDiagnostics.reset! if Integration::SchedulerDiagnostics.enabled?
65
+ prepare_run(mutants, integration, config, progress_reporter, options)
66
+
67
+ event_loop
68
+ @results
69
+ ensure
70
+ @wakeup&.close
71
+ @wakeup = nil
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :worker_count, :pending, :slots, :pid_to_slot, :results,
77
+ :integration, :config, :progress_reporter, :runtime
78
+
79
+ def event_loop
80
+ saved_traps = install_signal_traps
81
+ loop do
82
+ break if done?
83
+
84
+ break if process_cycle == :shutdown
85
+ end
86
+ ensure
87
+ restore_signal_traps(saved_traps)
88
+ raise Interrupt if @shutdown_requested
89
+ end
90
+
91
+ 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
96
+ return handle_shutdown if @shutdown_requested
97
+
98
+ drain_draining_slots if draining_slots?
99
+ fill_idle_slots unless @shutdown_requested
100
+ return :done if done?
101
+
102
+ wait_for_next_event
103
+ nil
104
+ end
105
+
106
+ def handle_shutdown
107
+ interrupt_active_slots
108
+ drain_draining_slots
109
+ :shutdown
110
+ end
111
+
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)
242
+ @wakeup&.drain
243
+ end
244
+
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
+ def install_signal_traps
297
+ saved = {}
298
+ %w[INT TERM HUP].each do |sig|
299
+ saved[sig] = runtime.trap(sig) { @shutdown_requested = true }
300
+ end
301
+ saved
302
+ end
303
+
304
+ 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
432
+ end
433
+ end
434
+ end
@@ -90,6 +90,14 @@ module Henitai
90
90
  end
91
91
 
92
92
  def summary_lines(result)
93
+ if result.respond_to?(:partial_rerun?) && result.partial_rerun?
94
+ partial_summary_lines(result)
95
+ else
96
+ full_summary_lines(result)
97
+ end
98
+ end
99
+
100
+ def full_summary_lines(result)
93
101
  [
94
102
  "Mutation testing summary",
95
103
  score_line(result),
@@ -101,6 +109,28 @@ module Henitai
101
109
  ]
102
110
  end
103
111
 
112
+ def partial_summary_lines(result)
113
+ lines = [
114
+ "Partial survivor rerun",
115
+ format_row("Survived", count_status(result, :survived)),
116
+ format_row("Duration", format_duration(result.duration))
117
+ ]
118
+ append_survivor_stats(lines, result)
119
+ lines
120
+ end
121
+
122
+ def append_survivor_stats(lines, result)
123
+ return unless result.respond_to?(:survivor_stats)
124
+
125
+ stats = result.survivor_stats # : Hash[Symbol, untyped]?
126
+ return unless stats
127
+
128
+ lines << format_row("Matched", stats.fetch(:matched))
129
+ lines << format_row("Skipped", stats.fetch(:skipped_count, 0))
130
+ lines << format_row("Unmatched", stats.fetch(:unmatched_count))
131
+ lines << format_row("Drift warning", stats.fetch(:drift_warning) ? "yes" : "no")
132
+ end
133
+
104
134
  def survived_detail_lines(result)
105
135
  survivors = result.mutants.select(&:survived?)
106
136
  return [] if survivors.empty?
@@ -212,14 +242,57 @@ module Henitai
212
242
  # JSON reporter.
213
243
  class Json < Base
214
244
  def report(result)
215
- FileUtils.mkdir_p(File.dirname(report_path))
216
- File.write(report_path, JSON.pretty_generate(result.to_stryker_schema))
245
+ schema = result.to_stryker_schema
246
+ write_canonical(schema)
247
+ write_session_snapshot(schema)
248
+ write_activation_recipes(result)
217
249
  write_history_report
218
250
  end
219
251
 
220
252
  private
221
253
 
222
- def report_path
254
+ def write_canonical(schema)
255
+ FileUtils.mkdir_p(File.dirname(canonical_path))
256
+ File.write(canonical_path, JSON.pretty_generate(schema))
257
+ end
258
+
259
+ def write_session_snapshot(schema)
260
+ session_id = schema[:sessionId]
261
+ return unless session_id
262
+
263
+ path = session_snapshot_path(session_id)
264
+ FileUtils.mkdir_p(File.dirname(path))
265
+ File.write(path, JSON.pretty_generate(schema))
266
+ end
267
+
268
+ def write_activation_recipes(result)
269
+ session_id = result.session_id if result.respond_to?(:session_id)
270
+ return unless session_id
271
+
272
+ survived = survived_mutants_for(result)
273
+ return if survived.empty?
274
+
275
+ recipes = SurvivorActivationCache.compute(survived)
276
+ return if recipes.empty?
277
+
278
+ SurvivorActivationCache.write(session_recipe_path(session_id), recipes)
279
+ end
280
+
281
+ def survived_mutants_for(result)
282
+ return [] unless result.respond_to?(:mutants)
283
+
284
+ result.mutants.select(&:survived?)
285
+ end
286
+
287
+ def session_snapshot_path(session_id)
288
+ File.join(config.reports_dir, "sessions", session_id, "mutation-report.json")
289
+ end
290
+
291
+ def session_recipe_path(session_id)
292
+ File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
293
+ end
294
+
295
+ def canonical_path
223
296
  File.join(config.reports_dir, "mutation-report.json")
224
297
  end
225
298
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
3
4
  require_relative "unparse_helper"
4
5
 
5
6
  module Henitai
@@ -13,14 +14,25 @@ module Henitai
13
14
  SCHEMA_VERSION = "1.0"
14
15
  DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
15
16
 
16
- attr_reader :mutants, :started_at, :finished_at, :thresholds
17
+ attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
18
+ :session_id, :git_sha
17
19
 
18
- def initialize(mutants:, started_at:, finished_at:, thresholds: nil)
19
- @mutants = mutants
20
- @started_at = started_at
21
- @finished_at = finished_at
22
- @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
20
+ # rubocop:disable Metrics/ParameterLists
21
+ def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
22
+ 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
23
32
  end
33
+ # rubocop:enable Metrics/ParameterLists
34
+
35
+ def partial_rerun? = @partial_rerun
24
36
 
25
37
  # @return [Integer] number of killed mutants
26
38
  def killed = mutants.count(&:killed?)
@@ -88,14 +100,32 @@ module Henitai
88
100
  # Serialise to Stryker mutation-testing-report-schema JSON (schema 1.0).
89
101
  # @return [Hash]
90
102
  def to_stryker_schema
91
- {
103
+ schema = base_schema
104
+ sha = @git_sha
105
+ schema[:gitSha] = sha if sha
106
+ return schema unless partial_rerun?
107
+
108
+ schema[:partialRerun] = true
109
+ schema[:unmatchedSurvivorIds] = unmatched_survivor_ids
110
+ schema
111
+ end
112
+
113
+ private
114
+
115
+ def base_schema
116
+ { # : Hash[Symbol, untyped]
92
117
  schemaVersion: SCHEMA_VERSION,
118
+ sessionId: @session_id,
93
119
  thresholds: thresholds,
94
120
  files: build_files_section
95
121
  }
96
122
  end
97
123
 
98
- private
124
+ def unmatched_survivor_ids
125
+ return survivor_stats.fetch(:unmatched_ids) if survivor_stats
126
+
127
+ [] # : Array[String]
128
+ end
99
129
 
100
130
  def build_files_section
101
131
  mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
@@ -115,6 +145,7 @@ module Henitai
115
145
  def mutant_to_schema(mutant)
116
146
  {
117
147
  id: mutant.id,
148
+ stableId: mutant.stable_id,
118
149
  mutatorName: mutant.operator,
119
150
  replacement: replacement_for(mutant),
120
151
  location: location_for(mutant),