evilution 0.32.0 → 0.34.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 +31 -0
  5. data/README.md +12 -10
  6. data/docs/integrations.md +15 -0
  7. data/docs/isolation.md +46 -2
  8. data/lib/evilution/baseline.rb +11 -4
  9. data/lib/evilution/cli/parser/options_builder.rb +17 -0
  10. data/lib/evilution/config/validators/example_targeting_strategy.rb +22 -0
  11. data/lib/evilution/config.rb +16 -2
  12. data/lib/evilution/coverage/digest.rb +16 -0
  13. data/lib/evilution/coverage/map.rb +64 -0
  14. data/lib/evilution/coverage/map_builder.rb +82 -0
  15. data/lib/evilution/coverage/map_store.rb +87 -0
  16. data/lib/evilution/coverage/recorder.rb +85 -0
  17. data/lib/evilution/coverage.rb +8 -0
  18. data/lib/evilution/coverage_example_filter.rb +41 -0
  19. data/lib/evilution/integration/loading/test_load_path.rb +76 -0
  20. data/lib/evilution/integration/minitest.rb +5 -1
  21. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +72 -0
  22. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +45 -0
  23. data/lib/evilution/integration/rspec/state_guard.rb +3 -1
  24. data/lib/evilution/integration/test_unit.rb +12 -4
  25. data/lib/evilution/isolation/fork.rb +38 -50
  26. data/lib/evilution/parallel/work_queue/dispatcher/deadline_tracker.rb +63 -0
  27. data/lib/evilution/parallel/work_queue/dispatcher.rb +70 -25
  28. data/lib/evilution/parallel/work_queue/worker.rb +50 -14
  29. data/lib/evilution/parallel/work_queue.rb +8 -0
  30. data/lib/evilution/process_supervisor.rb +259 -0
  31. data/lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb +50 -0
  32. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  33. data/lib/evilution/runner/baseline_runner.rb +52 -0
  34. data/lib/evilution/runner/isolation_resolver.rb +106 -12
  35. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
  36. data/lib/evilution/runner.rb +7 -0
  37. data/lib/evilution/spec_resolver.rb +147 -9
  38. data/lib/evilution/spec_selector.rb +14 -4
  39. data/lib/evilution/version.rb +1 -1
  40. data/lib/evilution.rb +1 -0
  41. data/lib/tasks/stress.rake +15 -0
  42. data/scripts/canary_manifest.yml +47 -0
  43. data/scripts/compare_targeting +277 -0
  44. data/scripts/compare_targeting.example.yml +24 -0
  45. metadata +20 -2
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "version"
5
+ require_relative "temp_dir_tracker"
6
+
7
+ # Single owner of the process-lifecycle invariant: every pid spawned here is
8
+ # group-isolated, tracked in a signal-safe registry, group-signalled through a
9
+ # TERM/KILL ladder, and reaped -- with its fds closed and sandbox dir removed.
10
+ #
11
+ # EV-9f3b / EV-5rrh, Track A step 1. Generalizes the lock-free COW
12
+ # WorkerRegistry (EV-jwao) and absorbs ProcessCleanup.safe_kill/safe_wait
13
+ # semantics. Pure unit: no call sites are migrated here -- Isolation::Fork
14
+ # (inner path) and WorkQueue::Worker (outer path) are routed through it in
15
+ # later steps (EV-3aw3, EV-dg69, EV-7a91).
16
+ #
17
+ # Shape: instances own the lifecycle of the children they spawn, but every
18
+ # handle is also recorded in ONE process-global registry so the Runner signal
19
+ # trap can `.signal_all` across every fork-site through a single owner.
20
+ #
21
+ # Signal-safety: under MRI a trap handler runs on the main thread between VM
22
+ # instructions, so it must not acquire a Mutex (the main thread may hold it ->
23
+ # deadlock). register/unregister swap @registry for a freshly built frozen
24
+ # array via a single atomic reference assignment (copy-on-write). The trap
25
+ # reads the current reference once and iterates that complete, immutable
26
+ # snapshot -- no torn reads, no lock.
27
+ class Evilution::ProcessSupervisor
28
+ GRACE_PERIOD = 2
29
+
30
+ # One tracked child: leader pid, its process-group id (== pid for a group
31
+ # leader), the parent-side fds to close on reap, and an optional sandbox dir
32
+ # to remove on reap.
33
+ Handle = Struct.new(:pid, :pgid, :fds, :sandbox_dir, keyword_init: true)
34
+
35
+ @registry = [].freeze
36
+
37
+ class << self
38
+ # Frozen snapshot. Safe to read from a signal handler.
39
+ attr_reader :registry
40
+
41
+ def register(handle)
42
+ @registry = (@registry + [handle]).freeze
43
+ end
44
+
45
+ def unregister(handle)
46
+ @registry = @registry.reject { |existing| existing.pid == handle.pid }.freeze
47
+ end
48
+
49
+ def signal_all(sig)
50
+ @registry.each do |handle|
51
+ Process.kill(sig, -handle.pgid)
52
+ rescue Errno::ESRCH
53
+ # Group already gone (leader + subtree reaped) -- nothing to signal.
54
+ nil
55
+ end
56
+ end
57
+
58
+ # Drop every inherited entry so a freshly forked child starts owning
59
+ # nothing. A child inherits a COW copy of this registry, but the handles in
60
+ # it belong to the PARENT (e.g. sibling workers); if the child later
61
+ # signalled or reaped them -- via signal_all / kill_and_reap_all in its own
62
+ # signal handler -- it would tear down processes it never spawned. The child
63
+ # re-registers only what it spawns itself.
64
+ def reset_for_child!
65
+ @registry = [].freeze
66
+ end
67
+
68
+ # Trap-safe teardown of every registered child: SIGKILL each process group
69
+ # (sweeping grandchildren) and the bare leader pid, then reap the leaders so
70
+ # they cannot zombie, and clear the registry. Reads the COW snapshot once --
71
+ # no Mutex, safe from a signal handler.
72
+ #
73
+ # EV-7a91: a process about to die on a fatal signal must not leave the
74
+ # children it OWNS behind. The Runner's group-kill reaches only the worker
75
+ # groups; the inner per-mutation children left those groups (setpgid, EV-2sh8)
76
+ # and live in the worker's own registry, so only the worker -- their parent --
77
+ # can kill AND reap them before it dies. Without the reap they survive as
78
+ # zombies until some ancestor exits and init collects them, which never comes
79
+ # when evilution runs embedded in a long-lived host process.
80
+ def kill_and_reap_all
81
+ snapshot = @registry
82
+ snapshot.each do |handle|
83
+ kill_tolerant("KILL", -handle.pgid)
84
+ kill_tolerant("KILL", handle.pid)
85
+ end
86
+ # Reap only after every group has been signalled, so a slow-to-die child
87
+ # never delays killing the others' subtrees.
88
+ snapshot.each { |handle| reap_tolerant(handle.pid) } # rubocop:disable Style/CombinableLoops
89
+ @registry = (@registry - snapshot).freeze
90
+ end
91
+
92
+ private
93
+
94
+ def kill_tolerant(sig, target)
95
+ Process.kill(sig, target)
96
+ rescue Errno::ESRCH
97
+ nil
98
+ end
99
+
100
+ def reap_tolerant(pid)
101
+ Process.waitpid(pid)
102
+ rescue Errno::ECHILD
103
+ nil
104
+ end
105
+ end
106
+
107
+ # Fork a child that becomes its own process-group leader and runs the block,
108
+ # returning a Handle. By default the child calls setpgid(0, 0) before
109
+ # yielding so any grandchildren it forks join its group and can be swept by a
110
+ # group signal; the parent repeats setpgid(pid, pid) to close the race where
111
+ # it signals before the child has isolated itself. The handle is registered
112
+ # BEFORE the parent-side setpgid so the trap can never observe a child that is
113
+ # already a group leader yet missing from the registry (EV-jwao race).
114
+ #
115
+ # isolate_in_child: false suppresses the child-side setpgid for long-lived
116
+ # workers (the outer path): the child must NOT become its own group leader
117
+ # until the parent has registered it, otherwise a trap firing between fork and
118
+ # register would see a leader it cannot signal. With only the parent-side,
119
+ # post-register setpgid, the child stays in the parent group (reachable by the
120
+ # terminal signal directly) until the registry already lists it.
121
+ def spawn(sandbox_dir: nil, fds: [], isolate_in_child: true)
122
+ pid = ::Process.fork do
123
+ self.class.reset_for_child!
124
+ isolate_self if isolate_in_child
125
+ yield
126
+ end
127
+
128
+ # Track the sandbox first thing after fork: if the parent takes a fatal
129
+ # signal before isolate_child returns, Runner's trap (TempDirTracker
130
+ # .cleanup_all) can still see and remove it, narrowing the leak window.
131
+ Evilution::TempDirTracker.register(sandbox_dir) if sandbox_dir
132
+ handle = Handle.new(pid: pid, pgid: pid, fds: fds, sandbox_dir: sandbox_dir)
133
+ self.class.register(handle)
134
+ isolate_child(pid)
135
+ handle
136
+ end
137
+
138
+ # Signal the child's whole process group (-pgid) to sweep any grandchildren,
139
+ # then the bare pid as a fallback for the case where setpgid failed (no group
140
+ # exists, so the group signal is a harmless Errno::ESRCH).
141
+ def signal_group(sig, handle)
142
+ safe_kill(sig, -handle.pgid)
143
+ safe_kill(sig, handle.pid)
144
+ end
145
+
146
+ # Bounded TERM -> grace -> KILL ladder, then reap. Always ends with the child
147
+ # reaped and its resources released, whichever rung it dies on.
148
+ def terminate(handle, grace: GRACE_PERIOD)
149
+ signal_group("TERM", handle)
150
+ unless exited?(handle.pid)
151
+ sleep(grace)
152
+ signal_group("KILL", handle) unless exited?(handle.pid)
153
+ end
154
+ reap(handle)
155
+ end
156
+
157
+ # Reap the leader (ECHILD-tolerant if already reaped), then unconditionally
158
+ # release the resources the handle owns: close parent-side fds, remove the
159
+ # sandbox dir, and drop the handle from the registry.
160
+ def reap(handle)
161
+ safe_wait(handle.pid)
162
+ ensure
163
+ release(handle)
164
+ end
165
+
166
+ # Non-blocking reap for callers that poll a child's liveness as part of a
167
+ # read protocol (e.g. Isolation::Fork's marshal-pipe loop). Returns false
168
+ # while the child is still running -- the handle stays registered so a signal
169
+ # trap can still reach it. Once the child has exited (or was already reaped),
170
+ # it releases the handle in the same step it reaps, so the process-global
171
+ # registry never holds a stale, already-reaped pgid.
172
+ def reap_nonblock(handle)
173
+ return false unless nonblocking_wait(handle.pid)
174
+
175
+ release(handle)
176
+ true
177
+ end
178
+
179
+ private
180
+
181
+ # WNOHANG wait: returns the pid once the child has exited, nil while it is
182
+ # still running, and -- treating an already-reaped child as exited -- the pid
183
+ # again on ECHILD so the caller still releases the handle.
184
+ def nonblocking_wait(pid)
185
+ ::Process.waitpid(pid, ::Process::WNOHANG)
186
+ rescue Errno::ECHILD
187
+ pid
188
+ end
189
+
190
+ def release(handle)
191
+ close_fds(handle)
192
+ cleanup_sandbox(handle)
193
+ self.class.unregister(handle)
194
+ end
195
+
196
+ def isolate_self
197
+ ::Process.setpgid(0, 0)
198
+ rescue SystemCallError
199
+ nil
200
+ end
201
+
202
+ def isolate_child(pid)
203
+ ::Process.setpgid(pid, pid)
204
+ rescue Errno::EACCES, Errno::ESRCH
205
+ # EACCES: child already exec'd/changed group; ESRCH: child already exited.
206
+ # Both are benign -- reaping handles the child either way.
207
+ nil
208
+ rescue SystemCallError => e
209
+ # Any other setpgid failure (e.g. EPERM) leaves the child in the parent
210
+ # group: a later group-kill won't sweep its subtree. Don't raise (spawn
211
+ # must still return a usable handle), but surface it so the leak is
212
+ # debuggable rather than silent.
213
+ warn "evilution: could not isolate process #{pid} into its own process " \
214
+ "group (#{e.class}: #{e.message}); grandchildren may survive a kill."
215
+ end
216
+
217
+ # True once the child has been reaped (now or earlier). WNOHANG returns the
218
+ # pid for a freshly exited child, nil while it still runs, and raises ECHILD
219
+ # if it was already reaped -- all of which we treat as "no longer running".
220
+ def exited?(pid)
221
+ !::Process.waitpid(pid, ::Process::WNOHANG).nil?
222
+ rescue Errno::ECHILD
223
+ true
224
+ end
225
+
226
+ def safe_kill(signal, target)
227
+ ::Process.kill(signal, target)
228
+ rescue Errno::ESRCH
229
+ nil
230
+ end
231
+
232
+ def safe_wait(pid)
233
+ ::Process.wait(pid)
234
+ rescue Errno::ECHILD
235
+ nil
236
+ end
237
+
238
+ def close_fds(handle)
239
+ handle.fds.each do |io|
240
+ io.close unless io.closed?
241
+ rescue IOError
242
+ nil
243
+ end
244
+ end
245
+
246
+ # Remove the sandbox first, then drop it from TempDirTracker only on success.
247
+ # If removal raises, leave the dir tracked so TempDirTracker.cleanup_all /
248
+ # at_exit can retry it, and swallow the error so reap's ensure-path still
249
+ # unregisters the handle (no stale entry in the process-global registry).
250
+ def cleanup_sandbox(handle)
251
+ dir = handle.sandbox_dir
252
+ return unless dir
253
+
254
+ FileUtils.rm_rf(dir)
255
+ Evilution::TempDirTracker.unregister(dir)
256
+ rescue StandardError
257
+ nil
258
+ end
259
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../line_formatters"
4
+
5
+ # EV-z7f5 / GH #1325: unresolved mutations are excluded from
6
+ # `score_denominator`, so a run whose specs could not be auto-resolved
7
+ # collapses to a bare "Score: 0.00% (0/0)" that reads like a genuine
8
+ # mutation-quality failure. This formatter surfaces a loud, actionable
9
+ # warning when the unresolved rate is high — and a distinct message when
10
+ # the denominator hit zero (nothing was measured at all) — so the user
11
+ # knows to pass --spec instead of trusting the 0.0.
12
+ #
13
+ # Sibling of ErrorRateWarning (EV-nrgw / GH #1168).
14
+ class Evilution::Reporter::CLI::LineFormatters::UnresolvedRateWarning
15
+ DEFAULT_THRESHOLD = 0.25
16
+ HINT = "Pass --spec to point evilution at the test file(s)."
17
+
18
+ def initialize(threshold: DEFAULT_THRESHOLD)
19
+ @threshold = threshold
20
+ end
21
+
22
+ def format(summary)
23
+ return nil if summary.total.zero?
24
+ return nil if summary.unresolved.zero?
25
+
26
+ rate = summary.unresolved.to_f / summary.total
27
+ return nil if rate <= @threshold
28
+
29
+ fraction = "#{summary.unresolved}/#{summary.total}"
30
+ pct = (rate * 100).round(1)
31
+ warning(summary, fraction, pct)
32
+ end
33
+
34
+ private
35
+
36
+ def warning(summary, fraction, pct)
37
+ if summary.unresolved == summary.total
38
+ "! No matching tests resolved: all #{fraction} mutations unresolved — " \
39
+ "no mutations were measured, so the score is not meaningful. #{HINT}"
40
+ elsif summary.score_denominator.zero?
41
+ # Denominator can also hit zero with a mix of unresolved + errors /
42
+ # neutral / equivalent, so do not attribute it solely to missing tests.
43
+ "! No mutations were measured (score not meaningful): " \
44
+ "#{fraction} (#{pct}%) mutations were unresolved. #{HINT}"
45
+ else
46
+ "! High unresolved rate: #{fraction} (#{pct}%) mutations had no matching " \
47
+ "test — score may be unreliable. #{HINT}"
48
+ end
49
+ end
50
+ end
@@ -4,6 +4,7 @@ require_relative "../cli"
4
4
  require_relative "line_formatters/mutations"
5
5
  require_relative "line_formatters/score"
6
6
  require_relative "line_formatters/error_rate_warning"
7
+ require_relative "line_formatters/unresolved_rate_warning"
7
8
  require_relative "line_formatters/duration"
8
9
  require_relative "line_formatters/efficiency"
9
10
  require_relative "line_formatters/peak_memory"
@@ -13,6 +14,7 @@ class Evilution::Reporter::CLI::MetricsBlock
13
14
  Evilution::Reporter::CLI::LineFormatters::Mutations.new,
14
15
  Evilution::Reporter::CLI::LineFormatters::Score.new,
15
16
  Evilution::Reporter::CLI::LineFormatters::ErrorRateWarning.new,
17
+ Evilution::Reporter::CLI::LineFormatters::UnresolvedRateWarning.new,
16
18
  Evilution::Reporter::CLI::LineFormatters::Duration.new,
17
19
  Evilution::Reporter::CLI::LineFormatters::Efficiency.new,
18
20
  Evilution::Reporter::CLI::LineFormatters::PeakMemory.new
@@ -5,6 +5,9 @@ require_relative "../baseline"
5
5
  require_relative "../spec_resolver"
6
6
  require_relative "../integration/rspec"
7
7
  require_relative "../integration/minitest"
8
+ require_relative "../coverage_example_filter"
9
+ require_relative "../coverage/map_store"
10
+ require_relative "../coverage/map_builder"
8
11
  require_relative "../integration/test_unit"
9
12
  require_relative "../example_filter"
10
13
  require_relative "../spec_ast_cache"
@@ -82,6 +85,13 @@ class Evilution::Runner::BaselineRunner
82
85
  def build_example_filter
83
86
  return nil unless config.example_targeting?
84
87
 
88
+ lexical = build_lexical_filter
89
+ return lexical unless config.coverage_targeting?
90
+
91
+ build_coverage_filter(lexical)
92
+ end
93
+
94
+ def build_lexical_filter
85
95
  Evilution::ExampleFilter.new(
86
96
  cache: Evilution::SpecAstCache.new(**config.example_targeting_cache),
87
97
  fallback: config.example_targeting_fallback,
@@ -89,6 +99,48 @@ class Evilution::Runner::BaselineRunner
89
99
  )
90
100
  end
91
101
 
102
+ # The coverage map is built (or loaded from cache) once here, in the parent,
103
+ # before any mutation fork. Any failure -- unsupported Ruby, suite error,
104
+ # corrupt cache that cannot rebuild -- degrades to lexical targeting rather
105
+ # than aborting the run (design: never abort, never silently mis-skip).
106
+ def build_coverage_filter(lexical)
107
+ map = resolve_coverage_map
108
+ return lexical unless map
109
+
110
+ Evilution::CoverageExampleFilter.new(map: map, lexical: lexical)
111
+ rescue StandardError => e
112
+ warn "evilution: coverage targeting unavailable (#{e.class}: #{e.message}); using lexical targeting"
113
+ lexical
114
+ end
115
+
116
+ def resolve_coverage_map
117
+ targets = config.target_files.map { |file| File.expand_path(file, Evilution::PROJECT_ROOT) }
118
+ return nil if targets.empty?
119
+
120
+ specs = resolved_spec_files
121
+ return nil if specs.empty? # nothing resolves -> no coverage to capture, lexical handles it
122
+
123
+ store = Evilution::Coverage::MapStore.new
124
+ cache_inputs = targets + specs
125
+ return store.load(targets) if store.stale_files(cache_inputs).empty?
126
+
127
+ map = Evilution::Coverage::MapBuilder.new(spec_files: specs, target_files: targets).call
128
+ store.save(map, cache_inputs)
129
+ map
130
+ end
131
+
132
+ # The RESOLVED spec files for the target sources -- the same lib-mirrored specs
133
+ # full-file targeting already runs cleanly -- NOT the whole suite. EV-7uui:
134
+ # capturing and replaying coverage only within these guarantees the covering
135
+ # examples load in the per-mutation run (cross-file integration specs, which a
136
+ # whole-suite map would surface, fail to bootstrap in isolation and lose kills).
137
+ def resolved_spec_files
138
+ config.target_files
139
+ .flat_map { |file| Array(config.spec_selector.call(file)) }
140
+ .map { |spec| File.expand_path(spec, Evilution::PROJECT_ROOT) }
141
+ .uniq
142
+ end
143
+
92
144
  def log_start
93
145
  return if config.quiet || !config.text? || !$stderr.tty?
94
146
 
@@ -5,6 +5,7 @@ require_relative "../isolation/fork"
5
5
  require_relative "../isolation/in_process"
6
6
  require_relative "../rails_detector"
7
7
  require_relative "../gem_detector"
8
+ require_relative "../integration/loading/test_load_path"
8
9
 
9
10
  class Evilution::Runner::IsolationResolver
10
11
  PRELOAD_CANDIDATES = [
@@ -12,6 +13,14 @@ class Evilution::Runner::IsolationResolver
12
13
  File.join("spec", "spec_helper.rb"),
13
14
  File.join("test", "test_helper.rb")
14
15
  ].freeze
16
+ # Conventional helpers for a non-Rails gem (no rails_helper). Ordered rspec
17
+ # then minitest/test-unit; test/helper.rb covers the flat-layout convention
18
+ # (rack, connection_pool, rake).
19
+ GEM_PRELOAD_CANDIDATES = [
20
+ File.join("spec", "spec_helper.rb"),
21
+ File.join("test", "test_helper.rb"),
22
+ File.join("test", "helper.rb")
23
+ ].freeze
15
24
 
16
25
  def initialize(config, target_files:, hooks:)
17
26
  @config = config
@@ -36,7 +45,7 @@ class Evilution::Runner::IsolationResolver
36
45
  path = resolve_preload_path
37
46
  return unless path
38
47
 
39
- prepare_load_path_for_preload
48
+ prepare_load_path_for_preload(path)
40
49
  prepare_integration_for_preload
41
50
  require File.expand_path(path)
42
51
  rescue Evilution::ConfigError
@@ -82,24 +91,64 @@ class Evilution::Runner::IsolationResolver
82
91
  warn_in_process_under_rails if rails_root_detected?
83
92
  :in_process
84
93
  else # :auto
85
- rails_root_detected? ? :fork : :in_process
94
+ fork_isolation_default? ? :fork : :in_process
86
95
  end
87
96
  end
88
97
 
98
+ # Auto-isolation picks :fork for both Rails apps and packaged gems. A gem has
99
+ # a spec/test suite whose helper (and the gem's own deps) must be preloaded in
100
+ # the parent before forking; in_process can't preload without polluting the
101
+ # host, so a plain non-Rails gem run with the in_process default scored 0.0
102
+ # out-of-box (every mutation errored with 0 examples / NameError). Detecting
103
+ # the gemspec and defaulting to :fork lets auto-preload fire.
104
+ def fork_isolation_default?
105
+ rails_root_detected? || gem_root_detected?
106
+ end
107
+
108
+ def gem_root_detected?
109
+ !detected_gem_root.nil?
110
+ end
111
+
89
112
  def detected_rails_root
90
113
  return @detected_rails_root if defined?(@detected_rails_root)
91
114
 
92
115
  @detected_rails_root = Evilution::RailsDetector.rails_root_for_any(target_files)
93
116
  end
94
117
 
95
- # Preload files (e.g. spec/rails_helper.rb) typically `require 'spec_helper'`
96
- # which needs spec/ on $LOAD_PATH, and use `RSpec.configure` which needs
97
- # rspec/core loaded. The RSpec CLI normally sets this up, but evilution
98
- # calls Runner.run directly.
99
- def prepare_load_path_for_preload
118
+ def detected_gem_root
119
+ return @detected_gem_root if defined?(@detected_gem_root)
120
+
121
+ @detected_gem_root = Evilution::GemDetector.gem_root_for_any(target_files)
122
+ end
123
+
124
+ # Preload files `require` a sibling helper relative to the test root, which
125
+ # the suite's own runner satisfies via -Ispec/-Itest; evilution calls
126
+ # Runner.run directly, so it must mirror that on $LOAD_PATH. The policy is
127
+ # integration-specific so it does not over-widen the path:
128
+ # - rspec: spec/rails_helper.rb / spec/spec_helper.rb need spec/ only (and
129
+ # rspec/core for RSpec.configure). Kept spec-only to match the RSpec
130
+ # FrameworkLoader and avoid prepending test/ ahead of spec/ in apps that
131
+ # have both (a bare `require "support/foo"` must still resolve from spec/).
132
+ # - minitest/test-unit: a test/test_helper.rb doing a non-relative
133
+ # `require "support/..."` needs test/ on $LOAD_PATH. Route through
134
+ # TestLoadPath -- the same policy the per-mutation test load uses -- so preload and mutation paths agree.
135
+ def prepare_load_path_for_preload(preload_path)
136
+ if config.integration == :rspec
137
+ prepare_rspec_preload_load_path
138
+ else
139
+ prepare_test_preload_load_path(preload_path)
140
+ end
141
+ end
142
+
143
+ def prepare_rspec_preload_load_path
100
144
  spec_dir = File.expand_path(resolve_spec_dir)
101
145
  $LOAD_PATH.unshift(spec_dir) unless $LOAD_PATH.include?(spec_dir)
102
- require "rspec/core" if config.integration == :rspec
146
+ require "rspec/core"
147
+ end
148
+
149
+ def prepare_test_preload_load_path(preload_path)
150
+ base = detected_rails_root || Evilution.project_base_dir
151
+ Evilution::Integration::Loading::TestLoadPath.add!([preload_path], base: base)
103
152
  end
104
153
 
105
154
  def resolve_spec_dir
@@ -145,21 +194,41 @@ class Evilution::Runner::IsolationResolver
145
194
  raise Evilution::ConfigError, autodetect_missing_message
146
195
  end
147
196
 
148
- detected_gem_entry
197
+ resolve_autodetected_gem_preload
198
+ end
199
+
200
+ # For a non-Rails gem, prefer the conventional test helper (which loads the
201
+ # gem's library AND the suite's framework/support setup) over the bare gem
202
+ # entry, so example groups actually register. Fall back to the gem entry, and
203
+ # flag a non-standard test layout so the user can pass --preload.
204
+ def resolve_autodetected_gem_preload
205
+ helper = find_first_existing_gem_helper
206
+ return helper if helper
207
+
208
+ entry = detected_gem_entry
209
+ warn_unconventional_test_layout(entry) if detected_gem_root
210
+ entry
149
211
  end
150
212
 
151
213
  def detected_gem_entry
152
214
  return @detected_gem_entry if defined?(@detected_gem_entry)
153
215
 
154
- root = Evilution::GemDetector.gem_root_for_any(target_files)
216
+ root = detected_gem_root
155
217
  @detected_gem_entry = root && Evilution::GemDetector.gem_entry_for(root, target_paths: target_files)
156
218
  end
157
219
 
158
220
  def find_first_existing_candidate
159
- root = detected_rails_root
221
+ find_first_existing_under(detected_rails_root, PRELOAD_CANDIDATES)
222
+ end
223
+
224
+ def find_first_existing_gem_helper
225
+ find_first_existing_under(detected_gem_root, GEM_PRELOAD_CANDIDATES)
226
+ end
227
+
228
+ def find_first_existing_under(root, candidates)
160
229
  return nil unless root
161
230
 
162
- PRELOAD_CANDIDATES.each do |rel|
231
+ candidates.each do |rel|
163
232
  abs = File.join(root, rel)
164
233
  return abs if File.file?(abs)
165
234
  end
@@ -209,6 +278,31 @@ class Evilution::Runner::IsolationResolver
209
278
  )
210
279
  end
211
280
 
281
+ # A gem was detected but none of the conventional test helpers exist, so the
282
+ # suite likely uses a non-standard layout. Point at the expected locations and
283
+ # the --preload escape hatch. The fallback wording reflects what will actually
284
+ # happen: preload the gem entry when one was found, otherwise nothing is
285
+ # preloaded (gem_entry can be nil when the gemspec name has no on-disk lib
286
+ # entry) — without a helper, the gem entry alone may not register example
287
+ # groups and mutations can error with 0 examples.
288
+ def warn_unconventional_test_layout(gem_entry)
289
+ return if config.quiet
290
+
291
+ fallback =
292
+ if gem_entry
293
+ "Falling back to the gem entry (#{gem_entry})"
294
+ else
295
+ "No gem entry found to fall back to, so nothing will be preloaded"
296
+ end
297
+
298
+ $stderr.write(
299
+ "[evilution] warning: no conventional test helper found under " \
300
+ "#{detected_gem_root.inspect} (looked for #{GEM_PRELOAD_CANDIDATES.join(", ")}). " \
301
+ "#{fallback}. If mutations error with '0 examples loaded' or NameError, " \
302
+ "your test layout is non-standard — pass --preload <your helper>.\n"
303
+ )
304
+ end
305
+
212
306
  # When the user explicitly requests InProcess on a Rails project, warn once
213
307
  # per run. Rails wraps ActiveRecord transactions in
214
308
  # Thread.handle_interrupt(Exception => :never), which defers Timeout's
@@ -1,8 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../strategy"
4
+ require_relative "../../../parallel/work_queue"
4
5
 
5
6
  class Evilution::Runner::MutationExecutor::Strategy::Parallel
7
+ # Compact result for a worker that exited unexpectedly (:error is not cached,
8
+ # so the 0.0 duration is never persisted).
9
+ DIED_COMPACT = {
10
+ status: :error,
11
+ duration: 0.0,
12
+ error_message: "worker process exited unexpectedly",
13
+ error_class: "Evilution::Error"
14
+ }.freeze
15
+
6
16
  def initialize(cache:, isolator:, packer:, pipeline:, notifier:, pool_factory:, config:, diagnostics: nil)
7
17
  @cache = cache
8
18
  @isolator = isolator
@@ -71,11 +81,28 @@ class Evilution::Runner::MutationExecutor::Strategy::Parallel
71
81
  return [] if uncached_indices.empty?
72
82
 
73
83
  uncached = uncached_indices.map { |i| batch[i] }
74
- pool.map(uncached) do |mutation|
84
+ worker_results = pool.map(uncached) do |mutation|
75
85
  test_command = ->(m) { integration.call(m) }
76
86
  result = @isolator.call(mutation: mutation, test_command: test_command, timeout: @config.timeout)
77
87
  @packer.compact(result)
78
88
  end
89
+ worker_results.map { |r| unpack_unfinished(r) }
90
+ end
91
+
92
+ def unpack_unfinished(result)
93
+ return result unless result.is_a?(Evilution::Parallel::WorkQueue::Unfinished)
94
+
95
+ case result.reason
96
+ when :timeout then timeout_compact
97
+ when :died then DIED_COMPACT
98
+ end
99
+ end
100
+
101
+ # A :timeout result is cacheable, so give it a realistic duration: the stuck
102
+ # worker exhausted the dispatcher's item_timeout (config.timeout * 2) before
103
+ # being killed. A 0.0 here would be cached and skew summaries/reuse.
104
+ def timeout_compact
105
+ { status: :timeout, duration: @config.timeout * 2.0 }
79
106
  end
80
107
 
81
108
  def merge(batch, uncached_indices, cached_results, worker_results)
@@ -178,6 +178,12 @@ class Evilution::Runner
178
178
  def install_signal_handler(sig)
179
179
  prev_handler = Signal.trap(sig) do
180
180
  Evilution::TempDirTracker.cleanup_all
181
+ # EV-jwao / GH #1332: workers are their own process-group leaders, so a
182
+ # terminal Ctrl-C reaches only the parent's group. Forward to each worker
183
+ # group here -- the parent's fatal-signal death skips work_queue#map's
184
+ # `ensure cleanup_workers`, so this trap is the reliable forwarding hook.
185
+ # EV-dg69: the signal-safe registry is now owned by ProcessSupervisor.
186
+ Evilution::ProcessSupervisor.signal_all(sig)
181
187
 
182
188
  case prev_handler
183
189
  when Proc, Method
@@ -226,6 +232,7 @@ require_relative "result/summary"
226
232
  require_relative "baseline"
227
233
  require_relative "cache"
228
234
  require_relative "parallel/pool"
235
+ require_relative "process_supervisor"
229
236
  require_relative "session/store"
230
237
  require_relative "temp_dir_tracker"
231
238
  require_relative "rails_detector"