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.
@@ -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
@@ -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(rails: config.rails)
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
- # #10: a mutant the user marked known-equivalent (inline disable-line comment
91
- # or .mutineer.yml ignore id) is classified :ignored here and NEVER forked —
92
- # it is removed from the killed+survived denominator so a strong file reaches
93
- # 100%. The stable id is computed per subject (occurrence needs the full list)
94
- # and carried on every job so the parent can reattach it after the run.
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
- # C3: 7a writes mutineer_mutant*.rb into each source dir (so require_relative
119
- # resolves). A SIGKILL'd child skips the tempfile's ensure-unlink, orphaning
120
- # it. `ensure` is unreliable vs SIGKILL, so the PARENT sweeps each source dir
121
- # before and after the run orphans are impossible after a normal run.
122
- source_dirs = config.sources
123
- .map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
124
- sweep_orphans(source_dirs)
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
- begin
129
- framework = config.framework
130
- # #21: --fail-fast stops scheduling new mutants after the first survivor;
131
- # in-flight workers drain, unscheduled jobs stay nil (dropped below).
132
- stop_when = config.fail_fast ? ->(r) { r.survived? } : nil
133
- bare = WorkerPool.new(config.jobs).run(jobs, stop_when: stop_when) do |subject, mutation|
134
- run(mutation, source_file: subject.file, coverage_map: coverage_map,
135
- subject: subject, strategy: strategy, rails: config.rails, framework: framework)
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
- sweep_orphans(source_dirs)
364
+ client.quit
143
365
  end
366
+ end.each(&:join)
144
367
 
145
- [AggregateResult.new(results + ignored_results), source_map]
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
- line = source.byteslice(0, mutation.start_offset).count("\n") + 1
261
- chosen = coverage_map.tests_for(source_file, line)
262
- # #9/#25: distinguish a genuine coverage gap from a line whose would-be test
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 = chosen.map { |t| File.expand_path(t, coverage_map.project_root) }
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.9.1"
5
+ VERSION = "0.11.0"
6
6
  end