mutineer 1.4.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +475 -0
  3. data/README.md +224 -28
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +117 -20
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +533 -100
  8. data/lib/mutineer/daemon_backend.rb +51 -33
  9. data/lib/mutineer/daemon_client.rb +212 -22
  10. data/lib/mutineer/daemon_server.rb +74 -27
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/file_swap.rb +1 -1
  13. data/lib/mutineer/isolation.rb +92 -6
  14. data/lib/mutineer/job_plan.rb +352 -0
  15. data/lib/mutineer/kill_channel.rb +284 -0
  16. data/lib/mutineer/kill_matrix.rb +88 -0
  17. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  18. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  19. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  20. data/lib/mutineer/minitest_integration.rb +75 -11
  21. data/lib/mutineer/mutant_id.rb +5 -4
  22. data/lib/mutineer/mutators/array_literal.rb +0 -7
  23. data/lib/mutineer/mutators/base.rb +63 -4
  24. data/lib/mutineer/mutators/chain_link.rb +0 -7
  25. data/lib/mutineer/mutators/collection_method.rb +1 -1
  26. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  27. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  28. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  29. data/lib/mutineer/mutators/return_nil.rb +5 -11
  30. data/lib/mutineer/mutators/string_literal.rb +1 -1
  31. data/lib/mutineer/orphan_guard.rb +28 -0
  32. data/lib/mutineer/pairing.rb +222 -15
  33. data/lib/mutineer/parser.rb +12 -3
  34. data/lib/mutineer/project.rb +344 -21
  35. data/lib/mutineer/rails_worker_db.rb +83 -9
  36. data/lib/mutineer/reporter.rb +306 -53
  37. data/lib/mutineer/result.rb +73 -5
  38. data/lib/mutineer/runner.rb +123 -242
  39. data/lib/mutineer/statement_lines.rb +183 -0
  40. data/lib/mutineer/subject.rb +6 -2
  41. data/lib/mutineer/test_runners/minitest.rb +4 -2
  42. data/lib/mutineer/test_runners/rspec.rb +282 -3
  43. data/lib/mutineer/test_runners.rb +5 -3
  44. data/lib/mutineer/version.rb +1 -1
  45. data/lib/mutineer/worker_pool.rb +4 -2
  46. data/lib/mutineer.rb +2 -0
  47. metadata +10 -3
@@ -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,27 +290,33 @@ 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),
282
- source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
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),
297
+ cache_dir: File.expand_path(config.cache_dir, config.project_root),
298
+ source_dirs: JobPlan.source_dirs(config), # so the daemon can sweep orphan mutant temps
283
299
  framework: config.framework,
284
300
  rails: config.rails,
285
- # Schema for per-worker DB isolation. Sent when present; the daemon
286
- # 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.
287
303
  schema: schema_path(config),
288
304
  # Coverage narrowing. Only the short-lived map-building daemon starts
289
305
  # Coverage (before boot); worker daemons boot with it OFF (no wasted
290
306
  # instrumentation/memory across every mutant fork). `sources`/`tests` are the
291
307
  # map-build inputs.
292
308
  coverage: coverage,
309
+ # The map-building daemon's capture limit (--capture-timeout); nil = default.
310
+ capture_timeout: config.capture_timeout,
293
311
  sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
294
312
  tests: abs_tests
295
313
  }
296
314
  end
297
315
 
298
- # Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
299
- # daemon to schema-load each fork's isolated worker database. Only `schema.rb`
300
- # is supported this pass; `structure.sql` apps get nil and fall back to
301
- # 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.
302
320
  #
303
321
  # @param config [Mutineer::Config] the run config.
304
322
  # @api private
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "io/wait"
4
5
  require "open3"
6
+ require_relative "external_backend"
5
7
 
6
8
  module Mutineer
7
9
  # Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
@@ -11,12 +13,19 @@ module Mutineer
11
13
  # to a runtime error (exit 1).
12
14
  class DaemonBootError < StandardError; end
13
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
+
14
21
  # Tool-side handle for the app-side daemon.
15
22
  #
16
- # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY (cleaned env so the
17
- # gem's bundler context never leaks; the daemon file is loaded by absolute path
18
- # with `-r`, which bypasses the app bundle that has no mutineer), completes the
19
- # ready handshake, then ships per-mutant payloads and reads structured verdicts.
23
+ # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
24
+ # environment from before the tool's Bundler activated, with
25
+ # <tt>unsetenv_others</tt>, because Open3 keeps a key the hash omits. The
26
+ # daemon file is loaded by absolute path with `-r`, which bypasses the app
27
+ # bundle that has no mutineer. The client completes the ready handshake,
28
+ # then ships per-mutant payloads and reads structured verdicts.
20
29
  # If the daemon dies mid-run it respawns (bounded) and marks the in-flight
21
30
  # mutant `error` rather than corrupting the run. Reuses the cleaned-env spawn
22
31
  # and stderr-drain proven in the spike driver and the spawn discipline of
@@ -26,11 +35,22 @@ module Mutineer
26
35
  DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
27
36
  # How many times to respawn a crashing daemon before aborting the run.
28
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
45
+ # Bundler's marker for a variable that was unset before it activated.
46
+ BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
29
47
 
30
48
  # @param boot [Hash] boot config sent to the daemon: project_root, boot,
31
49
  # load_paths, framework, rails.
32
50
  # @param app_root [String] directory to spawn the daemon in (the app root).
33
- # @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby (nil = inherit).
51
+ # @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby.
52
+ # nil keeps a pin already in the environment. That pin is often the
53
+ # Ruby that started the tool.
34
54
  # @param gemfile [String, nil] BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).
35
55
  # @param errio [IO] where daemon stderr is drained.
36
56
  def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
@@ -76,7 +96,7 @@ module Mutineer
76
96
  reply =
77
97
  begin
78
98
  send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
79
- read_line
99
+ read_line(timeout + REPLY_GRACE)
80
100
  rescue Errno::EPIPE, IOError
81
101
  nil
82
102
  end
@@ -110,32 +130,181 @@ module Mutineer
110
130
  return unless @stdin
111
131
 
112
132
  send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
113
- @wait_thr&.join
133
+ @wait_thr&.join(REPLY_GRACE) # a wedged daemon is killed by close_io
114
134
  ensure
115
135
  close_io
116
136
  end
117
137
 
118
138
  private
119
139
 
120
- # Cleaned environment for the app bundle: strip the gem's bundler/Ruby context
121
- # so `bundle exec` resolves the APP's Gemfile under the requested Ruby.
140
+ # Full environment for the daemon child. Spawn with +unsetenv_others+ so a
141
+ # key deleted here does not leak back from the parent. Undo only what
142
+ # Bundler saved before it activated, then point the child at the target app.
143
+ #
144
+ # @api private
145
+ # @return [Hash{String => String}]
122
146
  def app_env
123
- env = ENV.to_h.reject { |k, _| k.start_with?("BUNDLE_", "RUBY", "GEM_") }
147
+ env = restored_user_env
148
+ scrub_managed_ruby_bins!(env)
149
+ env.delete("BUNDLER_SETUP")
124
150
  env["BUNDLE_GEMFILE"] = @gemfile
125
151
  env["RBENV_VERSION"] = @ruby_version if @ruby_version
126
- env["RAILS_ENV"] ||= "test" if @boot[:rails] || @boot["rails"]
152
+ env["RAILS_ENV"] = "test" if rails_boot? && !env.key?("RAILS_ENV")
153
+ env
154
+ end
155
+
156
+ # Environment with every <tt>BUNDLER_ORIG_*</tt> value put back. Bundler
157
+ # writes one saved key per variable it replaced. Restoring all of them
158
+ # undoes the tool bundle without a copied key list.
159
+ #
160
+ # @api private
161
+ # @return [Hash{String => String}]
162
+ def restored_user_env
163
+ env = ENV.to_h
164
+ restored = []
165
+ env.keys.each do |saved_key|
166
+ next unless saved_key.start_with?("BUNDLER_ORIG_")
167
+
168
+ key = saved_key.delete_prefix("BUNDLER_ORIG_")
169
+ saved = env.delete(saved_key)
170
+ restored << key
171
+ if saved == BUNDLER_UNSET
172
+ env.delete(key)
173
+ else
174
+ env[key] = saved
175
+ end
176
+ end
177
+ # A parent require must not run in the app, even when Bundler saved it.
178
+ %w[RUBYOPT RUBYLIB].each { |key| env.delete(key) }
179
+ # No saved original means the value belongs to the tool process.
180
+ %w[GEM_HOME GEM_PATH BUNDLE_PATH BUNDLE_WITHOUT BUNDLER_VERSION].each do |key|
181
+ env.delete(key) unless restored.include?(key)
182
+ end
127
183
  env
128
184
  end
129
185
 
186
+ # Drop rbenv and asdf version bins so shims can select the app Ruby.
187
+ # Leave those bins in place when no shim directory exists, so +bundle+
188
+ # stays on PATH. Leave chruby bins in place.
189
+ #
190
+ # @api private
191
+ # @param env [Hash{String => String}]
192
+ # @return [void]
193
+ def scrub_managed_ruby_bins!(env)
194
+ parts = env["PATH"].to_s.split(File::PATH_SEPARATOR)
195
+ rbenv = rbenv_shim_dirs(env)
196
+ asdf = asdf_shim_dirs(env)
197
+ dropped_rbenv = false
198
+ dropped_asdf = false
199
+ kept = parts.reject do |part|
200
+ if rbenv_version_bin?(part, env) && rbenv.any?
201
+ dropped_rbenv = true
202
+ elsif asdf_version_bin?(part, env) && asdf.any?
203
+ dropped_asdf = true
204
+ else
205
+ false
206
+ end
207
+ end
208
+ return if kept.size == parts.size
209
+
210
+ shims = []
211
+ shims.concat(rbenv) if dropped_rbenv
212
+ shims.concat(asdf) if dropped_asdf
213
+ env["PATH"] = (shims + kept).uniq.join(File::PATH_SEPARATOR)
214
+ end
215
+
216
+ # Existing rbenv shim directory. +RBENV_ROOT+ wins. Otherwise the home
217
+ # directory's <tt>.rbenv</tt> is used.
218
+ #
219
+ # @api private
220
+ # @param env [Hash{String => String}]
221
+ # @return [Array<String>]
222
+ def rbenv_shim_dirs(env)
223
+ root = rbenv_root(env)
224
+ return [] if root.nil?
225
+
226
+ dir = File.join(root, "shims")
227
+ File.directory?(dir) ? [dir] : []
228
+ end
229
+
230
+ # rbenv install root. +RBENV_ROOT+ wins over <tt>~/.rbenv</tt>.
231
+ #
232
+ # @api private
233
+ # @param env [Hash{String => String}]
234
+ # @return [String, nil]
235
+ def rbenv_root(env)
236
+ root = env["RBENV_ROOT"]
237
+ return File.expand_path(root) if root && !root.empty?
238
+
239
+ home = env["HOME"]
240
+ return nil if home.nil? || home.empty?
241
+
242
+ File.join(home, ".rbenv")
243
+ end
244
+
245
+ # Existing asdf shim directories. A custom install uses +ASDF_DATA_DIR+.
246
+ #
247
+ # @api private
248
+ # @param env [Hash{String => String}]
249
+ # @return [Array<String>]
250
+ def asdf_shim_dirs(env)
251
+ dirs = []
252
+ data = env["ASDF_DATA_DIR"]
253
+ dirs << File.join(data, "shims") if data && !data.empty?
254
+ home = env["HOME"]
255
+ dirs << File.join(home, ".asdf", "shims") if home && !home.empty?
256
+ dirs.select { |dir| File.directory?(dir) }.uniq
257
+ end
258
+
259
+ # True when `part` is a version bin under the active rbenv root.
260
+ #
261
+ # @api private
262
+ # @param part [String] one PATH entry.
263
+ # @param env [Hash{String => String}]
264
+ # @return [Boolean]
265
+ def rbenv_version_bin?(part, env)
266
+ root = rbenv_root(env)
267
+ return false if root.nil?
268
+
269
+ prefix = File.join(root, "versions")
270
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
271
+ end
272
+
273
+ # True when `part` is an asdf Ruby version bin under ~/.asdf or +ASDF_DATA_DIR+.
274
+ #
275
+ # @api private
276
+ # @param part [String] one PATH entry.
277
+ # @param env [Hash{String => String}]
278
+ # @return [Boolean]
279
+ def asdf_version_bin?(part, env)
280
+ return true if part.match?(%r{/\.asdf/installs/ruby/[^/]+/bin/?\z})
281
+
282
+ data = env["ASDF_DATA_DIR"]
283
+ return false if data.nil? || data.empty?
284
+
285
+ prefix = File.join(File.expand_path(data), "installs", "ruby")
286
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
287
+ end
288
+
289
+ # True when this daemon boots a Rails app.
290
+ #
291
+ # @api private
292
+ # @return [Boolean]
293
+ def rails_boot?
294
+ @boot[:rails] || @boot["rails"]
295
+ end
296
+
130
297
  # Spawn the daemon under the app bundle and complete the ready handshake.
131
298
  #
132
299
  # @return [void]
133
300
  # @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
134
301
  def spawn_daemon
135
302
  # Plain `bundle exec ruby`, NOT `rbenv exec`, which would break CI and any
136
- # non-rbenv setup. When bundler/ruby are rbenv shims, the RBENV_VERSION
137
- # carried in app_env still selects the app's Ruby; otherwise the active
138
- # Ruby is used.
303
+ # non-rbenv setup. An explicit ruby_version replaces RBENV_VERSION.
304
+ # With no argument, a pin already in the environment stays. That pin is
305
+ # often the Ruby that started the tool. `.ruby-version` applies only
306
+ # when no pin is set. rbenv and asdf version bins leave PATH when a shim
307
+ # directory exists. chruby bins stay.
139
308
  # Everything up to the handshake is terminal, not one mutant's problem: a spawn
140
309
  # the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
141
310
  # resolve) and a daemon that dies before accepting the boot payload (EPIPE on
@@ -145,7 +314,8 @@ module Mutineer
145
314
  begin
146
315
  @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
147
316
  app_env, "bundle", "exec", "ruby",
148
- "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run", chdir: @app_root
317
+ "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
318
+ unsetenv_others: true, chdir: @app_root
149
319
  )
150
320
  # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
151
321
  # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
@@ -158,16 +328,21 @@ module Mutineer
158
328
  end
159
329
 
160
330
  send_line(@boot)
161
- read_line
331
+ read_line(BOOT_TIMEOUT)
162
332
  rescue SystemCallError, IOError => e
163
333
  close_io
164
334
  raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
165
335
  end
166
336
 
167
337
  unless ready && ready["ready"]
168
- 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
169
344
  close_io
170
- 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}"
171
346
  end
172
347
  end
173
348
 
@@ -176,10 +351,11 @@ module Mutineer
176
351
  close_io
177
352
  @restarts += 1
178
353
  if @restarts > MAX_RESTARTS
179
- raise DaemonBootError, "daemon crashed #{@restarts} times; aborting the run"
354
+ raise DaemonBootError, "daemon crashed or stopped answering #{@restarts} times; aborting the run"
180
355
  end
181
356
 
182
- @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})")
183
359
  spawn_daemon
184
360
  end
185
361
 
@@ -192,8 +368,15 @@ module Mutineer
192
368
  @stdin.flush
193
369
  end
194
370
 
195
- # Read one JSON reply line; nil on EOF/dead pipe (caller treats as a crash).
196
- 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
+
197
380
  line = @stdout.gets
198
381
  line && JSON.parse(line.strip)
199
382
  rescue IOError, Errno::EPIPE, JSON::ParserError
@@ -205,6 +388,13 @@ module Mutineer
205
388
  #
206
389
  # @return [void]
207
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
208
398
  @drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
209
399
  [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
210
400
  @wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie