evilution 0.31.0 → 0.33.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +28 -0
  3. data/.rubocop_todo.yml +1 -0
  4. data/CHANGELOG.md +28 -0
  5. data/README.md +8 -8
  6. data/docs/integrations.md +141 -0
  7. data/docs/isolation.md +15 -0
  8. data/lib/evilution/baseline.rb +11 -4
  9. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  10. data/lib/evilution/config/validators/integration.rb +5 -1
  11. data/lib/evilution/config.rb +1 -1
  12. data/lib/evilution/integration/loading/mutation_applier.rb +1 -2
  13. data/lib/evilution/integration/loading/source_evaluator.rb +1 -2
  14. data/lib/evilution/integration/loading/test_load_path.rb +76 -0
  15. data/lib/evilution/integration/minitest.rb +6 -3
  16. data/lib/evilution/integration/rspec/baseline_runner.rb +3 -1
  17. data/lib/evilution/integration/rspec/framework_loader.rb +5 -1
  18. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +72 -0
  19. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +45 -0
  20. data/lib/evilution/integration/rspec/state_guard.rb +3 -1
  21. data/lib/evilution/integration/test_unit/dispatcher.rb +26 -0
  22. data/lib/evilution/integration/test_unit/framework_loader.rb +33 -0
  23. data/lib/evilution/integration/test_unit/result_builder.rb +53 -0
  24. data/lib/evilution/integration/test_unit/subject_class_registry.rb +26 -0
  25. data/lib/evilution/integration/test_unit/test_file_resolver.rb +48 -0
  26. data/lib/evilution/integration/test_unit.rb +132 -0
  27. data/lib/evilution/integration/test_unit_crash_detector.rb +61 -0
  28. data/lib/evilution/isolation/fork.rb +45 -3
  29. data/lib/evilution/mcp/info_tool.rb +2 -2
  30. data/lib/evilution/mcp/mutate_tool.rb +3 -2
  31. data/lib/evilution/parallel/work_queue/dispatcher.rb +94 -22
  32. data/lib/evilution/parallel/work_queue/worker.rb +49 -3
  33. data/lib/evilution/parallel/work_queue/worker_registry.rb +47 -0
  34. data/lib/evilution/parallel/work_queue.rb +8 -0
  35. data/lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb +50 -0
  36. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  37. data/lib/evilution/runner/baseline_runner.rb +3 -1
  38. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
  39. data/lib/evilution/runner.rb +12 -1
  40. data/lib/evilution/spec_resolver.rb +81 -9
  41. data/lib/evilution/version.rb +1 -1
  42. data/lib/evilution.rb +11 -0
  43. data/lib/tasks/stress.rake +15 -0
  44. data/script/run_self_baseline +2 -2
  45. metadata +16 -2
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rspec"
4
+
5
+ # Restores the RSpec.configuration state that ::RSpec::Core::Runner.run mutates
6
+ # on the shared singleton during an in-process mutation run (EV-dwqw / GH #1343).
7
+ #
8
+ # The visible symptom was every SUBSEQUENT host example rendering its progress
9
+ # dots without color (white instead of green). Root cause: the run's args carry
10
+ # "--no-color", and ConfigurationOptions#configure applies it via
11
+ # Configuration#force, which does `@preferred_options.merge!(:color_mode => :off)`
12
+ # IN PLACE. `Configuration#color_mode` reads `@preferred_options.fetch(:color_mode)`
13
+ # first, so the host's color setting stays :off for the rest of the process.
14
+ # Separately, Runner#setup points `output_stream`/`error_stream` (attr_writers,
15
+ # i.e. the @output_stream/@error_stream ivars) at the run's throwaway StringIOs.
16
+ #
17
+ # Forked runs never leak these (the mutation happens in a child that dies); only
18
+ # the in-process isolation path mutates the host's own configuration.
19
+ #
20
+ # @preferred_options is mutated in place, so it is snapshotted by DUP and put
21
+ # back by replacing the ivar; the stream ivars are reassigned during the run,
22
+ # so capturing the original references is enough.
23
+ class Evilution::Integration::RSpec::StateGuard::ConfigurationState
24
+ PREFERRED_OPTIONS = :@preferred_options
25
+ STREAM_IVARS = %i[@output_stream @error_stream].freeze
26
+ ALL_IVARS = [PREFERRED_OPTIONS, *STREAM_IVARS].freeze
27
+
28
+ # configuration is injectable for isolated unit testing; production uses the
29
+ # shared RSpec.configuration singleton.
30
+ def initialize(configuration: nil)
31
+ @configuration = configuration
32
+ end
33
+
34
+ def snapshot
35
+ config = configuration
36
+ state = {}
37
+ capture_preferred_options(config, state)
38
+ STREAM_IVARS.each do |ivar|
39
+ state[ivar] = config.instance_variable_get(ivar) if config.instance_variable_defined?(ivar)
40
+ end
41
+ state
42
+ end
43
+
44
+ # Restore exactly the pre-run state: ivars present at snapshot get their value
45
+ # back; ivars created by the run but absent before are removed, so nothing the
46
+ # run introduced can leak into the host either.
47
+ def release(captured)
48
+ return unless captured
49
+
50
+ config = configuration
51
+ ALL_IVARS.each do |ivar|
52
+ if captured.key?(ivar)
53
+ config.instance_variable_set(ivar, captured[ivar])
54
+ elsif config.instance_variable_defined?(ivar)
55
+ config.remove_instance_variable(ivar)
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def configuration
63
+ @configuration || ::RSpec.configuration
64
+ end
65
+
66
+ def capture_preferred_options(config, state)
67
+ return unless config.instance_variable_defined?(PREFERRED_OPTIONS)
68
+
69
+ value = config.instance_variable_get(PREFERRED_OPTIONS)
70
+ state[PREFERRED_OPTIONS] = value.is_a?(Hash) ? value.dup : value
71
+ end
72
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rspec"
4
+ require_relative "internals"
5
+
6
+ # Restores the RSpec.configuration fields that ::RSpec::Core::Runner.run mutates
7
+ # on the shared singleton during an in-process mutation run (EV-dwqw / GH #1343):
8
+ #
9
+ # - @color_mode -- the run's args carry "--no-color", flipping it to :off,
10
+ # which makes every SUBSEQUENT host example render its
11
+ # progress dots without color (white instead of green).
12
+ # - @output_stream -- Runner#setup swaps it to the run's StringIO whenever the
13
+ # host's was $stdout.
14
+ # - @error_stream -- likewise pointed at the run's StringIO.
15
+ #
16
+ # Forked runs never leak these (the mutation happens in a child that dies), but
17
+ # the in-process isolation path mutates the host's own configuration. Restore by
18
+ # writing the ivars directly: configuration#output_stream= is guarded (it warns
19
+ # and no-ops once a reporter exists), so the public setter cannot put it back.
20
+ class Evilution::Integration::RSpec::StateGuard::ConfigurationStreams
21
+ IVARS = %i[@color_mode @output_stream @error_stream].freeze
22
+
23
+ def snapshot
24
+ config = ::RSpec.configuration
25
+ IVARS.each_with_object({}) do |ivar, acc|
26
+ acc[ivar] = config.instance_variable_get(ivar) if config.instance_variable_defined?(ivar)
27
+ end
28
+ end
29
+
30
+ # Restore exactly the pre-run state: ivars present at snapshot get their value
31
+ # back; ivars that were absent before but created by the run are removed, so a
32
+ # newly-defined ivar can't leak into the host either.
33
+ def release(captured)
34
+ return unless captured
35
+
36
+ config = ::RSpec.configuration
37
+ IVARS.each do |ivar|
38
+ if captured.key?(ivar)
39
+ config.instance_variable_set(ivar, captured[ivar])
40
+ elsif config.instance_variable_defined?(ivar)
41
+ config.remove_instance_variable(ivar)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -7,6 +7,7 @@ require_relative "state_guard/world_sources_by_path"
7
7
  require_relative "state_guard/world_filtered_examples"
8
8
  require_relative "state_guard/reporter_arrays"
9
9
  require_relative "state_guard/example_groups_constants"
10
+ require_relative "state_guard/configuration_state"
10
11
 
11
12
  class Evilution::Integration::RSpec::StateGuard
12
13
  DEFAULT_STRATEGIES = [
@@ -15,7 +16,8 @@ class Evilution::Integration::RSpec::StateGuard
15
16
  WorldSourcesByPath.new,
16
17
  WorldFilteredExamples.new,
17
18
  ReporterArrays.new,
18
- ExampleGroupsConstants.new
19
+ ExampleGroupsConstants.new,
20
+ ConfigurationState.new
19
21
  ].freeze
20
22
 
21
23
  def initialize(strategies: DEFAULT_STRATEGIES)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../test_unit"
5
+
6
+ # Builds a Test::Unit::TestSuite from a list of TestCase subclasses and runs
7
+ # it via the console runner with output captured to a StringIO. Owning this
8
+ # responsibility separately keeps the runner library require + suite assembly
9
+ # in one place — used by both the baseline path (Evilution::Integration::TestUnit
10
+ # .run_baseline_test_file) and the per-mutation path (#run_tests).
11
+ module Evilution::Integration::TestUnit::Dispatcher
12
+ module_function
13
+
14
+ def call(test_case_classes, name: "evilution")
15
+ require "test/unit/ui/console/testrunner"
16
+ suite = ::Test::Unit::TestSuite.new(name)
17
+ test_case_classes.each { |klass| suite << klass.suite }
18
+ out = StringIO.new
19
+ runner = ::Test::Unit::UI::Console::TestRunner.new(suite, output: out)
20
+ runner.start
21
+ end
22
+
23
+ def test_method_count(test_case_classes)
24
+ test_case_classes.sum { |klass| klass.suite.tests.length }
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_unit"
4
+
5
+ # Loads the test-unit gem and disables its at_exit auto-run handler.
6
+ # Mirrors Evilution::Integration::RSpec::FrameworkLoader's role: framework
7
+ # setup is one responsibility separated from dispatch + result building so
8
+ # integrations can compose it independently in tests.
9
+ class Evilution::Integration::TestUnit::FrameworkLoader
10
+ def loaded?
11
+ @loaded == true
12
+ end
13
+
14
+ def call
15
+ return if @loaded
16
+
17
+ require "test-unit"
18
+ self.class.stub_autorun!
19
+ @loaded = true
20
+ rescue LoadError => e
21
+ raise Evilution::Error, "test-unit is required but not available: #{e.message}"
22
+ end
23
+
24
+ # User code that `require "test-unit"` installs an at_exit hook that calls
25
+ # Test::Unit::AutoRunner.run when need_auto_run? is true. At evilution exit
26
+ # ARGV still holds evilution flags and the runner prints a misleading banner.
27
+ # Flipping need_auto_run = false prevents the handler from firing.
28
+ def self.stub_autorun!
29
+ return unless defined?(::Test::Unit::AutoRunner)
30
+
31
+ ::Test::Unit::AutoRunner.need_auto_run = false
32
+ end
33
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_unit"
4
+
5
+ # Shapes the result Hash that flows back to Evilution::Result::MutationResult
6
+ # / classify_status. Three orthogonal flavours — pass/fail/crash, no tests
7
+ # executed, and unresolved spec — each have their own change axis (e.g. the
8
+ # no-tests-ran error string evolved separately as the test-unit framework
9
+ # diagnostic improved). Putting them behind a single object documents the
10
+ # contract and lets the integration class drop them.
11
+ module Evilution::Integration::TestUnit::ResultBuilder
12
+ module_function
13
+
14
+ def call(passed:, command:, detector:)
15
+ if passed
16
+ { passed: true, test_command: command }
17
+ elsif detector.only_crashes?
18
+ crash(command, detector)
19
+ else
20
+ { passed: false, test_command: command }
21
+ end
22
+ end
23
+
24
+ def no_tests_ran(command)
25
+ {
26
+ passed: false,
27
+ error: "no Test::Unit tests executed (0 test methods ran) — the resolved " \
28
+ "spec registered no Test::Unit suite. Check --integration/--spec.",
29
+ error_class: "Evilution::Error",
30
+ test_command: command
31
+ }
32
+ end
33
+
34
+ def unresolved(mutation_file_path)
35
+ {
36
+ passed: false,
37
+ unresolved: true,
38
+ error: "no matching test resolved for #{mutation_file_path}",
39
+ test_command: "ruby -Itest (skipped: no test resolved for #{mutation_file_path})"
40
+ }
41
+ end
42
+
43
+ def crash(command, detector)
44
+ classes = detector.unique_crash_classes
45
+ {
46
+ passed: false,
47
+ test_crashed: true,
48
+ error: "test crashes: #{detector.crash_summary}",
49
+ error_class: (classes.first if classes.length == 1),
50
+ test_command: command
51
+ }
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_unit"
4
+
5
+ # Tracks Test::Unit::TestCase descendants in the host process. Test::Unit has
6
+ # no public registry-clear method analogous to Minitest::Runnable.runnables;
7
+ # the integration scopes each dispatch to classes that appeared during *this*
8
+ # round by diffing the descendant set before and after #load. Keeping that
9
+ # responsibility in its own object makes it cheap to stub in tests and lets
10
+ # the integration's main class read as orchestration.
11
+ module Evilution::Integration::TestUnit::SubjectClassRegistry
12
+ module_function
13
+
14
+ def descendants
15
+ return [] unless defined?(::Test::Unit::TestCase)
16
+
17
+ ObjectSpace.each_object(Class).select { |c| c < ::Test::Unit::TestCase }
18
+ end
19
+
20
+ # Yields, captures the descendant set before/after, and returns the diff.
21
+ def newly_loaded
22
+ before = descendants
23
+ yield
24
+ descendants - before
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_unit"
4
+
5
+ # Resolves the list of test files to load for a given mutation. Encapsulates
6
+ # the explicit-override path, spec-selector lookup, fallback glob, and the
7
+ # warn-once behaviour for unresolved sources. The integration class would
8
+ # otherwise carry both per-instance test resolution state (@test_files,
9
+ # @spec_selector, @warned_files, @fallback_to_full_suite) and dispatch
10
+ # orchestration in the same object; splitting them gives the resolver its
11
+ # own change axis (e.g. adding new resolution heuristics) independent of
12
+ # the runner.
13
+ class Evilution::Integration::TestUnit::TestFileResolver
14
+ def initialize(test_files:, spec_selector:, fallback_to_full_suite:)
15
+ @test_files = test_files
16
+ @spec_selector = spec_selector
17
+ @fallback_to_full_suite = fallback_to_full_suite
18
+ @warned_files = Set.new
19
+ end
20
+
21
+ # Returns the resolved file list, or nil if the source could not be
22
+ # resolved and fallback is disabled.
23
+ def call(mutation_file_path)
24
+ return @test_files if @test_files
25
+
26
+ resolved = Array(@spec_selector.call(mutation_file_path))
27
+ return resolved unless resolved.empty?
28
+
29
+ warn_unresolved(mutation_file_path)
30
+ @fallback_to_full_suite ? glob_test_files : nil
31
+ end
32
+
33
+ private
34
+
35
+ def glob_test_files
36
+ files = Dir.glob("test/**/*_test.rb")
37
+ files.empty? ? ["test"] : files
38
+ end
39
+
40
+ def warn_unresolved(file_path)
41
+ return if @warned_files.include?(file_path)
42
+
43
+ @warned_files << file_path
44
+ action = @fallback_to_full_suite ? "running full suite" : "marking mutation unresolved"
45
+ warn "[evilution] No matching test found for #{file_path}, #{action}. " \
46
+ "Use --spec to specify the test file."
47
+ end
48
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "test_unit_crash_detector"
5
+ require_relative "loading/test_load_path"
6
+ require_relative "../spec_resolver"
7
+ require_relative "../spec_selector"
8
+
9
+ require_relative "../integration"
10
+
11
+ # Test::Unit integration. Decomposed under lib/evilution/integration/test_unit/
12
+ # mirroring the RSpec integration's layout. This class is the orchestrator:
13
+ # it wires the framework loader, dispatcher, subject-class registry,
14
+ # test-file resolver, and result builder. The class is registered under
15
+ # Evilution::Runner::INTEGRATIONS[:test_unit] and reachable via the
16
+ # `--integration test-unit` CLI flag.
17
+ class Evilution::Integration::TestUnit < Evilution::Integration::Base
18
+ def self.baseline_runner
19
+ ->(test_file) { run_baseline_test_file(test_file) }
20
+ end
21
+
22
+ # SpecResolver tuned for the dominant Test::Unit layout: tests live under
23
+ # test/, named with the _test.rb suffix (the same convention Minitest uses).
24
+ # Rails plugins on the test-unit gem (e.g. kaminari-core) follow this layout.
25
+ # The test/test_<name>.rb prefix-style convention is rare enough in practice
26
+ # that we defer support to a follow-up if a project surfaces needing it.
27
+ def self.spec_resolver
28
+ Evilution::SpecResolver.new(test_dir: "test", test_suffix: "_test.rb", request_dir: "integration")
29
+ end
30
+
31
+ def self.baseline_options
32
+ {
33
+ runner: baseline_runner,
34
+ spec_resolver: spec_resolver,
35
+ fallback_dir: "test"
36
+ }
37
+ end
38
+
39
+ def self.run_baseline_test_file(test_file)
40
+ require_relative "test_unit/framework_loader"
41
+ require_relative "test_unit/subject_class_registry"
42
+ require_relative "test_unit/dispatcher"
43
+ FrameworkLoader.new.call
44
+ files = baseline_test_files(test_file)
45
+ Evilution::Integration::Loading::TestLoadPath.add!(files)
46
+ new_classes = SubjectClassRegistry.newly_loaded do
47
+ files.each { |f| load(File.expand_path(f)) }
48
+ end
49
+ Dispatcher.call(new_classes, name: "evilution baseline").passed?
50
+ end
51
+
52
+ def self.baseline_test_files(test_file)
53
+ File.directory?(test_file) ? Dir.glob(File.join(test_file, "**/*_test.rb")) : [test_file]
54
+ end
55
+
56
+ def initialize(test_files: nil, hooks: nil, fallback_to_full_suite: false, spec_selector: nil)
57
+ require_relative "test_unit/framework_loader"
58
+ require_relative "test_unit/subject_class_registry"
59
+ require_relative "test_unit/dispatcher"
60
+ require_relative "test_unit/test_file_resolver"
61
+ require_relative "test_unit/result_builder"
62
+ @framework_loader = FrameworkLoader.new
63
+ @file_resolver = TestFileResolver.new(
64
+ test_files: test_files,
65
+ spec_selector: spec_selector || Evilution::SpecSelector.new(spec_resolver: self.class.spec_resolver),
66
+ fallback_to_full_suite: fallback_to_full_suite
67
+ )
68
+ @crash_detector = nil
69
+ super(hooks: hooks)
70
+ end
71
+
72
+ private
73
+
74
+ def ensure_framework_loaded
75
+ return if @framework_loader.loaded?
76
+
77
+ fire_hook(:setup_integration_pre, integration: :test_unit)
78
+ @framework_loader.call
79
+ fire_hook(:setup_integration_post, integration: :test_unit)
80
+ end
81
+
82
+ def run_tests(mutation)
83
+ ensure_framework_loaded
84
+ reset_state
85
+ files = @file_resolver.call(mutation.file_path)
86
+ return ResultBuilder.unresolved(mutation.file_path) if files.nil?
87
+
88
+ command = "ruby -Itest #{files.join(" ")}"
89
+ execute_test_unit(files, command)
90
+ rescue StandardError => e
91
+ { passed: false, error: e.message, test_command: command }
92
+ end
93
+
94
+ def execute_test_unit(files, command)
95
+ new_classes = load_test_classes(files)
96
+ return ResultBuilder.no_tests_ran(command) if Dispatcher.test_method_count(new_classes).zero?
97
+
98
+ detector = reset_crash_detector
99
+ result = Dispatcher.call(new_classes, name: "evilution-mutation")
100
+ result.faults.each { |fault| detector.record(fault) }
101
+ ResultBuilder.call(passed: result.passed?, command: command, detector: detector)
102
+ end
103
+
104
+ def load_test_classes(files)
105
+ Evilution::Integration::Loading::TestLoadPath.add!(files)
106
+ SubjectClassRegistry.newly_loaded do
107
+ files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
108
+ end
109
+ end
110
+
111
+ # Test::Unit has no public registry-clear analogous to
112
+ # Minitest::Runnable.runnables.clear. SubjectClassRegistry's newly_loaded
113
+ # block scopes each dispatch to classes loaded in *this* round, so stale
114
+ # classes from prior mutations sit dormant on ObjectSpace without polluting
115
+ # the run. #reset_state stays as a contract no-op for parity with Minitest.
116
+ def reset_state
117
+ # no-op — see comment above
118
+ end
119
+
120
+ def build_args(_mutation)
121
+ []
122
+ end
123
+
124
+ def reset_crash_detector
125
+ if @crash_detector
126
+ @crash_detector.reset
127
+ else
128
+ @crash_detector = Evilution::Integration::TestUnitCrashDetector.new
129
+ end
130
+ @crash_detector
131
+ end
132
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ # Test::Unit analog of Evilution::Integration::MinitestCrashDetector. Tracks
6
+ # whether a Test::Unit test run produced only crashes (exceptions captured as
7
+ # Test::Unit::Error) vs assertion failures (Test::Unit::Failure). When only
8
+ # crashes occur, the mutation result can carry crash details while remaining
9
+ # classified as :killed — see classify_status / Result::MutationResult.
10
+ #
11
+ # Hook the detector into a Test::Unit::TestResult via .attach(result), or
12
+ # call #record(fault) directly when iterating a finished result's #faults.
13
+ class Evilution::Integration::TestUnitCrashDetector
14
+ def initialize
15
+ reset
16
+ end
17
+
18
+ def reset
19
+ @assertion_failures = 0
20
+ @crashes = []
21
+ end
22
+
23
+ def attach(test_result)
24
+ require "test/unit/testresult"
25
+ test_result.add_listener(Test::Unit::TestResult::FAULT) { |fault| record(fault) }
26
+ end
27
+
28
+ def record(fault)
29
+ if fault.is_a?(Test::Unit::Error)
30
+ @crashes << fault.exception
31
+ elsif fault.is_a?(Test::Unit::Failure)
32
+ @assertion_failures += 1
33
+ end
34
+ end
35
+
36
+ def passed?
37
+ @assertion_failures.zero? && @crashes.empty?
38
+ end
39
+
40
+ def assertion_failure?
41
+ @assertion_failures.positive?
42
+ end
43
+
44
+ def crashed?
45
+ @crashes.any?
46
+ end
47
+
48
+ def only_crashes?
49
+ @crashes.any? && @assertion_failures.zero?
50
+ end
51
+
52
+ def unique_crash_classes
53
+ @crashes.map { |e| e.class.name }.uniq
54
+ end
55
+
56
+ def crash_summary
57
+ return nil if @crashes.empty?
58
+
59
+ "#{unique_crash_classes.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
60
+ end
61
+ end
@@ -11,6 +11,7 @@ require_relative "../isolation"
11
11
 
12
12
  class Evilution::Isolation::Fork
13
13
  GRACE_PERIOD = 2
14
+ REAP_DEADLINE = 1.0
14
15
 
15
16
  def initialize(hooks: nil)
16
17
  @hooks = hooks
@@ -47,6 +48,7 @@ class Evilution::Isolation::Fork
47
48
 
48
49
  def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
49
50
  ::Process.fork do
51
+ isolate_into_own_process_group
50
52
  ENV["TMPDIR"] = sandbox_dir
51
53
  # Path-relativizing mutations (e.g. File.join(dir, name) -> name) would
52
54
  # otherwise write into the parent's CWD (typically the repo root) and
@@ -69,6 +71,21 @@ class Evilution::Isolation::Fork
69
71
  end
70
72
  end
71
73
 
74
+ # EV-2sh8 / GH #1330: make the mutation child its own process-group leader as
75
+ # its very first act, before it runs test_command (which may fork blocking
76
+ # grandchildren -- e.g. connection_pool / ractor / thread subject specs).
77
+ # Grandchildren then inherit this group, so terminate_child can group-kill the
78
+ # whole subtree on timeout. Without it, a blocking grandchild orphans to init
79
+ # and survives the rest of the run -- the inner path never SIGKILLs the worker,
80
+ # so EV-cnx8's outer process-group kill never sweeps it. Done child-side (not
81
+ # parent-side as in Worker) because the per-mutation timeout fires seconds
82
+ # later, long after this line has run, so no fork-before-setpgid race exists.
83
+ def isolate_into_own_process_group
84
+ ::Process.setpgid(0, 0)
85
+ rescue SystemCallError
86
+ nil
87
+ end
88
+
72
89
  def cleanup_resources(read_io, write_io, pid, sandbox_dir)
73
90
  read_io.close unless read_io.nil?
74
91
  write_io.close unless write_io.nil?
@@ -130,8 +147,23 @@ class Evilution::Isolation::Fork
130
147
  end
131
148
  end
132
149
 
150
+ # Process.wait without a deadline can hang the parent indefinitely. The
151
+ # per-mutation child may already have written a payload (or a grandchild may
152
+ # have written garbage that looks like one) while the child itself is stuck
153
+ # in execute_in_child waiting on a subject grandchild the mutation broke.
154
+ # wait_for_result has already returned by this point, so the per-mutation
155
+ # timeout cannot fire. Bound the wait and fall back to the TERM/KILL ladder.
133
156
  def reap_and_decode(pid, payload)
134
- ::Process.wait(pid)
157
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REAP_DEADLINE
158
+ loop do
159
+ break if ::Process.waitpid(pid, ::Process::WNOHANG)
160
+
161
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
162
+ terminate_child(pid)
163
+ break
164
+ end
165
+ sleep 0.05
166
+ end
135
167
  decode_payload(payload)
136
168
  end
137
169
 
@@ -200,7 +232,7 @@ class Evilution::Isolation::Fork
200
232
  end
201
233
 
202
234
  def terminate_child(pid)
203
- Evilution::ProcessCleanup.safe_kill("TERM", pid)
235
+ signal_tree("TERM", pid)
204
236
  _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
205
237
  return if status
206
238
 
@@ -208,10 +240,20 @@ class Evilution::Isolation::Fork
208
240
  _, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
209
241
  return if status
210
242
 
211
- Evilution::ProcessCleanup.safe_kill("KILL", pid)
243
+ signal_tree("KILL", pid)
212
244
  Evilution::ProcessCleanup.safe_wait(pid)
213
245
  end
214
246
 
247
+ # Signal the child's whole process group (-pid) to sweep any grandchildren it
248
+ # forked, then the bare pid as a fallback for the case where setpgid failed
249
+ # (no group exists, so the group signal is a harmless Errno::ESRCH). Only the
250
+ # leader pid is reaped here -- group-killed grandchildren are not our direct
251
+ # children, so init reaps them once they die.
252
+ def signal_tree(sig, pid)
253
+ Evilution::ProcessCleanup.safe_kill(sig, -pid)
254
+ Evilution::ProcessCleanup.safe_kill(sig, pid)
255
+ end
256
+
215
257
  def classify_status(result)
216
258
  return :timeout if result[:timeout]
217
259
  return :killed if result[:test_crashed]
@@ -58,8 +58,8 @@ class Evilution::MCP::InfoTool < MCP::Tool
58
58
  },
59
59
  integration: {
60
60
  type: "string",
61
- description: "[subjects, tests] Test integration (rspec, minitest) — 'tests' selects " \
62
- "the matching spec resolver (spec/*_spec.rb for rspec, test/*_test.rb for minitest)"
61
+ description: "[subjects, tests] Test integration (rspec, minitest, test-unit) — 'tests' selects " \
62
+ "the matching spec resolver (spec/*_spec.rb for rspec, test/*_test.rb for minitest/test-unit)"
63
63
  },
64
64
  skip_config: {
65
65
  type: "boolean",
@@ -72,8 +72,9 @@ class Evilution::MCP::MutateTool < MCP::Tool
72
72
  },
73
73
  integration: {
74
74
  type: "string",
75
- enum: %w[rspec minitest],
76
- description: "Test integration to use (default: rspec)"
75
+ enum: %w[rspec minitest test-unit],
76
+ description: "Test integration to use (default: rspec). " \
77
+ "Use test-unit for projects whose suites run on the test-unit gem."
77
78
  },
78
79
  isolation: {
79
80
  type: "string",