evilution 0.33.0 → 0.35.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/.beads/interactions.jsonl +16 -0
- data/.rubocop_todo.yml +2 -1
- data/CHANGELOG.md +26 -0
- data/README.md +39 -10
- data/assets/logo/evilution-mark-1024.png +0 -0
- data/assets/logo/evilution-mark-64.png +0 -0
- data/assets/logo/evilution-mark-transparent-1024.png +0 -0
- data/assets/logo/evilution-mark-transparent.svg +53 -0
- data/assets/logo/evilution-mark.svg +70 -0
- data/docs/isolation.md +32 -2
- data/docs/migration-from-mutant.md +226 -0
- data/lib/evilution/cli/parser/options_builder.rb +17 -0
- data/lib/evilution/config/builders/spec_resolver.rb +3 -1
- data/lib/evilution/config/validators/example_targeting_strategy.rb +22 -0
- data/lib/evilution/config.rb +16 -2
- data/lib/evilution/coverage/digest.rb +16 -0
- data/lib/evilution/coverage/map.rb +64 -0
- data/lib/evilution/coverage/map_builder.rb +82 -0
- data/lib/evilution/coverage/map_store.rb +87 -0
- data/lib/evilution/coverage/recorder.rb +85 -0
- data/lib/evilution/coverage.rb +8 -0
- data/lib/evilution/coverage_example_filter.rb +41 -0
- data/lib/evilution/gem_detector.rb +7 -0
- data/lib/evilution/integration/minitest.rb +14 -3
- data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +17 -3
- data/lib/evilution/integration/test_unit/test_file_resolver.rb +14 -3
- data/lib/evilution/isolation/fork.rb +38 -76
- data/lib/evilution/parallel/work_queue/dispatcher/deadline_tracker.rb +63 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +7 -34
- data/lib/evilution/parallel/work_queue/worker.rb +41 -51
- data/lib/evilution/process_supervisor.rb +259 -0
- data/lib/evilution/runner/baseline_runner.rb +52 -0
- data/lib/evilution/runner/canary.rb +31 -4
- data/lib/evilution/runner/isolation_resolver.rb +120 -12
- data/lib/evilution/runner.rb +3 -2
- data/lib/evilution/spec_resolver.rb +93 -1
- data/lib/evilution/spec_selector.rb +14 -4
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +1 -0
- data/scripts/canary_manifest.yml +47 -0
- data/scripts/compare_targeting +277 -0
- data/scripts/compare_targeting.example.yml +24 -0
- metadata +21 -3
- data/lib/evilution/parallel/work_queue/worker_registry.rb +0 -47
|
@@ -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
|
|
@@ -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
|
|
|
@@ -76,13 +76,30 @@ class Evilution::Runner::Canary
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def write_spec(dir)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
path = File.join(dir, name)
|
|
82
|
-
File.write(path, minitest ? minitest_spec_source : rspec_spec_source)
|
|
79
|
+
path = File.join(dir, spec_filename)
|
|
80
|
+
File.write(path, spec_source)
|
|
83
81
|
path
|
|
84
82
|
end
|
|
85
83
|
|
|
84
|
+
# minitest and test-unit both live under a `_test.rb` file; rspec uses
|
|
85
|
+
# `_spec.rb`. Picking the wrong shape makes the integration load nothing (or
|
|
86
|
+
# raise), so the synthetic mutation scores :error and aborts the run.
|
|
87
|
+
def spec_filename
|
|
88
|
+
test_framework? ? "canary_#{suffix}_test.rb" : "canary_#{suffix}_spec.rb"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_framework?
|
|
92
|
+
%i[minitest test_unit].include?(@config.integration)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def spec_source
|
|
96
|
+
case @config.integration
|
|
97
|
+
when :minitest then minitest_spec_source
|
|
98
|
+
when :test_unit then test_unit_spec_source
|
|
99
|
+
else rspec_spec_source
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
86
103
|
def rspec_spec_source
|
|
87
104
|
<<~RUBY
|
|
88
105
|
RSpec.describe("evilution proof-of-life canary") do
|
|
@@ -103,6 +120,16 @@ class Evilution::Runner::Canary
|
|
|
103
120
|
RUBY
|
|
104
121
|
end
|
|
105
122
|
|
|
123
|
+
def test_unit_spec_source
|
|
124
|
+
<<~RUBY
|
|
125
|
+
class EvilutionCanaryTest_#{suffix} < Test::Unit::TestCase
|
|
126
|
+
def test_pipeline_is_alive
|
|
127
|
+
assert(true)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
RUBY
|
|
131
|
+
end
|
|
132
|
+
|
|
106
133
|
def build_mutation(class_path)
|
|
107
134
|
Evilution::Mutation.new(
|
|
108
135
|
subject: Evilution::Subject.new(
|
|
@@ -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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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"
|
|
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,55 @@ class Evilution::Runner::IsolationResolver
|
|
|
145
194
|
raise Evilution::ConfigError, autodetect_missing_message
|
|
146
195
|
end
|
|
147
196
|
|
|
148
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
find_first_existing_under(detected_gem_root, nested_gem_helper_candidates)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Gems that namespace their suite keep the helper at test/<gem>/helper.rb
|
|
230
|
+
# (ruby/csv: test/csv/helper.rb) rather than the flat test/helper.rb, so the
|
|
231
|
+
# conventional chain misses it and autodetect would fall back to the bare gem
|
|
232
|
+
# entry — which never sets up the test framework, aborting the canary.
|
|
233
|
+
# Derive the namespace dir from the gem name (dotted for dash-named gems, plus
|
|
234
|
+
# the flat form).
|
|
235
|
+
def nested_gem_helper_candidates
|
|
236
|
+
name = detected_gem_root && Evilution::GemDetector.gem_name(detected_gem_root, target_paths: target_files)
|
|
237
|
+
return [] unless name
|
|
238
|
+
|
|
239
|
+
[name.tr("-", "/"), name].uniq.map { |ns| File.join("test", ns, "helper.rb") }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def find_first_existing_under(root, candidates)
|
|
160
243
|
return nil unless root
|
|
161
244
|
|
|
162
|
-
|
|
245
|
+
candidates.each do |rel|
|
|
163
246
|
abs = File.join(root, rel)
|
|
164
247
|
return abs if File.file?(abs)
|
|
165
248
|
end
|
|
@@ -209,6 +292,31 @@ class Evilution::Runner::IsolationResolver
|
|
|
209
292
|
)
|
|
210
293
|
end
|
|
211
294
|
|
|
295
|
+
# A gem was detected but none of the conventional test helpers exist, so the
|
|
296
|
+
# suite likely uses a non-standard layout. Point at the expected locations and
|
|
297
|
+
# the --preload escape hatch. The fallback wording reflects what will actually
|
|
298
|
+
# happen: preload the gem entry when one was found, otherwise nothing is
|
|
299
|
+
# preloaded (gem_entry can be nil when the gemspec name has no on-disk lib
|
|
300
|
+
# entry) — without a helper, the gem entry alone may not register example
|
|
301
|
+
# groups and mutations can error with 0 examples.
|
|
302
|
+
def warn_unconventional_test_layout(gem_entry)
|
|
303
|
+
return if config.quiet
|
|
304
|
+
|
|
305
|
+
fallback =
|
|
306
|
+
if gem_entry
|
|
307
|
+
"Falling back to the gem entry (#{gem_entry})"
|
|
308
|
+
else
|
|
309
|
+
"No gem entry found to fall back to, so nothing will be preloaded"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
$stderr.write(
|
|
313
|
+
"[evilution] warning: no conventional test helper found under " \
|
|
314
|
+
"#{detected_gem_root.inspect} (looked for #{GEM_PRELOAD_CANDIDATES.join(", ")}). " \
|
|
315
|
+
"#{fallback}. If mutations error with '0 examples loaded' or NameError, " \
|
|
316
|
+
"your test layout is non-standard — pass --preload <your helper>.\n"
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
212
320
|
# When the user explicitly requests InProcess on a Rails project, warn once
|
|
213
321
|
# per run. Rails wraps ActiveRecord transactions in
|
|
214
322
|
# Thread.handle_interrupt(Exception => :never), which defers Timeout's
|
data/lib/evilution/runner.rb
CHANGED
|
@@ -182,7 +182,8 @@ class Evilution::Runner
|
|
|
182
182
|
# terminal Ctrl-C reaches only the parent's group. Forward to each worker
|
|
183
183
|
# group here -- the parent's fatal-signal death skips work_queue#map's
|
|
184
184
|
# `ensure cleanup_workers`, so this trap is the reliable forwarding hook.
|
|
185
|
-
|
|
185
|
+
# EV-dg69: the signal-safe registry is now owned by ProcessSupervisor.
|
|
186
|
+
Evilution::ProcessSupervisor.signal_all(sig)
|
|
186
187
|
|
|
187
188
|
case prev_handler
|
|
188
189
|
when Proc, Method
|
|
@@ -231,7 +232,7 @@ require_relative "result/summary"
|
|
|
231
232
|
require_relative "baseline"
|
|
232
233
|
require_relative "cache"
|
|
233
234
|
require_relative "parallel/pool"
|
|
234
|
-
require_relative "
|
|
235
|
+
require_relative "process_supervisor"
|
|
235
236
|
require_relative "session/store"
|
|
236
237
|
require_relative "temp_dir_tracker"
|
|
237
238
|
require_relative "rails_detector"
|