mutineer 0.11.2 → 0.11.4

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.
@@ -13,21 +13,21 @@ require_relative "worker_pool"
13
13
  require_relative "mutant_id"
14
14
  require_relative "file_swap"
15
15
  require_relative "external_backend"
16
- require_relative "daemon_client"
16
+ require_relative "daemon_backend"
17
17
  require "set"
18
18
 
19
19
  module Mutineer
20
20
  # Orchestrates one mutation end-to-end: apply it textually, validate the
21
21
  # result, select its covering test files from the coverage map, then run only
22
- # those against the mutated source in an isolated child process (strategy 7a —
22
+ # those against the mutated source in an isolated child process (strategy
23
23
  # whole-file reload via `load`).
24
24
  #
25
25
  # The source file path is passed explicitly because Mutation carries only byte
26
- # offsets, not its file. M3 replaces M2's hardcoded `test_file:` with coverage-
27
- # map selection: a mutation whose line no test exercises is :no_coverage (no
28
- # fork); otherwise exactly the covering test files run in the child.
26
+ # offsets, not its file. Coverage-map selection replaces a hardcoded test file:
27
+ # a mutation whose line no test exercises is :no_coverage (no fork); otherwise
28
+ # exactly the covering test files run in the child.
29
29
  class Runner
30
- # Full Phase B orchestration: resolve operators, discover subjects, build the
30
+ # Full orchestration: resolve operators, discover subjects, build the
31
31
  # coverage map, run every mutation, and aggregate. Returns
32
32
  # [AggregateResult, source_map]. The CLI then reports + applies the exit code;
33
33
  # the integration test asserts directly on the AggregateResult.
@@ -41,25 +41,25 @@ module Mutineer
41
41
  def self.execute(config)
42
42
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
43
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.
44
+ # External backend: run the suite as a subprocess in the app's own runtime.
45
+ # It does no in-process boot/require or coverage build, so branch before any
46
+ # of that. The in-process path below is untouched.
47
47
  return execute_external(config, operator_classes) if config.test_command
48
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
49
+ # Daemon backend: boot the app ONCE in a persistent subprocess under the
50
+ # app's bundle and fork per mutant. Tool-side we only discover jobs + build
51
+ # payloads (Prism), so branch before any in-process boot.
52
+ return DaemonBackend.execute(config, operator_classes) if config.daemon
53
53
 
54
54
  # Boot mode: require the boot file ONCE so the app env (e.g. Rails) is booted
55
55
  # in the parent and inherited by every fork. Do NOT manually require the
56
- # sources — under Zeitwerk a manual require of an autoloadable file raises;
56
+ # sources. Under Zeitwerk a manual require of an autoloadable file raises;
57
57
  # the booted env autoloads them, and subject discovery is a static Prism
58
58
  # parse that needs nothing loaded. Standalone mode requires the sources as
59
59
  # before so their classes exist for the children to inherit.
60
60
  if config.boot
61
- # #7: under --rails an unset RAILS_ENV boots development, where the test
62
- # suite isn't loaded — coverage comes back empty and EVERY mutant is
61
+ # Under --rails an unset RAILS_ENV boots development, where the test
62
+ # suite is not loaded. Coverage comes back empty and EVERY mutant is
63
63
  # falsely reported no_coverage (score N/A, exit 0). Default it to test.
64
64
  ensure_rails_env(config)
65
65
 
@@ -104,10 +104,11 @@ module Mutineer
104
104
 
105
105
  jobs = filter_since(jobs, source_map, config) if config.since
106
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.
107
+ # Whole-file reload writes mutineer_mutant*.rb into each source dir (so
108
+ # require_relative resolves). A SIGKILL'd child skips the tempfile's
109
+ # ensure-unlink, orphaning it. `ensure` is unreliable vs SIGKILL, so the
110
+ # PARENT sweeps each source dir before and after the run. Orphans are
111
+ # impossible after a normal run.
111
112
  dirs = source_dirs(config)
112
113
  sweep_orphans(dirs)
113
114
 
@@ -136,12 +137,12 @@ module Mutineer
136
137
  end
137
138
 
138
139
  # Collect every (subject, mutation, id) up front so a backend can run them.
139
- # #10: a mutant the user marked known-equivalent (inline disable-line comment
140
- # or .mutineer.yml ignore id) is classified :ignored here and NEVER run — it is
140
+ # A mutant the user marked known-equivalent (inline disable-line comment or
141
+ # .mutineer.yml ignore id) is classified :ignored here and NEVER run. It is
141
142
  # removed from the killed+survived denominator so a strong file reaches 100%.
142
143
  # The stable id is computed per subject (occurrence needs the full list) and
143
144
  # carried on every job so the parent can reattach it after the run. Shared by
144
- # the in-process and external (#27) backends so job selection can never drift.
145
+ # the in-process, external, and daemon backends so job selection can never drift.
145
146
  #
146
147
  # @return [Array(Array, Array<Result>, Hash<String,String>)] jobs, ignored, source_map.
147
148
  def self.collect_jobs(config, operator_classes)
@@ -168,12 +169,12 @@ module Mutineer
168
169
  [jobs, ignored_results, source_map]
169
170
  end
170
171
 
171
- # #27: external backend orchestration. Runs each mutant's whole-file mutation on
172
+ # External backend orchestration. Runs each mutant's whole-file mutation on
172
173
  # disk (crash-safe swap) and executes the user's --test-command as a subprocess
173
- # in the app's own runtime. Serial by construction (KTD-5: one shared DB, no
174
- # per-worker isolation yet). No coverage narrowing — every mutant runs the full
175
- # --test set (KTD-6); the score is therefore an upper bound and not comparable
176
- # to an in-process run (the CLI discloses this).
174
+ # in the app's own runtime. Serial by construction (one shared DB, no
175
+ # per-worker isolation yet). No coverage narrowing: every mutant runs the full
176
+ # --test set; the score is therefore an upper bound and not comparable to an
177
+ # in-process run (the CLI discloses this).
177
178
  #
178
179
  # @param config [Mutineer::Config] run configuration (test_command set).
179
180
  # @param operator_classes [Array<Class>] resolved operators.
@@ -182,7 +183,7 @@ module Mutineer
182
183
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
183
184
  dirs = source_dirs(config)
184
185
 
185
- # Heal any file a prior hard-killed run left mutated BEFORE reading source —
186
+ # Heal any file a prior hard-killed run left mutated BEFORE reading source.
186
187
  # collect_jobs computes mutation offsets/ids from the on-disk bytes, so a
187
188
  # still-mutated file would yield garbage offsets against the later-healed
188
189
  # source. Heal first, then discover jobs from the clean tree.
@@ -191,11 +192,15 @@ module Mutineer
191
192
  jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
192
193
  jobs = filter_since(jobs, source_map, config) if config.since
193
194
 
195
+ # Nothing to mutate: return before the smoke check, which runs the whole
196
+ # --test set to calibrate a timeout no mutant would use (#76).
197
+ return [AggregateResult.new(ignored_results), source_map] if jobs.empty?
198
+
194
199
  # Calibrate the per-mutant timeout from the clean run (a real suite far
195
- # outlasts the 10s in-process fork budget), and abort if it isn't green.
196
- # ponytail: 3x the clean run, floor 30s, ceiling 300s — a heuristic. The
197
- # floor covers a fast suite; the ceiling bounds a hung mutant (infinite loop)
198
- # so a handful can't stall a serial run for ~45min on a slow suite.
200
+ # outlasts the 10s in-process fork budget), and abort if it is not green.
201
+ # 3x the clean run, floor 30s, ceiling 300s: a heuristic. The floor covers
202
+ # a fast suite; the ceiling bounds a hung mutant (infinite loop) so a
203
+ # handful cannot stall a serial run for ~45min on a slow suite.
199
204
  smoke_elapsed = ExternalBackend.smoke_check!(config.test_command, abs_tests)
200
205
  timeout = [[smoke_elapsed * 3, 30].max, 300].min.ceil
201
206
 
@@ -205,7 +210,7 @@ module Mutineer
205
210
  r = run_external(subject, mutation, config.test_command, abs_tests,
206
211
  timeout: timeout, verbose: config.verbose)
207
212
  results << r.with(subject: subject, mutation: mutation, id: id)
208
- break if config.fail_fast && r.survived? # #21: stop at the first survivor
213
+ break if config.fail_fast && r.survived? # stop at the first survivor
209
214
  end
210
215
  ensure
211
216
  FileSwap.restore_orphans(dirs)
@@ -214,11 +219,11 @@ module Mutineer
214
219
  [AggregateResult.new(results + ignored_results), source_map]
215
220
  end
216
221
 
217
- # Runs one mutant through the external backend: apply the whole-file mutation on
218
- # disk, run the command, restore. KTD-8: an invalid (non-reparsing) mutant would
222
+ # Runs one mutant through the external backend: apply the whole-file mutation
223
+ # on disk, run the command, restore. An invalid (non-reparsing) mutant would
219
224
  # fail to load and score a false `killed`, so skip it tool-side (Prism, already
220
- # cheap) and never write the file — preserving the `skipped` verdict the
221
- # in-process path gives at runner.rb's pre-fork check.
225
+ # cheap) and never write the file, preserving the `skipped` verdict the
226
+ # in-process path gives at the pre-fork check.
222
227
  #
223
228
  # @return [Mutineer::Result] verdict for this mutant.
224
229
  def self.run_external(subject, mutation, command, abs_tests, timeout:, verbose:)
@@ -231,185 +236,16 @@ module Mutineer
231
236
  end
232
237
  end
233
238
 
234
- # Daemon backend: boot the app once in a persistent subprocess and fork per
235
- # mutant. Tool-side we build the ready-to-`load` payload (whole-file reload by
236
- # default) and ship it; the daemon needs no Prism/mutineer. Coverage is built
237
- # once via a short-lived daemon so each mutant runs only its covering tests.
238
- # When jobs > 1, each worker uses its own database (SQLite). Fail-fast forces
239
- # serial so the survivor set matches jobs 1.
240
- #
241
- # @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
242
- def self.execute_daemon(config, operator_classes)
243
- jobs, ignored_results, source_map = collect_jobs(config, operator_classes)
244
- jobs = filter_since(jobs, source_map, config) if config.since
245
- abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
246
-
247
- # Build the coverage map once (app-side). nil when the build fails — runners
248
- # fall back to the full --test set (and emit a stderr warning) rather than
249
- # 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?
262
-
263
- results =
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
- # Build the coverage map via a short-lived daemon (boots the app once, captures
274
- # 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. Coverage-build IPC has no wall-clock (same limitation as
277
- # in-process build_via_fork). A normal nonempty map scores like in-process;
278
- # nil falls back to the full suite (more testing, not comparable).
279
- #
280
- # @param config [Mutineer::Config] the run config.
281
- # @param abs_tests [Array<String>] absolute --test paths.
282
- # @return [Mutineer::CoverageMap, nil]
283
- def self.daemon_coverage_map(config, abs_tests)
284
- client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests, coverage: true),
285
- app_root: config.project_root).start
286
- data = begin
287
- client.coverage
288
- ensure
289
- client.quit
290
- end
291
- unless data && !(data["map"] || {}).empty?
292
- reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
293
- warn_daemon_coverage_fallback(reason)
294
- return nil
295
- end
296
-
297
- CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
298
- project_root: config.project_root)
299
- rescue DaemonBootError => e
300
- warn_daemon_coverage_fallback("#{e.class}: #{e.message}")
301
- nil
302
- end
303
-
304
- # Stderr note when daemon coverage is unavailable (full --test set per mutant).
305
- #
306
- # @api private
307
- # @param reason [String] short cause (boot error message, empty map, …).
308
- # @return [void]
309
- def self.warn_daemon_coverage_fallback(reason = "unknown")
310
- warn "[mutineer] daemon coverage map unavailable (#{reason}); running every " \
311
- "mutant against the full --test set (score not comparable to an in-process run)."
312
- end
313
- private_class_method :warn_daemon_coverage_fallback
314
-
315
- # Serial daemon path: one daemon (worker 0), one mutant at a time. Honors
316
- # --fail-fast (#21: stop at the first survivor).
317
- #
318
- # @return [Array<Mutineer::Result>] results in input order.
319
- def self.run_daemon_serial(jobs, config, abs_tests, coverage_map, source_map)
320
- client = DaemonClient.new(boot: daemon_boot_config(config, abs_tests),
321
- app_root: config.project_root).start
322
- results = []
323
- begin
324
- jobs.each_with_index do |job, i|
325
- r = daemon_job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
326
- results << r
327
- break if config.fail_fast && r.survived?
328
- end
329
- ensure
330
- client.quit
331
- end
332
- results
333
- end
334
-
335
- # Parallel daemon path: N daemon handles, each pinned to its own worker slot
336
- # (own DB). A shared queue of job indices feeds N tool-side threads; results
337
- # are placed by input index so the verdict set matches serial. Callers must
338
- # not pass fail_fast here (execute_daemon forces serial for fail-fast).
339
- #
340
- # @return [Array<Mutineer::Result>] completed results in input order.
341
- def self.run_daemon_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
342
- results = Array.new(jobs.size)
343
- queue = Queue.new
344
- jobs.each_index { |i| queue << i }
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
- loop do
354
- i = begin
355
- queue.pop(true)
356
- rescue ThreadError
357
- break
358
- end
359
- results[i] = daemon_job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
360
- end
361
- ensure
362
- client.quit
363
- end
364
- end.each(&:join)
365
-
366
- results.compact
367
- end
368
-
369
- # Build the payload for one job, run it on the given daemon/worker, and attach
370
- # the subject/mutation/id — the shared body of both daemon paths.
371
- #
372
- # @param job [Array(Mutineer::Subject, Mutineer::Mutation, String)] the work item.
373
- # @param req_id [Integer] request id (echoed back for IPC ordering safety).
374
- # @param client [Mutineer::DaemonClient] the daemon handle to run on.
375
- # @param worker [Integer] the worker slot (→ worker DB) this daemon routes to.
376
- # @return [Mutineer::Result] the decorated result.
377
- def self.daemon_job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
378
- subject, mutation, id = job
379
- source = source_map[subject.file]
380
- mutated = mutation.apply(source)
381
- # KTD-8 (carried): skip an invalid mutant tool-side — never ship a payload that
382
- # would fail to load and read as a false `killed`.
383
- # #26/U7: narrow to covering tests (shared with the in-process path via
384
- # coverage_selection, so scores match). :verdict = no_coverage/uncapturable, no
385
- # fork. No map (build failed) → run the full --test set (fallback, not narrowed).
386
- sel = coverage_map && coverage_selection(subject.file, mutation, subject, source, coverage_map)
387
- r =
388
- if Parser.parse_string(mutated).errors.any?
389
- Result.skipped
390
- elsif sel && sel[0] == :verdict
391
- sel[1]
392
- else
393
- verdict = client.request(
394
- id: req_id, worker: worker, timeout: config.daemon_timeout || DAEMON_TIMEOUT,
395
- payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
396
- tests: sel ? sel[1] : abs_tests
397
- )
398
- daemon_result(verdict)
399
- end
400
- r.with(subject: subject, mutation: mutation, id: id)
401
- end
402
-
403
239
  # Coverage-based test selection, shared by the in-process ({run}) and daemon
404
- # paths so both narrow identically (score parity, U7/V5). Returns
240
+ # paths so both narrow identically (score parity). Returns
405
241
  # `[:run, abs_test_paths]` when some test covers the mutant's line, or
406
242
  # `[:verdict, Result]` (no_coverage / uncapturable) when none do.
407
243
  #
408
- # #9/#25: an empty selection is `:uncapturable` (not `:no_coverage`) when the
409
- # mutant's enclosing method body got coverage from no *successful* capture but a
410
- # sibling test failed to capture — the coverage was lost, not absent. Both are
411
- # excluded from the score denominator, so this distinction is reporting-only and
412
- # never changes the daemon-vs-in-process score.
244
+ # An empty selection is `:uncapturable` (not `:no_coverage`) when the
245
+ # mutant's enclosing method body got coverage from no *successful* capture but
246
+ # a sibling test failed to capture: the coverage was lost, not absent. Both are
247
+ # excluded from the score denominator, so this distinction is reporting-only
248
+ # and never changes the daemon-vs-in-process score.
413
249
  #
414
250
  # @param source_file [String] the mutated source file path.
415
251
  # @param mutation [Mutineer::Mutation] the mutation (for its line offset).
@@ -431,59 +267,6 @@ module Mutineer
431
267
  [:run, chosen.map { |t| File.expand_path(t, coverage_map.project_root) }]
432
268
  end
433
269
 
434
- # Default per-mutant timeout on the daemon path (seconds). Coverage narrowing
435
- # usually keeps each job short; this still covers a slow suite or full-suite
436
- # fallback when the coverage map is unavailable.
437
- DAEMON_TIMEOUT = 60
438
-
439
- # The boot config the daemon needs to boot the app once: where to boot, the test
440
- # load roots (so `require "test_helper"` resolves in every fork), framework, and
441
- # whether this is Rails.
442
- def self.daemon_boot_config(config, abs_tests, coverage: false)
443
- {
444
- project_root: config.project_root,
445
- boot: File.expand_path(config.boot || "config/environment", config.project_root),
446
- load_paths: test_load_roots(abs_tests),
447
- source_dirs: source_dirs(config), # so the daemon can sweep orphan mutant temps
448
- framework: config.framework,
449
- rails: config.rails,
450
- # #26/U5: schema for per-worker DB isolation. Sent when present; the daemon
451
- # skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
452
- schema: daemon_schema_path(config),
453
- # #26/U7: coverage narrowing. Only the short-lived map-building daemon starts
454
- # Coverage (before boot); worker daemons boot with it OFF (no wasted
455
- # instrumentation/memory across every mutant fork). `sources`/`tests` are the
456
- # map-build inputs.
457
- coverage: coverage,
458
- sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
459
- tests: abs_tests
460
- }
461
- end
462
-
463
- # Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
464
- # daemon to schema-load each fork's isolated worker database (#26/U5). Only
465
- # `schema.rb` is supported this pass; `structure.sql` apps get nil and fall back to
466
- # whatever the worker DB already holds (Postgres provisioning is U10).
467
- #
468
- # @param config [Mutineer::Config] the run config.
469
- # @return [String, nil] absolute schema path or nil.
470
- def self.daemon_schema_path(config)
471
- path = File.expand_path("db/schema.rb", config.project_root)
472
- File.exist?(path) ? path : nil
473
- end
474
-
475
- # Map a daemon verdict string to a Result. The daemon reports the four run-time
476
- # states it can decide (KTD-5); pre-fork states (skipped/no_coverage/…) are
477
- # resolved tool-side before a request is ever sent.
478
- def self.daemon_result(verdict)
479
- case verdict
480
- when "survived" then Result.survived
481
- when "killed" then Result.killed
482
- when "timeout" then Result.timeout
483
- else Result.error("daemon verdict: #{verdict}")
484
- end
485
- end
486
-
487
270
  # Scan a source once into { line_number => :all | Set[operator_syms] } from
488
271
  # inline `# mutineer:disable-line [ops]` markers (RuboCop semantics: the marker
489
272
  # sits on the same physical line as the code it silences). A bare marker
@@ -550,9 +333,9 @@ module Mutineer
550
333
  end.uniq
551
334
  end
552
335
 
553
- # #7: when --rails is on and RAILS_ENV is unset, default it to "test" (and
554
- # say so) before the app boots — otherwise it boots development and nothing
555
- # is measured. An explicitly-set RAILS_ENV is always respected.
336
+ # When --rails is on and RAILS_ENV is unset, default it to "test" (and say so)
337
+ # before the app boots. Otherwise it boots development and nothing is measured.
338
+ # An explicitly-set RAILS_ENV is always respected.
556
339
  def self.ensure_rails_env(config)
557
340
  return unless config.rails
558
341
  return unless ENV["RAILS_ENV"].nil? || ENV["RAILS_ENV"].empty?
@@ -561,25 +344,27 @@ module Mutineer
561
344
  warn "[mutineer] RAILS_ENV was unset; defaulting to 'test' for --rails."
562
345
  end
563
346
 
564
- # The unique absolute directories holding the sources — the sweep target for
565
- # both orphan mechanisms (in-process mutant tempfiles and external backup
566
- # files). Shared so the path-expansion rule can't drift between the two paths.
347
+ # The unique absolute directories holding the sources. Sweep target for both
348
+ # orphan mechanisms (in-process mutant tempfiles and external backup files),
349
+ # and shipped to the daemon via {DaemonBackend.boot_config} so it can sweep too.
350
+ # Shared so the path-expansion rule cannot drift between the paths.
567
351
  #
568
- # @api private
569
352
  # @param config [Mutineer::Config] run configuration.
570
353
  # @return [Array<String>] unique absolute source directories.
571
354
  def self.source_dirs(config)
572
355
  config.sources.map { |f| File.dirname(File.expand_path(f, config.project_root)) }.uniq
573
356
  end
574
357
 
575
- # Removes stale mutant tempfiles from the given directories.
358
+ # Removes stale mutant tempfiles from the given directories. The daemon writes a
359
+ # differently-named temp, so {DaemonBackend} passes its glob when it has to sweep
360
+ # tool-side (nothing boots on an empty run, so the daemon's own sweep never runs).
576
361
  #
577
- # @api private
578
362
  # @param dirs [Array<String>] directories to sweep.
363
+ # @param glob [String] filename pattern to remove.
579
364
  # @return [void]
580
- def self.sweep_orphans(dirs)
365
+ def self.sweep_orphans(dirs, glob = "mutineer_mutant*.rb")
581
366
  dirs.each do |dir|
582
- Dir.glob(File.join(dir, "mutineer_mutant*.rb")).each do |f|
367
+ Dir.glob(File.join(dir, glob)).each do |f|
583
368
  File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
584
369
  end
585
370
  end
@@ -601,13 +386,13 @@ module Mutineer
601
386
  source = File.read(source_file)
602
387
  mutated = mutation.apply(source)
603
388
 
604
- # Validity rule: a mutant that doesn't re-parse is skipped before forking.
389
+ # Validity rule: a mutant that does not re-parse is skipped before forking.
605
390
  return Result.skipped if Parser.parse_string(mutated).errors.any?
606
391
 
607
392
  # Coverage selection (both standalone and boot mode): a mutation on a line
608
393
  # no test exercises is :no_coverage (no fork); otherwise exactly the
609
394
  # covering test files run in the child. Shared with the daemon path so both
610
- # narrow identically (score parity, U7/V5).
395
+ # narrow identically (score parity).
611
396
  kind, payload = coverage_selection(source_file, mutation, subject, source, coverage_map)
612
397
  return payload if kind == :verdict
613
398
 
@@ -634,9 +419,9 @@ module Mutineer
634
419
  return unless defined?(ActiveRecord::Base)
635
420
 
636
421
  base = ActiveRecord::Base
637
- # #8: clearing connections here drops an open transactional-fixture
422
+ # Clearing connections here drops an open transactional-fixture
638
423
  # transaction, so the test loses its fixture rows and fails. Skip the clear
639
- # when a transaction is open; otherwise clear (v0.2 per-fork write-safety).
424
+ # when a transaction is open; otherwise clear (per-fork write-safety).
640
425
  return if fixture_transaction_open?(base)
641
426
 
642
427
  base.connection_handler.clear_all_connections!
@@ -646,9 +431,9 @@ module Mutineer
646
431
  private_class_method :reconnect_active_record
647
432
 
648
433
  # Pure, injectable predicate: true when a transactional-fixture transaction is
649
- # already open on the connection. Keys off open_transactions (KTD-2) so it is
650
- # correct whenever the transaction exists, regardless of when it opened. Any
651
- # probe error degrades safe to false -> caller clears (existing behaviour).
434
+ # already open on the connection. Keys off open_transactions so it is correct
435
+ # whenever the transaction exists, regardless of when it opened. Any probe
436
+ # error degrades safe to false -> caller clears (existing behaviour).
652
437
  def self.fixture_transaction_open?(base)
653
438
  pool = base.connection_pool
654
439
  pool.active_connection? && base.connection.open_transactions.positive?
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Mutineer
4
4
  # Current Mutineer release version.
5
- VERSION = "0.11.2"
5
+ VERSION = "0.11.4"
6
6
  end
@@ -3,17 +3,17 @@
3
3
  require_relative "result"
4
4
 
5
5
  module Mutineer
6
- # Fixed-size fork pool (KTD1/KTD2). `run` forks up to `size` children at
7
- # once; each child runs the block on one work item, marshals its Result to a
8
- # private pipe, and exits. The parent reaps any finished child with
9
- # Process.wait2(-1), opening exactly one slot per reap, then refills.
10
- # Results are returned in the SAME ORDER as `items` regardless of finish
11
- # order, so verdicts are identical to a serial run (R4) and downstream output
12
- # is stable.
6
+ # Fixed-size fork pool. `run` forks up to `size` children at once; each child
7
+ # runs the block on one work item, marshals its Result to a private pipe, and
8
+ # exits. The parent drains pipes with IO.select and reaps each finished child
9
+ # by known pid (never wait2(-1), which would steal the host suite's children),
10
+ # opening exactly one slot per reap, then refills. Results are returned in the
11
+ # SAME ORDER as `items` regardless of finish order, so verdicts are identical
12
+ # to a serial run and downstream output is stable.
13
13
  #
14
14
  # The block is run inside the child via `yield(*items[i])`; whatever it
15
15
  # returns (a Result) is the marshaled payload. Per-mutant timeout is handled
16
- # one level down by Isolation (KTD2) — the pool adds no separate wall clock.
16
+ # one level down by Isolation. The pool adds no separate wall clock.
17
17
  class WorkerPool
18
18
  # Builds a pool.
19
19
  #
@@ -27,7 +27,7 @@ module Mutineer
27
27
  # @param items [Array<Array>] work items.
28
28
  # @param stop_when [Proc, nil] called with each collected Result; when it
29
29
  # returns truthy, no further items are scheduled and the run drains and
30
- # returns early (#21 --fail-fast). Unscheduled slots stay nil.
30
+ # returns early (--fail-fast). Unscheduled slots stay nil.
31
31
  # @yieldparam item [Array] one work item.
32
32
  # @return [Array<Mutineer::Result>] results in input order (nil for any item
33
33
  # left unscheduled by an early stop).
@@ -51,15 +51,15 @@ module Mutineer
51
51
 
52
52
  private
53
53
 
54
- # R1: the child must ALWAYS hard-exit. If yield raises, marshal an error
55
- # Result and exit! in `ensure` — otherwise the child unwinds normally and
56
- # our Minitest at_exit autorun re-runs the parent suite inside the worker,
54
+ # The child must ALWAYS hard-exit. If yield raises, marshal an error Result
55
+ # and exit! in `ensure`. Otherwise the child unwinds normally and our
56
+ # Minitest at_exit autorun re-runs the parent suite inside the worker,
57
57
  # losing the real error.
58
58
  def fill(items, queue, running)
59
59
  while running.size < @size && !queue.empty?
60
60
  idx = queue.shift
61
61
  rd, wr = IO.pipe
62
- rd.binmode # #19: Marshal output is binary — keep the pipe byte-exact
62
+ rd.binmode # Marshal output is binary: keep the pipe byte-exact
63
63
  wr.binmode
64
64
  begin
65
65
  pid = fork do
@@ -94,13 +94,13 @@ module Mutineer
94
94
  end
95
95
  end
96
96
 
97
- # Drain pipes with IO.select and reap a child only on EOF (#4). The old
98
- # code reaped first and read after — but a child whose payload exceeds the
99
- # OS pipe buffer (~64KB) blocks on `write` before it can exit, so it was
100
- # never reaped and the pool deadlocked. Reading concurrently keeps the
101
- # pipe drained so the child can finish and exit; EOF means it closed its
102
- # write end (done writing). We waitpid only OUR known pids (R6: never
103
- # wait2(-1), which would steal the host suite's children).
97
+ # Drain pipes with IO.select and reap a child only on EOF. The old code
98
+ # reaped first and read after, but a child whose payload exceeds the OS pipe
99
+ # buffer (~64KB) blocks on `write` before it can exit, so it was never reaped
100
+ # and the pool deadlocked. Reading concurrently keeps the pipe drained so the
101
+ # child can finish and exit; EOF means it closed its write end (done writing).
102
+ # We waitpid only OUR known pids (never wait2(-1), which would steal the host
103
+ # suite's children).
104
104
  def reap(results, running)
105
105
  return if running.empty?
106
106
 
@@ -116,7 +116,7 @@ module Mutineer
116
116
  rd.close
117
117
  Process.waitpid(pid) # reap the now-finished child (no zombie)
118
118
  running.delete(pid)
119
- # Return the collected Result so the caller's stop_when (#21) can see it.
119
+ # Return the collected Result so the caller's stop_when can see it.
120
120
  return results[idx] = decode(buf)
121
121
  end
122
122
  buf << chunk
@@ -124,8 +124,8 @@ module Mutineer
124
124
  end
125
125
  end
126
126
 
127
- # R6: a partial/garbage Marshal stream (dead worker) must not crash the
128
- # pool — degrade to an error Result.
127
+ # A partial/garbage Marshal stream (dead worker) must not crash the pool.
128
+ # Degrade to an error Result.
129
129
  # @param data [String] marshaled payload.
130
130
  # @return [Mutineer::Result] decoded result or error result.
131
131
  def decode(data)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutineer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.2
4
+ version: 0.11.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -71,6 +71,7 @@ files:
71
71
  - lib/mutineer/cli.rb
72
72
  - lib/mutineer/config.rb
73
73
  - lib/mutineer/coverage_map.rb
74
+ - lib/mutineer/daemon_backend.rb
74
75
  - lib/mutineer/daemon_client.rb
75
76
  - lib/mutineer/daemon_server.rb
76
77
  - lib/mutineer/external_backend.rb