henitai 0.4.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -1
- data/README.md +1 -1
- data/lib/henitai/cli.rb +1 -1
- data/lib/henitai/configuration.rb +9 -2
- data/lib/henitai/dirty_source_detector.rb +53 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
- data/lib/henitai/equivalence_detector.rb +6 -23
- data/lib/henitai/excluded_test_filter.rb +47 -0
- data/lib/henitai/execution_engine.rb +5 -11
- data/lib/henitai/inherited_fd_registry.rb +66 -0
- data/lib/henitai/integration/base.rb +7 -2
- data/lib/henitai/integration/child_bootstrap.rb +27 -0
- data/lib/henitai/integration/child_debug_log.rb +135 -0
- data/lib/henitai/integration/child_runtime_control.rb +6 -18
- data/lib/henitai/integration/loaded_features.rb +38 -0
- data/lib/henitai/integration/mutant_run_support.rb +5 -5
- data/lib/henitai/integration/rspec_child_runner.rb +16 -15
- data/lib/henitai/integration/rspec_process_runner.rb +7 -2
- data/lib/henitai/integration.rb +10 -7
- data/lib/henitai/mutation_skip_directives.rb +7 -1
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/orphan_watchdog.rb +93 -0
- data/lib/henitai/process_liveness.rb +41 -0
- data/lib/henitai/reports_directory_lock.rb +12 -11
- data/lib/henitai/result.rb +30 -3
- data/lib/henitai/runner.rb +41 -123
- data/lib/henitai/runner_dependencies.rb +75 -0
- data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
- data/lib/henitai/slot_scheduler/draining.rb +7 -17
- data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
- data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
- data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
- data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
- data/lib/henitai/slot_scheduler.rb +68 -80
- data/lib/henitai/source_file_selection.rb +76 -0
- data/lib/henitai/subject_selection.rb +33 -0
- data/lib/henitai/survivor_rerun_strategy.rb +7 -19
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +92 -38
- metadata +31 -9
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34e96b6bb2bd74ac49e52a2acbe459ccb551498914d8b442f56c427c8b8ef45c
|
|
4
|
+
data.tar.gz: 2792932da92f73f6e4cfcc0a67aa28dca405fd4cee6d02437e13f76fbd9ee08d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fe64e42c96aaa19d26bdf1a45631146bea7cb6d8caca5891d75ac6da0bdb3c30843ea8a83e34a67d0f90559fe41229d46a7000478cdd3708fb7929efea9f209
|
|
7
|
+
data.tar.gz: 39432815b88d834f6eb30be76255240b8124339505a86d8f644f2cd48ad787d85c6eb9ea9e7a85191262540e06ac467a9c32ad326a306e10953d9e758355d7bf
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- `sqlite3` requirement raised from `~> 1.7` to `>= 2.9.5, < 3`, excluding the
|
|
14
|
+
vulnerable releases up to and including 2.9.4. This is a runtime major bump:
|
|
15
|
+
applications pinned to `sqlite3` 1.x will need to upgrade before installing
|
|
16
|
+
this version of henitai. The root bundle also moves to `json` 2.21.2, the
|
|
17
|
+
patched release. `spec/infra/gemspec_dependencies_spec.rb` now asserts both
|
|
18
|
+
floors so a regression fails the suite rather than shipping quietly
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `coverage_criteria` now actually affects scoring. The block was documented,
|
|
22
|
+
defaulted and validated since it was introduced, but nothing ever read it —
|
|
23
|
+
`Result` hardcoded the `MS` numerator as `killed + timeout + runtime_error`,
|
|
24
|
+
so flipping `coverage_criteria.timeout` silently did nothing. It is now
|
|
25
|
+
consumed by `Result`, mapping `test_result` to `Killed`, `timeout` to
|
|
26
|
+
`Timeout` and `process_abort` to `RuntimeError`.
|
|
27
|
+
|
|
28
|
+
The shipped defaults for `timeout` and `process_abort` were `false`, which
|
|
29
|
+
described behavior the scorer did not have. They are now `true`, so **scores
|
|
30
|
+
do not move**: wiring the knob up with the old defaults intact would have
|
|
31
|
+
dropped timeouts and process aborts out of every user's numerator. Turning a
|
|
32
|
+
criterion off removes that status from the numerator only — the denominator is
|
|
33
|
+
unaffected, and `MSI` remains `killed / total` by definition. Note that
|
|
34
|
+
turning one off can move a run across its threshold and so change the exit
|
|
35
|
+
code
|
|
36
|
+
- Minimum supported Ruby lowered from 4.0.0 to 3.3.6, making henitai usable by
|
|
37
|
+
projects not yet on Ruby 4. The only Ruby 4-only API in use was
|
|
38
|
+
`Enumerable#rfind`, replaced by `reverse_each.find`; `TargetRubyVersion` is
|
|
39
|
+
now `3.3` (a 4.0 target made RuboCop's `Style/ReverseFind` demand the
|
|
40
|
+
4.0-only form), and CI runs the floor alongside 4.0.2
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- Forked mutant children no longer outlive a parent that dies without warning.
|
|
44
|
+
Children run in their own process group, and all parent-side cleanup —
|
|
45
|
+
timeout kills, graceful drain, signal traps — only runs while the parent's
|
|
46
|
+
event loop is alive, so a `kill -9`, an OOM kill or a crash left children
|
|
47
|
+
running with no signal delivered: they reparented to init and each kept a
|
|
48
|
+
full Ruby and test-framework image resident (runs were observed leaving a
|
|
49
|
+
dozen behind, several gigabytes in total). Each child now starts a watchdog
|
|
50
|
+
that exits once its parent is gone. Set `HENITAI_CHILD_WATCHDOG=0` to
|
|
51
|
+
disable it, or `HENITAI_CHILD_WATCHDOG_INTERVAL` to change the poll interval
|
|
52
|
+
(default `1.5` seconds)
|
|
53
|
+
- The orphan watchdog no longer mistakes a live parent for a dead one when the
|
|
54
|
+
project's own suite stubs `Process.kill` or `Process.ppid`. Both primitives
|
|
55
|
+
are now captured as `Method` objects at load time, before any test double can
|
|
56
|
+
replace them — a captured `Method` keeps pointing at the original definition
|
|
57
|
+
even after the singleton method is redefined. This was not hypothetical: on
|
|
58
|
+
henitai's own dogfood run a mutant child running specs that stub
|
|
59
|
+
`Process.kill` to raise `ESRCH` concluded it had been orphaned and exited,
|
|
60
|
+
which the scheduler recorded as `CompileError`. Fixing it turned two spurious
|
|
61
|
+
`CompileError`s and one spurious `Timeout` back into `Killed` on a
|
|
62
|
+
1019-mutant run
|
|
63
|
+
- A surviving child could also pin the reports-directory lock open, because
|
|
64
|
+
`flock` is held on the open file description that parent and child share.
|
|
65
|
+
Once the parent died, every later run in that directory failed with
|
|
66
|
+
`ConcurrentRunError` naming a pid that no longer existed. Children now close
|
|
67
|
+
the inherited handle immediately after forking
|
|
68
|
+
- `# henitai:disable HashKeyType` and `# henitai:disable
|
|
69
|
+
EqualityIdentityOperator` no longer raise `ConfigurationError` and abort the
|
|
70
|
+
run. The directive whitelist validated names against `full` rather than the
|
|
71
|
+
operator registry, so the hard-set names 0.4.0 introduced — the very ones
|
|
72
|
+
ADR-12 and the README tell users to suppress per site — were rejected as
|
|
73
|
+
unknown operators. `# henitai:disable EqualityIdentityOperator` worked in
|
|
74
|
+
0.3.1; this restores it
|
|
75
|
+
|
|
76
|
+
### Internal
|
|
77
|
+
- Test-suite coupling to private implementation details is now guarded by
|
|
78
|
+
`spec/infra/private_method_reach_spec.rb`, a ratchet: every spec that reaches
|
|
79
|
+
a private method through `send` or an instance-variable poke carries a
|
|
80
|
+
documented budget, and a budget may only ever go down — beating one fails the
|
|
81
|
+
suite until the number is lowered in the same commit. Twelve tickets tracking
|
|
82
|
+
this debt had sat open while the debt itself moved into three *different*,
|
|
83
|
+
unticketed files, because nothing measured it
|
|
84
|
+
- Nine collaborators extracted, each because a rule had no public seam rather
|
|
85
|
+
than to satisfy the ratchet mechanically: `Integration::ChildDebugLog` and
|
|
86
|
+
`Integration::LoadedFeatures` (replacing `Integration::ChildDebugSupport`,
|
|
87
|
+
which declared `private` at the top of the module); `SlotScheduler::SlotTable`,
|
|
88
|
+
`RetryPolicy`, `SlotDeadline`, `TestFileSelection` and `DrainVerdict`;
|
|
89
|
+
`ExcludedTestFilter`; `EquivalenceDetector::OperandPredicates`;
|
|
90
|
+
`DirtySourceDetector`, `SourceFileSelection`, `SubjectSelection` and
|
|
91
|
+
`RunnerDependencies`. `SlotScheduler` and `Runner` both shed enough code to
|
|
92
|
+
come back under their complexity budgets
|
|
93
|
+
|
|
10
94
|
## [0.4.0] - 2026-07-14
|
|
11
95
|
|
|
12
96
|
### Added
|
|
@@ -422,7 +506,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
422
506
|
- CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
|
|
423
507
|
- RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
|
|
424
508
|
|
|
425
|
-
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.
|
|
509
|
+
[Unreleased]: https://github.com/martinotten/henitai/compare/v0.5.0...HEAD
|
|
510
|
+
[0.5.0]: https://github.com/martinotten/henitai/compare/v0.4.0...v0.5.0
|
|
426
511
|
[0.4.0]: https://github.com/martinotten/henitai/compare/v0.3.1...v0.4.0
|
|
427
512
|
[0.3.1]: https://github.com/martinotten/henitai/compare/v0.3.0...v0.3.1
|
|
428
513
|
[0.3.0]: https://github.com/martinotten/henitai/compare/v0.2.1...v0.3.0
|
data/README.md
CHANGED
data/lib/henitai/cli.rb
CHANGED
|
@@ -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:
|
|
32
|
-
process_abort:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
113
|
+
ExcludedTestFilter.new(patterns: configured_test_excludes(config)).reject(tests)
|
|
114
|
+
end
|
|
119
115
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 "
|
|
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
|
|
@@ -17,10 +17,13 @@ module Henitai
|
|
|
17
17
|
CoverageRuntimeSuppressors.suppress_coverage!
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Signalling, not logging: the thread dump itself is emitted by the
|
|
21
|
+
# child's USR1 handler, so this only asks for it and gives the child a
|
|
22
|
+
# moment to write before the caller tears the process group down.
|
|
20
23
|
def debug_child_timeout_dump(pid)
|
|
21
|
-
return unless
|
|
24
|
+
return unless child_debug_log.enabled?
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
child_debug_log.timeout_signal_sent(pid)
|
|
24
27
|
Process.kill(:USR1, pid)
|
|
25
28
|
pause(0.2)
|
|
26
29
|
rescue Errno::ESRCH
|
|
@@ -28,22 +31,7 @@ module Henitai
|
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def install_debug_timeout_trap
|
|
31
|
-
Signal.trap("USR1") {
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def debug_child_thread_dump(reason)
|
|
35
|
-
return unless debug_child?
|
|
36
|
-
|
|
37
|
-
debug_child_puts("[henitai-debug-child] thread_dump reason=#{reason}")
|
|
38
|
-
Thread.list.each_with_index do |thread, index|
|
|
39
|
-
debug_child_puts(
|
|
40
|
-
"[henitai-debug-child] thread index=#{index} id=#{thread.object_id} " \
|
|
41
|
-
"status=#{thread.status.inspect}"
|
|
42
|
-
)
|
|
43
|
-
Array(thread.backtrace).each do |line|
|
|
44
|
-
debug_child_puts("[henitai-debug-child] #{line}")
|
|
45
|
-
end
|
|
46
|
-
end
|
|
34
|
+
Signal.trap("USR1") { child_debug_log.thread_dump("timeout") }
|
|
47
35
|
end
|
|
48
36
|
end
|
|
49
37
|
end
|