mutineer 1.5.0 → 1.6.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.
@@ -5,10 +5,7 @@ require_relative "result"
5
5
  require_relative "coverage_map"
6
6
  require_relative "daemon_client"
7
7
  require_relative "progress"
8
- # No require_relative "runner" on purpose: runner.rb requires this file, and the
9
- # reverse edge makes Ruby warn "circular require considered harmful" on every -w
10
- # load. Runner is loaded first on every real path; requiring this file alone leaves
11
- # it undefined. Rationale and the real fix: #75.
8
+ require_relative "job_plan"
12
9
 
13
10
  module Mutineer
14
11
  # Daemon execution backend. Boots the app ONCE in a persistent subprocess under
@@ -19,12 +16,12 @@ module Mutineer
19
16
  # When jobs > 1 each worker runs against its OWN database, which is what makes
20
17
  # `--jobs N` safe under Rails (#26): parallel verdicts are identical to serial.
21
18
  #
22
- # Job collection, `--since` filtering and coverage selection stay on {Runner} and
23
- # are called from here, so the daemon path can never drift from the in-process
24
- # path on which mutants run or which tests narrow a mutant (score parity).
19
+ # Job collection, `--since` filtering and coverage selection live in {JobPlan},
20
+ # which the in-process path calls too, so the daemon path can never drift from
21
+ # it on which mutants run or which tests narrow a mutant (score parity).
25
22
  #
26
23
  # Unlike {ExternalBackend}, which is a leaf {Runner} calls into, this module owns
27
- # its orchestration and calls back for that shared vocabulary.
24
+ # its orchestration and takes that shared vocabulary from {JobPlan}.
28
25
  module DaemonBackend
29
26
  # Default per-mutant timeout on the daemon path (seconds), overridden by
30
27
  # config.daemon_timeout. Coverage narrowing usually keeps each job short; this
@@ -44,10 +41,10 @@ module Mutineer
44
41
  # @param config [Mutineer::Config] run configuration (daemon set).
45
42
  # @param operator_classes [Array<Class>] resolved operators.
46
43
  # @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
47
- # source map, and the {Runner.collect_jobs} extras.
44
+ # source map, and the {JobPlan.collect_jobs} extras.
48
45
  def self.execute(config, operator_classes)
49
- jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
50
- jobs = Runner.filter_since(jobs, source_map, config) if config.since
46
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
47
+ jobs = JobPlan.filter_since(jobs, source_map, config) if config.since
51
48
  abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
52
49
 
53
50
  # Nothing to mutate (`--since` matched no changed line, or every mutant is
@@ -58,7 +55,7 @@ module Mutineer
58
55
  # The daemon sweeps orphaned temps at boot and nothing boots here, so sweep
59
56
  # tool-side. A file a hard-killed run left in app/models breaks the app's own
60
57
  # Zeitwerk boot, not just Mutineer's next run.
61
- Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB)
58
+ JobPlan.sweep_orphans(JobPlan.source_dirs(config), DAEMON_TEMP_GLOB)
62
59
  return [AggregateResult.new(ignored_results), source_map, extras]
63
60
  end
64
61
 
@@ -91,8 +88,11 @@ module Mutineer
91
88
  # Build the coverage map via a short-lived daemon (boots the app once, captures
92
89
  # per-test coverage app-side, ships the map back). Returns a query-only
93
90
  # CoverageMap, or nil when the build fails / returns empty. Callers then run the
94
- # full --test set. Coverage-build IPC has no wall-clock (same limitation as
95
- # in-process build_via_fork). A normal nonempty map scores like in-process;
91
+ # full --test set. An empty map that came with boot lines returns a map with
92
+ # those lines only: it narrows nothing, but still classifies `ran_at_load`.
93
+ # Each capture is bounded by capture_timeout (#101); the client's wait for
94
+ # the whole map is not, because its length grows with the number of test
95
+ # files. A normal nonempty map scores like in-process;
96
96
  # nil falls back to the full suite (more testing, not comparable).
97
97
  #
98
98
  # @param config [Mutineer::Config] the run config.
@@ -109,7 +109,7 @@ module Mutineer
109
109
  # A red unmutated suite must abort, even when the shipped map is empty.
110
110
  # Falling back to the full --test set would treat those failures as kills.
111
111
  if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any?
112
- Runner.abort_if_unclean!(CoverageMap.from_data(
112
+ JobPlan.abort_if_unclean!(CoverageMap.from_data(
113
113
  map: data["map"] || {},
114
114
  failed_test_files: data["failed_test_files"] || [],
115
115
  project_root: config.project_root,
@@ -120,11 +120,21 @@ module Mutineer
120
120
  unless data && !(data["map"] || {}).empty?
121
121
  reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
122
122
  warn_coverage_fallback(reason)
123
- return nil
123
+ # No narrowing ({job_result} runs every test), but the lines and
124
+ # methods that ran at boot still classify a survivor on one of them.
125
+ load_lines = data.is_a?(Hash) ? Array(data["load_lines"]) : []
126
+ load_methods = data.is_a?(Hash) ? Array(data["load_methods"]) : []
127
+ return nil if load_lines.empty? && load_methods.empty?
128
+
129
+ return CoverageMap.from_data(map: {}, failed_test_files: [], project_root: config.project_root,
130
+ load_lines: load_lines, load_methods: load_methods)
124
131
  end
125
132
 
126
133
  CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
127
- project_root: config.project_root)
134
+ project_root: config.project_root, load_lines: data["load_lines"] || [],
135
+ load_methods: data["load_methods"] || [])
136
+ rescue DaemonBootTimeout
137
+ raise # a second daemon for the mutant runs would hang just as long
128
138
  rescue DaemonBootError => e
129
139
  warn_coverage_fallback("#{e.class}: #{e.message}")
130
140
  nil
@@ -246,10 +256,10 @@ module Mutineer
246
256
  # Skip an invalid mutant tool-side: never ship a payload that would fail to
247
257
  # load and read as a false `killed`.
248
258
  # Narrow to covering tests (shared with the in-process path via
249
- # Runner.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
250
- # no fork. No map (build failed) → run the full --test set (fallback, not
251
- # narrowed).
252
- sel = coverage_map && Runner.coverage_selection(subject.file, mutation, subject, source, coverage_map)
259
+ # JobPlan.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
260
+ # no fork. No map, or an empty one (build failed) → run the full --test set
261
+ # (fallback, not narrowed).
262
+ sel = coverage_map && !coverage_map.map.empty? && JobPlan.coverage_selection(subject.file, mutation, subject, source, coverage_map)
253
263
  r =
254
264
  if Parser.parse_string(mutated).errors.any?
255
265
  Result.skipped
@@ -261,14 +271,16 @@ module Mutineer
261
271
  payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
262
272
  tests: sel ? sel[1] : abs_tests
263
273
  )
264
- result_for(verdict)
274
+ # A survivor whose line ran at load, as in-process (Runner.run).
275
+ JobPlan.load_verdict(result_for(verdict), subject.file, mutation, subject, source, coverage_map)
265
276
  end
266
277
  r.with(subject: subject, mutation: mutation, id: id)
267
278
  end
268
279
 
269
- # The boot config the daemon needs to boot the app once: where to boot, the test
270
- # load roots (so `require "test_helper"` resolves in every fork), framework, and
271
- # whether this is Rails.
280
+ # The boot config the daemon needs to boot the app once: where to boot, the
281
+ # --require files to load after it, the test load roots (so
282
+ # `require "test_helper"` resolves in every fork), framework, and whether this
283
+ # is Rails.
272
284
  #
273
285
  # @param config [Mutineer::Config] the run config.
274
286
  # @param abs_tests [Array<String>] absolute --test paths.
@@ -278,13 +290,16 @@ module Mutineer
278
290
  {
279
291
  project_root: config.project_root,
280
292
  boot: File.expand_path(config.boot || "config/environment", config.project_root),
281
- load_paths: Runner.test_load_roots(abs_tests),
293
+ # Required after the boot, as in-process (Runner.execute); also part of
294
+ # the coverage digest.
295
+ require_paths: config.require_paths.map { |f| File.expand_path(f, config.project_root) },
296
+ load_paths: JobPlan.test_load_roots(abs_tests),
282
297
  cache_dir: File.expand_path(config.cache_dir, config.project_root),
283
- source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
298
+ source_dirs: JobPlan.source_dirs(config), # so the daemon can sweep orphan mutant temps
284
299
  framework: config.framework,
285
300
  rails: config.rails,
286
- # Schema for per-worker DB isolation. Sent when present; the daemon
287
- # skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
301
+ # Schema for per-worker DB isolation. Sent when present; the daemon loads
302
+ # it over a worker's copy of the test DB only when that copy is out of date.
288
303
  schema: schema_path(config),
289
304
  # Coverage narrowing. Only the short-lived map-building daemon starts
290
305
  # Coverage (before boot); worker daemons boot with it OFF (no wasted
@@ -298,10 +313,10 @@ module Mutineer
298
313
  }
299
314
  end
300
315
 
301
- # Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
302
- # daemon to schema-load each fork's isolated worker database. Only `schema.rb`
303
- # is supported this pass; `structure.sql` apps get nil and fall back to
304
- # whatever the worker DB already holds.
316
+ # Absolute path to the app's `db/schema.rb` if it exists, else nil. Each worker
317
+ # DB starts as a copy of the test DB; the daemon loads this file over the copy
318
+ # when the copy's schema differs. `structure.sql` apps get nil and keep the
319
+ # copy as it is.
305
320
  #
306
321
  # @param config [Mutineer::Config] the run config.
307
322
  # @api private
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "io/wait"
4
5
  require "open3"
5
6
  require_relative "external_backend"
6
7
 
@@ -12,6 +13,11 @@ module Mutineer
12
13
  # to a runtime error (exit 1).
13
14
  class DaemonBootError < StandardError; end
14
15
 
16
+ # A boot that has not answered the handshake within DaemonClient::BOOT_TIMEOUT
17
+ # (#101). Unlike other boot errors it is not worth retrying with a second
18
+ # daemon, which would wait just as long.
19
+ class DaemonBootTimeout < DaemonBootError; end
20
+
15
21
  # Tool-side handle for the app-side daemon.
16
22
  #
17
23
  # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
@@ -29,6 +35,13 @@ module Mutineer
29
35
  DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
30
36
  # How many times to respawn a crashing daemon before aborting the run.
31
37
  MAX_RESTARTS = 3
38
+ # Seconds a verdict may lag the request's own timeout. The daemon enforces
39
+ # that timeout on the mutant child, so a reply later than this means the
40
+ # daemon itself is wedged (#101): it is killed and respawned.
41
+ REPLY_GRACE = 30
42
+ # Seconds the daemon gets to boot the app and answer the handshake. A boot
43
+ # that never returns ends the run instead of hanging it (#101).
44
+ BOOT_TIMEOUT = 600
32
45
  # Bundler's marker for a variable that was unset before it activated.
33
46
  BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
34
47
 
@@ -83,7 +96,7 @@ module Mutineer
83
96
  reply =
84
97
  begin
85
98
  send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
86
- read_line
99
+ read_line(timeout + REPLY_GRACE)
87
100
  rescue Errno::EPIPE, IOError
88
101
  nil
89
102
  end
@@ -117,7 +130,7 @@ module Mutineer
117
130
  return unless @stdin
118
131
 
119
132
  send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
120
- @wait_thr&.join
133
+ @wait_thr&.join(REPLY_GRACE) # a wedged daemon is killed by close_io
121
134
  ensure
122
135
  close_io
123
136
  end
@@ -315,16 +328,21 @@ module Mutineer
315
328
  end
316
329
 
317
330
  send_line(@boot)
318
- read_line
331
+ read_line(BOOT_TIMEOUT)
319
332
  rescue SystemCallError, IOError => e
320
333
  close_io
321
334
  raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
322
335
  end
323
336
 
324
337
  unless ready && ready["ready"]
325
- detail = ready && ready["error"] ? ready["error"] : "daemon exited before the handshake"
338
+ detail =
339
+ if ready && ready["error"] then ready["error"]
340
+ elsif @timed_out then "the daemon did not finish booting within #{BOOT_TIMEOUT}s"
341
+ else "daemon exited before the handshake"
342
+ end
343
+ timed_out = @timed_out
326
344
  close_io
327
- raise DaemonBootError, "daemon failed to boot under the app bundle: #{detail}"
345
+ raise (timed_out ? DaemonBootTimeout : DaemonBootError), "daemon failed to boot under the app bundle: #{detail}"
328
346
  end
329
347
  end
330
348
 
@@ -333,10 +351,11 @@ module Mutineer
333
351
  close_io
334
352
  @restarts += 1
335
353
  if @restarts > MAX_RESTARTS
336
- raise DaemonBootError, "daemon crashed #{@restarts} times; aborting the run"
354
+ raise DaemonBootError, "daemon crashed or stopped answering #{@restarts} times; aborting the run"
337
355
  end
338
356
 
339
- @errio.puts("[mutineer] daemon crashed — respawning (#{@restarts}/#{MAX_RESTARTS})")
357
+ cause = @timed_out ? "stopped answering" : "crashed"
358
+ @errio.puts("[mutineer] daemon #{cause} — respawning (#{@restarts}/#{MAX_RESTARTS})")
340
359
  spawn_daemon
341
360
  end
342
361
 
@@ -349,8 +368,15 @@ module Mutineer
349
368
  @stdin.flush
350
369
  end
351
370
 
352
- # Read one JSON reply line; nil on EOF/dead pipe (caller treats as a crash).
353
- def read_line
371
+ # Read one JSON reply line; nil on EOF/dead pipe, or when no line arrives
372
+ # within `timeout` seconds (caller treats either as a crash).
373
+ #
374
+ # @param timeout [Numeric, nil] seconds to wait; nil waits for the reply.
375
+ # @return [Hash, nil]
376
+ def read_line(timeout = nil)
377
+ @timed_out = timeout && !@stdout.wait_readable(timeout)
378
+ return nil if @timed_out
379
+
354
380
  line = @stdout.gets
355
381
  line && JSON.parse(line.strip)
356
382
  rescue IOError, Errno::EPIPE, JSON::ParserError
@@ -362,6 +388,13 @@ module Mutineer
362
388
  #
363
389
  # @return [void]
364
390
  def close_io
391
+ # A wedged daemon may never read the closed stdin, so the reap below would
392
+ # wait forever: kill it first. An exited daemon makes this a no-op.
393
+ begin
394
+ Process.kill(:KILL, @wait_thr.pid) if @wait_thr&.alive?
395
+ rescue Errno::ESRCH
396
+ nil # it exited between the check and the kill
397
+ end
365
398
  @drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
366
399
  [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
367
400
  @wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
@@ -3,15 +3,18 @@
3
3
  require "json"
4
4
  require "tempfile"
5
5
  require_relative "child_stdout"
6
+ require_relative "orphan_guard"
6
7
 
7
8
  module Mutineer
8
9
  # App-side daemon (persistent worker).
9
10
  #
10
11
  # Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
11
12
  # `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
12
- # requests over stdin/stdout as newline-delimited JSON. For each request it
13
- # FORKS a child that loads the mutated source text the tool sent, runs the
14
- # covering tests, and exits with a status the parent decodes into a verdict.
13
+ # requests over stdin/stdout as newline-delimited JSON (the protocol keeps a
14
+ # private copy of the original stdout; the app's own stdout goes to stderr).
15
+ # For each request it FORKS a child that loads the mutated source text the
16
+ # tool sent, runs the covering tests, and exits with a status the parent
17
+ # decodes into a verdict.
15
18
  #
16
19
  # HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
17
20
  # mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
@@ -21,7 +24,7 @@ module Mutineer
21
24
  # tool-side; the daemon only `load`s text.
22
25
  #
23
26
  # Protocol (one JSON object per line, both directions):
24
- # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
27
+ # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
25
28
  # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
26
29
  # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
27
30
  # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
@@ -49,10 +52,14 @@ module Mutineer
49
52
  # Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
50
53
  #
51
54
  # @param input [IO] request stream.
52
- # @param output [IO] verdict stream.
55
+ # @param output [IO, nil] verdict stream. nil (the default) reserves the
56
+ # process's original stdout for the protocol and points fd 1 at stderr.
53
57
  # @param errio [IO] diagnostics stream (never the IPC channel).
54
58
  # @return [void]
55
- def run(input: $stdin, output: $stdout, errio: $stderr)
59
+ def run(input: $stdin, output: nil, errio: $stderr)
60
+ # Only a stream `run` reserved itself is the daemon's to close in a fork.
61
+ @protocol = output ? nil : reserve_protocol_output
62
+ output ||= @protocol
56
63
  @errio = errio
57
64
  @output = output
58
65
  boot_line = input.gets
@@ -92,6 +99,21 @@ module Mutineer
92
99
 
93
100
  private
94
101
 
102
+ # Move the protocol channel off fd 1 before the app boots. The app may
103
+ # print through `puts`, `STDOUT`, or a raw fd 1 write (a logger, a
104
+ # subprocess). A line like that ahead of the ready message would break
105
+ # the client's JSON read. The protocol keeps a private dup of the
106
+ # original stdout. Fd 1 then points at stderr, so app output stays
107
+ # visible in the tool's diagnostics.
108
+ #
109
+ # @return [IO] the reserved protocol stream.
110
+ def reserve_protocol_output
111
+ protocol = STDOUT.dup
112
+ STDOUT.reopen(STDERR)
113
+ $stdout = STDOUT
114
+ protocol
115
+ end
116
+
95
117
  # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
96
118
  # and inherited by every fork. Never requires mutineer.
97
119
  def boot!(cfg)
@@ -105,13 +127,16 @@ module Mutineer
105
127
  # instrumented. The map build (build_via_fork) forks this booted parent.
106
128
  if cfg["coverage"]
107
129
  require "coverage"
108
- Coverage.start(lines: true)
130
+ Coverage.start(lines: true, methods: true)
109
131
  end
110
132
  # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
111
133
  # source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
112
134
  # tempfile's non-constant name during autoload setup.
113
135
  sweep_temps
114
136
  require File.expand_path(cfg["boot"]) if cfg["boot"]
137
+ # The --require files, after the boot as in-process (Runner.execute), so
138
+ # the coverage peek sees what they ran at load.
139
+ Array(cfg["require_paths"]).each { |f| require File.expand_path(f) }
115
140
  setup_worker_db(cfg) if cfg["rails"]
116
141
  rescue Exception => e # rubocop:disable Lint/RescueException
117
142
  # Boot failed (bad boot path, app error). Tell the client and exit so it
@@ -131,15 +156,15 @@ module Mutineer
131
156
  @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
132
157
  schema = cfg["schema"] && File.expand_path(cfg["schema"])
133
158
  @schema_path = schema if schema && File.exist?(schema)
134
- # Schema is loaded once per worker slot on first use (not every mutant fork).
135
- @schema_ready = {}
159
+ # Each worker slot is seeded once, on first use (not every mutant fork).
160
+ @slot_ready = {}
136
161
  rescue LoadError => e
137
162
  @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
138
163
  @worker_db = nil
139
164
  end
140
165
 
141
166
  # Build the coverage map app-side (Coverage was started at boot) and return
142
- # it as `{map, failed_test_files}` for the tool to select covering tests.
167
+ # it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
143
168
  # Capture forks route to worker 0's DB (isolated, serial). On any failure
144
169
  # return an empty map + an error string. The tool then falls back to the
145
170
  # full test set rather than mis-scoring everything as no_coverage.
@@ -149,42 +174,61 @@ module Mutineer
149
174
  cmap = CoverageMap.new(
150
175
  source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
151
176
  load_paths: Array(@cfg["load_paths"]), project_root: root,
152
- boot_path: @cfg["boot"], framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
177
+ boot_path: @cfg["boot"], require_paths: Array(@cfg["require_paths"]),
178
+ framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
153
179
  capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
154
180
  ).build_via_fork(after_fork: coverage_after_fork)
181
+ # Nothing reads Coverage after the map, so a mutant forked from this
182
+ # daemon does not pay for it (#228).
183
+ Coverage.suspend if Coverage.running?
155
184
  { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
156
- "failed_clean_tests" => cmap.failed_clean_tests }
185
+ "failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
186
+ "load_methods" => cmap.load_methods.to_a }
157
187
  rescue Exception => e # rubocop:disable Lint/RescueException
158
188
  @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
159
189
  { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
160
190
  end
161
191
 
162
- # Fork-safety hook for coverage capture: route each capture fork to worker
163
- # 0's isolated DB (captures run serially, so one worker is enough). Nil when
164
- # the app has no worker-DB adapter (non-Rails). Capture then runs as before.
192
+ # Fork-safety hook for coverage capture. Each capture fork drops its copy
193
+ # of the protocol channel (see close_protocol) and, when the app has a
194
+ # worker-DB adapter, routes to a fresh copy of the base DB in worker 0's
195
+ # slot (captures run serially, so one worker is enough).
165
196
  def coverage_after_fork
166
- return nil unless @worker_db
167
-
197
+ worker_db = @worker_db
168
198
  schema = @schema_path
169
- -> { @worker_db.after_fork(0, schema) }
199
+ lambda do
200
+ close_protocol
201
+ worker_db&.after_fork(0, schema, seed: true)
202
+ end
203
+ end
204
+
205
+ # A forked child never answers on the protocol channel. Closing its
206
+ # inherited copy lets a daemon crash read as EOF on the client at once,
207
+ # not only after a slow or hung child exits. A caller-supplied output
208
+ # (for example STDOUT) is left open.
209
+ def close_protocol
210
+ @protocol&.close rescue nil # rubocop:disable Style/RescueModifier
170
211
  end
171
212
 
172
213
  # Fork a child to run one mutant in isolation; decode its exit into a verdict.
173
214
  def run_mutant(req)
174
215
  timeout = req.fetch("timeout", 30)
175
216
  worker = req.fetch("worker", 0)
176
- # Load schema until the first killed/survived fork for this worker slot.
177
- schema_for_fork = (@worker_db && @schema_path && !@schema_ready[worker]) ? @schema_path : nil
217
+ # Seed the slot's DB until the first killed/survived fork for this worker slot.
218
+ first_use = @worker_db && !@slot_ready[worker]
219
+ daemon = Process.pid
178
220
  pid = fork do
179
221
  # New process group so a per-fork timeout can SIGKILL the whole subtree,
180
- # and silence the child's stdout so test output never corrupts the IPC pipe.
222
+ # and silence the child's stdout so test output stays out of the diagnostics.
181
223
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
224
+ OrphanGuard.start(daemon)
182
225
  code =
183
226
  begin
184
227
  ChildStdout.silence
228
+ close_protocol
185
229
  # Route THIS fork at its own worker database before any test loads.
186
230
  # A routing failure raises here and is scored `error`, never a false verdict.
187
- @worker_db&.after_fork(worker, schema_for_fork)
231
+ @worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
188
232
  apply_payload(req["payload"])
189
233
  run_tests(Array(req["tests"]))
190
234
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -194,10 +238,10 @@ module Mutineer
194
238
  exit!(code)
195
239
  end
196
240
  verdict = wait_verdict(pid, timeout)
197
- # Mark ready only when the child finished cleanly after schema load
198
- # (killed/survived). Timeout can interrupt mid-load_schema; error is a
199
- # routing failure. Both leave the slot unready so the next fork reloads.
200
- @schema_ready[worker] = true if schema_for_fork && %w[killed survived].include?(verdict)
241
+ # Mark ready only when the child finished cleanly after seeding
242
+ # (killed/survived). Timeout can interrupt mid-copy; error is a routing
243
+ # failure. Both leave the slot unready so the next fork seeds again.
244
+ @slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
201
245
  # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
202
246
  # so it cannot outlive the run or trip Zeitwerk on a later fork.
203
247
  sweep_temps if verdict == "timeout"
@@ -205,7 +249,7 @@ module Mutineer
205
249
  end
206
250
 
207
251
  # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
208
- # SIGKILL path cannot run the child's ensure). Mirrors Runner.sweep_orphans.
252
+ # SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
209
253
  def sweep_temps
210
254
  @source_dirs.to_a.each do |dir|
211
255
  Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
@@ -219,6 +263,8 @@ module Mutineer
219
263
  # pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
220
264
  # the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
221
265
  # the kill/reap/decode logic must be applied to all three in lockstep.
266
+ # CoverageMap#await_child applies the same deadline and group kill to
267
+ # coverage capture.
222
268
  # SIGKILL the child's process group past the deadline; a signalled child
223
269
  # (nil exitstatus) is `error`.
224
270
  def wait_verdict(pid, timeout)
@@ -21,7 +21,7 @@ module Mutineer
21
21
  # which makes leaving the file mutated the one genuinely dangerous failure mode.
22
22
  #
23
23
  # Defense in depth, mirroring the tempfile-orphan discipline
24
- # (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
24
+ # (`JobPlan.sweep_orphans`, `isolation.rb` tempfiles):
25
25
  # - exclusive OS ownership (flock) is acquired before swap or recovery;
26
26
  # - the original bytes are held in memory AND written to a sibling backup;
27
27
  # - `ensure` restores from memory around every mutant;
@@ -234,6 +234,7 @@ module Mutineer
234
234
  else
235
235
  mutated_def
236
236
  end
237
+ inner = "#{subject.block_owner}.class_eval do\n#{inner}\nend" if subject.block_owner
237
238
  wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
238
239
 
239
240
  # A snippet that fails to reparse must NOT silently fall through to