mutineer 0.9.1 → 0.11.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/CHANGELOG.md +35 -0
- data/README.md +67 -1
- data/lib/mutineer/cli.rb +100 -0
- data/lib/mutineer/config.rb +8 -2
- data/lib/mutineer/coverage_map.rb +27 -7
- data/lib/mutineer/daemon_client.rb +187 -0
- data/lib/mutineer/daemon_server.rb +261 -0
- data/lib/mutineer/external_backend.rb +168 -0
- data/lib/mutineer/file_swap.rb +95 -0
- data/lib/mutineer/rails_worker_db.rb +161 -0
- data/lib/mutineer/runner.rb +384 -45
- data/lib/mutineer/version.rb +1 -1
- metadata +6 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# #26/#27 Phase 2b (U5) — per-worker database isolation for the daemon path.
|
|
5
|
+
#
|
|
6
|
+
# Loaded APP-SIDE by {DaemonServer} (a sibling gem file, pulled in by absolute path
|
|
7
|
+
# so it bypasses the app bundle — the same trick {DaemonClient} uses to run
|
|
8
|
+
# `daemon_server.rb` under a bundle that has no mutineer). It uses the app's OWN
|
|
9
|
+
# already-booted ActiveRecord and NEVER `require "active_record"` (R10/KTD-8): every
|
|
10
|
+
# method that touches AR first confirms {available?}, so the daemon core stays
|
|
11
|
+
# framework-agnostic and the gem keeps its zero-runtime-dependency promise.
|
|
12
|
+
#
|
|
13
|
+
# Isolation model (KTD-7): each parallel worker gets its OWN database so concurrent
|
|
14
|
+
# forks can't clobber each other's transactional fixtures (the measured #26
|
|
15
|
+
# corruption). {after_fork} runs inside a freshly-forked child and points that
|
|
16
|
+
# child's connection at the worker's database BEFORE any test loads; transactional
|
|
17
|
+
# fixtures then repopulate that isolated database per test.
|
|
18
|
+
#
|
|
19
|
+
# Scope: this pass ships the **SQLite** adapter (per-worker file, hermetic,
|
|
20
|
+
# spike-proven). Postgres per-worker provisioning (`CREATE DATABASE <db>-<worker>` —
|
|
21
|
+
# the measured corruption case) extends {worker_db_config}/{provision} at the marked
|
|
22
|
+
# seam in U10; until then a non-SQLite config raises a clear NotImplementedError
|
|
23
|
+
# rather than silently mis-routing.
|
|
24
|
+
#
|
|
25
|
+
# Honest limit (KTD-5): routing failures surface as `error` via {verify_connection!}.
|
|
26
|
+
# Re-raising an AR error that fires *inside a test body* past Minitest (so an in-test
|
|
27
|
+
# DB failure is `error`, not `killed`) is only observable under concurrent load and
|
|
28
|
+
# is deferred to U6 with the parallel gate — noted, not silently skipped.
|
|
29
|
+
module RailsWorkerDb
|
|
30
|
+
# True when the app has ActiveRecord loaded — the only condition under which any
|
|
31
|
+
# other method here may touch AR. Never triggers an autoload/require of AR itself.
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def self.available?
|
|
35
|
+
defined?(ActiveRecord::Base) ? true : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Derive a per-worker database path from a base path by inserting `-<worker>`
|
|
39
|
+
# before the extension. Pure string transform (no AR) so it is unit-testable in
|
|
40
|
+
# the zero-dep suite. `storage/test.sqlite3`, worker 1 -> `storage/test-1.sqlite3`.
|
|
41
|
+
#
|
|
42
|
+
# @param database [String] the base database path.
|
|
43
|
+
# @param worker [Integer] the worker slot index (0..N-1).
|
|
44
|
+
# @return [String] the per-worker database path.
|
|
45
|
+
def self.worker_database_path(database, worker)
|
|
46
|
+
ext = File.extname(database)
|
|
47
|
+
"#{database.delete_suffix(ext)}-#{worker}#{ext}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Build the AR connection config for one worker by copying the app's current
|
|
51
|
+
# (default test) config and swapping in the per-worker database path. SQLite only
|
|
52
|
+
# this pass — a non-SQLite adapter raises so the SQLite-first scope fails loud
|
|
53
|
+
# instead of mis-routing (Postgres is U10).
|
|
54
|
+
#
|
|
55
|
+
# @param worker [Integer] the worker slot index.
|
|
56
|
+
# @return [Hash] a symbol-keyed AR configuration hash for the worker database.
|
|
57
|
+
# @raise [NotImplementedError] when the app's database is non-SQLite or in-memory.
|
|
58
|
+
def self.worker_db_config(worker)
|
|
59
|
+
hash = ActiveRecord::Base.connection_db_config.configuration_hash
|
|
60
|
+
adapter = hash[:adapter].to_s
|
|
61
|
+
# U10 seam: the config-SHAPING below (per_worker_config) is already
|
|
62
|
+
# adapter-general — it derives correct SQLite *and* Postgres worker-DB names.
|
|
63
|
+
# What's gated is runtime PROVISIONING: SQLite files are created on connect,
|
|
64
|
+
# but Postgres needs an explicit `CREATE DATABASE` per worker (U10). Until that
|
|
65
|
+
# lands, refuse non-SQLite loudly rather than route to a database that doesn't
|
|
66
|
+
# exist. To finish U10: implement provision() for PG and drop this guard.
|
|
67
|
+
unless adapter.start_with?("sqlite")
|
|
68
|
+
raise NotImplementedError,
|
|
69
|
+
"worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
|
|
70
|
+
"Postgres per-worker provisioning is U10 (#26/#35) — use a SQLite test DB, or drop --jobs."
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
per_worker_config(hash, worker)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Pure config-shaping (no AR): given a connection config hash, return the
|
|
77
|
+
# per-worker variant with its database swapped to the worker's own name.
|
|
78
|
+
# Adapter-general — SQLite (`storage/test.sqlite3` → `storage/test-<w>.sqlite3`)
|
|
79
|
+
# and Postgres (`myapp_test` → `myapp_test-<w>`, Rails `parallelize` naming) both
|
|
80
|
+
# fall out of {worker_database_path}. Extracted + unit-tested so the Postgres
|
|
81
|
+
# SHAPE is proven ready for U10 without a live database.
|
|
82
|
+
#
|
|
83
|
+
# @param config_hash [Hash] a connection config hash (symbol or string keys).
|
|
84
|
+
# @param worker [Integer] the worker slot index.
|
|
85
|
+
# @return [Hash] the per-worker config (symbol keys), database swapped.
|
|
86
|
+
# @raise [NotImplementedError] for an in-memory or empty database (no per-worker split).
|
|
87
|
+
def self.per_worker_config(config_hash, worker)
|
|
88
|
+
hash = config_hash.transform_keys(&:to_sym)
|
|
89
|
+
database = hash[:database].to_s
|
|
90
|
+
if database.empty? || database == ":memory:"
|
|
91
|
+
raise NotImplementedError,
|
|
92
|
+
"worker-DB isolation needs a file/name-backed database (got #{database.inspect})."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
hash.merge(database: worker_database_path(database, worker))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Child-side (after fork): route this process's ActiveRecord at the worker's own
|
|
99
|
+
# database and confirm it is reachable, so a routing failure reads as `error`
|
|
100
|
+
# (via the daemon's child rescue) rather than a false verdict. Loads the schema
|
|
101
|
+
# into the worker database when a schema path is given (idempotent — schema.rb
|
|
102
|
+
# runs with `force: true`), covering a fresh worker file.
|
|
103
|
+
#
|
|
104
|
+
# @param worker [Integer] the worker slot index.
|
|
105
|
+
# @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
|
|
106
|
+
# @return [void]
|
|
107
|
+
def self.after_fork(worker, schema_path = nil)
|
|
108
|
+
return unless available?
|
|
109
|
+
|
|
110
|
+
ActiveRecord::Base.establish_connection(worker_db_config(worker))
|
|
111
|
+
load_schema(schema_path) if schema_path
|
|
112
|
+
verify_connection!
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Explicit, parent-side provisioning: create + schema-load every worker database
|
|
116
|
+
# up front, then restore the app's default connection. This is the EXPLICIT
|
|
117
|
+
# provisioning entry point (V2 — never silent auto-create mid-audit) and the seam
|
|
118
|
+
# U10 extends for Postgres (`CREATE DATABASE` per worker). For SQLite the files are
|
|
119
|
+
# created on connect and the schema load makes them ready; idempotent to re-run.
|
|
120
|
+
#
|
|
121
|
+
# @param worker_count [Integer] number of worker databases to provision.
|
|
122
|
+
# @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
|
|
123
|
+
# @return [void]
|
|
124
|
+
def self.provision(worker_count, schema_path)
|
|
125
|
+
return unless available?
|
|
126
|
+
|
|
127
|
+
original = ActiveRecord::Base.connection_db_config
|
|
128
|
+
(0...worker_count).each do |worker|
|
|
129
|
+
ActiveRecord::Base.establish_connection(worker_db_config(worker))
|
|
130
|
+
load_schema(schema_path) if schema_path
|
|
131
|
+
end
|
|
132
|
+
ensure
|
|
133
|
+
ActiveRecord::Base.establish_connection(original) if original
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Load a Rails `schema.rb` into the current connection with its output silenced,
|
|
137
|
+
# so a parent-side {provision} call can never spill schema chatter onto the daemon
|
|
138
|
+
# IPC pipe. (In a fork the child's stdout is already `File::NULL`; this guards the
|
|
139
|
+
# parent path too.)
|
|
140
|
+
#
|
|
141
|
+
# @param schema_path [String] absolute path to `db/schema.rb`.
|
|
142
|
+
# @return [void]
|
|
143
|
+
def self.load_schema(schema_path)
|
|
144
|
+
ActiveRecord::Migration.verbose = false if defined?(ActiveRecord::Migration)
|
|
145
|
+
original = $stdout
|
|
146
|
+
$stdout = File.open(File::NULL, "w")
|
|
147
|
+
load schema_path
|
|
148
|
+
ensure
|
|
149
|
+
$stdout.close unless $stdout.equal?(original)
|
|
150
|
+
$stdout = original
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Force a round-trip to the freshly-routed connection so a broken route fails HERE
|
|
154
|
+
# (→ `error`) instead of later masquerading as a test failure (→ false `killed`).
|
|
155
|
+
#
|
|
156
|
+
# @return [void]
|
|
157
|
+
def self.verify_connection!
|
|
158
|
+
ActiveRecord::Base.connection.execute("SELECT 1")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -11,6 +11,9 @@ require_relative "changed_lines"
|
|
|
11
11
|
require_relative "mutator_registry"
|
|
12
12
|
require_relative "worker_pool"
|
|
13
13
|
require_relative "mutant_id"
|
|
14
|
+
require_relative "file_swap"
|
|
15
|
+
require_relative "external_backend"
|
|
16
|
+
require_relative "daemon_client"
|
|
14
17
|
require "set"
|
|
15
18
|
|
|
16
19
|
module Mutineer
|
|
@@ -38,6 +41,16 @@ module Mutineer
|
|
|
38
41
|
def self.execute(config)
|
|
39
42
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
40
43
|
|
|
44
|
+
# #27: the external backend runs the suite as a subprocess in the app's own
|
|
45
|
+
# runtime — it does no in-process boot/require or coverage build, so branch
|
|
46
|
+
# before any of that. The in-process path below is untouched.
|
|
47
|
+
return execute_external(config, operator_classes) if config.test_command
|
|
48
|
+
|
|
49
|
+
# #26/#27 Phase 2a: the daemon backend boots the app ONCE in a persistent
|
|
50
|
+
# subprocess under the app's bundle and forks per mutant. Tool-side we only
|
|
51
|
+
# discover jobs + build payloads (Prism), so branch before any in-process boot.
|
|
52
|
+
return execute_daemon(config, operator_classes) if config.daemon
|
|
53
|
+
|
|
41
54
|
# Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
|
|
42
55
|
# in the parent and inherited by every fork. Do NOT manually require the
|
|
43
56
|
# sources — under Zeitwerk a manual require of an autoloadable file raises;
|
|
@@ -77,7 +90,7 @@ module Mutineer
|
|
|
77
90
|
load_paths: config.load_paths, framework: config.framework,
|
|
78
91
|
boot_path: File.expand_path(config.boot, config.project_root),
|
|
79
92
|
verbose: config.verbose
|
|
80
|
-
).build_via_fork(
|
|
93
|
+
).build_via_fork(after_fork: (config.rails ? -> { reconnect_active_record } : nil))
|
|
81
94
|
else
|
|
82
95
|
coverage_map = CoverageMap.new(
|
|
83
96
|
source_paths: config.sources, test_paths: config.tests,
|
|
@@ -87,11 +100,49 @@ module Mutineer
|
|
|
87
100
|
end
|
|
88
101
|
|
|
89
102
|
# Collect every (subject, mutation) up front so the pool can fan them out.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
#
|
|
103
|
+
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
104
|
+
|
|
105
|
+
jobs = filter_since(jobs, source_map, config) if config.since
|
|
106
|
+
|
|
107
|
+
# C3: 7a writes mutineer_mutant*.rb into each source dir (so require_relative
|
|
108
|
+
# resolves). A SIGKILL'd child skips the tempfile's ensure-unlink, orphaning
|
|
109
|
+
# it. `ensure` is unreliable vs SIGKILL, so the PARENT sweeps each source dir
|
|
110
|
+
# before and after the run — orphans are impossible after a normal run.
|
|
111
|
+
dirs = source_dirs(config)
|
|
112
|
+
sweep_orphans(dirs)
|
|
113
|
+
|
|
114
|
+
strategy = config.strategy
|
|
115
|
+
results =
|
|
116
|
+
begin
|
|
117
|
+
framework = config.framework
|
|
118
|
+
# #21: --fail-fast stops scheduling new mutants after the first survivor;
|
|
119
|
+
# in-flight workers drain, unscheduled jobs stay nil (dropped below).
|
|
120
|
+
stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
|
|
121
|
+
bare = WorkerPool.new(config.jobs).run(jobs, stop_when: stop_when) do |subject, mutation|
|
|
122
|
+
run(mutation, source_file: subject.file, coverage_map: coverage_map,
|
|
123
|
+
subject: subject, strategy: strategy, rails: config.rails, framework: framework)
|
|
124
|
+
end
|
|
125
|
+
# The bare Results carry only status (Subjects hold live AST nodes that
|
|
126
|
+
# do not marshal); reattach subject+mutation+id in the parent, in order.
|
|
127
|
+
# filter_map drops nils for jobs --fail-fast left unscheduled.
|
|
128
|
+
bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
|
|
129
|
+
ensure
|
|
130
|
+
sweep_orphans(dirs)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
[AggregateResult.new(results + ignored_results), source_map]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Collect every (subject, mutation, id) up front so a backend can run them.
|
|
137
|
+
# #10: a mutant the user marked known-equivalent (inline disable-line comment
|
|
138
|
+
# or .mutineer.yml ignore id) is classified :ignored here and NEVER run — it is
|
|
139
|
+
# removed from the killed+survived denominator so a strong file reaches 100%.
|
|
140
|
+
# The stable id is computed per subject (occurrence needs the full list) and
|
|
141
|
+
# carried on every job so the parent can reattach it after the run. Shared by
|
|
142
|
+
# the in-process and external (#27) backends so job selection can never drift.
|
|
143
|
+
#
|
|
144
|
+
# @return [Array(Array, Array<Result>, Hash<String,String>)] jobs, ignored, source_map.
|
|
145
|
+
def self.collect_jobs(config, operator_classes)
|
|
95
146
|
source_map = {}
|
|
96
147
|
disabled_map = {}
|
|
97
148
|
ignore_set = config.ignore.to_set
|
|
@@ -112,37 +163,326 @@ module Mutineer
|
|
|
112
163
|
end
|
|
113
164
|
end
|
|
114
165
|
end
|
|
166
|
+
[jobs, ignored_results, source_map]
|
|
167
|
+
end
|
|
115
168
|
|
|
169
|
+
# #27: external backend orchestration. Runs each mutant's whole-file mutation on
|
|
170
|
+
# disk (crash-safe swap) and executes the user's --test-command as a subprocess
|
|
171
|
+
# in the app's own runtime. Serial by construction (KTD-5: one shared DB, no
|
|
172
|
+
# per-worker isolation yet). No coverage narrowing — every mutant runs the full
|
|
173
|
+
# --test set (KTD-6); the score is therefore an upper bound and not comparable
|
|
174
|
+
# to an in-process run (the CLI discloses this).
|
|
175
|
+
#
|
|
176
|
+
# @param config [Mutineer::Config] run configuration (test_command set).
|
|
177
|
+
# @param operator_classes [Array<Class>] resolved operators.
|
|
178
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
|
|
179
|
+
def self.execute_external(config, operator_classes)
|
|
180
|
+
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
181
|
+
dirs = source_dirs(config)
|
|
182
|
+
|
|
183
|
+
# Heal any file a prior hard-killed run left mutated BEFORE reading source —
|
|
184
|
+
# collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
|
|
185
|
+
# still-mutated file would yield garbage offsets against the later-healed
|
|
186
|
+
# source. Heal first, then discover jobs from the clean tree.
|
|
187
|
+
FileSwap.restore_orphans(dirs)
|
|
188
|
+
|
|
189
|
+
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
116
190
|
jobs = filter_since(jobs, source_map, config) if config.since
|
|
117
191
|
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
192
|
+
# Calibrate the per-mutant timeout from the clean run (a real suite far
|
|
193
|
+
# outlasts the 10s in-process fork budget), and abort if it isn't green.
|
|
194
|
+
# ponytail: 3x the clean run, floor 30s, ceiling 300s — a heuristic. The
|
|
195
|
+
# floor covers a fast suite; the ceiling bounds a hung mutant (infinite loop)
|
|
196
|
+
# so a handful can't stall a serial run for ~45min on a slow suite.
|
|
197
|
+
smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
|
|
198
|
+
timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
|
|
199
|
+
|
|
200
|
+
results = []
|
|
201
|
+
begin
|
|
202
|
+
jobs.each do |subject, mutation, id|
|
|
203
|
+
r = run_external(subject, mutation, config.test_command, abs_tests,
|
|
204
|
+
timeout: timeout, verbose: config.verbose)
|
|
205
|
+
results << r.with(subject: subject, mutation: mutation, id: id)
|
|
206
|
+
break if config.fail_fast && r.survived? # #21: stop at the first survivor
|
|
207
|
+
end
|
|
208
|
+
ensure
|
|
209
|
+
FileSwap.restore_orphans(dirs)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
[AggregateResult.new(results + ignored_results), source_map]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Runs one mutant through the external backend: apply the whole-file mutation on
|
|
216
|
+
# disk, run the command, restore. KTD-8: an invalid (non-reparsing) mutant would
|
|
217
|
+
# fail to load and score a false `killed`, so skip it tool-side (Prism, already
|
|
218
|
+
# cheap) and never write the file — preserving the `skipped` verdict the
|
|
219
|
+
# in-process path gives at runner.rb's pre-fork check.
|
|
220
|
+
#
|
|
221
|
+
# @return [Mutineer::Result] verdict for this mutant.
|
|
222
|
+
def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
|
|
223
|
+
source = File.read(subject.file)
|
|
224
|
+
mutated = mutation.apply(source)
|
|
225
|
+
return Result.skipped if Parser.parse_string(mutated).errors.any?
|
|
226
|
+
|
|
227
|
+
FileSwap.with(subject.file, mutated) do
|
|
228
|
+
ExternalBackend.run(command, abs_tests, timeout: timeout, verbose: verbose)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# #26/#27 Phase 2a — daemon backend orchestration (serial). Boots the app ONCE in
|
|
233
|
+
# a persistent subprocess and forks per mutant, restoring the one-boot speed the
|
|
234
|
+
# Phase 1 subprocess path gives up. Tool-side we build the ready-to-`load` payload
|
|
235
|
+
# (KTD-2/KTD-3: whole-file reload by default — the spike-proven path) and ship it;
|
|
236
|
+
# the daemon needs no Prism/mutineer. Serial in 2a (worker-DB isolation +
|
|
237
|
+
# parallelism is Phase 2b). No coverage narrowing yet (Phase 2c), so every mutant
|
|
238
|
+
# runs the full `--test` set.
|
|
239
|
+
#
|
|
240
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
|
|
241
|
+
def self.execute_daemon(config, operator_classes)
|
|
242
|
+
jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
|
|
243
|
+
jobs = filter_since(jobs, source_map, config) if config.since
|
|
244
|
+
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
245
|
+
|
|
246
|
+
# #26/U7: build the coverage map once (app-side, via a short-lived daemon) so
|
|
247
|
+
# each mutant runs ONLY its covering tests and a mutant on an uncovered line is
|
|
248
|
+
# no_coverage. nil when the build fails — the runners fall back to the full
|
|
249
|
+
# --test set (no narrowing) rather than mis-scoring everything as no_coverage.
|
|
250
|
+
coverage_map = daemon_coverage_map(config, abs_tests)
|
|
251
|
+
|
|
252
|
+
# #26/U6: worker count = resolved --jobs, capped at the job count (no idle
|
|
253
|
+
# daemons). >1 → N concurrent daemon handles, each on its OWN worker DB (V6:
|
|
254
|
+
# N-handles, the spike-proven shape). 1 → the serial single-daemon path.
|
|
255
|
+
# --fail-fast forces serial: parallel's stop flag fires on the first survivor
|
|
256
|
+
# by WALL-CLOCK, not input index, so the verdict set would diverge from serial
|
|
257
|
+
# (a different, non-deterministic survivor set/score) — the "identical to
|
|
258
|
+
# --jobs 1" guarantee below only holds when fail-fast can't race.
|
|
259
|
+
worker_count = [config.jobs || 1, 1].max
|
|
260
|
+
worker_count = 1 if config.fail_fast
|
|
261
|
+
worker_count = [worker_count, jobs.size].min if jobs.size.positive?
|
|
125
262
|
|
|
126
|
-
strategy = config.strategy
|
|
127
263
|
results =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
264
|
+
if worker_count > 1
|
|
265
|
+
run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
266
|
+
else
|
|
267
|
+
run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
[AggregateResult.new(results + ignored_results), source_map]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# #26/U7: build the coverage map via a short-lived daemon (boots the app once,
|
|
274
|
+
# captures per-test coverage app-side, ships the map back). Returns a query-only
|
|
275
|
+
# CoverageMap, or nil when the build fails / returns empty — callers then run the
|
|
276
|
+
# full --test set (no narrowing) rather than mis-scoring. The dedicated daemon
|
|
277
|
+
# costs one extra boot; correctness over that boot (ponytail — the map is reused
|
|
278
|
+
# for every mutant).
|
|
279
|
+
#
|
|
280
|
+
# @param config [Mutineer::Config] the run config.
|
|
281
|
+
# @param abs_tests [Array<String>] absolute --test paths.
|
|
282
|
+
# @return [Mutineer::CoverageMap, nil]
|
|
283
|
+
#
|
|
284
|
+
# ponytail: the coverage-build IPC read is unbounded (no wall-clock) — a --test
|
|
285
|
+
# file that infinite-loops during capture wedges this call, mirroring the existing
|
|
286
|
+
# in-process build_via_fork limitation. A capture timeout is a follow-up. When the
|
|
287
|
+
# map comes back empty (all captures failed), this returns nil and the run falls
|
|
288
|
+
# back to the FULL --test set per mutant — so on a total capture failure the daemon
|
|
289
|
+
# score is an upper bound (more testing), diverging from the in-process no_coverage
|
|
290
|
+
# scoring for that degenerate case only; a normal (nonempty) map scores identically.
|
|
291
|
+
def self.daemon_coverage_map(config, abs_tests)
|
|
292
|
+
client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests, coverage: true),
|
|
293
|
+
app_root: config.project_root).start
|
|
294
|
+
data = begin
|
|
295
|
+
client.coverage
|
|
296
|
+
ensure
|
|
297
|
+
client.quit
|
|
298
|
+
end
|
|
299
|
+
return nil unless data && !(data["map"] || {}).empty?
|
|
300
|
+
|
|
301
|
+
CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
|
|
302
|
+
project_root: config.project_root)
|
|
303
|
+
rescue DaemonBootError
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Serial daemon path: one daemon (worker 0), one mutant at a time. Honors
|
|
308
|
+
# --fail-fast (#21: stop at the first survivor).
|
|
309
|
+
#
|
|
310
|
+
# @return [Array<Mutineer::Result>] results in input order.
|
|
311
|
+
def self.run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
312
|
+
client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
|
|
313
|
+
app_root: config.project_root).start
|
|
314
|
+
results = []
|
|
315
|
+
begin
|
|
316
|
+
jobs.each_with_index do |job, i|
|
|
317
|
+
r = daemon_job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
|
|
318
|
+
results << r
|
|
319
|
+
break if config.fail_fast && r.survived?
|
|
320
|
+
end
|
|
321
|
+
ensure
|
|
322
|
+
client.quit
|
|
323
|
+
end
|
|
324
|
+
results
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Parallel daemon path (#26/U6): N daemon handles, each pinned to its own worker
|
|
328
|
+
# slot (→ its own DB, so concurrent workers can't clobber each other's fixtures).
|
|
329
|
+
# A shared queue of job indices feeds N tool-side threads; each thread blocks on
|
|
330
|
+
# IPC (GVL released), so the N daemons run genuinely concurrently. Results are
|
|
331
|
+
# placed by input index and compacted, so the verdict SET is identical to serial
|
|
332
|
+
# regardless of finish order (R7). `execute_daemon` never calls this with
|
|
333
|
+
# `config.fail_fast` set (forced to the serial path instead) — a wall-clock stop
|
|
334
|
+
# here, unlike serial's input-index break, would make the survivor set and score
|
|
335
|
+
# non-deterministic. The stop-flag machinery below stays as a defensive no-op for
|
|
336
|
+
# any direct caller that bypasses that guard.
|
|
337
|
+
#
|
|
338
|
+
# @return [Array<Mutineer::Result>] completed results in input order.
|
|
339
|
+
def self.run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
|
|
340
|
+
results = Array.new(jobs.size)
|
|
341
|
+
queue = Queue.new
|
|
342
|
+
jobs.each_index { |i| queue << i }
|
|
343
|
+
stop = false
|
|
344
|
+
stop_mutex = Mutex.new
|
|
345
|
+
|
|
346
|
+
clients = Array.new(worker_count) do
|
|
347
|
+
DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
|
|
348
|
+
app_root: config.project_root).start
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
clients.each_with_index.map do |client, worker|
|
|
352
|
+
Thread.new do
|
|
353
|
+
until stop_mutex.synchronize { stop }
|
|
354
|
+
i = begin
|
|
355
|
+
queue.pop(true) # non-blocking; ThreadError when drained
|
|
356
|
+
rescue ThreadError
|
|
357
|
+
break
|
|
358
|
+
end
|
|
359
|
+
r = daemon_job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
|
|
360
|
+
results[i] = r
|
|
361
|
+
stop_mutex.synchronize { stop = true } if config.fail_fast && r.survived?
|
|
136
362
|
end
|
|
137
|
-
# The bare Results carry only status (Subjects hold live AST nodes that
|
|
138
|
-
# do not marshal); reattach subject+mutation+id in the parent, in order.
|
|
139
|
-
# filter_map drops nils for jobs --fail-fast left unscheduled.
|
|
140
|
-
bare.each_with_index.filter_map { |r, i| r&.with(subject: jobs[i][0], mutation: jobs[i][1], id: jobs[i][2]) }
|
|
141
363
|
ensure
|
|
142
|
-
|
|
364
|
+
client.quit
|
|
143
365
|
end
|
|
366
|
+
end.each(&:join)
|
|
144
367
|
|
|
145
|
-
|
|
368
|
+
results.compact
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Build the payload for one job, run it on the given daemon/worker, and attach
|
|
372
|
+
# the subject/mutation/id — the shared body of both daemon paths.
|
|
373
|
+
#
|
|
374
|
+
# @param job [Array(Mutineer::Subject, Mutineer::Mutation, String)] the work item.
|
|
375
|
+
# @param req_id [Integer] request id (echoed back for IPC ordering safety).
|
|
376
|
+
# @param client [Mutineer::DaemonClient] the daemon handle to run on.
|
|
377
|
+
# @param worker [Integer] the worker slot (→ worker DB) this daemon routes to.
|
|
378
|
+
# @return [Mutineer::Result] the decorated result.
|
|
379
|
+
def self.daemon_job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
|
|
380
|
+
subject, mutation, id = job
|
|
381
|
+
source = source_map[subject.file]
|
|
382
|
+
mutated = mutation.apply(source)
|
|
383
|
+
# KTD-8 (carried): skip an invalid mutant tool-side — never ship a payload that
|
|
384
|
+
# would fail to load and read as a false `killed`.
|
|
385
|
+
# #26/U7: narrow to covering tests (shared with the in-process path via
|
|
386
|
+
# coverage_selection, so scores match). :verdict = no_coverage/uncapturable, no
|
|
387
|
+
# fork. No map (build failed) → run the full --test set (fallback, not narrowed).
|
|
388
|
+
sel = coverage_map && coverage_selection(subject.file, mutation, subject, source, coverage_map)
|
|
389
|
+
r =
|
|
390
|
+
if Parser.parse_string(mutated).errors.any?
|
|
391
|
+
Result.skipped
|
|
392
|
+
elsif sel && sel[0] == :verdict
|
|
393
|
+
sel[1]
|
|
394
|
+
else
|
|
395
|
+
verdict = client.request(
|
|
396
|
+
id: req_id, worker: worker, timeout: config.daemon_timeout || DAEMON_TIMEOUT,
|
|
397
|
+
payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
|
|
398
|
+
tests: sel ? sel[1] : abs_tests
|
|
399
|
+
)
|
|
400
|
+
daemon_result(verdict)
|
|
401
|
+
end
|
|
402
|
+
r.with(subject: subject, mutation: mutation, id: id)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Coverage-based test selection, shared by the in-process ({run}) and daemon
|
|
406
|
+
# paths so both narrow identically (score parity, U7/V5). Returns
|
|
407
|
+
# `[:run, abs_test_paths]` when some test covers the mutant's line, or
|
|
408
|
+
# `[:verdict, Result]` (no_coverage / uncapturable) when none do.
|
|
409
|
+
#
|
|
410
|
+
# #9/#25: an empty selection is `:uncapturable` (not `:no_coverage`) when the
|
|
411
|
+
# mutant's enclosing method body got coverage from no *successful* capture but a
|
|
412
|
+
# sibling test failed to capture — the coverage was lost, not absent. Both are
|
|
413
|
+
# excluded from the score denominator, so this distinction is reporting-only and
|
|
414
|
+
# never changes the daemon-vs-in-process score.
|
|
415
|
+
#
|
|
416
|
+
# @param source_file [String] the mutated source file path.
|
|
417
|
+
# @param mutation [Mutineer::Mutation] the mutation (for its line offset).
|
|
418
|
+
# @param subject [Mutineer::Subject, nil] the subject (for its method body range).
|
|
419
|
+
# @param source [String] the original source text.
|
|
420
|
+
# @param coverage_map [Mutineer::CoverageMap] the built/loaded coverage map.
|
|
421
|
+
# @return [Array(Symbol, Object)] `[:run, Array<String>]` or `[:verdict, Result]`.
|
|
422
|
+
def self.coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
423
|
+
line = source.byteslice(0, mutation.start_offset).count("\n") + 1
|
|
424
|
+
chosen = coverage_map.tests_for(source_file, line)
|
|
425
|
+
if chosen.empty?
|
|
426
|
+
# Method BODY range, not the whole def: the def/end lines are "covered" at
|
|
427
|
+
# class-load even when the body never runs (body_loc is the statements' span).
|
|
428
|
+
loc = subject&.body_loc
|
|
429
|
+
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
430
|
+
return [:verdict, coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
[:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Default per-mutant timeout on the daemon path. Generous because 2a runs the full
|
|
437
|
+
# `--test` set per mutant (no coverage narrowing until Phase 2c).
|
|
438
|
+
DAEMON_TIMEOUT = 60
|
|
439
|
+
|
|
440
|
+
# The boot config the daemon needs to boot the app once: where to boot, the test
|
|
441
|
+
# load roots (so `require "test_helper"` resolves in every fork), framework, and
|
|
442
|
+
# whether this is Rails.
|
|
443
|
+
def self.daemon_boot_config(config, abs_tests, coverage: false)
|
|
444
|
+
{
|
|
445
|
+
project_root: config.project_root,
|
|
446
|
+
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
447
|
+
load_paths: test_load_roots(abs_tests),
|
|
448
|
+
source_dirs: source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
449
|
+
framework: config.framework,
|
|
450
|
+
rails: config.rails,
|
|
451
|
+
# #26/U5: schema for per-worker DB isolation. Sent when present; the daemon
|
|
452
|
+
# skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
|
|
453
|
+
schema: daemon_schema_path(config),
|
|
454
|
+
# #26/U7: coverage narrowing. Only the short-lived map-building daemon starts
|
|
455
|
+
# Coverage (before boot); worker daemons boot with it OFF (no wasted
|
|
456
|
+
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
457
|
+
# map-build inputs.
|
|
458
|
+
coverage: coverage,
|
|
459
|
+
sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
|
|
460
|
+
tests: abs_tests
|
|
461
|
+
}
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
|
|
465
|
+
# daemon to schema-load each fork's isolated worker database (#26/U5). Only
|
|
466
|
+
# `schema.rb` is supported this pass; `structure.sql` apps get nil and fall back to
|
|
467
|
+
# whatever the worker DB already holds (Postgres provisioning is U10).
|
|
468
|
+
#
|
|
469
|
+
# @param config [Mutineer::Config] the run config.
|
|
470
|
+
# @return [String, nil] absolute schema path or nil.
|
|
471
|
+
def self.daemon_schema_path(config)
|
|
472
|
+
path = File.expand_path("db/schema.rb", config.project_root)
|
|
473
|
+
File.exist?(path) ? path : nil
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Map a daemon verdict string to a Result. The daemon reports the four run-time
|
|
477
|
+
# states it can decide (KTD-5); pre-fork states (skipped/no_coverage/…) are
|
|
478
|
+
# resolved tool-side before a request is ever sent.
|
|
479
|
+
def self.daemon_result(verdict)
|
|
480
|
+
case verdict
|
|
481
|
+
when "survived" then Result.survived
|
|
482
|
+
when "killed" then Result.killed
|
|
483
|
+
when "timeout" then Result.timeout
|
|
484
|
+
else Result.error("daemon verdict: #{verdict}")
|
|
485
|
+
end
|
|
146
486
|
end
|
|
147
487
|
|
|
148
488
|
# Scan a source once into { line_number => :all | Set[operator_syms] } from
|
|
@@ -222,6 +562,17 @@ module Mutineer
|
|
|
222
562
|
warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
|
|
223
563
|
end
|
|
224
564
|
|
|
565
|
+
# The unique absolute directories holding the sources — the sweep target for
|
|
566
|
+
# both orphan mechanisms (in-process mutant tempfiles and external backup
|
|
567
|
+
# files). Shared so the path-expansion rule can't drift between the two paths.
|
|
568
|
+
#
|
|
569
|
+
# @api private
|
|
570
|
+
# @param config [Mutineer::Config] run configuration.
|
|
571
|
+
# @return [Array<String>] unique absolute source directories.
|
|
572
|
+
def self.source_dirs(config)
|
|
573
|
+
config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
|
|
574
|
+
end
|
|
575
|
+
|
|
225
576
|
# Removes stale mutant tempfiles from the given directories.
|
|
226
577
|
#
|
|
227
578
|
# @api private
|
|
@@ -256,24 +607,12 @@ module Mutineer
|
|
|
256
607
|
|
|
257
608
|
# Coverage selection (both standalone and boot mode): a mutation on a line
|
|
258
609
|
# no test exercises is :no_coverage (no fork); otherwise exactly the
|
|
259
|
-
# covering test files run in the child.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
# errored during capture (coverage lost) — the latter is :uncapturable.
|
|
264
|
-
# #25: taint per-METHOD (the mutant's enclosing def range), not whole-file,
|
|
265
|
-
# so a covered method's uncovered line stays :no_coverage while a method
|
|
266
|
-
# reachable only by a failed capture is :uncapturable.
|
|
267
|
-
if chosen.empty?
|
|
268
|
-
# Use the method BODY range, not the whole def: the `def`/`end` lines are
|
|
269
|
-
# "covered" at class-load even when the body never runs, which would mask
|
|
270
|
-
# an uncovered method. body_loc is the body statements' span.
|
|
271
|
-
loc = subject&.body_loc
|
|
272
|
-
range = loc ? (loc.start_line..loc.end_line) : (line..line)
|
|
273
|
-
return coverage_map.method_uncapturable?(source_file, range) ? Result.uncapturable : Result.no_coverage
|
|
274
|
-
end
|
|
610
|
+
# covering test files run in the child. Shared with the daemon path so both
|
|
611
|
+
# narrow identically (score parity, U7/V5).
|
|
612
|
+
kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
|
|
613
|
+
return payload if kind == :verdict
|
|
275
614
|
|
|
276
|
-
abs_tests =
|
|
615
|
+
abs_tests = payload
|
|
277
616
|
|
|
278
617
|
Isolation.run(timeout: timeout) do
|
|
279
618
|
# Forking inherits the parent's live DB connection; sharing one socket
|
data/lib/mutineer/version.rb
CHANGED