evilution 0.33.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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +16 -0
- data/.rubocop_todo.yml +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +11 -9
- data/docs/isolation.md +31 -2
- data/lib/evilution/cli/parser/options_builder.rb +17 -0
- 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/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/isolation_resolver.rb +106 -12
- data/lib/evilution/runner.rb +3 -2
- data/lib/evilution/spec_resolver.rb +66 -0
- 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 +15 -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
|
|
|
@@ -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,41 @@ 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
|
+
end
|
|
227
|
+
|
|
228
|
+
def find_first_existing_under(root, candidates)
|
|
160
229
|
return nil unless root
|
|
161
230
|
|
|
162
|
-
|
|
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
|
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"
|
|
@@ -28,6 +28,22 @@ class Evilution::SpecResolver
|
|
|
28
28
|
Array(source_paths).filter_map { |path| call(path) }.uniq
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Like #call, but returns an ARRAY of test files and additionally covers the
|
|
32
|
+
# dir-grouped layout (a source file's tests live in a directory named after
|
|
33
|
+
# the source basename, e.g. lib/x/branch.rb -> test/unit/branch/*_test.rb,
|
|
34
|
+
# rather than a single mirror file). The deterministic file mirror from #call
|
|
35
|
+
# always wins; only when no mirror file exists is the first matching grouped
|
|
36
|
+
# directory expanded into its test files (EV-bi41). Returns nil when nothing
|
|
37
|
+
# resolves.
|
|
38
|
+
def resolve_specs(source_path, spec_pattern: nil)
|
|
39
|
+
return nil if source_path.nil? || source_path.empty?
|
|
40
|
+
|
|
41
|
+
file = call(source_path, spec_pattern: spec_pattern)
|
|
42
|
+
return [file] if file
|
|
43
|
+
|
|
44
|
+
resolve_grouped_dir(source_path, spec_pattern: spec_pattern)
|
|
45
|
+
end
|
|
46
|
+
|
|
31
47
|
# Best-guess candidate for an unresolved source, found by basename glob
|
|
32
48
|
# rather than the deterministic path mirroring used by #call. Used only to
|
|
33
49
|
# enrich the "no matching test" hint (EV-z7f5 / GH #1325) — never to pick a
|
|
@@ -75,6 +91,56 @@ class Evilution::SpecResolver
|
|
|
75
91
|
candidates.select { |path| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
76
92
|
end
|
|
77
93
|
|
|
94
|
+
# Dir-grouped resolution: find the first candidate directory that exists and
|
|
95
|
+
# expand it into its test files. Ranked below #call's file mirrors (callers
|
|
96
|
+
# try #call first), so a 1:1 spec always wins when present.
|
|
97
|
+
def resolve_grouped_dir(source_path, spec_pattern: nil)
|
|
98
|
+
dir = directory_candidates(normalize_path(source_path)).find { |c| project_relative_dir?(c) }
|
|
99
|
+
return nil unless dir
|
|
100
|
+
|
|
101
|
+
files = test_files_in(dir)
|
|
102
|
+
files = filter_by_pattern(files, spec_pattern) if spec_pattern
|
|
103
|
+
files.empty? ? nil : files
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The grouped-directory analogue of #mirror_candidates: cross every
|
|
107
|
+
# conventional root with each layout variant, using the source basename
|
|
108
|
+
# (suffix dropped) as a DIRECTORY name rather than a test FILE name.
|
|
109
|
+
def directory_candidates(source_path)
|
|
110
|
+
stripped = strip_source_prefix(source_path)
|
|
111
|
+
mirror_variants(stripped).flat_map { |variant| grouped_dir_candidates(variant) }.uniq
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def grouped_dir_candidates(variant)
|
|
115
|
+
dir, _, file = variant.rpartition("/")
|
|
116
|
+
name = file.delete_suffix(@test_suffix)
|
|
117
|
+
return [] if name.empty?
|
|
118
|
+
|
|
119
|
+
relative = dir.empty? ? name : "#{dir}/#{name}"
|
|
120
|
+
roots.map { |root| "#{root}/#{relative}" }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def strip_source_prefix(source_path)
|
|
124
|
+
base = source_path.sub(/\.rb\z/, @test_suffix)
|
|
125
|
+
prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
|
|
126
|
+
prefix ? base.delete_prefix(prefix) : base
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every test file under a grouped directory: the mirrored suffix plus, for
|
|
130
|
+
# minitest/test-unit, the `test_` prefix convention. Sorted for determinism.
|
|
131
|
+
def test_files_in(dir)
|
|
132
|
+
globs = ["#{dir}/**/*#{@test_suffix}", ("#{dir}/**/test_*.rb" if @test_suffix == MINITEST_SUFFIX)]
|
|
133
|
+
globs.compact.flat_map { |glob| glob_relative(glob) }.uniq.sort
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Directory analogue of #project_relative_exists?.
|
|
137
|
+
def project_relative_dir?(path)
|
|
138
|
+
return true if File.directory?(path)
|
|
139
|
+
return false unless Evilution.in_isolated_worker?
|
|
140
|
+
|
|
141
|
+
File.directory?(File.expand_path(path, Evilution::PROJECT_ROOT))
|
|
142
|
+
end
|
|
143
|
+
|
|
78
144
|
def normalize_path(path)
|
|
79
145
|
path = path.delete_prefix("./")
|
|
80
146
|
if path.start_with?("/")
|
|
@@ -19,12 +19,23 @@ class Evilution::SpecSelector
|
|
|
19
19
|
return existing unless existing.empty?
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
resolved =
|
|
23
|
-
resolved ?
|
|
22
|
+
resolved = resolve_via_resolver(source_path)
|
|
23
|
+
resolved && !resolved.empty? ? resolved : nil
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
+
# Prefer the array-returning #resolve_specs, but fall back to the older single-file #call contract so a custom
|
|
29
|
+
# resolver that only implements #call keeps working.
|
|
30
|
+
def resolve_via_resolver(source_path)
|
|
31
|
+
if @spec_resolver.respond_to?(:resolve_specs)
|
|
32
|
+
@spec_resolver.resolve_specs(source_path, spec_pattern: @spec_pattern)
|
|
33
|
+
else
|
|
34
|
+
file = @spec_resolver.call(source_path, spec_pattern: @spec_pattern)
|
|
35
|
+
file ? [file] : nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
28
39
|
def mapping_for(source_path)
|
|
29
40
|
@spec_mappings[normalize(source_path)]
|
|
30
41
|
end
|
|
@@ -40,8 +51,7 @@ class Evilution::SpecSelector
|
|
|
40
51
|
normalized.delete_prefix("./")
|
|
41
52
|
end
|
|
42
53
|
|
|
43
|
-
# Same semantics as Evilution::SpecResolver#project_relative_exists?
|
|
44
|
-
# that method for the EV-wqxu / GH #1278 rationale.
|
|
54
|
+
# Same semantics as Evilution::SpecResolver#project_relative_exists?
|
|
45
55
|
def project_relative_exists?(path)
|
|
46
56
|
return true if File.exist?(path)
|
|
47
57
|
return false unless Evilution.in_isolated_worker?
|
data/lib/evilution/version.rb
CHANGED
data/lib/evilution.rb
CHANGED
|
@@ -100,6 +100,7 @@ require_relative "evilution/mutator/registry"
|
|
|
100
100
|
require_relative "evilution/equivalent"
|
|
101
101
|
require_relative "evilution/equivalent/heuristic"
|
|
102
102
|
require_relative "evilution/equivalent/detector"
|
|
103
|
+
require_relative "evilution/process_supervisor"
|
|
103
104
|
require_relative "evilution/isolation"
|
|
104
105
|
require_relative "evilution/isolation/fork"
|
|
105
106
|
require_relative "evilution/isolation/in_process"
|