mutineer 1.5.0 → 1.7.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.
@@ -3,15 +3,21 @@
3
3
  require "json"
4
4
  require "tempfile"
5
5
  require_relative "child_stdout"
6
+ require_relative "child_coverage"
7
+ require_relative "orphan_guard"
8
+ require_relative "concern_blocks"
9
+ require_relative "redaction"
6
10
 
7
11
  module Mutineer
8
12
  # App-side daemon (persistent worker).
9
13
  #
10
14
  # Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
11
15
  # `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.
16
+ # requests over stdin/stdout as newline-delimited JSON (the protocol keeps a
17
+ # private copy of the original stdout; the app's own stdout goes to stderr).
18
+ # For each request it FORKS a child that loads the mutated source text the
19
+ # tool sent, runs the covering tests, and exits with a status the parent
20
+ # decodes into a verdict.
15
21
  #
16
22
  # HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
17
23
  # mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
@@ -21,19 +27,21 @@ module Mutineer
21
27
  # tool-side; the daemon only `load`s text.
22
28
  #
23
29
  # Protocol (one JSON object per line, both directions):
24
- # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
30
+ # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
25
31
  # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
26
- # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
32
+ # ready out: {"ready":true,"ruby":"3.3.6","database_count":1}
33
+ # (or {"ready":false,"error":"..."} then exit)
34
+ # provision: {"cmd":"provision","slots":N} -> {"ok":true} or {"ok":false,"error":"..."}
27
35
  # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
28
36
  # "tests":["test/models/order_test.rb"],"timeout":30}
29
37
  # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
30
38
  # quit in : {"cmd":"quit"}
31
39
  #
32
40
  # Worker isolation: when the app is Rails, each fork is routed to its own
33
- # database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so
34
- # concurrent workers cannot clobber each other's transactional fixtures.
35
- # `worker` defaults to 0 (serial). SQLite this pass; Postgres provisioning is
36
- # not yet implemented.
41
+ # database via {RailsWorkerDb} BEFORE any test loads, so concurrent workers
42
+ # cannot clobber each other's transactional fixtures. `worker` defaults to 0
43
+ # (serial). SQLite copies a file per slot. Postgres and MySQL use
44
+ # `<base>-mutineer-<w>`, created once before the worker daemons start.
37
45
  #
38
46
  # Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite
39
47
  # failed), 2=error (child raised AROUND the test: load, boot, or worker-DB
@@ -44,22 +52,46 @@ module Mutineer
44
52
  module DaemonServer
45
53
  # Poll interval (seconds) for the per-fork deadline wait loop.
46
54
  POLL = 0.02
55
+ # Basename glob for a mutant file written beside a source file.
56
+ # Tempfile puts hyphens in the name, so it is not a constant name. The
57
+ # leading dot hides a new file from Zeitwerk (#253); a file an older
58
+ # version left behind has none, so the glob matches both.
59
+ DAEMON_TEMP_BASENAME = "{.,}mutineer_daemon*.rb"
60
+
61
+ # Skips daemon mutant files when Zeitwerk scans an autoload directory.
62
+ # The boot sweep keeps a file another process still holds. Zeitwerk skips a
63
+ # hidden one (#253), but one an older version wrote has no dot, and it would
64
+ # stop the next boot, because its name is not a constant.
65
+ module DaemonTempIgnore
66
+ # @return [void]
67
+ def setup
68
+ dirs = Array(Mutineer::DaemonServer.instance_variable_get(:@source_dirs))
69
+ dirs.each do |dir|
70
+ ignore(File.join(dir, Mutineer::DaemonServer::DAEMON_TEMP_BASENAME))
71
+ end
72
+ super
73
+ end
74
+ end
47
75
 
48
76
  class << self
49
77
  # Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
50
78
  #
51
79
  # @param input [IO] request stream.
52
- # @param output [IO] verdict stream.
80
+ # @param output [IO, nil] verdict stream. nil (the default) reserves the
81
+ # process's original stdout for the protocol and points fd 1 at stderr.
53
82
  # @param errio [IO] diagnostics stream (never the IPC channel).
54
83
  # @return [void]
55
- def run(input: $stdin, output: $stdout, errio: $stderr)
84
+ def run(input: $stdin, output: nil, errio: $stderr)
85
+ # Only a stream `run` reserved itself is the daemon's to close in a fork.
86
+ @protocol = output ? nil : reserve_protocol_output
87
+ output ||= @protocol
56
88
  @errio = errio
57
89
  @output = output
58
90
  boot_line = input.gets
59
91
  return if boot_line.nil? # client vanished before boot
60
92
 
61
93
  boot!(JSON.parse(boot_line.strip))
62
- output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
94
+ output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION, "database_count" => database_count))
63
95
  output.flush
64
96
 
65
97
  input.each_line do |line|
@@ -77,6 +109,14 @@ module Mutineer
77
109
  end
78
110
  break if req["cmd"] == "quit"
79
111
 
112
+ # Create worker databases before coverage forks or mutant forks.
113
+ # The first daemon does this. A restarted worker daemon does not.
114
+ if req["cmd"] == "provision"
115
+ output.puts(JSON.generate(provision_slots(req["slots"])))
116
+ output.flush
117
+ next
118
+ end
119
+
80
120
  # Build the coverage map app-side and ship it to the tool, which then
81
121
  # selects covering tests per mutant. One-shot control message.
82
122
  if req["cmd"] == "coverage"
@@ -92,6 +132,21 @@ module Mutineer
92
132
 
93
133
  private
94
134
 
135
+ # Move the protocol channel off fd 1 before the app boots. The app may
136
+ # print through `puts`, `STDOUT`, or a raw fd 1 write (a logger, a
137
+ # subprocess). A line like that ahead of the ready message would break
138
+ # the client's JSON read. The protocol keeps a private dup of the
139
+ # original stdout. Fd 1 then points at stderr, so app output stays
140
+ # visible in the tool's diagnostics.
141
+ #
142
+ # @return [IO] the reserved protocol stream.
143
+ def reserve_protocol_output
144
+ protocol = STDOUT.dup
145
+ STDOUT.reopen(STDERR)
146
+ $stdout = STDOUT
147
+ protocol
148
+ end
149
+
95
150
  # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
96
151
  # and inherited by every fork. Never requires mutineer.
97
152
  def boot!(cfg)
@@ -105,18 +160,27 @@ module Mutineer
105
160
  # instrumented. The map build (build_via_fork) forks this booted parent.
106
161
  if cfg["coverage"]
107
162
  require "coverage"
108
- Coverage.start(lines: true)
163
+ Coverage.start(lines: true, methods: true)
109
164
  end
110
- # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
111
- # source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
112
- # tempfile's non-constant name during autoload setup.
165
+ # Clear a mutant tempfile a prior SIGKILLed child orphaned in a source
166
+ # dir BEFORE the app boots. Zeitwerk skips a hidden one, but it would
167
+ # choke on the non-constant name of one an older version wrote without
168
+ # the dot (#253). A file another
169
+ # live process still owns stays: this runs before the database lock,
170
+ # so a refused boot must not delete that run's mutant. The kept file
171
+ # is hidden from Zeitwerk instead.
113
172
  sweep_temps
173
+ install_zeitwerk_ignore! if cfg["rails"]
114
174
  require File.expand_path(cfg["boot"]) if cfg["boot"]
175
+ @zeitwerk_trace&.disable
176
+ # The --require files, after the boot as in-process (Runner.execute), so
177
+ # the coverage peek sees what they ran at load.
178
+ Array(cfg["require_paths"]).each { |f| require File.expand_path(f) }
115
179
  setup_worker_db(cfg) if cfg["rails"]
116
180
  rescue Exception => e # rubocop:disable Lint/RescueException
117
181
  # Boot failed (bad boot path, app error). Tell the client and exit so it
118
182
  # can surface a clean error rather than hang on the handshake.
119
- @output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
183
+ @output.puts(JSON.generate("ready" => false, "error" => describe_error(e)))
120
184
  @output.flush
121
185
  exit!(1)
122
186
  end
@@ -124,22 +188,61 @@ module Mutineer
124
188
  # Load the per-worker DB adapter app-side (sibling gem file, by relative path
125
189
  # so it bypasses the app bundle, like this daemon itself). No-op unless the
126
190
  # app has ActiveRecord. Records the adapter + schema path so each fork can
127
- # route to its own database. SQLite-only this pass; a non-SQLite config
128
- # raises in the fork and reads as `error`, never a mis-routed verdict.
191
+ # route to its own database.
192
+ #
193
+ # `db_role` "prepare" waits for a provision command (the first daemon).
194
+ # "worker" only takes the shared lock (a worker daemon, including a
195
+ # respawn). Anything else provisions now, which is what a direct client
196
+ # that never sends the command needs. SQLite provisioning does nothing.
129
197
  def setup_worker_db(cfg)
130
198
  require_relative "rails_worker_db"
131
199
  @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
132
200
  schema = cfg["schema"] && File.expand_path(cfg["schema"])
133
201
  @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 = {}
202
+ # Each SQLite slot is seeded once, on first use (not every mutant fork).
203
+ # Postgres and MySQL slots are copied at provision time, so this flag is unused there.
204
+ @slot_ready = {}
205
+ return unless @worker_db
206
+
207
+ case cfg["db_role"]
208
+ when "prepare"
209
+ nil
210
+ when "worker"
211
+ @worker_db.prepare_worker!
212
+ else
213
+ @worker_db.provision(cfg.fetch("slots", 2).to_i)
214
+ end
136
215
  rescue LoadError => e
137
- @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
216
+ @errio.puts("[daemon] worker-DB routing unavailable: #{redact(e.message)}")
138
217
  @worker_db = nil
139
218
  end
140
219
 
220
+ # Database configs in the booted test environment. 1 when the app has no
221
+ # worker-DB adapter. The tool uses this to force a single worker.
222
+ #
223
+ # @return [Integer]
224
+ def database_count
225
+ return 1 unless @worker_db
226
+
227
+ @worker_db.database_count
228
+ rescue StandardError
229
+ 1
230
+ end
231
+
232
+ # Run {RailsWorkerDb.provision} and report success or the cause. A failure
233
+ # stays on this reply. It does not become a mutant verdict.
234
+ #
235
+ # @param slots [Integer, String]
236
+ # @return [Hash]
237
+ def provision_slots(slots)
238
+ @worker_db.provision(Integer(slots)) if @worker_db
239
+ { "ok" => true }
240
+ rescue Exception => e # rubocop:disable Lint/RescueException
241
+ { "ok" => false, "error" => describe_error(e) }
242
+ end
243
+
141
244
  # 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.
245
+ # it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
143
246
  # Capture forks route to worker 0's DB (isolated, serial). On any failure
144
247
  # return an empty map + an error string. The tool then falls back to the
145
248
  # full test set rather than mis-scoring everything as no_coverage.
@@ -149,67 +252,134 @@ module Mutineer
149
252
  cmap = CoverageMap.new(
150
253
  source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
151
254
  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"),
255
+ boot_path: @cfg["boot"], require_paths: Array(@cfg["require_paths"]),
256
+ framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
153
257
  capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
154
258
  ).build_via_fork(after_fork: coverage_after_fork)
259
+ # Nothing reads Coverage after the map, so a mutant forked from this
260
+ # daemon does not pay for it (#228).
261
+ Coverage.suspend if Coverage.running?
155
262
  { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
156
- "failed_clean_tests" => cmap.failed_clean_tests }
263
+ "failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
264
+ "load_methods" => cmap.load_methods.to_a }
157
265
  rescue Exception => e # rubocop:disable Lint/RescueException
158
- @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
159
- { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
266
+ error = describe_error(e)
267
+ @errio.puts("[daemon] coverage build failed: #{error}")
268
+ { "map" => {}, "failed_test_files" => [], "error" => error }
160
269
  end
161
270
 
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.
165
- def coverage_after_fork
166
- return nil unless @worker_db
271
+ # `Class: message` for an error the daemon reports, with credentials hidden.
272
+ # Rails parses DATABASE_URL itself, so its URI error can carry the password.
273
+ #
274
+ # @param error [Exception]
275
+ # @return [String]
276
+ def describe_error(error)
277
+ redact("#{error.class}: #{error.message}")
278
+ end
279
+
280
+ # Hide database credentials in text bound for the client or stderr.
281
+ #
282
+ # @param text [String]
283
+ # @return [String]
284
+ def redact(text)
285
+ Redaction.text(text)
286
+ end
167
287
 
288
+ # Fork-safety hook for coverage capture. Each capture fork drops its copy
289
+ # of the protocol channel (see close_protocol) and, when the app has a
290
+ # worker-DB adapter, routes to a fresh copy of the base DB in worker 0's
291
+ # slot (captures run serially, so one worker is enough).
292
+ def coverage_after_fork
293
+ worker_db = @worker_db
168
294
  schema = @schema_path
169
- -> { @worker_db.after_fork(0, schema) }
295
+ lambda do
296
+ close_protocol
297
+ worker_db&.after_fork(0, schema, seed: true)
298
+ end
299
+ end
300
+
301
+ # A forked child never answers on the protocol channel. Closing its
302
+ # inherited copy lets a daemon crash read as EOF on the client at once,
303
+ # not only after a slow or hung child exits. A caller-supplied output
304
+ # (for example STDOUT) is left open.
305
+ def close_protocol
306
+ @protocol&.close rescue nil # rubocop:disable Style/RescueModifier
170
307
  end
171
308
 
172
309
  # Fork a child to run one mutant in isolation; decode its exit into a verdict.
173
310
  def run_mutant(req)
174
311
  timeout = req.fetch("timeout", 30)
175
312
  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
313
+ # Seed the slot's DB until the first killed/survived fork for this worker slot.
314
+ first_use = @worker_db && !@slot_ready[worker]
315
+ daemon = Process.pid
178
316
  pid = fork do
179
317
  # 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.
318
+ # and silence the child's stdout so test output stays out of the diagnostics.
181
319
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
320
+ OrphanGuard.start(daemon)
182
321
  code =
183
322
  begin
184
323
  ChildStdout.silence
324
+ ChildCoverage.allow_start
325
+ close_protocol
185
326
  # Route THIS fork at its own worker database before any test loads.
186
327
  # A routing failure raises here and is scored `error`, never a false verdict.
187
- @worker_db&.after_fork(worker, schema_for_fork)
328
+ @worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
188
329
  apply_payload(req["payload"])
189
330
  run_tests(Array(req["tests"]))
190
331
  rescue Exception => e # rubocop:disable Lint/RescueException
191
- @errio.puts("[daemon-child] #{e.class}: #{e.message}")
332
+ @errio.puts("[daemon-child] #{describe_error(e)}")
192
333
  2
193
334
  end
194
335
  exit!(code)
195
336
  end
196
337
  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)
338
+ # Mark ready only when the child finished cleanly after seeding
339
+ # (killed/survived). Timeout can interrupt mid-copy; error is a routing
340
+ # failure. Both leave the slot unready so the next fork seeds again.
341
+ @slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
201
342
  # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
202
- # so it cannot outlive the run or trip Zeitwerk on a later fork.
343
+ # so it cannot outlive the run.
203
344
  sweep_temps if verdict == "timeout"
204
345
  { "id" => req["id"], "verdict" => verdict }
205
346
  end
206
347
 
348
+ # Hide kept mutant files from Zeitwerk before the app builds its loaders.
349
+ # The loader class may not exist yet. Watch for it, then stop watching.
350
+ #
351
+ # @return [void]
352
+ def install_zeitwerk_ignore!
353
+ if defined?(Zeitwerk::Loader)
354
+ prepend_daemon_temp_ignore
355
+ return
356
+ end
357
+
358
+ @zeitwerk_trace = TracePoint.new(:end) do |event|
359
+ next unless event.self.is_a?(Class) && event.self.name == "Zeitwerk::Loader"
360
+
361
+ @zeitwerk_trace.disable
362
+ prepend_daemon_temp_ignore
363
+ end
364
+ @zeitwerk_trace.enable
365
+ end
366
+
367
+ # @return [void]
368
+ def prepend_daemon_temp_ignore
369
+ return if Zeitwerk::Loader.ancestors.include?(DaemonTempIgnore)
370
+
371
+ Zeitwerk::Loader.prepend(DaemonTempIgnore)
372
+ end
373
+
207
374
  # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
208
- # SIGKILL path cannot run the child's ensure). Mirrors Runner.sweep_orphans.
375
+ # SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
376
+ # Skips a file another process still owns ({OrphanGuard.mutant_file_in_use?}).
209
377
  def sweep_temps
210
378
  @source_dirs.to_a.each do |dir|
211
- Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
212
- File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
379
+ Dir.glob(File.join(dir, DAEMON_TEMP_BASENAME)).each do |path|
380
+ next if OrphanGuard.mutant_file_in_use?(path)
381
+
382
+ File.unlink(path) rescue nil # rubocop:disable Style/RescueModifier
213
383
  end
214
384
  end
215
385
  end
@@ -219,6 +389,8 @@ module Mutineer
219
389
  # pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
220
390
  # the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
221
391
  # the kill/reap/decode logic must be applied to all three in lockstep.
392
+ # CoverageMap#await_child applies the same deadline and group kill to
393
+ # coverage capture.
222
394
  # SIGKILL the child's process group past the deadline; a signalled child
223
395
  # (nil exitstatus) is `error`.
224
396
  def wait_verdict(pid, timeout)
@@ -254,15 +426,21 @@ module Mutineer
254
426
  # `require_relative` in the mutated source resolves against its real
255
427
  # neighbours. Writing it to the tmpdir would LoadError on such files and
256
428
  # score a spurious `error` that diverges from the in-process path. The
257
- # Zeitwerk hazard (a stray `.rb` in an autoload dir) is handled by the
258
- # boot/timeout `sweep_temps`, not by relocating the file. Same path for
259
- # reload (whole file) and redefine (wrapped snippet).
429
+ # leading dot keeps the file away from Zeitwerk, which skips hidden files:
430
+ # loading it can be the first time Zeitwerk scans a namespace directory,
431
+ # and a parallel worker's file may sit there too (#253). `sweep_temps` only
432
+ # removes orphans and never touches a live file. Same path for reload
433
+ # (whole file) and redefine (wrapped snippet). The backend sends the whole
434
+ # file, so the concern blocks it stores replace the forgotten ones (#256).
260
435
  def apply_payload(payload)
261
436
  dir = File.dirname(File.expand_path(payload.fetch("source_file")))
262
- Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
437
+ Tempfile.create([".mutineer_daemon", ".rb"], dir) do |f|
438
+ # Held until this block ends, including SIGKILL (the kernel drops it).
439
+ # Another run's sweep sees the lock and does not delete the file.
440
+ f.flock(File::LOCK_EX) rescue nil # rubocop:disable Style/RescueModifier
263
441
  f.write(payload.fetch("code"))
264
442
  f.flush
265
- load f.path
443
+ ConcernBlocks.replace(payload.fetch("source_file")) { load f.path }
266
444
  end
267
445
  end
268
446
 
@@ -3,6 +3,7 @@
3
3
  require "shellwords"
4
4
  require "tempfile"
5
5
  require_relative "result"
6
+ require_relative "rails_run_env"
6
7
 
7
8
  module Mutineer
8
9
  # Raised when the smoke check (the unmutated suite) is not green, so the run
@@ -25,6 +26,7 @@ module Mutineer
25
26
  # Child environment: bundler/gem env injected by Mutineer's Ruby is stripped, and
26
27
  # version-manager concrete version bins (e.g. `~/.rbenv/versions/3.4.9/bin`) are
27
28
  # removed from PATH so shims / `.ruby-version` can select the app's Ruby (#32).
29
+ # PARALLEL_WORKERS and DISABLE_SPRING are set to 1 ({RailsRunEnv}).
28
30
  module ExternalBackend
29
31
  # Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
30
32
  # the full suite). The per-mutant timeout is derived from how long this took.
@@ -32,6 +34,9 @@ module Mutineer
32
34
  # Poll interval for the deadline wait loop. Independent of Isolation's loop —
33
35
  # this backend waits on an external process TREE, not an in-process fork.
34
36
  POLL = 0.02
37
+ # Exit codes a shell or wrapper uses when the command could not run at all
38
+ # (126: not executable, 127: not found). Scored `error`, never `killed`.
39
+ CANNOT_RUN_CODES = [126, 127].freeze
35
40
 
36
41
  # PATH entries that pin a concrete Ruby under a version manager (ahead of
37
42
  # shims). Optional trailing slash; normal bins are left alone.
@@ -67,7 +72,8 @@ module Mutineer
67
72
  # onto the parent env: set a key to +nil+ to unset it. Scrubs Mutineer/bundler
68
73
  # injection and version-manager PATH pins so shims / `.ruby-version` can win.
69
74
  # Keeps other vars (e.g. `RAILS_ENV`) so setting them on the Mutineer command
70
- # still reaches the suite.
75
+ # still reaches the suite. Also sets +PARALLEL_WORKERS+ to 1 and
76
+ # +DISABLE_SPRING+ to 1. A wrapper that sets either variable again wins.
71
77
  #
72
78
  # @api private
73
79
  # @return [Hash{String => String, nil}] env for Process.spawn.
@@ -90,6 +96,7 @@ module Mutineer
90
96
  kept
91
97
  end
92
98
  env["PATH"] = path.join(File::PATH_SEPARATOR)
99
+ RailsRunEnv.pin_child!(env, spring: true)
93
100
  env
94
101
  end
95
102
 
@@ -145,6 +152,10 @@ module Mutineer
145
152
  end
146
153
 
147
154
  warn output if verbose && !output.empty?
155
+ # 126 (cannot execute) and 127 (command not found) come from a wrapper
156
+ # such as `bundle exec` that could not start the suite: no test ran.
157
+ return Result.error("test-command could not start (exit #{code})") if CANNOT_RUN_CODES.include?(code)
158
+
148
159
  Result.killed
149
160
  end
150
161
  end
@@ -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;
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "config"
5
+ require_relative "mutator_registry"
6
+
7
+ module Mutineer
8
+ # Writes a starter `.mutineer.yml` and prints the first run command.
9
+ # It does not replace an existing file unless `--force` is set.
10
+ module Init
11
+ # Usage line for a bad invocation.
12
+ USAGE = "Usage: mutineer init [--rails] [--force]"
13
+
14
+ # Writes the config and prints the first command.
15
+ # 0 is success. 2 is a usage error, a refused overwrite, or a missing directory.
16
+ #
17
+ # @param argv [Array<String>] arguments after `init`.
18
+ # @param out [IO] where the command goes.
19
+ # @param err [IO] where errors go.
20
+ # @param dir [String] directory that receives `.mutineer.yml`.
21
+ # @return [Integer] 0 or 2.
22
+ def self.run(argv, out: $stdout, err: $stderr, dir: Dir.pwd)
23
+ rails = false
24
+ force = false
25
+ help = false
26
+ parser = OptionParser.new do |o|
27
+ o.banner = USAGE
28
+ o.on("--rails") { rails = true }
29
+ o.on("--force") { force = true }
30
+ o.on("--help") { help = true }
31
+ end
32
+ begin
33
+ parser.parse!(argv)
34
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
35
+ err.puts "mutineer: #{e.message}"
36
+ return 2
37
+ end
38
+ if help
39
+ out.puts USAGE
40
+ return 0
41
+ end
42
+ unless argv.empty?
43
+ err.puts "mutineer: init takes no source paths"
44
+ return 2
45
+ end
46
+
47
+ path = File.join(dir, CONFIG_FILE)
48
+ if File.directory?(path)
49
+ err.puts "mutineer: #{CONFIG_FILE} is a directory"
50
+ return 2
51
+ end
52
+ if File.exist?(path) && !force
53
+ err.puts "mutineer: #{CONFIG_FILE} already exists. Pass --force to replace it."
54
+ return 2
55
+ end
56
+ # The command writes one file. It does not create the project directory.
57
+ unless File.directory?(dir)
58
+ err.puts "mutineer: cannot write #{CONFIG_FILE}: no such directory"
59
+ return 2
60
+ end
61
+
62
+ begin
63
+ File.write(path, template(rails: rails))
64
+ rescue SystemCallError => e
65
+ err.puts "mutineer: cannot write #{CONFIG_FILE}: #{e.message}"
66
+ return 2
67
+ end
68
+ command = command_for(dir, rails: rails)
69
+ if command
70
+ out.puts command
71
+ else
72
+ err.puts "mutineer: no source folder found. Create app/models or lib, then run mutineer on that folder."
73
+ end
74
+ 0
75
+ end
76
+
77
+ # Starter config. `since` stays commented so the first run is a full scan.
78
+ # `--rails` also sets `rails: true` and leaves `daemon` commented.
79
+ #
80
+ # @param rails [Boolean] when true, set `rails: true`.
81
+ # @return [String] YAML text.
82
+ def self.template(rails:)
83
+ operators = MutatorRegistry::DEFAULT_NAMES.map { |name| " - #{name}" }.join("\n")
84
+ head = <<~YAML
85
+ # Mutineer config. Command-line flags override this file.
86
+ # These operators are the default set.
87
+ operators:
88
+ YAML
89
+ tail = <<~YAML
90
+ # since: origin/main
91
+ # Leave this commented so the first run is a full scan.
92
+ YAML
93
+ body = "#{head}#{operators}\n\n#{tail}"
94
+ return body unless rails
95
+
96
+ extra = <<~YAML
97
+ rails: true
98
+
99
+ # daemon: true
100
+ # Leave this commented until the support matrix says the daemon fits.
101
+ # The matrix is on https://davidteren.github.io/mutineer/rails.html#daemon
102
+ YAML
103
+ "#{body.rstrip}\n\n#{extra}"
104
+ end
105
+
106
+ # The first run command. Plain init names `lib`.
107
+ # `--rails` names each existing folder among `app/models`, `app/services`, and `lib`.
108
+ # A missing folder is left out: `mutineer run` rejects a path that is not there.
109
+ #
110
+ # @param dir [String] project directory.
111
+ # @param rails [Boolean] when true, print the Rails source folders.
112
+ # @return [String, nil] the command, or nil when no Rails source folder exists.
113
+ def self.command_for(dir, rails:)
114
+ return "mutineer run lib" unless rails
115
+
116
+ folders = ["app/models", "app/services", "lib"].select do |folder|
117
+ File.directory?(File.join(dir, folder))
118
+ end
119
+ return if folders.empty?
120
+
121
+ "mutineer run #{folders.join(" ")}"
122
+ end
123
+ end
124
+ end