henitai 0.4.0 → 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -1
  3. data/README.md +1 -1
  4. data/lib/henitai/cli.rb +1 -1
  5. data/lib/henitai/configuration.rb +9 -2
  6. data/lib/henitai/dirty_source_detector.rb +53 -0
  7. data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
  8. data/lib/henitai/equivalence_detector.rb +6 -23
  9. data/lib/henitai/excluded_test_filter.rb +47 -0
  10. data/lib/henitai/execution_engine.rb +5 -11
  11. data/lib/henitai/inherited_fd_registry.rb +66 -0
  12. data/lib/henitai/integration/base.rb +7 -2
  13. data/lib/henitai/integration/child_bootstrap.rb +27 -0
  14. data/lib/henitai/integration/child_debug_log.rb +135 -0
  15. data/lib/henitai/integration/child_runtime_control.rb +6 -18
  16. data/lib/henitai/integration/loaded_features.rb +38 -0
  17. data/lib/henitai/integration/mutant_run_support.rb +5 -5
  18. data/lib/henitai/integration/rspec_child_runner.rb +16 -15
  19. data/lib/henitai/integration/rspec_process_runner.rb +7 -2
  20. data/lib/henitai/integration.rb +10 -7
  21. data/lib/henitai/mutation_skip_directives.rb +7 -1
  22. data/lib/henitai/operators/return_value.rb +1 -1
  23. data/lib/henitai/orphan_watchdog.rb +93 -0
  24. data/lib/henitai/process_liveness.rb +41 -0
  25. data/lib/henitai/reports_directory_lock.rb +12 -11
  26. data/lib/henitai/result.rb +30 -3
  27. data/lib/henitai/runner.rb +41 -123
  28. data/lib/henitai/runner_dependencies.rb +75 -0
  29. data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
  30. data/lib/henitai/slot_scheduler/draining.rb +7 -17
  31. data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
  32. data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
  33. data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
  34. data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
  35. data/lib/henitai/slot_scheduler.rb +68 -80
  36. data/lib/henitai/source_file_selection.rb +76 -0
  37. data/lib/henitai/subject_selection.rb +33 -0
  38. data/lib/henitai/survivor_rerun_strategy.rb +7 -19
  39. data/lib/henitai/version.rb +1 -1
  40. data/lib/henitai.rb +8 -0
  41. data/sig/henitai.rbs +92 -38
  42. metadata +39 -11
  43. data/lib/henitai/integration/child_debug_support.rb +0 -119
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 458245aa72b025224bd6c95c2bcca98ea095dd86a41e93ab8f0671ba56644369
4
- data.tar.gz: 94a822238b18f41803753a85adae77675f422d2a901cdd89723ea68b14a8842a
3
+ metadata.gz: 321426b14e3d03dae68885665b6abee8e11ea48d7a00036664dd0b6a10af2659
4
+ data.tar.gz: 262e708c7393a11e57f8fe25b9588b8b6aa04c026bebef1dec0aa77fedd782ba
5
5
  SHA512:
6
- metadata.gz: ed70e5425023aa08cd3ecd4ff6cb20dbd6c78b14f1811caaf9b43034e7649cdfeed617398a4fbf200730c1f4e8a36ab1fdfbe727b27379f4ed3b4eeef53be781
7
- data.tar.gz: 0f2b4a04643f9e1bcd3f188e06d851f34eafbbf65dcafd42b9698678cb0ee540fca70e31207003d81cb4713ee1ca2c78901780e5f474128e20331c150949052a
6
+ metadata.gz: 4ef8ed1d823903bceca334c81a895a1734745480e0966ab55e100ea95b8479201824ebbd048ea86f0465c0a98fd39dd561a62876614e20bed7b1f64460d10e20
7
+ data.tar.gz: b152279d4854b7aeb55afb5696a77054184947f0ea16570dd9e3f90a62388295b6f4f6499f101d323a8fa0071c9d83d8847e3348c878061c980587ab6ddfd53f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-08-25
11
+
12
+ ### Changed
13
+ - `minitest` runtime dependency widened from `~> 5.25` to `>= 5.25, < 7`.
14
+ The floor is unchanged; only the ceiling moved. `~> 5.25` capped the
15
+ range below 6.0, so bundles resolving minitest 6.x could not install
16
+ henitai even though the integration makes no 5.x-only assumptions.
17
+ `spec/infra/gemspec_dependencies_spec.rb` now asserts both ends of the
18
+ range so a regression fails the suite rather than shipping quietly
19
+
20
+ ## [0.5.0] - 2026-08-21
21
+
22
+ ### Security
23
+ - `sqlite3` requirement raised from `~> 1.7` to `>= 2.9.5, < 3`, excluding the
24
+ vulnerable releases up to and including 2.9.4. This is a runtime major bump:
25
+ applications pinned to `sqlite3` 1.x will need to upgrade before installing
26
+ this version of henitai. The root bundle also moves to `json` 2.21.2, the
27
+ patched release. `spec/infra/gemspec_dependencies_spec.rb` now asserts both
28
+ floors so a regression fails the suite rather than shipping quietly
29
+
30
+ ### Changed
31
+ - `coverage_criteria` now actually affects scoring. The block was documented,
32
+ defaulted and validated since it was introduced, but nothing ever read it —
33
+ `Result` hardcoded the `MS` numerator as `killed + timeout + runtime_error`,
34
+ so flipping `coverage_criteria.timeout` silently did nothing. It is now
35
+ consumed by `Result`, mapping `test_result` to `Killed`, `timeout` to
36
+ `Timeout` and `process_abort` to `RuntimeError`.
37
+
38
+ The shipped defaults for `timeout` and `process_abort` were `false`, which
39
+ described behavior the scorer did not have. They are now `true`, so **scores
40
+ do not move**: wiring the knob up with the old defaults intact would have
41
+ dropped timeouts and process aborts out of every user's numerator. Turning a
42
+ criterion off removes that status from the numerator only — the denominator is
43
+ unaffected, and `MSI` remains `killed / total` by definition. Note that
44
+ turning one off can move a run across its threshold and so change the exit
45
+ code
46
+ - Minimum supported Ruby lowered from 4.0.0 to 3.3.6, making henitai usable by
47
+ projects not yet on Ruby 4. The only Ruby 4-only API in use was
48
+ `Enumerable#rfind`, replaced by `reverse_each.find`; `TargetRubyVersion` is
49
+ now `3.3` (a 4.0 target made RuboCop's `Style/ReverseFind` demand the
50
+ 4.0-only form), and CI runs the floor alongside 4.0.2
51
+
52
+ ### Fixed
53
+ - Forked mutant children no longer outlive a parent that dies without warning.
54
+ Children run in their own process group, and all parent-side cleanup —
55
+ timeout kills, graceful drain, signal traps — only runs while the parent's
56
+ event loop is alive, so a `kill -9`, an OOM kill or a crash left children
57
+ running with no signal delivered: they reparented to init and each kept a
58
+ full Ruby and test-framework image resident (runs were observed leaving a
59
+ dozen behind, several gigabytes in total). Each child now starts a watchdog
60
+ that exits once its parent is gone. Set `HENITAI_CHILD_WATCHDOG=0` to
61
+ disable it, or `HENITAI_CHILD_WATCHDOG_INTERVAL` to change the poll interval
62
+ (default `1.5` seconds)
63
+ - The orphan watchdog no longer mistakes a live parent for a dead one when the
64
+ project's own suite stubs `Process.kill` or `Process.ppid`. Both primitives
65
+ are now captured as `Method` objects at load time, before any test double can
66
+ replace them — a captured `Method` keeps pointing at the original definition
67
+ even after the singleton method is redefined. This was not hypothetical: on
68
+ henitai's own dogfood run a mutant child running specs that stub
69
+ `Process.kill` to raise `ESRCH` concluded it had been orphaned and exited,
70
+ which the scheduler recorded as `CompileError`. Fixing it turned two spurious
71
+ `CompileError`s and one spurious `Timeout` back into `Killed` on a
72
+ 1019-mutant run
73
+ - A surviving child could also pin the reports-directory lock open, because
74
+ `flock` is held on the open file description that parent and child share.
75
+ Once the parent died, every later run in that directory failed with
76
+ `ConcurrentRunError` naming a pid that no longer existed. Children now close
77
+ the inherited handle immediately after forking
78
+ - `# henitai:disable HashKeyType` and `# henitai:disable
79
+ EqualityIdentityOperator` no longer raise `ConfigurationError` and abort the
80
+ run. The directive whitelist validated names against `full` rather than the
81
+ operator registry, so the hard-set names 0.4.0 introduced — the very ones
82
+ ADR-12 and the README tell users to suppress per site — were rejected as
83
+ unknown operators. `# henitai:disable EqualityIdentityOperator` worked in
84
+ 0.3.1; this restores it
85
+
86
+ ### Internal
87
+ - Test-suite coupling to private implementation details is now guarded by
88
+ `spec/infra/private_method_reach_spec.rb`, a ratchet: every spec that reaches
89
+ a private method through `send` or an instance-variable poke carries a
90
+ documented budget, and a budget may only ever go down — beating one fails the
91
+ suite until the number is lowered in the same commit. Twelve tickets tracking
92
+ this debt had sat open while the debt itself moved into three *different*,
93
+ unticketed files, because nothing measured it
94
+ - Nine collaborators extracted, each because a rule had no public seam rather
95
+ than to satisfy the ratchet mechanically: `Integration::ChildDebugLog` and
96
+ `Integration::LoadedFeatures` (replacing `Integration::ChildDebugSupport`,
97
+ which declared `private` at the top of the module); `SlotScheduler::SlotTable`,
98
+ `RetryPolicy`, `SlotDeadline`, `TestFileSelection` and `DrainVerdict`;
99
+ `ExcludedTestFilter`; `EquivalenceDetector::OperandPredicates`;
100
+ `DirtySourceDetector`, `SourceFileSelection`, `SubjectSelection` and
101
+ `RunnerDependencies`. `SlotScheduler` and `Runner` both shed enough code to
102
+ come back under their complexity budgets
103
+
10
104
  ## [0.4.0] - 2026-07-14
11
105
 
12
106
  ### Added
@@ -422,7 +516,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
422
516
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
423
517
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
424
518
 
425
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.4.0...HEAD
519
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.5.1...HEAD
520
+ [0.5.1]: https://github.com/martinotten/henitai/compare/v0.5.0...v0.5.1
521
+ [0.5.0]: https://github.com/martinotten/henitai/compare/v0.4.0...v0.5.0
426
522
  [0.4.0]: https://github.com/martinotten/henitai/compare/v0.3.1...v0.4.0
427
523
  [0.3.1]: https://github.com/martinotten/henitai/compare/v0.3.0...v0.3.1
428
524
  [0.3.0]: https://github.com/martinotten/henitai/compare/v0.2.1...v0.3.0
data/README.md CHANGED
@@ -43,7 +43,7 @@ Or install globally:
43
43
  gem install henitai
44
44
  ```
45
45
 
46
- **Requires Ruby 4.0.0+**
46
+ **Requires Ruby 3.3.6+**
47
47
 
48
48
  ## Quick start
49
49
 
data/lib/henitai/cli.rb CHANGED
@@ -78,7 +78,7 @@ module Henitai
78
78
 
79
79
  def help_text
80
80
  <<~HELP
81
- Hen'i-tai 変異体 #{Henitai::VERSION} — Ruby 4 Mutation Testing
81
+ Hen'i-tai 変異体 #{Henitai::VERSION} — Ruby Mutation Testing
82
82
 
83
83
  Usage:
84
84
  henitai run [options] [SUBJECT_PATTERN...]
@@ -26,10 +26,17 @@ module Henitai
26
26
  DEFAULT_CHECKPOINT_EVERY = 200
27
27
  DEFAULT_CHECKPOINT_INTERVAL = 30.0
28
28
  DEFAULT_REPORTS_DIR = "reports"
29
+ # All three default to true because Result's MS numerator has always counted
30
+ # killed + timeout + runtime_error unconditionally. Until 0.5.0 this block
31
+ # was validated but never read, so the shipped `false` defaults described
32
+ # behavior the scorer did not have. Wiring it up with those defaults intact
33
+ # would have silently dropped timeouts and aborts out of every user's score;
34
+ # flipping them keeps the numerator exactly as it was and makes the knob
35
+ # mean what it says. See Result::CRITERION_STATUSES for the mapping.
29
36
  DEFAULT_COVERAGE_CRITERIA = {
30
37
  test_result: true,
31
- timeout: false,
32
- process_abort: false
38
+ timeout: true,
39
+ process_abort: true
33
40
  }.freeze
34
41
  DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
35
42
  CONFIG_FILE = ".henitai.yml"
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Answers whether any configured source root has changed, which is what makes
5
+ # a prior run's survivor verdicts unsafe to reuse.
6
+ #
7
+ # Two change sets are considered together: files dirty in the worktree, and
8
+ # files committed since the run being reused. Test-only churn is deliberately
9
+ # ignored — a spec edit invalidates nothing about a *mutant*, only about
10
+ # whether it is still killed, which the survivor rerun is about to re-measure
11
+ # anyway.
12
+ #
13
+ # Every failure mode answers `true`. A wrong `true` costs one extra rerun; a
14
+ # wrong `false` silently reuses a stale verdict, which is the whole failure
15
+ # this guard exists to prevent.
16
+ class DirtySourceDetector
17
+ def initialize(includes:, git_diff_analyzer:)
18
+ @includes = includes
19
+ @git_diff_analyzer = git_diff_analyzer
20
+ end
21
+
22
+ def dirty?(dirty_worktree_files, git_sha: nil)
23
+ # A nil list means the worktree could not be read at all, not that it is
24
+ # clean.
25
+ return true if dirty_worktree_files.nil?
26
+
27
+ all_changed = dirty_worktree_files + committed_changed_files(git_sha)
28
+ all_changed.any? { |path| in_include_root?(normalize_path(path)) }
29
+ rescue StandardError
30
+ true
31
+ end
32
+
33
+ private
34
+
35
+ def committed_changed_files(git_sha)
36
+ return [] unless git_sha
37
+
38
+ @git_diff_analyzer.changed_files(from: git_sha, to: "HEAD")
39
+ end
40
+
41
+ def include_roots
42
+ @include_roots ||= Array(@includes).map { |path| normalize_path(path) }
43
+ end
44
+
45
+ # Prefix match on a path boundary, not a bare start_with?: "lib" must not
46
+ # match "library/foo.rb".
47
+ def in_include_root?(path)
48
+ include_roots.any? { |root| path == root || path.start_with?("#{root}/") }
49
+ end
50
+
51
+ def normalize_path(path) = File.expand_path(path)
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+
5
+ module Henitai
6
+ class EquivalenceDetector
7
+ # Recognizes the arithmetic operators and neutral operands that make a
8
+ # mutation provably equivalent to its original -- `x + 0`, `x * 1` and
9
+ # friends.
10
+ #
11
+ # Extracted from EquivalenceDetector because these decisions are
12
+ # load-bearing for scoring: a mutant marked equivalent leaves *both* sides
13
+ # of the mutation score, so a false positive quietly changes the reported
14
+ # number. They deserve tests of their own rather than being reached through
15
+ # the detector's private interface.
16
+ class OperandPredicates
17
+ ADDITIVE = %i[+ -].freeze
18
+ MULTIPLICATIVE = %i[* / **].freeze
19
+
20
+ def additive_operator?(operator) = ADDITIVE.include?(operator)
21
+
22
+ def multiplicative_operator?(operator) = MULTIPLICATIVE.include?(operator)
23
+
24
+ # Additive identity: `x + 0` and `x - 0` both reduce to `x`.
25
+ def zero_operand?(node) = numeric_operand?(node, 0)
26
+
27
+ # Multiplicative identity: `x * 1`, `x / 1` and `x ** 1` all reduce to `x`.
28
+ def one_operand?(node) = numeric_operand?(node, 1)
29
+
30
+ private
31
+
32
+ # Reads the right-hand operand of a binary send. Guards against a
33
+ # malformed node whose operand is a bare Ruby value rather than an AST
34
+ # node -- that shape does not occur in parsed source, but the detector
35
+ # must not raise on synthesized input.
36
+ def numeric_operand?(node, value)
37
+ operand = node.children[2]
38
+ return false unless operand.is_a?(Parser::AST::Node)
39
+
40
+ case operand.type
41
+ when :int, :float
42
+ operand.children.first == value || operand.children.first == value.to_i
43
+ else
44
+ false
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "parser_current"
4
+ require_relative "equivalence_detector/operand_predicates"
4
5
 
5
6
  module Henitai
6
7
  # Detects obvious equivalent mutants before execution.
@@ -112,33 +113,15 @@ module Henitai
112
113
  node.is_a?(Parser::AST::Node) && node.type == type && node.children.empty?
113
114
  end
114
115
 
115
- def additive_operator?(operator)
116
- %i[+ -].include?(operator)
117
- end
116
+ def additive_operator?(operator) = operand_predicates.additive_operator?(operator)
118
117
 
119
- def multiplicative_operator?(operator)
120
- %i[* / **].include?(operator)
121
- end
118
+ def multiplicative_operator?(operator) = operand_predicates.multiplicative_operator?(operator)
122
119
 
123
- def zero_operand?(node)
124
- numeric_operand?(node, 0)
125
- end
126
-
127
- def one_operand?(node)
128
- numeric_operand?(node, 1)
129
- end
120
+ def zero_operand?(node) = operand_predicates.zero_operand?(node)
130
121
 
131
- def numeric_operand?(node, value)
132
- operand = node.children[2]
133
- return false unless operand.is_a?(Parser::AST::Node)
122
+ def one_operand?(node) = operand_predicates.one_operand?(node)
134
123
 
135
- case operand.type
136
- when :int, :float
137
- operand.children.first == value || operand.children.first == value.to_i
138
- else
139
- false
140
- end
141
- end
124
+ def operand_predicates = @operand_predicates ||= OperandPredicates.new
142
125
 
143
126
  # Detects `lhs == <singleton>` mutated to `lhs.equal?(<singleton>)` (or the
144
127
  # reverse), but only when the receiver is itself a singleton literal.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Drops test files matching any of the configured exclude globs.
5
+ #
6
+ # This keeps a mutant child from re-running tests that themselves spawn
7
+ # henitai or forked subprocesses -- the CLI and process-scheduler specs, when
8
+ # dogfooding henitai on itself -- which would otherwise multiply processes and
9
+ # log noise.
10
+ #
11
+ # Takes the pattern list rather than a configuration object: exclusion is a
12
+ # path-matching rule, and keeping it free of configuration lookup makes it
13
+ # directly testable.
14
+ class ExcludedTestFilter
15
+ # @param patterns [Array<String>, nil] exclude globs; nil and [] both mean
16
+ # "exclude nothing"
17
+ def initialize(patterns:)
18
+ @patterns = Array(patterns)
19
+ end
20
+
21
+ # @param tests [Array<String>] candidate test paths
22
+ # @return [Array<String>] paths not matched by any pattern
23
+ def reject(tests)
24
+ return tests if @patterns.empty?
25
+
26
+ tests.reject { |path| excluded?(path) }
27
+ end
28
+
29
+ private
30
+
31
+ # FNM_PATHNAME so a single `*` does not match across a directory separator.
32
+ # Without it, an exclude as narrow as "spec/a/*_spec.rb" would swallow every
33
+ # test below spec/a as well.
34
+ def excluded?(path)
35
+ candidate = File.expand_path(path)
36
+ expanded_patterns.any? do |pattern|
37
+ File.fnmatch?(pattern, candidate, File::FNM_PATHNAME)
38
+ end
39
+ end
40
+
41
+ # Both sides are expanded so a relative pattern still matches an absolute
42
+ # test path, and vice versa.
43
+ def expanded_patterns
44
+ @expanded_patterns ||= @patterns.map { |pattern| File.expand_path(pattern) }
45
+ end
46
+ end
47
+ end
@@ -109,19 +109,13 @@ module Henitai
109
109
  test_prioritizer(config).sort(tests, mutant, test_history(config))
110
110
  end
111
111
 
112
- # Drops test files matching any config.test_excludes glob. Used to keep a
113
- # mutant child from re-running tests that themselves spawn henitai/forked
114
- # subprocesses (e.g. the CLI and process-scheduler specs when dogfooding
115
- # henitai on itself), which otherwise multiplies processes and log noise.
116
112
  def reject_excluded_tests(tests, config)
117
- patterns = config.respond_to?(:test_excludes) ? Array(config.test_excludes) : []
118
- return tests if patterns.empty?
113
+ ExcludedTestFilter.new(patterns: configured_test_excludes(config)).reject(tests)
114
+ end
119
115
 
120
- expanded = patterns.map { |pattern| File.expand_path(pattern) }
121
- tests.reject do |path|
122
- candidate = File.expand_path(path)
123
- expanded.any? { |pattern| File.fnmatch?(pattern, candidate, File::FNM_PATHNAME) }
124
- end
116
+ # Specs pass bare config doubles exposing only what the example needs.
117
+ def configured_test_excludes(config)
118
+ config.respond_to?(:test_excludes) ? config.test_excludes : nil
125
119
  end
126
120
 
127
121
  def test_prioritizer(config)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Tracks parent-process file handles that a forked child must not keep open.
5
+ #
6
+ # The motivating case is the reports-directory lock. `flock` is held on the
7
+ # open file *description*, which parent and child share after a fork. If a
8
+ # child outlives its parent while holding an inherited copy of that handle,
9
+ # the lock stays held by a process that is no longer running a mutation run,
10
+ # and every later invocation fails with a ConcurrentRunError naming a dead
11
+ # pid. Closing the child's copy immediately after fork means the lock dies
12
+ # with the parent, as intended.
13
+ #
14
+ # Registration is parent-side; #close_all! is the child-side call. The
15
+ # registry deliberately holds IO objects rather than file descriptor numbers:
16
+ # a child that recreated the handle via IO.for_fd would leave the original
17
+ # object alive, and its finalizer could later close a descriptor number the
18
+ # child had since reused for something else.
19
+ module InheritedFdRegistry
20
+ @ios = []
21
+ @mutex = Mutex.new
22
+
23
+ class << self
24
+ def register(io)
25
+ @mutex.synchronize { @ios << io }
26
+ io
27
+ end
28
+
29
+ def unregister(io)
30
+ @mutex.synchronize { @ios.delete(io) }
31
+ io
32
+ end
33
+
34
+ # @return [Array<IO>] copy of the tracked handles
35
+ def registered
36
+ @mutex.synchronize { @ios.dup }
37
+ end
38
+
39
+ # Closes every tracked handle. Call this in the child, immediately after
40
+ # Process.fork.
41
+ #
42
+ # This deliberately does NOT take the mutex. `fork` can land while
43
+ # another thread in the parent holds it -- the coverage bootstrap thread
44
+ # and the scheduler's worker threads both run concurrently with spawning
45
+ # -- and only the forking thread survives into the child. Waiting on a
46
+ # mutex whose owner does not exist there would hang the child forever.
47
+ # Reading a stale snapshot is harmless; deadlocking is not.
48
+ def close_all!
49
+ ios = @ios.dup
50
+ @ios = []
51
+ ios.each { |io| close_quietly(io) }
52
+ nil
53
+ end
54
+
55
+ private
56
+
57
+ # Runs in a just-forked child, where raising would take down the whole
58
+ # mutant run rather than the one handle that failed.
59
+ def close_quietly(io)
60
+ io.close unless io.closed?
61
+ rescue IOError, SystemCallError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "../process_wakeup"
5
- require_relative "child_debug_support"
5
+ require_relative "child_debug_log"
6
6
  require_relative "child_runtime_control"
7
7
  require_relative "scenario_log_support"
8
8
 
@@ -13,9 +13,14 @@ module Henitai
13
13
  # environment helpers. Concrete adapters mix in MutantRunSupport and
14
14
  # implement #run_tests plus test selection.
15
15
  class Base
16
- include ChildDebugSupport
17
16
  include ChildRuntimeControl
18
17
 
18
+ # Child-run diagnostics. Public so the child-side modules mixed into
19
+ # concrete adapters can reach it without `send`.
20
+ def child_debug_log
21
+ @child_debug_log ||= ChildDebugLog.new
22
+ end
23
+
19
24
  # @param subject [Subject]
20
25
  # @return [Array<String>] paths to test files that cover this subject
21
26
  def select_tests(subject)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # The first thing a forked mutant child does, before any test-framework
6
+ # work begins.
7
+ #
8
+ # Extracted from the fork block so the sequence is named and unit-testable
9
+ # without forking, and so both the RSpec and Minitest paths -- which share
10
+ # MutantRunSupport#spawn_mutant -- get identical treatment.
11
+ module ChildBootstrap
12
+ # @param parent_pid [Integer] captured in the parent *before* Process.fork.
13
+ # Reading Process.ppid here instead would race the very death the
14
+ # watchdog is looking for: a parent that dies between fork and this
15
+ # line leaves the child with ppid 1 as its baseline, making it look
16
+ # permanently healthy.
17
+ def self.after_fork!(parent_pid:)
18
+ # First, so that even a crash later in this method releases the
19
+ # reports-directory lock rather than pinning it with an inherited fd.
20
+ InheritedFdRegistry.close_all!
21
+ Process.setpgid(0, 0)
22
+ OrphanWatchdog.start(parent_pid:)
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "loaded_features"
4
+
5
+ module Henitai
6
+ module Integration
7
+ # Diagnostics writer for the mutant child process, gated on
8
+ # `HENITAI_DEBUG_CHILD=1`.
9
+ #
10
+ # Every method gates on {#enabled?} itself rather than trusting call sites,
11
+ # so no unguarded caller can leak debug lines into every child log by
12
+ # default.
13
+ class ChildDebugLog
14
+ PREFIX = "[henitai-debug-child]"
15
+
16
+ def initialize(io: nil, loaded_features: LoadedFeatures.new)
17
+ @io = io
18
+ @loaded_features = loaded_features
19
+ end
20
+
21
+ def enabled? = ENV["HENITAI_DEBUG_CHILD"] == "1"
22
+
23
+ def write(message)
24
+ return unless enabled?
25
+
26
+ io.puts(message)
27
+ io.flush
28
+ end
29
+
30
+ def rspec_trace(test_files:, rspec_options:, rspec_argv:)
31
+ return unless enabled?
32
+
33
+ write(
34
+ "#{PREFIX} cwd=#{Dir.pwd}\n" \
35
+ "#{PREFIX} files_exist=#{test_files.map { |file| [file, File.exist?(file)] }.inspect}\n" \
36
+ "#{PREFIX} loaded_features_check=#{@loaded_features.map(test_files).inspect}\n" \
37
+ "#{PREFIX} test_files=#{test_files.inspect}\n" \
38
+ "#{PREFIX} rspec_options=#{rspec_options.inspect}\n" \
39
+ "#{PREFIX} rspec_argv=#{rspec_argv.inspect}"
40
+ )
41
+ end
42
+
43
+ def rspec_exit(status)
44
+ write("#{PREFIX} RSpec result=#{status.inspect}")
45
+ end
46
+
47
+ def example_count(stage)
48
+ return unless enabled?
49
+
50
+ write("#{PREFIX} rspec_world_example_count_#{stage}=#{rspec_world_example_count.inspect}")
51
+ end
52
+
53
+ def activation_start(mutant_id)
54
+ write("#{PREFIX} activate_start mutant=#{mutant_id}")
55
+ end
56
+
57
+ def activation_end(activation_result, test_files:)
58
+ write(
59
+ "#{PREFIX} activate_end result=#{activation_result.inspect}\n" \
60
+ "#{PREFIX} run_tests_start test_files=#{test_files.inspect}"
61
+ )
62
+ end
63
+
64
+ def mutant_meta(mutant)
65
+ return unless enabled?
66
+
67
+ write(
68
+ "#{PREFIX} mutant_meta stableId=#{value_of(mutant, :stable_id)}\n" \
69
+ "#{PREFIX} mutant_meta operator=#{value_of(mutant, :operator)}\n" \
70
+ "#{PREFIX} mutant_meta subject=#{subject_expression_of(mutant)}\n" \
71
+ "#{PREFIX} mutant_meta location=#{location_of(mutant)}\n"
72
+ )
73
+ end
74
+
75
+ # Reports where Runner#resolve_subjects was loaded from, which is how a
76
+ # child that activated a mutant on Henitai's own source is identified.
77
+ def activation_check
78
+ return unless enabled?
79
+
80
+ location = begin
81
+ Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":") # henitai:disable
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ write("#{PREFIX} activation_check resolve_subjects_location=#{location}\n")
87
+ end
88
+
89
+ def timeout_signal_sent(pid)
90
+ write("#{PREFIX} timeout_signal_sent pid=#{pid}")
91
+ end
92
+
93
+ def thread_dump(reason)
94
+ return unless enabled?
95
+
96
+ write("#{PREFIX} thread_dump reason=#{reason}")
97
+ Thread.list.each_with_index { |thread, index| dump_thread(thread, index) }
98
+ end
99
+
100
+ def rspec_world_example_count
101
+ ::RSpec.world.example_count
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ private
107
+
108
+ def dump_thread(thread, index)
109
+ write(
110
+ "#{PREFIX} thread index=#{index} id=#{thread.object_id} " \
111
+ "status=#{thread.status.inspect}"
112
+ )
113
+ Array(thread.backtrace).each { |line| write("#{PREFIX} #{line}") }
114
+ end
115
+
116
+ def value_of(mutant, name) = mutant.respond_to?(name) ? mutant.public_send(name) : nil
117
+
118
+ # Deliberately `.inspect` inside the ternary, not on the result: a mutant
119
+ # without #location must render as empty, not as the string "nil".
120
+ def location_of(mutant) = mutant.respond_to?(:location) ? mutant.location.inspect : nil
121
+
122
+ def subject_expression_of(mutant)
123
+ return nil unless mutant.respond_to?(:subject) && mutant.subject.respond_to?(:expression)
124
+
125
+ mutant.subject.expression
126
+ end
127
+
128
+ # Resolved per call, never memoized: ScenarioLogSupport#capture_child_output
129
+ # reassigns $stdout inside the child *after* this object is built, so a
130
+ # captured-at-construction stream would send every debug line to the
131
+ # parent's terminal instead of the child's log file.
132
+ def io = @io || $stdout
133
+ end
134
+ end
135
+ end