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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc948250a218198fef7420e04ab49e5a2f8e01ff50ae279f5299bb85db07ab77
4
- data.tar.gz: df6e3a2ea4ee5512f9c946f4df514f8a1a2df8308ac77236ceafa3de81b435d8
3
+ metadata.gz: 07051270c176d0d26e28c2978c6bfa2eb0d76dd2e6ba453387f9731f04a0d79b
4
+ data.tar.gz: 1562d91a064d05af5b6264c89eacf93077630bc09b4a149010d6e4e5514409ed
5
5
  SHA512:
6
- metadata.gz: 43c08e6baa4904c1122d639c957cc1139c9e4760e3e38aed8664a70ff46388aab91b64924816739d4c7a9f00abb049f2da579c50f3b5a585c49b2175f3461dff
7
- data.tar.gz: e30f5845c332781de9431c9a4238df1ac9048960d3ef36323dac0a8beaf2f5c5d56eb1d3132d618a7e076bb7547520286fc127347499b0b95640331e16912fb8
6
+ metadata.gz: 9957755214557a006e461cadd076d57b5c1d55536e9cec5105df1b1b37580150a6ff5fc8a33c816c0356a8e890eab1ac8e5fcfccac1acb6a46e18c76ba8ec909
7
+ data.tar.gz: 8a2a4000adbaf10f178cbed4cb117644d25bd27ec2730fb02024fa68b3cf8424181b0d5e45bbfbe53b3c9a9a37266e306de7592f16443d0d8545ad46f878f4af
@@ -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
@@ -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).each { |f| load(File.expand_path(f)) }
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
- baseline_test_files(test_file).each { |f| load(File.expand_path(f)) }
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 = SubjectClassRegistry.newly_loaded do
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
- Evilution::ProcessCleanup.safe_kill("TERM", pid)
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
- Evilution::ProcessCleanup.safe_kill("KILL", pid)
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, @item_timeout)
47
- if readable.nil?
48
- record_timeout
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 { |io| process_readable(io, io_to_worker, result_ios) }
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 record_timeout
57
- terminate_stuck
58
- @state.first_error ||= Evilution::Error.new("worker timed out after #{@item_timeout}s")
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
- def handle_dead(worker)
88
- @state.first_error ||= Evilution::Error.new("worker process exited unexpectedly")
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
- false
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
- io_to_worker.delete(old_worker.res_io)
117
- result_ios.delete(old_worker.res_io)
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[@workers.index(old_worker)] = new_worker
122
- io_to_worker[new_worker.res_io] = new_worker
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 terminate_stuck
138
- @workers.each(&:kill)
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
- new(pid: pid, cmd_write: cmd_write, res_read: res_read, worker_index: worker_index)
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)
@@ -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
- candidates = if prefix
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
- fallbacks = candidates.flat_map { |c| parent_fallback_candidates(c) }.uniq
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
- candidates + fallbacks
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.32.0"
4
+ VERSION = "0.33.0"
5
5
  end
@@ -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.32.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-05-31 00:00:00.000000000 Z
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