evilution 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +12 -0
- data/.rubocop_todo.yml +1 -0
- data/CHANGELOG.md +17 -0
- data/README.md +1 -1
- data/docs/integrations.md +15 -0
- data/docs/isolation.md +15 -0
- data/lib/evilution/baseline.rb +11 -4
- data/lib/evilution/integration/loading/test_load_path.rb +76 -0
- data/lib/evilution/integration/minitest.rb +5 -1
- data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +72 -0
- data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +45 -0
- data/lib/evilution/integration/rspec/state_guard.rb +3 -1
- data/lib/evilution/integration/test_unit.rb +12 -4
- data/lib/evilution/isolation/fork.rb +28 -2
- data/lib/evilution/parallel/work_queue/dispatcher.rb +94 -22
- data/lib/evilution/parallel/work_queue/worker.rb +49 -3
- data/lib/evilution/parallel/work_queue/worker_registry.rb +47 -0
- data/lib/evilution/parallel/work_queue.rb +8 -0
- data/lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb +50 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +28 -1
- data/lib/evilution/runner.rb +6 -0
- data/lib/evilution/spec_resolver.rb +81 -9
- data/lib/evilution/version.rb +1 -1
- data/lib/tasks/stress.rake +15 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07051270c176d0d26e28c2978c6bfa2eb0d76dd2e6ba453387f9731f04a0d79b
|
|
4
|
+
data.tar.gz: 1562d91a064d05af5b6264c89eacf93077630bc09b4a149010d6e4e5514409ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9957755214557a006e461cadd076d57b5c1d55536e9cec5105df1b1b37580150a6ff5fc8a33c816c0356a8e890eab1ac8e5fcfccac1acb6a46e18c76ba8ec909
|
|
7
|
+
data.tar.gz: 8a2a4000adbaf10f178cbed4cb117644d25bd27ec2730fb02024fa68b3cf8424181b0d5e45bbfbe53b3c9a9a37266e306de7592f16443d0d8545ad46f878f4af
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -409,3 +409,15 @@
|
|
|
409
409
|
{"id":"int-c7bcc360","kind":"field_change","created_at":"2026-05-31T03:12:47.029582311Z","actor":"Denis Kiselev","issue_id":"EV-6az9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
410
410
|
{"id":"int-fa3ade4f","kind":"field_change","created_at":"2026-05-31T08:47:32.000198989Z","actor":"Denis Kiselev","issue_id":"EV-d3av","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
|
411
411
|
{"id":"int-b1831323","kind":"field_change","created_at":"2026-05-31T08:47:50.244781995Z","actor":"Denis Kiselev","issue_id":"EV-d7re","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
|
|
412
|
+
{"id":"int-6325348d","kind":"field_change","created_at":"2026-06-05T17:09:56.549588702Z","actor":"Denis Kiselev","issue_id":"EV-rxob","extra":{"field":"status","new_value":"in_progress","old_value":"closed"}}
|
|
413
|
+
{"id":"int-2cd0e23a","kind":"field_change","created_at":"2026-06-06T03:49:11.649710835Z","actor":"Denis Kiselev","issue_id":"EV-axze","extra":{"field":"priority","new_value":"1","old_value":"3"}}
|
|
414
|
+
{"id":"int-586865ca","kind":"field_change","created_at":"2026-06-06T03:49:12.267581064Z","actor":"Denis Kiselev","issue_id":"EV-axze","extra":{"field":"priority","new_value":"2","old_value":"1"}}
|
|
415
|
+
{"id":"int-c295efed","kind":"field_change","created_at":"2026-06-06T03:56:26.595461244Z","actor":"Denis Kiselev","issue_id":"EV-gl1e","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
|
416
|
+
{"id":"int-552b93f8","kind":"field_change","created_at":"2026-06-06T06:34:06.544071647Z","actor":"Denis Kiselev","issue_id":"EV-cnx8","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
|
|
417
|
+
{"id":"int-38e50f55","kind":"field_change","created_at":"2026-06-06T06:34:58.433491242Z","actor":"Denis Kiselev","issue_id":"EV-gl1e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1329"}}
|
|
418
|
+
{"id":"int-121d6c46","kind":"field_change","created_at":"2026-06-06T07:31:53.274033616Z","actor":"Denis Kiselev","issue_id":"EV-cnx8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1331"}}
|
|
419
|
+
{"id":"int-17dbc619","kind":"field_change","created_at":"2026-06-06T08:17:59.133451819Z","actor":"Denis Kiselev","issue_id":"EV-jwao","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1333. Forward INT/TERM to worker process groups before parent dies; WorkerRegistry (lock-free COW pgid registry, signal-safe), register-before-isolate (race fix from review), unregister on reap. Specs + e2e green."}}
|
|
420
|
+
{"id":"int-9eb0b37f","kind":"field_change","created_at":"2026-06-06T10:31:43.322237231Z","actor":"Denis Kiselev","issue_id":"EV-2sh8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1335. Mutation child setpgid(0,0) as own group leader; terminate_child group-kills via signal_tree(-pid + pid) on TERM/KILL ladder; sweeps blocking grandchildren on inner timeout. Specs green, memory:check PASS. Flaky-test review hardened (timeout 3s, atomic pid write)."}}
|
|
421
|
+
{"id":"int-cdc02b29","kind":"field_change","created_at":"2026-06-06T15:19:26.153490037Z","actor":"Denis Kiselev","issue_id":"EV-dlnn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1344. Flaky /tmp glob replaced with parent-side sandbox capture (spy Dir.mktmpdir, assert the evilution-run dir gone after timeout). Deterministic, fork.rb untouched."}}
|
|
422
|
+
{"id":"int-af4ca762","kind":"field_change","created_at":"2026-06-06T17:23:17.168065946Z","actor":"Denis Kiselev","issue_id":"EV-dwqw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #1345. Real carrier was RSpec.configuration @preferred_options (color_mode getter reads it first); inner Runner.run --no-color force-merges color_mode=:off in place. StateGuard::ConfigurationState snapshots @preferred_options by dup + stream ivars and restores them; in-process example re-enabled. Visual dots green, suite 4881 green."}}
|
|
423
|
+
{"id":"int-f7253919","kind":"field_change","created_at":"2026-06-07T04:14:56.864785049Z","actor":"Denis Kiselev","issue_id":"EV-z7f5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged PR #1347 (master). opt1+2+3 + Copilot review fixes. Empirical 4-repro re-run remains under EV-rxob canary harness (the dep)."}}
|
data/.rubocop_todo.yml
CHANGED
|
@@ -12,6 +12,7 @@ Metrics/ClassLength:
|
|
|
12
12
|
- "lib/evilution/isolation/fork.rb"
|
|
13
13
|
- "lib/evilution/integration/minitest.rb"
|
|
14
14
|
- "lib/evilution/mcp/mutate_tool.rb"
|
|
15
|
+
- "lib/evilution/parallel/work_queue/dispatcher.rb"
|
|
15
16
|
- "lib/evilution/runner.rb"
|
|
16
17
|
- "lib/evilution/runner/isolation_resolver.rb"
|
|
17
18
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Versioning policy: see [docs/versioning.md](docs/versioning.md).
|
|
4
4
|
|
|
5
|
+
## [0.33.0] - 2026-06-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Auto spec-resolution now finds specs in non-mirrored test layouts (`spec/unit`, `spec/lib`, `test/unit`, `test/lib`) instead of scoring 0% out of the box** — when `--spec` is omitted, `Evilution::SpecResolver` previously only resolved tests that mirrored the `lib/` tree 1:1 (`lib/foo/bar.rb` → `spec/foo/bar_spec.rb`). Projects that park their suites under a `unit`/`lib` bucket (`spec/unit/foo/bar_spec.rb`, `test/unit/foo/bar_test.rb` — a very common convention) resolved nothing, so every mutation reported `:unresolved` and the score collapsed to 0.0 with no actionable signal. The resolver now layers conventional-subdir candidates (`CONVENTIONAL_SUBDIRS = %w[unit lib]`) and parent-directory fallbacks on top of the deterministic mirror, ranking full mirrors above dropped-namespace and bare-basename guesses. When mutations still cannot be paired with a spec, a new **unresolved-rate warning** surfaces in both CLI (`reporter/cli/line_formatters/unresolved_rate_warning.rb`) and HTML (`reporter/html/sections/unresolved_details.rb`) output with a best-guess suggestion per source file, so a high unresolved rate reads as a resolution problem to fix (pass `--spec`, adjust layout) rather than a silently meaningless 0%. Repros that went 0.0 → real scores out of the box: webmock, doorkeeper, concurrent-ruby (EV-z7f5, PR #1347, GH #1325)
|
|
10
|
+
- **Opt-in parallel/isolation stress + load suite (`rake stress`) plus a weekly CI guardrail** — a new `:stress`-tagged spec (`spec/evilution/parallel/stress_spec.rb`) drives `Evilution::Parallel::WorkQueue` and `Evilution::Isolation::Fork` far past the per-class fixtures: 10k items at `-j8` with worker recycling, simultaneous worker-timeout and worker-death cascades (folding the EV-gl1e per-item-timeout recovery regression), deadline precision under scheduling churn, sustained-load RSS bounds, and hundreds of real forked mutation runs mixing fast and blocking children. It asserts the invariants that matter under load — correct ordered results, no deadlocks (every run is `Timeout`-wrapped), no zombie/unreaped workers (non-blocking `waitpid(WNOHANG)`), no FD leaks, bounded memory growth. Excluded from the default run; lifted by `RUN_STRESS=1` (set automatically by `rake stress`). A scheduled `.github/workflows/stress.yml` runs it weekly (Mondays 06:00 UTC) and on demand with CI-tuned scale knobs (`STRESS_JOBS`/`STRESS_ITEMS`/`STRESS_FORK_MUTS`/`STRESS_RUN_TIMEOUT`). The full-scale run surfaced no new defects — the EV-gl1e/EV-cnx8/EV-jwao/EV-2sh8 isolation hardening below holds under load (EV-axze, PR #1348, GH #861)
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A single stuck or dead worker no longer aborts the entire parallel run** — `Evilution::Parallel::WorkQueue::Dispatcher` enforced timeouts with a coarse pool-wide watchdog: one mutation that blocked past `item_timeout` (e.g. a checkout-breaking mutation that stalls a child in `ConditionVariable#wait`) raised `"worker timed out after Ns"` as the run's `first_error` and tore down every worker, and a worker that exited unexpectedly (`handle_dead`) did the same. The dispatcher now tracks a **per-worker deadline**: it kills and recycles only the stuck worker, marks just that worker's in-flight item with a `WorkQueue::TIMED_OUT` / `DIED` sentinel, and continues the remaining work. `Strategy::Parallel` translates the sentinels into `:timeout` / `:error` `MutationResult`s. connection_pool jobs=4 (which previously whole-run-aborted) now completes with the stuck mutation scored `:timed_out` and a real non-zero score (EV-gl1e, PR #1329, GH #1324)
|
|
15
|
+
- **Worker SIGKILL now reaps grandchild processes instead of orphaning them** — when the dispatcher SIGKILLed a stuck pool worker, any grandchildren the worker had forked (the per-mutation test subprocess and anything *it* spawned) were left orphaned to init, leaking processes across a long run. Each worker now makes itself a process-group leader (`Process.setpgid(pid, pid)` in `Worker.spawn`) and `Worker#kill` signals the whole group (`-pid`) with a single-pid fallback, so killing a worker takes its entire subtree down (EV-cnx8, PR #1331, GH #1327)
|
|
16
|
+
- **Terminal interrupts (Ctrl-C) now forward to worker process groups instead of leaking them** — once workers ran in their own process groups (above), a SIGINT/SIGTERM to the evilution parent no longer reached the workers' groups, so an interrupted parallel run could leave workers and their mutation children alive. A new `WorkerRegistry` tracks live worker pgids; the `Runner` signal handler forwards the terminating signal to every registered group. The worker registers its pgid **before** `setpgid` to close a trap race where a signal could arrive after the child had changed groups but before the registry knew about it (EV-jwao, PR #1333, GH #1332)
|
|
17
|
+
- **A mutation that blocks a grandchild no longer hangs the per-mutation timeout** — `Evilution::Isolation::Fork` makes the per-mutation child its own process-group leader (`Process.setpgid(0, 0)`) before running the test command, so when the mutation spawns a grandchild that blocks (or traps interrupts), the timeout's TERM → KILL ladder reaches the whole subtree and the child is bounded rather than wedged behind a blocking grandchild (EV-2sh8, PR #1335, GH #1330)
|
|
18
|
+
- **Minitest / test-unit projects whose test helper is loaded via `require "test_helper"` no longer error every mutation with `LoadError`** — the mutation child did not place the project's test root on `$LOAD_PATH`, so a suite that does a bare `require "test_helper"` (the standard Minitest convention) raised `LoadError` and every mutation reported `:error`, collapsing the run. A new `Evilution::Integration::Loading::TestLoadPath` prepends the relevant test directories to `$LOAD_PATH` for the load, and restores it afterward; the Minitest and Test::Unit integrations both route their test-file loads through it. Repro: state_machines (EV-52hf, PR #1341, GH #1326)
|
|
19
|
+
- **In-process isolation no longer leaks RSpec global configuration into the host process** — an `--isolation=in_process` run mutates `RSpec.configuration` while dispatching each mutation, and two of those fields (`color_mode` and `output_stream`) were left mutated after the run, bleeding RSpec's color setting and redirected output stream into the host process that invoked evilution. A new `ConfigurationState` strategy (wired into `StateGuard`) snapshots and restores the affected configuration fields around each in-process run, so the host's RSpec state is unchanged afterward (EV-dwqw, PR #1345/#1346, GH #1343)
|
|
20
|
+
- **Flaky `fork_spec` sandbox-cleanup example decoupled from global `/tmp`** — the spec asserting that a child timeout cleans up its sandbox temp directory keyed off the shared `/tmp`, so concurrent activity made it intermittently fail. It now scopes to its own directory, removing the false negative (test-only) (EV-dlnn, PR #1344, GH #1342)
|
|
21
|
+
|
|
5
22
|
## [0.32.0] - 2026-05-31
|
|
6
23
|
|
|
7
24
|
### Added
|
data/README.md
CHANGED
|
@@ -375,7 +375,7 @@ Compatibility policy for the `1.x` gem line:
|
|
|
375
375
|
| `unresolved` | No spec file resolved for the mutated source — **coverage gap, not a failure**. Use `--fallback-full-suite` to run the full suite instead. | excluded |
|
|
376
376
|
| `unparseable` | Mutated source failed to parse (e.g. dangling heredoc opener after `method_body_replacement`). Short-circuited — never executed. | excluded |
|
|
377
377
|
|
|
378
|
-
Unresolved mutations indicate a missing test mapping — the file has no corresponding test file that the resolver could find (for example, an RSpec `_spec.rb` file or a Minitest `_test.rb` file, depending on configuration). They are reported separately so you can act on them (add a test, adjust test naming, or opt in to the full-suite fallback) without inflating the error count.
|
|
378
|
+
Unresolved mutations indicate a missing test mapping — the file has no corresponding test file that the resolver could find (for example, an RSpec `_spec.rb` file or a Minitest `_test.rb` file, depending on configuration). The resolver searches both the `lib/`-mirrored path and common non-mirrored buckets (`spec/unit`, `spec/lib`, `test/unit`, `test/lib`), so a high unresolved rate usually means a genuinely missing or unconventionally-placed test; a run that leaves many mutations unresolved prints an unresolved-rate warning with a best-guess spec path per source file. They are reported separately so you can act on them (add a test, adjust test naming, pass `--spec`, or opt in to the full-suite fallback) without inflating the error count.
|
|
379
379
|
|
|
380
380
|
## Mutation Operators (74 total)
|
|
381
381
|
|
data/docs/integrations.md
CHANGED
|
@@ -76,6 +76,21 @@ For controllers, the resolver tries the request-spec / integration-test
|
|
|
76
76
|
location first, then falls back to the controller-spec / controller-test
|
|
77
77
|
location.
|
|
78
78
|
|
|
79
|
+
### Non-mirrored layouts
|
|
80
|
+
|
|
81
|
+
Many projects do not mirror the `lib/` tree 1:1 — they park suites under a
|
|
82
|
+
`unit` or `lib` bucket (`spec/unit/foo/bar_spec.rb`, `test/unit/foo/bar_test.rb`,
|
|
83
|
+
`spec/lib/...`, `test/lib/...`). On top of the deterministic mirror above, the
|
|
84
|
+
resolver also tries these conventional sub-directories plus parent-directory
|
|
85
|
+
fallbacks, ranking the full mirror highest and bare-basename guesses lowest. So
|
|
86
|
+
a `spec/unit`-style layout resolves out of the box without `--spec`.
|
|
87
|
+
|
|
88
|
+
If mutations still cannot be paired with a spec, the run prints an
|
|
89
|
+
**unresolved-rate warning** (and an "unresolved" section in HTML output) with a
|
|
90
|
+
best-guess candidate per source file — so a high unresolved count reads as a
|
|
91
|
+
resolution problem to fix (pass `--spec`, or adjust the layout) rather than a
|
|
92
|
+
silently meaningless 0% score.
|
|
93
|
+
|
|
79
94
|
## Suggest-tests caveat
|
|
80
95
|
|
|
81
96
|
The `--suggest-tests` mode emits ready-to-paste test snippets for survived
|
data/docs/isolation.md
CHANGED
|
@@ -130,6 +130,21 @@ On by default; toggle with `--[no-]canary` or `canary: true|false` in
|
|
|
130
130
|
`.evilution.yml`. The canary mirrors the configured `--isolation` so
|
|
131
131
|
isolation-specific defects are caught too.
|
|
132
132
|
|
|
133
|
+
## Parallel run resilience (`--jobs N`)
|
|
134
|
+
|
|
135
|
+
Under `--jobs N` a stuck or crashed worker no longer takes the whole run down.
|
|
136
|
+
The work-queue dispatcher tracks a per-worker deadline: if one mutation blocks
|
|
137
|
+
past its timeout (e.g. a mutation that wedges a child in `ConditionVariable#wait`)
|
|
138
|
+
or a worker exits unexpectedly, only that worker is killed and recycled, its
|
|
139
|
+
single in-flight mutation is recorded as `:timeout` / `:error`, and the run
|
|
140
|
+
continues with the remaining work. A single pathological mutation costs you one
|
|
141
|
+
result, not the entire run.
|
|
142
|
+
|
|
143
|
+
Each worker is also its own process-group leader, so killing a worker reaps the
|
|
144
|
+
mutation subprocess and any grandchildren it spawned rather than orphaning them,
|
|
145
|
+
and a terminal interrupt (Ctrl-C) is forwarded to every worker group — an
|
|
146
|
+
aborted parallel run leaves no stray worker or mutation processes behind.
|
|
147
|
+
|
|
133
148
|
## Related flags
|
|
134
149
|
|
|
135
150
|
- `--timeout N` sets the per-mutation time limit. Under `fork`, this drives
|
data/lib/evilution/baseline.rb
CHANGED
|
@@ -113,11 +113,18 @@ class Evilution::Baseline
|
|
|
113
113
|
warned = Set.new
|
|
114
114
|
subjects.map do |s|
|
|
115
115
|
resolved = @spec_resolver.call(s.file_path)
|
|
116
|
-
if resolved.nil? && warned.add?(s.file_path)
|
|
117
|
-
warn "[evilution] No matching test found for #{s.file_path}, running full suite. " \
|
|
118
|
-
"Use --spec to specify the test file."
|
|
119
|
-
end
|
|
116
|
+
warn_no_matching_test(s.file_path) if resolved.nil? && warned.add?(s.file_path)
|
|
120
117
|
resolved || @fallback_dir
|
|
121
118
|
end.uniq
|
|
122
119
|
end
|
|
120
|
+
|
|
121
|
+
def warn_no_matching_test(file_path)
|
|
122
|
+
suggestion = @spec_resolver.suggest(file_path)
|
|
123
|
+
hint = if suggestion
|
|
124
|
+
"Pass --spec #{suggestion} (best guess) or the correct test file."
|
|
125
|
+
else
|
|
126
|
+
"Use --spec to specify the test file."
|
|
127
|
+
end
|
|
128
|
+
warn "[evilution] No matching test found for #{file_path}, running full suite. #{hint}"
|
|
129
|
+
end
|
|
123
130
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# Mirrors `ruby -Itest` / `-Ispec` for in-process test loading.
|
|
6
|
+
#
|
|
7
|
+
# evilution loads resolved test files with Kernel#load instead of shelling out,
|
|
8
|
+
# so the `-Itest` shown in the displayed command string is never actually
|
|
9
|
+
# applied to $LOAD_PATH. Minitest and Test::Unit suites near-universally
|
|
10
|
+
# `require "test_helper"` (which the suite's own runner satisfies via -Itest);
|
|
11
|
+
# without the test root on $LOAD_PATH that bare require raises LoadError and
|
|
12
|
+
# every mutation errors with score 0.0 (EV-52hf / GH #1326).
|
|
13
|
+
#
|
|
14
|
+
# Anchors against Evilution.project_base_dir, which resolves to PROJECT_ROOT
|
|
15
|
+
# inside an isolated worker (EV-wqxu / GH #1278) and Dir.pwd otherwise, so the
|
|
16
|
+
# same call works on both the baseline (parent) and mutation (child) paths.
|
|
17
|
+
module Evilution::Integration::Loading::TestLoadPath
|
|
18
|
+
ROOT_NAMES = %w[test spec].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Prepend every relevant test directory to $LOAD_PATH (idempotently).
|
|
23
|
+
# Iterate in reverse so the first entry from #dirs_for ends up frontmost,
|
|
24
|
+
# preserving its order (mirrors how `ruby -Ia -Ib` lands a before b).
|
|
25
|
+
def add!(files, base: Evilution.project_base_dir)
|
|
26
|
+
dirs_for(files, base).reverse_each do |dir|
|
|
27
|
+
$LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The directories to put on $LOAD_PATH for the given resolved test files:
|
|
32
|
+
# the conventional test/ and spec/ roots under base, each file's own
|
|
33
|
+
# directory, and the topmost test/spec ancestor of each file (covers nested
|
|
34
|
+
# layouts like test/unit, spec/lib, spec/unit). Existing directories only,
|
|
35
|
+
# and only those inside the project base -- never a broad outside-project dir
|
|
36
|
+
# (e.g. a /tmp test file), which would over-widen $LOAD_PATH for the whole
|
|
37
|
+
# process (the baseline runs in the long-lived parent).
|
|
38
|
+
def dirs_for(files, base)
|
|
39
|
+
base = File.expand_path(base)
|
|
40
|
+
dirs = conventional_roots(base)
|
|
41
|
+
Array(files).each do |file|
|
|
42
|
+
file_dir = File.dirname(File.expand_path(file, base))
|
|
43
|
+
dirs << file_dir
|
|
44
|
+
root = root_ancestor(file_dir, base)
|
|
45
|
+
dirs << root if root
|
|
46
|
+
end
|
|
47
|
+
dirs.uniq.select { |dir| File.directory?(dir) && within?(dir, base) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def within?(dir, base)
|
|
51
|
+
dir == base || dir.start_with?("#{base}/")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def conventional_roots(base)
|
|
55
|
+
ROOT_NAMES.map { |name| File.join(base, name) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Walk from `dir` up to `base`, returning the highest ancestor whose basename
|
|
59
|
+
# is a conventional test root (test/spec). Highest, so test/unit/foo_test.rb
|
|
60
|
+
# yields `test` (matching -Itest), not the intermediate test/unit.
|
|
61
|
+
def root_ancestor(dir, base)
|
|
62
|
+
base = File.expand_path(base)
|
|
63
|
+
found = nil
|
|
64
|
+
current = File.expand_path(dir)
|
|
65
|
+
loop do
|
|
66
|
+
found = current if ROOT_NAMES.include?(File.basename(current))
|
|
67
|
+
break if current == base
|
|
68
|
+
|
|
69
|
+
parent = File.dirname(current)
|
|
70
|
+
break if parent == current
|
|
71
|
+
|
|
72
|
+
current = parent
|
|
73
|
+
end
|
|
74
|
+
found
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "stringio"
|
|
4
4
|
require_relative "base"
|
|
5
5
|
require_relative "minitest_crash_detector"
|
|
6
|
+
require_relative "loading/test_load_path"
|
|
6
7
|
require_relative "../spec_resolver"
|
|
7
8
|
require_relative "../spec_selector"
|
|
8
9
|
|
|
@@ -18,7 +19,9 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
18
19
|
require "stringio"
|
|
19
20
|
stub_autorun!
|
|
20
21
|
::Minitest::Runnable.runnables.clear
|
|
21
|
-
baseline_test_files(test_file)
|
|
22
|
+
files = baseline_test_files(test_file)
|
|
23
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
24
|
+
files.each { |f| load(File.expand_path(f)) }
|
|
22
25
|
run_baseline_minitest
|
|
23
26
|
end
|
|
24
27
|
|
|
@@ -128,6 +131,7 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
|
|
|
128
131
|
end
|
|
129
132
|
|
|
130
133
|
def execute_minitest(mutation, files, command)
|
|
134
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
131
135
|
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
132
136
|
|
|
133
137
|
detector = reset_crash_detector
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rspec"
|
|
4
|
+
|
|
5
|
+
# Restores the RSpec.configuration state that ::RSpec::Core::Runner.run mutates
|
|
6
|
+
# on the shared singleton during an in-process mutation run (EV-dwqw / GH #1343).
|
|
7
|
+
#
|
|
8
|
+
# The visible symptom was every SUBSEQUENT host example rendering its progress
|
|
9
|
+
# dots without color (white instead of green). Root cause: the run's args carry
|
|
10
|
+
# "--no-color", and ConfigurationOptions#configure applies it via
|
|
11
|
+
# Configuration#force, which does `@preferred_options.merge!(:color_mode => :off)`
|
|
12
|
+
# IN PLACE. `Configuration#color_mode` reads `@preferred_options.fetch(:color_mode)`
|
|
13
|
+
# first, so the host's color setting stays :off for the rest of the process.
|
|
14
|
+
# Separately, Runner#setup points `output_stream`/`error_stream` (attr_writers,
|
|
15
|
+
# i.e. the @output_stream/@error_stream ivars) at the run's throwaway StringIOs.
|
|
16
|
+
#
|
|
17
|
+
# Forked runs never leak these (the mutation happens in a child that dies); only
|
|
18
|
+
# the in-process isolation path mutates the host's own configuration.
|
|
19
|
+
#
|
|
20
|
+
# @preferred_options is mutated in place, so it is snapshotted by DUP and put
|
|
21
|
+
# back by replacing the ivar; the stream ivars are reassigned during the run,
|
|
22
|
+
# so capturing the original references is enough.
|
|
23
|
+
class Evilution::Integration::RSpec::StateGuard::ConfigurationState
|
|
24
|
+
PREFERRED_OPTIONS = :@preferred_options
|
|
25
|
+
STREAM_IVARS = %i[@output_stream @error_stream].freeze
|
|
26
|
+
ALL_IVARS = [PREFERRED_OPTIONS, *STREAM_IVARS].freeze
|
|
27
|
+
|
|
28
|
+
# configuration is injectable for isolated unit testing; production uses the
|
|
29
|
+
# shared RSpec.configuration singleton.
|
|
30
|
+
def initialize(configuration: nil)
|
|
31
|
+
@configuration = configuration
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def snapshot
|
|
35
|
+
config = configuration
|
|
36
|
+
state = {}
|
|
37
|
+
capture_preferred_options(config, state)
|
|
38
|
+
STREAM_IVARS.each do |ivar|
|
|
39
|
+
state[ivar] = config.instance_variable_get(ivar) if config.instance_variable_defined?(ivar)
|
|
40
|
+
end
|
|
41
|
+
state
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Restore exactly the pre-run state: ivars present at snapshot get their value
|
|
45
|
+
# back; ivars created by the run but absent before are removed, so nothing the
|
|
46
|
+
# run introduced can leak into the host either.
|
|
47
|
+
def release(captured)
|
|
48
|
+
return unless captured
|
|
49
|
+
|
|
50
|
+
config = configuration
|
|
51
|
+
ALL_IVARS.each do |ivar|
|
|
52
|
+
if captured.key?(ivar)
|
|
53
|
+
config.instance_variable_set(ivar, captured[ivar])
|
|
54
|
+
elsif config.instance_variable_defined?(ivar)
|
|
55
|
+
config.remove_instance_variable(ivar)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def configuration
|
|
63
|
+
@configuration || ::RSpec.configuration
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def capture_preferred_options(config, state)
|
|
67
|
+
return unless config.instance_variable_defined?(PREFERRED_OPTIONS)
|
|
68
|
+
|
|
69
|
+
value = config.instance_variable_get(PREFERRED_OPTIONS)
|
|
70
|
+
state[PREFERRED_OPTIONS] = value.is_a?(Hash) ? value.dup : value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rspec"
|
|
4
|
+
require_relative "internals"
|
|
5
|
+
|
|
6
|
+
# Restores the RSpec.configuration fields that ::RSpec::Core::Runner.run mutates
|
|
7
|
+
# on the shared singleton during an in-process mutation run (EV-dwqw / GH #1343):
|
|
8
|
+
#
|
|
9
|
+
# - @color_mode -- the run's args carry "--no-color", flipping it to :off,
|
|
10
|
+
# which makes every SUBSEQUENT host example render its
|
|
11
|
+
# progress dots without color (white instead of green).
|
|
12
|
+
# - @output_stream -- Runner#setup swaps it to the run's StringIO whenever the
|
|
13
|
+
# host's was $stdout.
|
|
14
|
+
# - @error_stream -- likewise pointed at the run's StringIO.
|
|
15
|
+
#
|
|
16
|
+
# Forked runs never leak these (the mutation happens in a child that dies), but
|
|
17
|
+
# the in-process isolation path mutates the host's own configuration. Restore by
|
|
18
|
+
# writing the ivars directly: configuration#output_stream= is guarded (it warns
|
|
19
|
+
# and no-ops once a reporter exists), so the public setter cannot put it back.
|
|
20
|
+
class Evilution::Integration::RSpec::StateGuard::ConfigurationStreams
|
|
21
|
+
IVARS = %i[@color_mode @output_stream @error_stream].freeze
|
|
22
|
+
|
|
23
|
+
def snapshot
|
|
24
|
+
config = ::RSpec.configuration
|
|
25
|
+
IVARS.each_with_object({}) do |ivar, acc|
|
|
26
|
+
acc[ivar] = config.instance_variable_get(ivar) if config.instance_variable_defined?(ivar)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Restore exactly the pre-run state: ivars present at snapshot get their value
|
|
31
|
+
# back; ivars that were absent before but created by the run are removed, so a
|
|
32
|
+
# newly-defined ivar can't leak into the host either.
|
|
33
|
+
def release(captured)
|
|
34
|
+
return unless captured
|
|
35
|
+
|
|
36
|
+
config = ::RSpec.configuration
|
|
37
|
+
IVARS.each do |ivar|
|
|
38
|
+
if captured.key?(ivar)
|
|
39
|
+
config.instance_variable_set(ivar, captured[ivar])
|
|
40
|
+
elsif config.instance_variable_defined?(ivar)
|
|
41
|
+
config.remove_instance_variable(ivar)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -7,6 +7,7 @@ require_relative "state_guard/world_sources_by_path"
|
|
|
7
7
|
require_relative "state_guard/world_filtered_examples"
|
|
8
8
|
require_relative "state_guard/reporter_arrays"
|
|
9
9
|
require_relative "state_guard/example_groups_constants"
|
|
10
|
+
require_relative "state_guard/configuration_state"
|
|
10
11
|
|
|
11
12
|
class Evilution::Integration::RSpec::StateGuard
|
|
12
13
|
DEFAULT_STRATEGIES = [
|
|
@@ -15,7 +16,8 @@ class Evilution::Integration::RSpec::StateGuard
|
|
|
15
16
|
WorldSourcesByPath.new,
|
|
16
17
|
WorldFilteredExamples.new,
|
|
17
18
|
ReporterArrays.new,
|
|
18
|
-
ExampleGroupsConstants.new
|
|
19
|
+
ExampleGroupsConstants.new,
|
|
20
|
+
ConfigurationState.new
|
|
19
21
|
].freeze
|
|
20
22
|
|
|
21
23
|
def initialize(strategies: DEFAULT_STRATEGIES)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "test_unit_crash_detector"
|
|
5
|
+
require_relative "loading/test_load_path"
|
|
5
6
|
require_relative "../spec_resolver"
|
|
6
7
|
require_relative "../spec_selector"
|
|
7
8
|
|
|
@@ -40,8 +41,10 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
40
41
|
require_relative "test_unit/subject_class_registry"
|
|
41
42
|
require_relative "test_unit/dispatcher"
|
|
42
43
|
FrameworkLoader.new.call
|
|
44
|
+
files = baseline_test_files(test_file)
|
|
45
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
43
46
|
new_classes = SubjectClassRegistry.newly_loaded do
|
|
44
|
-
|
|
47
|
+
files.each { |f| load(File.expand_path(f)) }
|
|
45
48
|
end
|
|
46
49
|
Dispatcher.call(new_classes, name: "evilution baseline").passed?
|
|
47
50
|
end
|
|
@@ -89,9 +92,7 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
def execute_test_unit(files, command)
|
|
92
|
-
new_classes =
|
|
93
|
-
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
94
|
-
end
|
|
95
|
+
new_classes = load_test_classes(files)
|
|
95
96
|
return ResultBuilder.no_tests_ran(command) if Dispatcher.test_method_count(new_classes).zero?
|
|
96
97
|
|
|
97
98
|
detector = reset_crash_detector
|
|
@@ -100,6 +101,13 @@ class Evilution::Integration::TestUnit < Evilution::Integration::Base
|
|
|
100
101
|
ResultBuilder.call(passed: result.passed?, command: command, detector: detector)
|
|
101
102
|
end
|
|
102
103
|
|
|
104
|
+
def load_test_classes(files)
|
|
105
|
+
Evilution::Integration::Loading::TestLoadPath.add!(files)
|
|
106
|
+
SubjectClassRegistry.newly_loaded do
|
|
107
|
+
files.each { |f| load(File.expand_path(f, Evilution.project_base_dir)) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
103
111
|
# Test::Unit has no public registry-clear analogous to
|
|
104
112
|
# Minitest::Runnable.runnables.clear. SubjectClassRegistry's newly_loaded
|
|
105
113
|
# block scopes each dispatch to classes loaded in *this* round, so stale
|
|
@@ -48,6 +48,7 @@ class Evilution::Isolation::Fork
|
|
|
48
48
|
|
|
49
49
|
def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
|
|
50
50
|
::Process.fork do
|
|
51
|
+
isolate_into_own_process_group
|
|
51
52
|
ENV["TMPDIR"] = sandbox_dir
|
|
52
53
|
# Path-relativizing mutations (e.g. File.join(dir, name) -> name) would
|
|
53
54
|
# otherwise write into the parent's CWD (typically the repo root) and
|
|
@@ -70,6 +71,21 @@ class Evilution::Isolation::Fork
|
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
73
|
|
|
74
|
+
# EV-2sh8 / GH #1330: make the mutation child its own process-group leader as
|
|
75
|
+
# its very first act, before it runs test_command (which may fork blocking
|
|
76
|
+
# grandchildren -- e.g. connection_pool / ractor / thread subject specs).
|
|
77
|
+
# Grandchildren then inherit this group, so terminate_child can group-kill the
|
|
78
|
+
# whole subtree on timeout. Without it, a blocking grandchild orphans to init
|
|
79
|
+
# and survives the rest of the run -- the inner path never SIGKILLs the worker,
|
|
80
|
+
# so EV-cnx8's outer process-group kill never sweeps it. Done child-side (not
|
|
81
|
+
# parent-side as in Worker) because the per-mutation timeout fires seconds
|
|
82
|
+
# later, long after this line has run, so no fork-before-setpgid race exists.
|
|
83
|
+
def isolate_into_own_process_group
|
|
84
|
+
::Process.setpgid(0, 0)
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
def cleanup_resources(read_io, write_io, pid, sandbox_dir)
|
|
74
90
|
read_io.close unless read_io.nil?
|
|
75
91
|
write_io.close unless write_io.nil?
|
|
@@ -216,7 +232,7 @@ class Evilution::Isolation::Fork
|
|
|
216
232
|
end
|
|
217
233
|
|
|
218
234
|
def terminate_child(pid)
|
|
219
|
-
|
|
235
|
+
signal_tree("TERM", pid)
|
|
220
236
|
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
221
237
|
return if status
|
|
222
238
|
|
|
@@ -224,10 +240,20 @@ class Evilution::Isolation::Fork
|
|
|
224
240
|
_, status = ::Process.waitpid2(pid, ::Process::WNOHANG)
|
|
225
241
|
return if status
|
|
226
242
|
|
|
227
|
-
|
|
243
|
+
signal_tree("KILL", pid)
|
|
228
244
|
Evilution::ProcessCleanup.safe_wait(pid)
|
|
229
245
|
end
|
|
230
246
|
|
|
247
|
+
# Signal the child's whole process group (-pid) to sweep any grandchildren it
|
|
248
|
+
# forked, then the bare pid as a fallback for the case where setpgid failed
|
|
249
|
+
# (no group exists, so the group signal is a harmless Errno::ESRCH). Only the
|
|
250
|
+
# leader pid is reaped here -- group-killed grandchildren are not our direct
|
|
251
|
+
# children, so init reaps them once they die.
|
|
252
|
+
def signal_tree(sig, pid)
|
|
253
|
+
Evilution::ProcessCleanup.safe_kill(sig, -pid)
|
|
254
|
+
Evilution::ProcessCleanup.safe_kill(sig, pid)
|
|
255
|
+
end
|
|
256
|
+
|
|
231
257
|
def classify_status(result)
|
|
232
258
|
return :timeout if result[:timeout]
|
|
233
259
|
return :killed if result[:test_crashed]
|
|
@@ -38,24 +38,47 @@ class Evilution::Parallel::WorkQueue::Dispatcher
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Each worker carries its own deadline (set when it goes busy, refreshed on
|
|
42
|
+
# every result). The select blocks only until the nearest worker deadline,
|
|
43
|
+
# so a single stuck worker is reaped in isolation -- its in-flight item gets
|
|
44
|
+
# the WorkQueue::TIMED_OUT sentinel and the worker is recycled -- instead of
|
|
45
|
+
# the old pool-wide watchdog that SIGKILLed every worker and aborted the run.
|
|
41
46
|
def collect
|
|
42
47
|
io_to_worker = @workers.to_h { |w| [w.res_io, w] }
|
|
43
48
|
result_ios = io_to_worker.keys
|
|
44
49
|
|
|
45
50
|
while @state.in_flight.positive?
|
|
46
|
-
readable, = IO.select(result_ios, nil, nil,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
break
|
|
50
|
-
end
|
|
51
|
+
readable, = IO.select(result_ios, nil, nil, select_timeout)
|
|
52
|
+
reap_timed_out(io_to_worker, result_ios)
|
|
53
|
+
next if readable.nil?
|
|
51
54
|
|
|
52
|
-
readable.each
|
|
55
|
+
readable.each do |io|
|
|
56
|
+
process_readable(io, io_to_worker, result_ios) if result_ios.include?(io)
|
|
57
|
+
end
|
|
53
58
|
end
|
|
54
59
|
end
|
|
55
60
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
def select_timeout
|
|
62
|
+
return @item_timeout unless @item_timeout
|
|
63
|
+
|
|
64
|
+
deadlines = @workers.filter_map(&:deadline)
|
|
65
|
+
return @item_timeout if deadlines.empty?
|
|
66
|
+
|
|
67
|
+
[deadlines.min - monotonic, 0].max
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reap_timed_out(io_to_worker, result_ios)
|
|
71
|
+
return unless @item_timeout
|
|
72
|
+
|
|
73
|
+
now = monotonic
|
|
74
|
+
stuck = @workers.select { |w| w.deadline && w.deadline <= now && w.pending.positive? }
|
|
75
|
+
stuck.each { |w| time_out_worker(w, io_to_worker, result_ios) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def time_out_worker(worker, io_to_worker, result_ios)
|
|
79
|
+
worker.kill
|
|
80
|
+
mark_unfinished(worker, Evilution::Parallel::WorkQueue::TIMED_OUT)
|
|
81
|
+
retire_or_replace(worker, io_to_worker, result_ios)
|
|
59
82
|
end
|
|
60
83
|
|
|
61
84
|
def process_readable(io, io_to_worker, result_ios)
|
|
@@ -65,7 +88,7 @@ class Evilution::Parallel::WorkQueue::Dispatcher
|
|
|
65
88
|
|
|
66
89
|
def handle(worker, io_to_worker, result_ios)
|
|
67
90
|
message = worker.read_result
|
|
68
|
-
return handle_dead(worker) if message.nil?
|
|
91
|
+
return handle_dead(worker, io_to_worker, result_ios) if message.nil?
|
|
69
92
|
|
|
70
93
|
record(message, worker)
|
|
71
94
|
return false if recycle_and_dispatch(worker, io_to_worker, result_ios)
|
|
@@ -82,13 +105,24 @@ class Evilution::Parallel::WorkQueue::Dispatcher
|
|
|
82
105
|
@state.in_flight -= 1
|
|
83
106
|
worker.pending -= 1
|
|
84
107
|
worker.items_completed += 1
|
|
108
|
+
worker.in_flight_indices.delete(index)
|
|
109
|
+
worker.deadline = next_deadline(worker)
|
|
85
110
|
end
|
|
86
111
|
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
# A worker that exited without replying loses only its in-flight item(s)
|
|
113
|
+
# (marked :died) and is recycled; the run continues rather than aborting.
|
|
114
|
+
def handle_dead(worker, io_to_worker, result_ios)
|
|
115
|
+
mark_unfinished(worker, Evilution::Parallel::WorkQueue::DIED)
|
|
116
|
+
retire_or_replace(worker, io_to_worker, result_ios)
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def mark_unfinished(worker, sentinel)
|
|
121
|
+
worker.in_flight_indices.each { |index| @state.results[index] = sentinel }
|
|
89
122
|
@state.in_flight -= worker.pending
|
|
90
123
|
worker.pending = 0
|
|
91
|
-
|
|
124
|
+
worker.in_flight_indices.clear
|
|
125
|
+
worker.deadline = nil
|
|
92
126
|
end
|
|
93
127
|
|
|
94
128
|
def draining_for_recycle?(worker)
|
|
@@ -113,28 +147,66 @@ class Evilution::Parallel::WorkQueue::Dispatcher
|
|
|
113
147
|
end
|
|
114
148
|
|
|
115
149
|
def recycle(old_worker, io_to_worker, result_ios)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
@retired << old_worker.retire
|
|
119
|
-
|
|
150
|
+
index = @workers.index(old_worker)
|
|
151
|
+
detach(old_worker, io_to_worker, result_ios)
|
|
120
152
|
new_worker = @recycle_factory.call(old_worker)
|
|
121
|
-
@workers[
|
|
122
|
-
|
|
123
|
-
result_ios << new_worker.res_io
|
|
153
|
+
@workers[index] = new_worker
|
|
154
|
+
attach(new_worker, io_to_worker, result_ios)
|
|
124
155
|
new_worker
|
|
125
156
|
end
|
|
126
157
|
|
|
158
|
+
# Shared failure-path recovery: retire the worker, and as long as work
|
|
159
|
+
# remains spin up a replacement to keep the pool full and hand it the next
|
|
160
|
+
# item. When the queue is already drained, just drop the worker.
|
|
161
|
+
def retire_or_replace(worker, io_to_worker, result_ios)
|
|
162
|
+
index = @workers.index(worker)
|
|
163
|
+
detach(worker, io_to_worker, result_ios)
|
|
164
|
+
|
|
165
|
+
if more_to_send? && @state.first_error.nil?
|
|
166
|
+
new_worker = @recycle_factory.call(worker)
|
|
167
|
+
@workers[index] = new_worker
|
|
168
|
+
attach(new_worker, io_to_worker, result_ios)
|
|
169
|
+
send_item(new_worker)
|
|
170
|
+
else
|
|
171
|
+
@workers.delete_at(index)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def detach(worker, io_to_worker, result_ios)
|
|
176
|
+
io_to_worker.delete(worker.res_io)
|
|
177
|
+
result_ios.delete(worker.res_io)
|
|
178
|
+
@retired << worker.retire
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def attach(worker, io_to_worker, result_ios)
|
|
182
|
+
io_to_worker[worker.res_io] = worker
|
|
183
|
+
result_ios << worker.res_io
|
|
184
|
+
end
|
|
185
|
+
|
|
127
186
|
def send_item(worker)
|
|
128
187
|
worker.send_item(@state.next_index, @items[@state.next_index])
|
|
129
188
|
@state.next_index += 1
|
|
130
189
|
@state.in_flight += 1
|
|
190
|
+
start_deadline(worker)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def start_deadline(worker)
|
|
194
|
+
return unless @item_timeout
|
|
195
|
+
|
|
196
|
+
worker.deadline ||= monotonic + @item_timeout
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def next_deadline(worker)
|
|
200
|
+
return nil unless @item_timeout && worker.pending.positive?
|
|
201
|
+
|
|
202
|
+
monotonic + @item_timeout
|
|
131
203
|
end
|
|
132
204
|
|
|
133
205
|
def more_to_send?
|
|
134
206
|
@state.next_index < @items.length
|
|
135
207
|
end
|
|
136
208
|
|
|
137
|
-
def
|
|
138
|
-
|
|
209
|
+
def monotonic
|
|
210
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
211
|
end
|
|
140
212
|
end
|
|
@@ -4,12 +4,13 @@ require_relative "../work_queue"
|
|
|
4
4
|
require_relative "../../child_output"
|
|
5
5
|
require_relative "channel"
|
|
6
6
|
require_relative "channel/frame"
|
|
7
|
+
require_relative "worker_registry"
|
|
7
8
|
|
|
8
9
|
class Evilution::Parallel::WorkQueue::Worker
|
|
9
10
|
Timing = Data.define(:busy, :wall)
|
|
10
11
|
|
|
11
|
-
attr_reader :pid, :worker_index
|
|
12
|
-
attr_accessor :items_completed, :pending, :busy_time, :wall_time
|
|
12
|
+
attr_reader :pid, :worker_index, :in_flight_indices
|
|
13
|
+
attr_accessor :items_completed, :pending, :busy_time, :wall_time, :deadline
|
|
13
14
|
|
|
14
15
|
def self.spawn(worker_index:, hooks:, &block)
|
|
15
16
|
cmd_read, cmd_write = IO.pipe
|
|
@@ -26,7 +27,36 @@ class Evilution::Parallel::WorkQueue::Worker
|
|
|
26
27
|
|
|
27
28
|
cmd_read.close
|
|
28
29
|
res_write.close
|
|
29
|
-
|
|
30
|
+
# Register BEFORE isolating so the trap can never observe a worker that is
|
|
31
|
+
# already its own group leader yet missing from the registry (EV-jwao race,
|
|
32
|
+
# GH #1333 review): the spawn runs on the same main thread the trap
|
|
33
|
+
# interrupts, so a signal arriving between setpgid and register would
|
|
34
|
+
# otherwise leak a leader the trap cannot reach. Ordering register first
|
|
35
|
+
# leaves only safe windows -- pre-setpgid the child still shares the parent
|
|
36
|
+
# group and receives the terminal signal directly; once it is its own
|
|
37
|
+
# leader the registry already lists it. Registering unconditionally is safe
|
|
38
|
+
# because signal_all's kill(-pid) is a no-op (Errno::ESRCH) for a pid that
|
|
39
|
+
# never became a group leader (setpgid failed).
|
|
40
|
+
Evilution::Parallel::WorkQueue::WorkerRegistry.register(pid)
|
|
41
|
+
isolate_process_group(pid)
|
|
42
|
+
new(pid:, cmd_write:, res_read:, worker_index:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# EV-cnx8 / GH #1324: make the worker its own process-group leader so #kill
|
|
46
|
+
# can signal the whole subtree. A mutation's spec may fork a grandchild that
|
|
47
|
+
# blocks (e.g. ConditionVariable#wait); when the dispatcher SIGKILLs a stuck
|
|
48
|
+
# worker, that grandchild must die with it rather than orphan to init holding
|
|
49
|
+
# memory/fds/connections. Done parent-side (before the child forks anything)
|
|
50
|
+
# so a failure is visible here instead of being swallowed in the child.
|
|
51
|
+
def self.isolate_process_group(pid)
|
|
52
|
+
Process.setpgid(pid, pid)
|
|
53
|
+
rescue Errno::EACCES, Errno::ESRCH
|
|
54
|
+
# EACCES: child already exec'd/changed group; ESRCH: child already exited.
|
|
55
|
+
# Both are benign -- reaping handles the child either way.
|
|
56
|
+
nil
|
|
57
|
+
rescue SystemCallError => e
|
|
58
|
+
warn "evilution: could not isolate worker #{pid} into its own process " \
|
|
59
|
+
"group (#{e.class}: #{e.message}); grandchildren may survive a kill."
|
|
30
60
|
end
|
|
31
61
|
|
|
32
62
|
# EV-kdns / GH #817: translate 0-based worker slot to parallel_tests'
|
|
@@ -46,6 +76,8 @@ class Evilution::Parallel::WorkQueue::Worker
|
|
|
46
76
|
@pending = 0
|
|
47
77
|
@busy_time = 0.0
|
|
48
78
|
@wall_time = 0.0
|
|
79
|
+
@in_flight_indices = []
|
|
80
|
+
@deadline = nil
|
|
49
81
|
end
|
|
50
82
|
|
|
51
83
|
def res_io
|
|
@@ -55,6 +87,7 @@ class Evilution::Parallel::WorkQueue::Worker
|
|
|
55
87
|
def send_item(index, item)
|
|
56
88
|
Evilution::Parallel::WorkQueue::Channel.write(@cmd_write, [index, item])
|
|
57
89
|
@pending += 1
|
|
90
|
+
@in_flight_indices << index
|
|
58
91
|
end
|
|
59
92
|
|
|
60
93
|
def read_result
|
|
@@ -67,7 +100,16 @@ class Evilution::Parallel::WorkQueue::Worker
|
|
|
67
100
|
nil
|
|
68
101
|
end
|
|
69
102
|
|
|
103
|
+
# SIGKILL the worker's whole process group (negative pid), reaping any
|
|
104
|
+
# grandchildren it forked. Falls back to the single pid if the group is gone
|
|
105
|
+
# -- already reaped, or setpgid did not take in the child.
|
|
70
106
|
def kill
|
|
107
|
+
Process.kill("KILL", -@pid)
|
|
108
|
+
rescue Errno::ESRCH
|
|
109
|
+
kill_pid
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def kill_pid
|
|
71
113
|
Process.kill("KILL", @pid)
|
|
72
114
|
rescue Errno::ESRCH
|
|
73
115
|
nil
|
|
@@ -82,6 +124,10 @@ class Evilution::Parallel::WorkQueue::Worker
|
|
|
82
124
|
Process.wait(@pid)
|
|
83
125
|
rescue Errno::ECHILD
|
|
84
126
|
nil
|
|
127
|
+
ensure
|
|
128
|
+
# Drop the pgid once the leader is reaped so the trap never signals a group
|
|
129
|
+
# whose pid the OS may have recycled. No-op if it was never registered.
|
|
130
|
+
Evilution::Parallel::WorkQueue::WorkerRegistry.unregister(@pid)
|
|
85
131
|
end
|
|
86
132
|
|
|
87
133
|
def retire
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../work_queue"
|
|
4
|
+
|
|
5
|
+
# Process-global registry of live worker process-group ids (pgids).
|
|
6
|
+
#
|
|
7
|
+
# EV-jwao / GH #1332: EV-cnx8 made each Worker its own process-group leader so a
|
|
8
|
+
# stuck worker's whole subtree can be group-killed. Side effect: a terminal
|
|
9
|
+
# Ctrl-C delivers SIGINT only to the parent's foreground group, so workers (now
|
|
10
|
+
# in their own groups) no longer receive it -- and the parent's fatal-signal
|
|
11
|
+
# death skips work_queue#map's `ensure cleanup_workers`, leaking any worker that
|
|
12
|
+
# was actively running a (possibly blocking) mutation at interrupt time.
|
|
13
|
+
#
|
|
14
|
+
# Runner#install_signal_handler reads this registry from inside the trap and
|
|
15
|
+
# forwards INT/TERM to each worker group before re-raising to DEFAULT.
|
|
16
|
+
#
|
|
17
|
+
# Signal-safety: under MRI a trap handler runs on the main thread between VM
|
|
18
|
+
# instructions, so it must not acquire a Mutex (the main thread may hold it ->
|
|
19
|
+
# deadlock). register/unregister therefore swap @pgids for a freshly built
|
|
20
|
+
# frozen array via a single atomic reference assignment (copy-on-write). The
|
|
21
|
+
# trap reads the current reference once and iterates that complete, immutable
|
|
22
|
+
# snapshot -- no torn reads, no lock.
|
|
23
|
+
module Evilution::Parallel::WorkQueue::WorkerRegistry
|
|
24
|
+
@pgids = [].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Frozen snapshot. Safe to read from a signal handler.
|
|
28
|
+
attr_reader :pgids
|
|
29
|
+
|
|
30
|
+
def register(pgid)
|
|
31
|
+
@pgids = (@pgids + [pgid]).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unregister(pgid)
|
|
35
|
+
@pgids = @pgids.reject { |existing| existing == pgid }.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def signal_all(sig)
|
|
39
|
+
@pgids.each do |pgid|
|
|
40
|
+
Process.kill(sig, -pgid)
|
|
41
|
+
rescue Errno::ESRCH
|
|
42
|
+
# Group already gone (worker + subtree reaped) -- nothing to signal.
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -9,6 +9,14 @@ class Evilution::Parallel::WorkQueue
|
|
|
9
9
|
|
|
10
10
|
TIMING_GRACE_PERIOD = 5
|
|
11
11
|
|
|
12
|
+
# Sentinel results for items whose worker never produced a value. The
|
|
13
|
+
# dispatcher writes these into the results array (instead of aborting the
|
|
14
|
+
# whole run) so a single stuck/dead worker only loses its own in-flight
|
|
15
|
+
# item(s). Mutation-aware callers translate the reason into a status.
|
|
16
|
+
Unfinished = Data.define(:reason)
|
|
17
|
+
TIMED_OUT = Unfinished.new(reason: :timeout)
|
|
18
|
+
DIED = Unfinished.new(reason: :died)
|
|
19
|
+
|
|
12
20
|
def initialize(size:, hooks: nil, prefetch: 1, item_timeout: nil, worker_max_items: nil)
|
|
13
21
|
Validators::PositiveInt.call!(:size, size)
|
|
14
22
|
Validators::PositiveInt.call!(:prefetch, prefetch)
|
|
@@ -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
|
|
@@ -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)
|
data/lib/evilution/runner.rb
CHANGED
|
@@ -178,6 +178,11 @@ 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
|
+
Evilution::Parallel::WorkQueue::WorkerRegistry.signal_all(sig)
|
|
181
186
|
|
|
182
187
|
case prev_handler
|
|
183
188
|
when Proc, Method
|
|
@@ -226,6 +231,7 @@ require_relative "result/summary"
|
|
|
226
231
|
require_relative "baseline"
|
|
227
232
|
require_relative "cache"
|
|
228
233
|
require_relative "parallel/pool"
|
|
234
|
+
require_relative "parallel/work_queue/worker_registry"
|
|
229
235
|
require_relative "session/store"
|
|
230
236
|
require_relative "temp_dir_tracker"
|
|
231
237
|
require_relative "rails_detector"
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
class Evilution::SpecResolver
|
|
4
4
|
STRIPPABLE_PREFIXES = %w[lib/ app/].freeze
|
|
5
5
|
CONTROLLER_PREFIX = "controllers/"
|
|
6
|
+
# Conventional test subdirectories appended to @test_dir. Real-world gems
|
|
7
|
+
# frequently park specs under spec/unit or spec/lib (test/unit, test/lib)
|
|
8
|
+
# rather than mirroring the lib/ tree 1:1 (EV-z7f5 / GH #1325).
|
|
9
|
+
CONVENTIONAL_SUBDIRS = %w[unit lib].freeze
|
|
10
|
+
MINITEST_SUFFIX = "_test.rb"
|
|
6
11
|
|
|
7
12
|
def initialize(test_dir: "spec", test_suffix: "_spec.rb", request_dir: "requests")
|
|
8
13
|
@test_dir = test_dir
|
|
@@ -23,8 +28,38 @@ class Evilution::SpecResolver
|
|
|
23
28
|
Array(source_paths).filter_map { |path| call(path) }.uniq
|
|
24
29
|
end
|
|
25
30
|
|
|
31
|
+
# Best-guess candidate for an unresolved source, found by basename glob
|
|
32
|
+
# rather than the deterministic path mirroring used by #call. Used only to
|
|
33
|
+
# enrich the "no matching test" hint (EV-z7f5 / GH #1325) — never to pick a
|
|
34
|
+
# test to run — so a fuzzy substring match is acceptable here. Returns the
|
|
35
|
+
# shallowest match, or nil when nothing resembles the basename.
|
|
36
|
+
def suggest(source_path)
|
|
37
|
+
return nil if source_path.nil? || source_path.empty?
|
|
38
|
+
|
|
39
|
+
stem = File.basename(normalize_path(source_path), ".rb")
|
|
40
|
+
return nil if stem.empty?
|
|
41
|
+
|
|
42
|
+
suggestion_globs(stem).flat_map { |glob| glob_relative(glob) }.uniq.min_by(&:length)
|
|
43
|
+
end
|
|
44
|
+
|
|
26
45
|
private
|
|
27
46
|
|
|
47
|
+
# Glob for project-relative paths. Mirrors #call's project_relative_exists?
|
|
48
|
+
# contract: when run inside an isolated worker chdir'd into a sandbox, glob
|
|
49
|
+
# against PROJECT_ROOT so suggestions still find real project files. base:
|
|
50
|
+
# already yields paths relative to the root, matching the CWD-glob shape.
|
|
51
|
+
def glob_relative(glob)
|
|
52
|
+
return Dir.glob(glob) unless Evilution.in_isolated_worker?
|
|
53
|
+
|
|
54
|
+
Dir.glob(glob, base: Evilution::PROJECT_ROOT)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def suggestion_globs(stem)
|
|
58
|
+
globs = ["#{@test_dir}/**/*#{stem}*#{@test_suffix}"]
|
|
59
|
+
globs << "#{@test_dir}/**/test_#{stem}.rb" if @test_suffix == MINITEST_SUFFIX
|
|
60
|
+
globs
|
|
61
|
+
end
|
|
62
|
+
|
|
28
63
|
# Existence check that succeeds against the current CWD. When the caller
|
|
29
64
|
# is an isolated worker that chdir'd into a per-mutation sandbox (Evilution
|
|
30
65
|
# signals this via in_isolated_worker?), also try PROJECT_ROOT so the
|
|
@@ -52,18 +87,55 @@ class Evilution::SpecResolver
|
|
|
52
87
|
def candidate_test_paths(source_path)
|
|
53
88
|
base = source_path.sub(/\.rb\z/, @test_suffix)
|
|
54
89
|
prefix = STRIPPABLE_PREFIXES.find { |p| source_path.start_with?(p) }
|
|
90
|
+
stripped = prefix ? base.delete_prefix(prefix) : base
|
|
91
|
+
|
|
92
|
+
primary = mirror_candidates(stripped)
|
|
93
|
+
primary.unshift(controller_to_request_test(stripped)) if prefix
|
|
94
|
+
primary.compact!
|
|
55
95
|
|
|
56
|
-
|
|
57
|
-
stripped = base.delete_prefix(prefix)
|
|
58
|
-
request_test = controller_to_request_test(stripped)
|
|
59
|
-
[request_test, "#{@test_dir}/#{stripped}", "#{@test_dir}/#{base}"].compact
|
|
60
|
-
else
|
|
61
|
-
["#{@test_dir}/#{base}"]
|
|
62
|
-
end
|
|
96
|
+
fallbacks = primary.flat_map { |c| parent_fallback_candidates(c) }
|
|
63
97
|
|
|
64
|
-
|
|
98
|
+
(primary + fallbacks + prefix_convention_candidates(stripped)).uniq
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Conventional roots that may hold tests: the mirrored root plus the common
|
|
102
|
+
# spec/unit, spec/lib (test/unit, test/lib) buckets.
|
|
103
|
+
def roots
|
|
104
|
+
[@test_dir, *CONVENTIONAL_SUBDIRS.map { |d| "#{@test_dir}/#{d}" }]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Cross every conventional root with every layout variant of the stripped
|
|
108
|
+
# source path: the full mirror, the mirror with the leading gem-namespace
|
|
109
|
+
# dir dropped, and the bare basename. Full mirrors rank above dropped ones
|
|
110
|
+
# so a 1:1 layout always wins when present.
|
|
111
|
+
def mirror_candidates(stripped)
|
|
112
|
+
mirror_variants(stripped).flat_map do |variant|
|
|
113
|
+
roots.map { |root| "#{root}/#{variant}" }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
65
116
|
|
|
66
|
-
|
|
117
|
+
def mirror_variants(stripped)
|
|
118
|
+
segments = stripped.split("/")
|
|
119
|
+
variants = [stripped]
|
|
120
|
+
variants << segments[1..].join("/") if segments.length > 1
|
|
121
|
+
variants << segments.last if segments.length > 2
|
|
122
|
+
variants.uniq
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Test::Unit / minitest gems frequently name files with a `test_` PREFIX
|
|
126
|
+
# (test/test_connection_pool.rb) instead of the mirrored `_test.rb` suffix.
|
|
127
|
+
# Only meaningful when resolving against the minitest suffix.
|
|
128
|
+
def prefix_convention_candidates(stripped)
|
|
129
|
+
return [] unless @test_suffix == MINITEST_SUFFIX
|
|
130
|
+
|
|
131
|
+
mirror_variants(stripped).flat_map do |variant|
|
|
132
|
+
dir, _, file = variant.rpartition("/")
|
|
133
|
+
name = file.delete_suffix(@test_suffix)
|
|
134
|
+
next [] if name.empty?
|
|
135
|
+
|
|
136
|
+
relative = dir.empty? ? "test_#{name}.rb" : "#{dir}/test_#{name}.rb"
|
|
137
|
+
roots.map { |root| "#{root}/#{relative}" }
|
|
138
|
+
end
|
|
67
139
|
end
|
|
68
140
|
|
|
69
141
|
def controller_to_request_test(stripped_path)
|
data/lib/evilution/version.rb
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core/rake_task"
|
|
4
|
+
|
|
5
|
+
# RUN_STRESS lifts the default :stress exclusion in spec_helper. Set via a
|
|
6
|
+
# prerequisite so it runs before the RSpec task, without polluting other tasks.
|
|
7
|
+
task :stress_env do
|
|
8
|
+
ENV["RUN_STRESS"] = "1"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "Run parallel/isolation stress + load specs (tagged :stress, slow)"
|
|
12
|
+
RSpec::Core::RakeTask.new(stress: :stress_env) do |t|
|
|
13
|
+
t.pattern = "spec/evilution/parallel/stress_spec.rb"
|
|
14
|
+
t.rspec_opts = "--tag stress"
|
|
15
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: evilution
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.33.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Denis Kiselev
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-06-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: diff-lcs
|
|
@@ -223,6 +223,7 @@ files:
|
|
|
223
223
|
- lib/evilution/integration/loading/redefinition_recovery.rb
|
|
224
224
|
- lib/evilution/integration/loading/source_evaluator.rb
|
|
225
225
|
- lib/evilution/integration/loading/syntax_validator.rb
|
|
226
|
+
- lib/evilution/integration/loading/test_load_path.rb
|
|
226
227
|
- lib/evilution/integration/minitest.rb
|
|
227
228
|
- lib/evilution/integration/minitest_crash_detector.rb
|
|
228
229
|
- lib/evilution/integration/rspec.rb
|
|
@@ -232,6 +233,8 @@ files:
|
|
|
232
233
|
- lib/evilution/integration/rspec/framework_loader.rb
|
|
233
234
|
- lib/evilution/integration/rspec/result_builder.rb
|
|
234
235
|
- lib/evilution/integration/rspec/state_guard.rb
|
|
236
|
+
- lib/evilution/integration/rspec/state_guard/configuration_state.rb
|
|
237
|
+
- lib/evilution/integration/rspec/state_guard/configuration_streams.rb
|
|
235
238
|
- lib/evilution/integration/rspec/state_guard/example_groups_constants.rb
|
|
236
239
|
- lib/evilution/integration/rspec/state_guard/internals.rb
|
|
237
240
|
- lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb
|
|
@@ -371,6 +374,7 @@ files:
|
|
|
371
374
|
- lib/evilution/parallel/work_queue/validators/positive_int.rb
|
|
372
375
|
- lib/evilution/parallel/work_queue/worker.rb
|
|
373
376
|
- lib/evilution/parallel/work_queue/worker/loop.rb
|
|
377
|
+
- lib/evilution/parallel/work_queue/worker_registry.rb
|
|
374
378
|
- lib/evilution/parallel/work_queue/worker_stat.rb
|
|
375
379
|
- lib/evilution/parallel_db_warning.rb
|
|
376
380
|
- lib/evilution/process_cleanup.rb
|
|
@@ -394,6 +398,7 @@ files:
|
|
|
394
398
|
- lib/evilution/reporter/cli/line_formatters/result_line.rb
|
|
395
399
|
- lib/evilution/reporter/cli/line_formatters/score.rb
|
|
396
400
|
- lib/evilution/reporter/cli/line_formatters/truncation_notice.rb
|
|
401
|
+
- lib/evilution/reporter/cli/line_formatters/unresolved_rate_warning.rb
|
|
397
402
|
- lib/evilution/reporter/cli/metrics_block.rb
|
|
398
403
|
- lib/evilution/reporter/cli/pct.rb
|
|
399
404
|
- lib/evilution/reporter/cli/section.rb
|
|
@@ -484,6 +489,7 @@ files:
|
|
|
484
489
|
- lib/evilution/temp_dir_tracker.rb
|
|
485
490
|
- lib/evilution/version.rb
|
|
486
491
|
- lib/tasks/memory_check.rake
|
|
492
|
+
- lib/tasks/stress.rake
|
|
487
493
|
- schema/evilution.config.schema.json
|
|
488
494
|
- script/build_runtime_snapshot
|
|
489
495
|
- script/memory_check
|