henitai 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +123 -2
  4. data/assets/schema/henitai.schema.json +35 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +64 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +66 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +238 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +72 -9
  57. data/lib/henitai/runner.rb +82 -40
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +230 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Henitai
8
+ # Coordinates exclusive access to a reports directory across processes.
9
+ class ReportsDirectoryLock
10
+ LOCK_FILENAME = ".henitai-run.lock"
11
+
12
+ def initialize(reports_dir:)
13
+ @reports_dir = File.expand_path(reports_dir)
14
+ end
15
+
16
+ def synchronize
17
+ FileUtils.mkdir_p(@reports_dir)
18
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
19
+ acquire(file)
20
+ write_owner(file)
21
+ yield
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def lock_path
28
+ File.join(@reports_dir, LOCK_FILENAME)
29
+ end
30
+
31
+ def acquire(file)
32
+ return if file.flock(File::LOCK_EX | File::LOCK_NB)
33
+
34
+ raise ConcurrentRunError, contention_message(owner_pid(file))
35
+ end
36
+
37
+ def owner_pid(file)
38
+ file.rewind
39
+ metadata = JSON.parse(file.read)
40
+ pid = metadata["pid"] if metadata.is_a?(Hash)
41
+ pid.is_a?(Integer) ? pid : "unknown"
42
+ rescue JSON::ParserError, IOError, SystemCallError
43
+ "unknown"
44
+ end
45
+
46
+ def contention_message(pid)
47
+ message = "another henitai run is active in #{@reports_dir} " \
48
+ "(pid #{pid}); use a separate reports_dir or wait"
49
+ return message unless dead_owner?(pid)
50
+
51
+ "#{message}. The recorded owner pid #{pid} is not running — an " \
52
+ "orphaned child may still hold the lock " \
53
+ "(check: lsof #{lock_path})"
54
+ end
55
+
56
+ # True only when the recorded owner pid provably no longer exists. EPERM
57
+ # means the process is alive but owned by someone else — treated as alive.
58
+ def dead_owner?(pid)
59
+ return false unless pid.is_a?(Integer)
60
+
61
+ Process.kill(0, pid)
62
+ false
63
+ rescue Errno::ESRCH
64
+ true
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ def write_owner(file)
70
+ file.rewind
71
+ file.truncate(0)
72
+ file.write(JSON.generate(pid: Process.pid, started_at: Time.now.iso8601))
73
+ file.flush
74
+ end
75
+ end
76
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "mutant_identity"
4
5
  require_relative "unparse_helper"
5
6
 
6
7
  module Henitai
@@ -22,11 +23,16 @@ module Henitai
22
23
  # already knows the file paths) supplies the contents. The default
23
24
  # provider returns "" for every file — callers that need real source in
24
25
  # the schema (e.g. the runner) inject a provider primed with file content.
26
+ # @param authoritative [Boolean] whether this run covers the full
27
+ # configured mutation scope. Authoritative runs fully replace the
28
+ # canonical report; non-authoritative (subject-scoped, --since,
29
+ # --survivors-from) runs are merged into it instead — see
30
+ # CanonicalReportMerger.
25
31
  # rubocop:disable Metrics/ParameterLists
26
32
  def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
27
33
  partial_rerun: false, survivor_stats: nil,
28
34
  session_id: SecureRandom.uuid, git_sha: nil,
29
- source_provider: ->(_file) { "" })
35
+ source_provider: ->(_file) { "" }, authoritative: true)
30
36
  @mutants = mutants
31
37
  @started_at = started_at
32
38
  @finished_at = finished_at
@@ -36,10 +42,12 @@ module Henitai
36
42
  @session_id = session_id
37
43
  @git_sha = git_sha
38
44
  @source_provider = source_provider
45
+ @authoritative = authoritative
39
46
  end
40
47
  # rubocop:enable Metrics/ParameterLists
41
48
 
42
49
  def partial_rerun? = @partial_rerun
50
+ def authoritative? = @authoritative
43
51
 
44
52
  # @return [Integer] number of killed mutants
45
53
  def killed = mutants.count(&:killed?)
@@ -53,7 +61,7 @@ module Henitai
53
61
  # Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)
54
62
  # @return [Integer]
55
63
  def detected
56
- mutants.count { |m| %i[killed timeout runtime_error].include?(m.status) }
64
+ detected_in(mutants)
57
65
  end
58
66
 
59
67
  # Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
@@ -66,11 +74,7 @@ module Henitai
66
74
  #
67
75
  # @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
68
76
  def mutation_score
69
- excluded = %i[ignored no_coverage compile_error equivalent]
70
- valid = mutants.reject { |m| excluded.include?(m.status) }
71
- return nil if valid.empty?
72
-
73
- ((detected.to_f / valid.count) * 100.0).round(2).to_f
77
+ mutation_score_for(mutants)
74
78
  end
75
79
 
76
80
  # Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
@@ -82,9 +86,23 @@ module Henitai
82
86
  #
83
87
  # @return [Float, nil]
84
88
  def mutation_score_indicator
85
- return nil if mutants.empty?
89
+ mutation_score_indicator_for(mutants)
90
+ end
86
91
 
87
- ((killed.to_f / mutants.count) * 100.0).round(2).to_f
92
+ # Scores over only the mutants actually executed this run — verdicts
93
+ # reused from the history store (`--incremental`) excluded. nil when
94
+ # nothing was reused: the combined scores already tell the whole story,
95
+ # and reporters use nil to suppress the extra line.
96
+ #
97
+ # @return [Hash, nil] { mutation_score:, mutation_score_indicator: }
98
+ def executed_scoring_summary
99
+ executed = mutants.reject { |m| m.respond_to?(:from_cache?) && m.from_cache? }
100
+ return nil if executed.size == mutants.size
101
+
102
+ {
103
+ mutation_score: mutation_score_for(executed),
104
+ mutation_score_indicator: mutation_score_indicator_for(executed)
105
+ }
88
106
  end
89
107
 
90
108
  # Compact public summary for reporters.
@@ -119,6 +137,24 @@ module Henitai
119
137
 
120
138
  private
121
139
 
140
+ def detected_in(list)
141
+ list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
142
+ end
143
+
144
+ def mutation_score_for(list)
145
+ excluded = %i[ignored no_coverage compile_error equivalent]
146
+ valid = list.reject { |m| excluded.include?(m.status) }
147
+ return nil if valid.empty?
148
+
149
+ ((detected_in(list).to_f / valid.count) * 100.0).round(2).to_f
150
+ end
151
+
152
+ def mutation_score_indicator_for(list)
153
+ return nil if list.empty?
154
+
155
+ ((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
156
+ end
157
+
122
158
  def base_schema
123
159
  { # : Hash[Symbol, untyped]
124
160
  schemaVersion: SCHEMA_VERSION,
@@ -148,10 +184,13 @@ module Henitai
148
184
  {
149
185
  id: mutant.id,
150
186
  stableId: mutant.stable_id,
187
+ legacyStableId: legacy_stable_id_for(mutant),
151
188
  mutatorName: mutant.operator,
152
189
  replacement: replacement_for(mutant),
153
190
  location: location_for(mutant),
154
191
  status: stryker_status(mutant.status),
192
+ statusReason: status_reason_for(mutant),
193
+ fromCache: from_cache_for(mutant),
155
194
  description: mutant.description,
156
195
  duration: duration_for(mutant)
157
196
  }.compact.merge(coverage_schema(mutant))
@@ -168,6 +207,8 @@ module Henitai
168
207
  end
169
208
 
170
209
  def replacement_for(mutant)
210
+ return mutant.mutated_source if mutant.respond_to?(:mutated_source)
211
+
171
212
  safe_unparse(mutant.mutated_node)
172
213
  end
173
214
 
@@ -190,6 +231,28 @@ module Henitai
190
231
  mutant.duration&.then { |d| (d * 1000).round }
191
232
  end
192
233
 
234
+ # Serialized as the schema's statusReason so directive reasons show up
235
+ # next to the Ignored status in the HTML report.
236
+ def status_reason_for(mutant)
237
+ return nil unless mutant.respond_to?(:ignore_reason)
238
+
239
+ mutant.ignore_reason
240
+ end
241
+
242
+ # Vendored extension field (like stableId): marks verdicts reused from
243
+ # the history store so reports stay honest about what actually executed.
244
+ def from_cache_for(mutant)
245
+ return nil unless mutant.respond_to?(:from_cache?)
246
+
247
+ mutant.from_cache? || nil
248
+ end
249
+
250
+ def legacy_stable_id_for(mutant)
251
+ return if mutant.respond_to?(:precomputed_stable_id) && mutant.precomputed_stable_id
252
+
253
+ MutantIdentity.legacy_stable_id(mutant)
254
+ end
255
+
193
256
  def equivalence_uncertainty
194
257
  return nil if mutation_score.nil?
195
258
 
@@ -28,11 +28,16 @@ module Henitai
28
28
  class Runner
29
29
  attr_reader :config, :result
30
30
 
31
- def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil)
31
+ # @param mode [Hash] execution-mode flags: +dry_run:+ stops before Gate 4,
32
+ # +incremental:+ reuses still-valid Killed verdicts from history.
33
+ def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil,
34
+ mode: {})
32
35
  @config = config
33
36
  @subjects = subjects
34
37
  @since = since
35
38
  @survivors_from = survivors_from
39
+ @dry_run = mode.fetch(:dry_run, false)
40
+ @incremental = mode.fetch(:incremental, false)
36
41
  end
37
42
 
38
43
  # Entry point — runs the full pipeline and returns a Result.
@@ -48,21 +53,40 @@ module Henitai
48
53
  #
49
54
  # @return [Result]
50
55
  def run
51
- started_at = Time.now
56
+ ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
57
+ started_at = Time.now
52
58
 
53
- mutants = if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
54
- execute_mutants(fast_mutants)
55
- else
56
- source_files = self.source_files
57
- subjects = resolve_subjects(source_files)
58
- execute_mutants(mutants_for(subjects, source_files))
59
- end
59
+ mutants = pipeline_mutants
60
+ return dry_run_result(mutants, started_at, Time.now) if @dry_run
60
61
 
61
- build_result(mutants, started_at, Time.now)
62
+ build_result(execute_mutants(mutants), started_at, Time.now)
63
+ end
62
64
  end
63
65
 
64
66
  private
65
67
 
68
+ # Gates 0–3 only: coverage bootstrap, subject resolution, generation and
69
+ # static/skip/arid/stillborn filtering — everything short of execution.
70
+ def pipeline_mutants
71
+ if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
72
+ return fast_mutants
73
+ end
74
+
75
+ source_files = self.source_files
76
+ subjects = resolve_subjects(source_files)
77
+ mutants_for(subjects, source_files)
78
+ end
79
+
80
+ # Dry run stops before Gate 4: prints the post-filter listing and returns
81
+ # a Result without executing mutants, persisting history or running the
82
+ # configured reporters. Gate 0 and lock coordination may still write under
83
+ # reports_dir.
84
+ def dry_run_result(mutants, started_at, finished_at)
85
+ @result = build_result_object(mutants, started_at, finished_at)
86
+ Reporter::DryRun.new(config:).report(@result)
87
+ @result
88
+ end
89
+
66
90
  def resolve_subjects(source_files = self.source_files)
67
91
  subjects = subject_resolver.resolve_from_files(source_files)
68
92
  return subjects if pattern_subjects.empty?
@@ -85,16 +109,27 @@ module Henitai
85
109
  bootstrap_thread = bootstrap_mutants(source_files)
86
110
  mutants = generate_mutants(subjects)
87
111
  bootstrap_thread.value
88
- filtered = filter_mutants(mutants)
112
+ filtered = apply_incremental_filter(filter_mutants(mutants))
89
113
  return filtered unless survivor_rerun?
90
114
 
91
115
  survivor_strategy.apply_selection(filtered)
92
116
  end
93
117
 
94
- def bootstrap_mutants(source_files)
95
- Thread.new { bootstrap_coverage(source_files) }
118
+ # Opt-in verdict reuse (`--incremental`): still-valid Killed and Survived
119
+ # verdicts from the history store are marked with their stored status +
120
+ # from_cache before execution. The filter is only ever constructed when
121
+ # the flag is set; it runs after the coverage bootstrap join in
122
+ # mutants_for, so the live per-test map it reads is never mid-write.
123
+ def apply_incremental_filter(mutants)
124
+ return mutants unless @incremental
125
+
126
+ IncrementalFilter.new(history_store:, per_test_coverage:,
127
+ dependency_fingerprint: VerdictFingerprint.dependency_fingerprint)
128
+ .apply(mutants)
96
129
  end
97
130
 
131
+ def bootstrap_mutants(source_files) = Thread.new { bootstrap_coverage(source_files) }
132
+
98
133
  def execute_mutants(mutants)
99
134
  execution_engine.run(
100
135
  mutants,
@@ -117,7 +152,14 @@ module Henitai
117
152
  end
118
153
 
119
154
  def build_result(mutants, started_at, finished_at)
120
- @result = Result.new(
155
+ @result = build_result_object(mutants, started_at, finished_at)
156
+ persist_history(@result, finished_at)
157
+ report(@result)
158
+ @result
159
+ end
160
+
161
+ def build_result_object(mutants, started_at, finished_at)
162
+ Result.new(
121
163
  mutants:,
122
164
  started_at:,
123
165
  finished_at:,
@@ -125,11 +167,9 @@ module Henitai
125
167
  partial_rerun: survivor_rerun?,
126
168
  survivor_stats: survivor_strategy.survivor_stats,
127
169
  git_sha: safe_head_sha,
128
- source_provider: source_provider
170
+ source_provider: source_provider,
171
+ authoritative: full_run?
129
172
  )
130
- persist_history(@result, finished_at)
131
- report(@result)
132
- @result
133
173
  end
134
174
 
135
175
  # Reads each source file once and caches it, so Result consumes source
@@ -158,29 +198,17 @@ module Henitai
158
198
  coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
159
199
  end
160
200
 
161
- def subject_resolver
162
- @subject_resolver ||= SubjectResolver.new
163
- end
201
+ def subject_resolver = @subject_resolver ||= SubjectResolver.new
164
202
 
165
- def git_diff_analyzer
166
- @git_diff_analyzer ||= GitDiffAnalyzer.new
167
- end
203
+ def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
168
204
 
169
- def mutant_generator
170
- @mutant_generator ||= MutantGenerator.new
171
- end
205
+ def mutant_generator = @mutant_generator ||= MutantGenerator.new
172
206
 
173
- def static_filter
174
- @static_filter ||= StaticFilter.new
175
- end
207
+ def static_filter = @static_filter ||= StaticFilter.new
176
208
 
177
- def execution_engine
178
- @execution_engine ||= ExecutionEngine.new
179
- end
209
+ def execution_engine = @execution_engine ||= ExecutionEngine.new
180
210
 
181
- def coverage_bootstrapper
182
- @coverage_bootstrapper ||= CoverageBootstrapper.new
183
- end
211
+ def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
184
212
 
185
213
  def integration
186
214
  @integration ||= Integration.for(config.integration).new
@@ -190,18 +218,26 @@ module Henitai
190
218
  @operators ||= Operator.for_set(config.operators)
191
219
  end
192
220
 
221
+ # Fans progress out to the terminal reporter (when enabled) and the
222
+ # checkpoint writer (when enabled and a file report is configured), so a
223
+ # long run persists partial results incrementally.
193
224
  def progress_reporter
194
- return nil unless Array(config.reporters).map(&:to_s).include?("terminal")
195
-
196
- Reporter::Terminal.new(config:)
225
+ CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
197
226
  end
198
227
 
199
228
  def history_store
200
229
  @history_store ||= MutantHistoryStore.new(
201
- path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
230
+ path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
202
231
  )
203
232
  end
204
233
 
234
+ # One shared live view of the per-test coverage map: the incremental
235
+ # filter proves survivor reuse against it and the history store records
236
+ # the same intersection set — one implementation, one snapshot.
237
+ def per_test_coverage
238
+ @per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
239
+ end
240
+
205
241
  def source_files
206
242
  @source_files ||= filter_changed(reject_excluded(included_source_files))
207
243
  end
@@ -259,6 +295,12 @@ module Henitai
259
295
  !@survivors_from.nil?
260
296
  end
261
297
 
298
+ # Mutation-scope full run, controlling Result#authoritative? — distinct
299
+ # from the per-test-coverage plan's test-suite-scope "full run".
300
+ def full_run?
301
+ pattern_subjects.empty? && @since.nil? && !survivor_rerun?
302
+ end
303
+
262
304
  def survivor_strategy
263
305
  @survivor_strategy ||= SurvivorRerunStrategy.new(
264
306
  survivors_from: @survivors_from,
@@ -23,6 +23,18 @@ module Henitai
23
23
  @exit_status = exit_status
24
24
  end
25
25
 
26
+ # Frees the captured child output once it has been consumed (e.g. by the
27
+ # progress reporter). The scheduler retains one result per mutant for the
28
+ # whole run; without this a runaway mutant's multi-hundred-MB output would
29
+ # stay resident until the run ends. Status/log_path/exit_status are kept so
30
+ # the result stays usable for scoring and log lookups.
31
+ def release_output!
32
+ @stdout = ""
33
+ @stderr = ""
34
+ @log_text = nil
35
+ self
36
+ end
37
+
26
38
  def survived?
27
39
  status == :survived
28
40
  end
@@ -106,24 +106,30 @@ module Henitai
106
106
  # parent signal was sent. Once SIGTERM has been dispatched, the forced
107
107
  # outcome is authoritative — a child handling SIGTERM and exiting 0 must
108
108
  # not be misclassified as :survived.
109
- def reap_and_remove_draining(draining) # rubocop:disable Metrics/AbcSize
110
- draining.each_value do |slot|
111
- # One last WNOHANG before blocking: catches processes that exited
112
- # between SIGKILL and here.
113
- _, final_status = wnohang_reap(slot.pid)
114
- reap_pid(slot.pid) unless final_status
109
+ def reap_and_remove_draining(draining)
110
+ draining.each_value { |slot| reap_and_finalize_slot(slot) }
111
+ end
115
112
 
116
- pid_to_slot.delete(slot.pid)
117
- slots.delete(slot.slot_id)
118
- Integration::SchedulerDiagnostics.child_ended(slot.pid)
113
+ # One last WNOHANG before blocking: catches processes that exited
114
+ # between SIGKILL and here.
115
+ def reap_and_finalize_slot(slot)
116
+ _, final_status = wnohang_reap(slot.pid)
117
+ reap_pid(slot.pid) unless final_status
119
118
 
120
- next if slot.forced_outcome == :interrupted
119
+ pid_to_slot.delete(slot.pid)
120
+ slots.delete(slot.slot_id)
121
+ Integration::SchedulerDiagnostics.child_ended(slot.pid)
121
122
 
122
- result = build_drain_result(slot, final_status)
123
- slot.mutant.status = result.status
124
- results << result
125
- progress_reporter&.progress(slot.mutant, scenario_result: result)
126
- end
123
+ return if slot.forced_outcome == :interrupted
124
+
125
+ record_drain_result(slot, final_status)
126
+ end
127
+
128
+ def record_drain_result(slot, final_status)
129
+ result = build_drain_result(slot, final_status)
130
+ slot.mutant.status = result.status
131
+ results << result
132
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
127
133
  end
128
134
 
129
135
  # Choose result: use real exit status only if observed before any parent
@@ -23,11 +23,17 @@ module Henitai
23
23
 
24
24
  PROCESS_DRAIN_WINDOW = 0.2
25
25
 
26
+ # Environment variable exposing a stable worker-slot index (0..jobs-1) to
27
+ # each forked child so test suites can isolate shared resources per slot
28
+ # (e.g. "myapp_test_#{ENV['HENITAI_WORKER_SLOT']}"). A flaky-retry respawn
29
+ # keeps the original attempt's value.
30
+ WORKER_SLOT_ENV = "HENITAI_WORKER_SLOT"
31
+
26
32
  # Tracks one in-flight mutant child process.
27
33
  Slot = Struct.new(
28
34
  :slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
29
35
  :log_paths, :retry_count, :draining, :term_sent_at_monotonic,
30
- :forced_outcome
36
+ :forced_outcome, :worker_index
31
37
  )
32
38
 
33
39
  # @return [Integer] mutants that required at least one retry during the run.
@@ -98,28 +104,48 @@ module Henitai
98
104
  test_files = resolve_test_files(mutant)
99
105
  mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
100
106
  mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
101
- handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
102
- register_slot(handle, mutant)
107
+ return record_no_coverage(mutant) if resolved_selection_empty?(test_files)
108
+
109
+ worker_index = next_free_worker_index
110
+ with_worker_slot(worker_index) do
111
+ handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
112
+ register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
113
+ end
103
114
  rescue StandardError => e
104
115
  record_spawn_failure(mutant, e)
105
116
  end
106
117
 
107
- def register_slot(handle, mutant)
118
+ def register_slot(handle, mutant, worker_index, timeout)
108
119
  slot_id = next_slot_id!
109
- slot = build_slot(slot_id, mutant, handle)
120
+ slot = build_slot(slot_id, mutant, handle, worker_index, timeout)
110
121
  slots[slot_id] = slot
111
122
  pid_to_slot[handle.pid] = slot_id
112
123
  Integration::SchedulerDiagnostics.child_started(handle.pid)
113
124
  end
114
125
 
115
- def build_slot(slot_id, mutant, handle)
126
+ def build_slot(slot_id, mutant, handle, worker_index, timeout)
116
127
  Slot.new(
117
128
  slot_id, mutant, handle.pid,
118
129
  monotonic_time,
119
- config.timeout, handle.log_paths, 0, false, nil, nil
130
+ timeout, handle.log_paths, 0, false, nil, nil,
131
+ worker_index
120
132
  )
121
133
  end
122
134
 
135
+ def slot_timeout(mutant, test_files)
136
+ resolver = options[:timeout_resolver]
137
+ resolver ? resolver.call(mutant, test_files) : config.timeout
138
+ end
139
+
140
+ # Smallest index in 0...worker_count not held by a live slot, so
141
+ # concurrently-running children always see distinct values and freed
142
+ # indices are reused. Slot ids themselves grow monotonically and are
143
+ # unsuitable as a resource token.
144
+ def next_free_worker_index
145
+ used = slots.each_value.map(&:worker_index)
146
+ (0...worker_count).find { |index| !used.include?(index) } || used.size
147
+ end
148
+
123
149
  def complete_slot(pid, wait_result)
124
150
  slot_id = pid_to_slot.delete(pid)
125
151
  return unless slot_id
@@ -140,6 +166,7 @@ module Henitai
140
166
  slot.mutant.status = result.status
141
167
  results << result
142
168
  progress_reporter&.progress(slot.mutant, scenario_result: result)
169
+ result.release_output! if result.respond_to?(:release_output!)
143
170
  end
144
171
  end
145
172
 
@@ -147,22 +174,32 @@ module Henitai
147
174
  !shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
148
175
  end
149
176
 
150
- def retry_slot(slot) # rubocop:disable Metrics/AbcSize
177
+ def retry_slot(slot)
178
+ test_files = resolve_test_files(slot.mutant)
179
+ with_worker_slot(slot.worker_index) do
180
+ handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
181
+ finish_retry(slot, handle)
182
+ end
183
+ rescue StandardError => e
184
+ slots.delete(slot.slot_id)
185
+ record_spawn_failure(slot.mutant, e)
186
+ end
187
+
188
+ def finish_retry(slot, handle)
151
189
  @flaky_retry_count += 1 if slot.retry_count.zero?
152
190
  slot.retry_count += 1
153
- test_files = resolve_test_files(slot.mutant)
154
- handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
191
+ reset_slot_for_retry(slot, handle)
192
+ pid_to_slot[handle.pid] = slot.slot_id
193
+ Integration::SchedulerDiagnostics.child_started(handle.pid)
194
+ end
195
+
196
+ def reset_slot_for_retry(slot, handle)
155
197
  slot.pid = handle.pid
156
198
  slot.log_paths = handle.log_paths
157
199
  slot.started_at_monotonic = monotonic_time
158
200
  slot.draining = false
159
201
  slot.term_sent_at_monotonic = nil
160
202
  slot.forced_outcome = nil
161
- pid_to_slot[handle.pid] = slot.slot_id
162
- Integration::SchedulerDiagnostics.child_started(handle.pid)
163
- rescue StandardError => e
164
- slots.delete(slot.slot_id)
165
- record_spawn_failure(slot.mutant, e)
166
203
  end
167
204
 
168
205
  def record_spawn_failure(mutant, error)
@@ -178,6 +215,15 @@ module Henitai
178
215
  progress_reporter&.progress(mutant, scenario_result: result)
179
216
  end
180
217
 
218
+ def record_no_coverage(mutant)
219
+ mutant.status = :no_coverage
220
+ progress_reporter&.progress(mutant, scenario_result: nil)
221
+ end
222
+
223
+ def resolved_selection_empty?(test_files)
224
+ options.key?(:test_file_resolver) && test_files.empty?
225
+ end
226
+
181
227
  def remaining_slot_timeout(slot, now)
182
228
  # Invariant: drain_draining_slots runs (and removes draining slots) before
183
229
  # the event wait, so next_event_timeout never observes a draining slot
@@ -210,5 +256,17 @@ module Henitai
210
256
  @next_slot_id += 1
211
257
  id
212
258
  end
259
+
260
+ def with_worker_slot(worker_index)
261
+ previous = ENV.fetch(WORKER_SLOT_ENV, nil)
262
+ ENV[WORKER_SLOT_ENV] = worker_index.to_s
263
+ yield
264
+ ensure
265
+ if previous.nil?
266
+ ENV.delete(WORKER_SLOT_ENV)
267
+ else
268
+ ENV[WORKER_SLOT_ENV] = previous
269
+ end
270
+ end
213
271
  end
214
272
  end