mutineer 1.4.0 → 1.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +250 -0
  3. data/README.md +198 -27
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +112 -16
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutators/array_literal.rb +0 -7
  21. data/lib/mutineer/mutators/base.rb +63 -4
  22. data/lib/mutineer/mutators/chain_link.rb +0 -7
  23. data/lib/mutineer/mutators/collection_method.rb +1 -1
  24. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  25. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  26. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  27. data/lib/mutineer/mutators/return_nil.rb +5 -11
  28. data/lib/mutineer/mutators/string_literal.rb +1 -1
  29. data/lib/mutineer/pairing.rb +222 -15
  30. data/lib/mutineer/parser.rb +12 -3
  31. data/lib/mutineer/project.rb +7 -0
  32. data/lib/mutineer/rails_worker_db.rb +1 -1
  33. data/lib/mutineer/reporter.rb +288 -52
  34. data/lib/mutineer/result.rb +37 -1
  35. data/lib/mutineer/runner.rb +133 -16
  36. data/lib/mutineer/statement_lines.rb +143 -0
  37. data/lib/mutineer/test_runners/minitest.rb +4 -2
  38. data/lib/mutineer/test_runners/rspec.rb +243 -3
  39. data/lib/mutineer/test_runners.rb +5 -3
  40. data/lib/mutineer/version.rb +1 -1
  41. data/lib/mutineer/worker_pool.rb +4 -2
  42. data/lib/mutineer.rb +1 -0
  43. metadata +8 -3
@@ -10,6 +10,7 @@ require_relative "minitest_integration"
10
10
  require_relative "test_runners"
11
11
  require_relative "child_stdout"
12
12
  require_relative "project_path"
13
+ require_relative "pairing"
13
14
 
14
15
  module Mutineer
15
16
  # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
@@ -98,8 +99,11 @@ module Mutineer
98
99
  # rather than a genuine coverage gap? True iff some capture failed this run
99
100
  # AND this file got zero coverage from any successful capture AND a failed
100
101
  # test file maps to it by the _test/_spec/test_ naming convention. Derived
101
- # purely from already-persisted state (@map keys + @failed_test_files); no
102
- # rerun, no new cached field, no digest change.
102
+ # from already-persisted state (@map keys + @failed_test_files). A split
103
+ # name checks the longer source files that existed when that check first
104
+ # ran for this map. Adding or deleting one of those files changes the
105
+ # coverage digest, so the next run captures again instead of retargeting
106
+ # a stored failure.
103
107
  #
104
108
  # File-level, convention-based attribution. A line covered only by a failed
105
109
  # test in an otherwise-covered file stays no_coverage (condition 2), and a
@@ -112,7 +116,7 @@ module Mutineer
112
116
  rel = relativize(absolute(file))
113
117
  return false if covered_source_files.include?(rel)
114
118
 
115
- failed_test_targets.include?(File.basename(rel, ".rb"))
119
+ failed_test_blames?(rel)
116
120
  end
117
121
 
118
122
  # Per-method taint. A mutant on a line whose enclosing method got zero
@@ -131,7 +135,7 @@ module Mutineer
131
135
  return false if @failed_test_files.empty?
132
136
 
133
137
  rel = relativize(absolute(file))
134
- return false unless failed_test_targets.include?(File.basename(rel, ".rb"))
138
+ return false unless failed_test_blames?(rel)
135
139
 
136
140
  line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
137
141
  end
@@ -143,17 +147,119 @@ module Mutineer
143
147
  @map.keys.map { |k| k.rpartition(":").first }.to_set
144
148
  end
145
149
 
146
- # Basenames of the sources that failed test files pair with by convention:
147
- # a trailing _test/_spec is stripped first, as pairing tries that form first.
148
- def failed_test_targets
149
- @failed_test_files.map do |t|
150
- name = File.basename(t, ".rb")
151
- case name
152
- when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
153
- when "test_helper" then name # Minitest's support file pairs with no source
154
- else name.delete_prefix("test_")
150
+ # True when a failed test file pairs with `source_rel` by convention.
151
+ # Exact `_test` / `_spec` / `test_` names match by basename, as before.
152
+ # A split `<name>_*_test.rb` matches only in the mirrored test directory,
153
+ # and only when no longer source file owns that name (#87). The answer
154
+ # is kept for this map, so a later delete does not move the failure.
155
+ #
156
+ # @param source_rel [String] project-relative source path.
157
+ # @return [Boolean]
158
+ def failed_test_blames?(source_rel)
159
+ @blame_for ||= {}
160
+ return @blame_for[source_rel] if @blame_for.key?(source_rel)
161
+
162
+ @blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
163
+ end
164
+
165
+ # True when a failed exact `_test` / `_spec` / `test_` file pairs with
166
+ # this source. An `app/` or `lib/` source only accepts a `test/` or
167
+ # `spec/` file from its mirrored directory, so
168
+ # `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
169
+ # Any other source still matches by basename. A fixture under
170
+ # `test/fixtures/` and an explicit test both name their source that way.
171
+ #
172
+ # @param source_rel [String] project-relative source path.
173
+ # @return [Boolean]
174
+ def exact_failed_test?(source_rel)
175
+ name = File.basename(source_rel, ".rb")
176
+ base, lib = Pairing.logical_path(source_rel)
177
+ @failed_test_files.any? do |test_path|
178
+ next false unless failed_test_target_name(test_path) == name
179
+
180
+ rel = relativize(test_path)
181
+ dir = File.dirname(rel)
182
+ if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
183
+ mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
184
+ else
185
+ true
155
186
  end
156
- end.to_set
187
+ end
188
+ end
189
+
190
+ # True when pairing gives this source a mirrored test directory.
191
+ #
192
+ # @param source_rel [String] project-relative source path.
193
+ # @return [Boolean]
194
+ def app_or_lib_source?(source_rel)
195
+ source_rel.start_with?("app/", "lib/")
196
+ end
197
+
198
+ # Basename a failed test file would pair with, before the directory check.
199
+ #
200
+ # @param test_path [String]
201
+ # @return [String]
202
+ def failed_test_target_name(test_path)
203
+ name = File.basename(test_path, ".rb")
204
+ case name
205
+ when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
206
+ when "test_helper" then name
207
+ else name.delete_prefix("test_")
208
+ end
209
+ end
210
+
211
+ # True when `dir` is `test`, `spec`, or a directory under one of them.
212
+ #
213
+ # @param dir [String] project-relative directory.
214
+ # @return [Boolean]
215
+ def conventional_test_dir?(dir)
216
+ dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
217
+ end
218
+
219
+ # True when a failed split test file pairs with this source. Directory and
220
+ # the longer-source check match {Pairing}, so a test outside the mirror, or
221
+ # one owned by `user_session.rb`, does not taint `user.rb`.
222
+ #
223
+ # @param source_rel [String] project-relative source path.
224
+ # @return [Boolean]
225
+ def split_failed_test?(source_rel)
226
+ base, lib = Pairing.logical_path(source_rel)
227
+ name = File.basename(base)
228
+ @failed_test_files.any? do |t|
229
+ test_rel = relativize(t)
230
+ entry = File.basename(test_rel)
231
+ next false unless Pairing.split_entry?(entry, name)
232
+ next false unless mirrored_test_dir?(test_rel, base, lib)
233
+ next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
234
+
235
+ true
236
+ end
237
+ end
238
+
239
+ # True when `test_rel` sits in a mirrored test directory for `base`.
240
+ #
241
+ # @param test_rel [String] project-relative test path.
242
+ # @param base [String] logical source path without extension.
243
+ # @param lib [Boolean] whether the source originated from lib/.
244
+ # @return [Boolean]
245
+ def mirrored_test_dir?(test_rel, base, lib)
246
+ dirs = [Pairing.mirror_dir("test", base)]
247
+ dirs << Pairing.mirror_dir("test/lib", base) if lib
248
+ dirs.include?(File.dirname(test_rel))
249
+ end
250
+
251
+ # True when `test_rel` sits in a mirrored spec directory for `base`.
252
+ # Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
253
+ # file uses this directory.
254
+ #
255
+ # @param test_rel [String] project-relative test path.
256
+ # @param base [String] logical source path without extension.
257
+ # @param lib [Boolean] whether the source originated from lib/.
258
+ # @return [Boolean]
259
+ def spec_mirror?(test_rel, base, lib)
260
+ dirs = [Pairing.mirror_dir("spec", base)]
261
+ dirs << Pairing.mirror_dir("spec/lib", base) if lib
262
+ dirs.include?(File.dirname(test_rel))
157
263
  end
158
264
 
159
265
  # Shared cache dance for both build paths: hit the digest-keyed cache, else
@@ -809,11 +915,52 @@ module Mutineer
809
915
  digest_group(d, "source", @source_paths)
810
916
  digest_group(d, "test", @test_paths)
811
917
  digest_group(d, "boot", [boot_digest_path]) if @boot_path
918
+ ownership_paths.each do |rel|
919
+ d.update("owner\0")
920
+ d.update(rel)
921
+ d.update("\0")
922
+ end
812
923
  @load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
813
924
  d.update("framework\0#{@framework}\0")
814
925
  d.hexdigest
815
926
  end
816
927
 
928
+ # Longer source files that can take a split test from a configured source.
929
+ # Only their presence is digested. A content edit of a sibling does not
930
+ # change pairing, and it does not rebuild coverage. Each directory is
931
+ # listed once, even when many sources share it.
932
+ #
933
+ # @return [Array<String>] project-relative paths, sorted.
934
+ def ownership_paths
935
+ root = File.expand_path(@project_root)
936
+ listings = {}
937
+ paths = @source_paths.flat_map do |source|
938
+ rel = relativize(absolute(source))
939
+ next [] if rel.start_with?("/")
940
+
941
+ base, = Pairing.logical_path(rel)
942
+ name = File.basename(base)
943
+ dir = File.dirname(base)
944
+ folders = [dir == "." ? nil : dir]
945
+ %w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
946
+ folders.uniq.flat_map do |folder|
947
+ abs = folder ? File.join(root, folder) : root
948
+ entries = listings.fetch(abs) do
949
+ listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
950
+ end
951
+ entries.filter_map do |entry|
952
+ next unless entry.end_with?(".rb")
953
+
954
+ stem = entry.delete_suffix(".rb")
955
+ next unless stem.start_with?("#{name}_") && stem.length > name.length
956
+
957
+ folder ? File.join(folder, entry) : entry
958
+ end
959
+ end
960
+ end
961
+ paths.uniq.sort
962
+ end
963
+
817
964
  # boot_path is a require-style path (e.g. "config/environment", no extension);
818
965
  # resolve it to the real file for reading, appending ".rb" when needed.
819
966
  def boot_digest_path
@@ -279,6 +279,7 @@ module Mutineer
279
279
  project_root: config.project_root,
280
280
  boot: File.expand_path(config.boot || "config/environment", config.project_root),
281
281
  load_paths: Runner.test_load_roots(abs_tests),
282
+ cache_dir: File.expand_path(config.cache_dir, config.project_root),
282
283
  source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
283
284
  framework: config.framework,
284
285
  rails: config.rails,
@@ -290,6 +291,8 @@ module Mutineer
290
291
  # instrumentation/memory across every mutant fork). `sources`/`tests` are the
291
292
  # map-build inputs.
292
293
  coverage: coverage,
294
+ # The map-building daemon's capture limit (--capture-timeout); nil = default.
295
+ capture_timeout: config.capture_timeout,
293
296
  sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
294
297
  tests: abs_tests
295
298
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
+ require_relative "external_backend"
5
6
 
6
7
  module Mutineer
7
8
  # Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
@@ -13,10 +14,12 @@ module Mutineer
13
14
 
14
15
  # Tool-side handle for the app-side daemon.
15
16
  #
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.
17
+ # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY. The child gets the
18
+ # environment from before the tool's Bundler activated, with
19
+ # <tt>unsetenv_others</tt>, because Open3 keeps a key the hash omits. The
20
+ # daemon file is loaded by absolute path with `-r`, which bypasses the app
21
+ # bundle that has no mutineer. The client completes the ready handshake,
22
+ # then ships per-mutant payloads and reads structured verdicts.
20
23
  # If the daemon dies mid-run it respawns (bounded) and marks the in-flight
21
24
  # mutant `error` rather than corrupting the run. Reuses the cleaned-env spawn
22
25
  # and stderr-drain proven in the spike driver and the spawn discipline of
@@ -26,11 +29,15 @@ module Mutineer
26
29
  DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
27
30
  # How many times to respawn a crashing daemon before aborting the run.
28
31
  MAX_RESTARTS = 3
32
+ # Bundler's marker for a variable that was unset before it activated.
33
+ BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
29
34
 
30
35
  # @param boot [Hash] boot config sent to the daemon: project_root, boot,
31
36
  # load_paths, framework, rails.
32
37
  # @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).
38
+ # @param ruby_version [String, nil] RBENV_VERSION for the app's Ruby.
39
+ # nil keeps a pin already in the environment. That pin is often the
40
+ # Ruby that started the tool.
34
41
  # @param gemfile [String, nil] BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).
35
42
  # @param errio [IO] where daemon stderr is drained.
36
43
  def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
@@ -117,25 +124,174 @@ module Mutineer
117
124
 
118
125
  private
119
126
 
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.
127
+ # Full environment for the daemon child. Spawn with +unsetenv_others+ so a
128
+ # key deleted here does not leak back from the parent. Undo only what
129
+ # Bundler saved before it activated, then point the child at the target app.
130
+ #
131
+ # @api private
132
+ # @return [Hash{String => String}]
122
133
  def app_env
123
- env = ENV.to_h.reject { |k, _| k.start_with?("BUNDLE_", "RUBY", "GEM_") }
134
+ env = restored_user_env
135
+ scrub_managed_ruby_bins!(env)
136
+ env.delete("BUNDLER_SETUP")
124
137
  env["BUNDLE_GEMFILE"] = @gemfile
125
138
  env["RBENV_VERSION"] = @ruby_version if @ruby_version
126
- env["RAILS_ENV"] ||= "test" if @boot[:rails] || @boot["rails"]
139
+ env["RAILS_ENV"] = "test" if rails_boot? && !env.key?("RAILS_ENV")
140
+ env
141
+ end
142
+
143
+ # Environment with every <tt>BUNDLER_ORIG_*</tt> value put back. Bundler
144
+ # writes one saved key per variable it replaced. Restoring all of them
145
+ # undoes the tool bundle without a copied key list.
146
+ #
147
+ # @api private
148
+ # @return [Hash{String => String}]
149
+ def restored_user_env
150
+ env = ENV.to_h
151
+ restored = []
152
+ env.keys.each do |saved_key|
153
+ next unless saved_key.start_with?("BUNDLER_ORIG_")
154
+
155
+ key = saved_key.delete_prefix("BUNDLER_ORIG_")
156
+ saved = env.delete(saved_key)
157
+ restored << key
158
+ if saved == BUNDLER_UNSET
159
+ env.delete(key)
160
+ else
161
+ env[key] = saved
162
+ end
163
+ end
164
+ # A parent require must not run in the app, even when Bundler saved it.
165
+ %w[RUBYOPT RUBYLIB].each { |key| env.delete(key) }
166
+ # No saved original means the value belongs to the tool process.
167
+ %w[GEM_HOME GEM_PATH BUNDLE_PATH BUNDLE_WITHOUT BUNDLER_VERSION].each do |key|
168
+ env.delete(key) unless restored.include?(key)
169
+ end
127
170
  env
128
171
  end
129
172
 
173
+ # Drop rbenv and asdf version bins so shims can select the app Ruby.
174
+ # Leave those bins in place when no shim directory exists, so +bundle+
175
+ # stays on PATH. Leave chruby bins in place.
176
+ #
177
+ # @api private
178
+ # @param env [Hash{String => String}]
179
+ # @return [void]
180
+ def scrub_managed_ruby_bins!(env)
181
+ parts = env["PATH"].to_s.split(File::PATH_SEPARATOR)
182
+ rbenv = rbenv_shim_dirs(env)
183
+ asdf = asdf_shim_dirs(env)
184
+ dropped_rbenv = false
185
+ dropped_asdf = false
186
+ kept = parts.reject do |part|
187
+ if rbenv_version_bin?(part, env) && rbenv.any?
188
+ dropped_rbenv = true
189
+ elsif asdf_version_bin?(part, env) && asdf.any?
190
+ dropped_asdf = true
191
+ else
192
+ false
193
+ end
194
+ end
195
+ return if kept.size == parts.size
196
+
197
+ shims = []
198
+ shims.concat(rbenv) if dropped_rbenv
199
+ shims.concat(asdf) if dropped_asdf
200
+ env["PATH"] = (shims + kept).uniq.join(File::PATH_SEPARATOR)
201
+ end
202
+
203
+ # Existing rbenv shim directory. +RBENV_ROOT+ wins. Otherwise the home
204
+ # directory's <tt>.rbenv</tt> is used.
205
+ #
206
+ # @api private
207
+ # @param env [Hash{String => String}]
208
+ # @return [Array<String>]
209
+ def rbenv_shim_dirs(env)
210
+ root = rbenv_root(env)
211
+ return [] if root.nil?
212
+
213
+ dir = File.join(root, "shims")
214
+ File.directory?(dir) ? [dir] : []
215
+ end
216
+
217
+ # rbenv install root. +RBENV_ROOT+ wins over <tt>~/.rbenv</tt>.
218
+ #
219
+ # @api private
220
+ # @param env [Hash{String => String}]
221
+ # @return [String, nil]
222
+ def rbenv_root(env)
223
+ root = env["RBENV_ROOT"]
224
+ return File.expand_path(root) if root && !root.empty?
225
+
226
+ home = env["HOME"]
227
+ return nil if home.nil? || home.empty?
228
+
229
+ File.join(home, ".rbenv")
230
+ end
231
+
232
+ # Existing asdf shim directories. A custom install uses +ASDF_DATA_DIR+.
233
+ #
234
+ # @api private
235
+ # @param env [Hash{String => String}]
236
+ # @return [Array<String>]
237
+ def asdf_shim_dirs(env)
238
+ dirs = []
239
+ data = env["ASDF_DATA_DIR"]
240
+ dirs << File.join(data, "shims") if data && !data.empty?
241
+ home = env["HOME"]
242
+ dirs << File.join(home, ".asdf", "shims") if home && !home.empty?
243
+ dirs.select { |dir| File.directory?(dir) }.uniq
244
+ end
245
+
246
+ # True when `part` is a version bin under the active rbenv root.
247
+ #
248
+ # @api private
249
+ # @param part [String] one PATH entry.
250
+ # @param env [Hash{String => String}]
251
+ # @return [Boolean]
252
+ def rbenv_version_bin?(part, env)
253
+ root = rbenv_root(env)
254
+ return false if root.nil?
255
+
256
+ prefix = File.join(root, "versions")
257
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
258
+ end
259
+
260
+ # True when `part` is an asdf Ruby version bin under ~/.asdf or +ASDF_DATA_DIR+.
261
+ #
262
+ # @api private
263
+ # @param part [String] one PATH entry.
264
+ # @param env [Hash{String => String}]
265
+ # @return [Boolean]
266
+ def asdf_version_bin?(part, env)
267
+ return true if part.match?(%r{/\.asdf/installs/ruby/[^/]+/bin/?\z})
268
+
269
+ data = env["ASDF_DATA_DIR"]
270
+ return false if data.nil? || data.empty?
271
+
272
+ prefix = File.join(File.expand_path(data), "installs", "ruby")
273
+ File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
274
+ end
275
+
276
+ # True when this daemon boots a Rails app.
277
+ #
278
+ # @api private
279
+ # @return [Boolean]
280
+ def rails_boot?
281
+ @boot[:rails] || @boot["rails"]
282
+ end
283
+
130
284
  # Spawn the daemon under the app bundle and complete the ready handshake.
131
285
  #
132
286
  # @return [void]
133
287
  # @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
134
288
  def spawn_daemon
135
289
  # 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.
290
+ # non-rbenv setup. An explicit ruby_version replaces RBENV_VERSION.
291
+ # With no argument, a pin already in the environment stays. That pin is
292
+ # often the Ruby that started the tool. `.ruby-version` applies only
293
+ # when no pin is set. rbenv and asdf version bins leave PATH when a shim
294
+ # directory exists. chruby bins stay.
139
295
  # Everything up to the handshake is terminal, not one mutant's problem: a spawn
140
296
  # the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
141
297
  # resolve) and a daemon that dies before accepting the boot payload (EPIPE on
@@ -145,7 +301,8 @@ module Mutineer
145
301
  begin
146
302
  @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
147
303
  app_env, "bundle", "exec", "ruby",
148
- "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run", chdir: @app_root
304
+ "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
305
+ unsetenv_others: true, chdir: @app_root
149
306
  )
150
307
  # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
151
308
  # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
@@ -149,7 +149,8 @@ module Mutineer
149
149
  cmap = CoverageMap.new(
150
150
  source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
151
151
  load_paths: Array(@cfg["load_paths"]), project_root: root,
152
- boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
152
+ boot_path: @cfg["boot"], framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
153
+ capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
153
154
  ).build_via_fork(after_fork: coverage_after_fork)
154
155
  { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
155
156
  "failed_clean_tests" => cmap.failed_clean_tests }
@@ -52,11 +52,11 @@ module Mutineer
52
52
  ].freeze
53
53
 
54
54
  # Turn a command template into an argv array (no shell → no eval, no
55
- # injection). The `%{files}` token expands IN PLACE to N separate argv
55
+ # injection). The <tt>%{files}</tt> token expands IN PLACE to N separate argv
56
56
  # elements — one per path, unescaped — so a path containing a space stays a
57
57
  # single argument. It is not a space-joined string.
58
58
  #
59
- # @param command [String] the --test-command template (contains %{files}).
59
+ # @param command [String] the --test-command template (contains <tt>%{files}</tt>).
60
60
  # @param files [Array<String>] test file paths to substitute.
61
61
  # @return [Array<String>] argv.
62
62
  def self.build_argv(command, files)
@@ -4,6 +4,7 @@ require "tempfile"
4
4
  require_relative "result"
5
5
  require_relative "parser"
6
6
  require_relative "child_stdout"
7
+ require_relative "kill_channel"
7
8
 
8
9
  module Mutineer
9
10
  # Fork-based isolation for running one mutant. The block runs in a child
@@ -23,6 +24,9 @@ module Mutineer
23
24
  class Isolation
24
25
  DEFAULT_TIMEOUT = 10 # seconds
25
26
 
27
+ # Seconds between the parent's checks on a running child.
28
+ POLL_INTERVAL = 0.005
29
+
26
30
  # Runs the block in a forked child. The block's return value (an Integer
27
31
  # exit code) or any explicit `exit` is honoured; an unhandled exception
28
32
  # becomes exit 2 with the cause written to STDERR.
@@ -30,18 +34,30 @@ module Mutineer
30
34
  # The child silences its stdout (see {ChildStdout.silence}) before the
31
35
  # block runs, so test output never reaches the user. Stderr stays open.
32
36
  #
37
+ # With `channel: true` (a `--matrix` run) the block gets the write end of a
38
+ # pipe for {KillChannel} lines. The parent reads it while it waits, so a
39
+ # child with more than a pipe buffer to say never blocks, and attaches the
40
+ # lines to the Result as {Kills}. The verdict is the one a run without
41
+ # `--matrix` gives (see {.finish}).
42
+ #
33
43
  # @param timeout [Integer] timeout in seconds.
44
+ # @param channel [Boolean] open a {KillChannel} pipe for the block.
45
+ # @yieldparam channel [IO, nil] the pipe's write end, or nil without `channel`.
34
46
  # @yieldreturn [Integer] child exit status.
35
47
  # @return [Mutineer::Result] result from the child process.
36
- def self.run(timeout: DEFAULT_TIMEOUT)
48
+ def self.run(timeout: DEFAULT_TIMEOUT, channel: false)
49
+ rd, wr = IO.pipe if channel
50
+ wr&.sync = true
37
51
  pid = fork do
52
+ rd&.close
38
53
  # Own process group so a timeout kill can reap grandchildren (match
39
54
  # daemon/external backends). Best-effort: if setpgid fails, kill the pid.
40
55
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
41
56
  code = 0
42
57
  begin
43
58
  ChildStdout.silence
44
- result = yield
59
+ # A block that takes no channel (a zero-arity lambda) still runs.
60
+ result = channel ? yield(wr) : yield
45
61
  code = result.is_a?(Integer) ? result : 0
46
62
  rescue SystemExit => e
47
63
  code = e.status
@@ -57,13 +73,17 @@ module Mutineer
57
73
  exit!(code)
58
74
  end
59
75
 
76
+ wr&.close
77
+ buffer = +"" if rd
78
+ reading = !rd.nil?
79
+
60
80
  # Single-threaded deadline poll: we are the ONLY caller of waitpid on this
61
81
  # pid, so we never reap-then-kill. SIGKILL the process group only after
62
82
  # WNOHANG shows the child is still alive past the deadline.
63
83
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
64
84
  loop do
65
85
  reaped, status = Process.waitpid2(pid, Process::WNOHANG)
66
- return decode(status) if reaped
86
+ return finish(decode(status), rd, buffer, finished: true) if reaped
67
87
 
68
88
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
69
89
  begin
@@ -74,14 +94,79 @@ module Mutineer
74
94
  begin
75
95
  _reaped, status = Process.waitpid2(pid)
76
96
  # Child may have finished cleanly between WNOHANG and kill; honor it.
77
- return decode(status) if status && status.exited? && !status.signaled?
97
+ return finish(decode(status), rd, buffer, finished: true) if status && status.exited? && !status.signaled?
78
98
  rescue Errno::ECHILD
79
99
  # already reaped
80
100
  end
81
- return Result.timeout
101
+ return finish(Result.timeout, rd, buffer, finished: false)
102
+ end
103
+ if reading
104
+ IO.select([rd], nil, nil, POLL_INTERVAL)
105
+ reading = drain(rd, buffer)
106
+ else
107
+ sleep POLL_INTERVAL
82
108
  end
83
- sleep 0.005
84
109
  end
110
+ ensure
111
+ rd&.close
112
+ # Closed after the fork in the normal path; still open if fork raised.
113
+ wr.close if wr && !wr.closed?
114
+ end
115
+
116
+ # Reads what the channel holds now, without blocking.
117
+ #
118
+ # @api private
119
+ # @param rd [IO] the channel's read end.
120
+ # @param buffer [String] bytes read so far; appended to.
121
+ # @return [Boolean] false once the channel is at end of file.
122
+ def self.drain(rd, buffer)
123
+ loop do
124
+ chunk = rd.read_nonblock(65_536, exception: false)
125
+ return true if chunk == :wait_readable
126
+ return false if chunk.nil?
127
+
128
+ buffer << chunk
129
+ end
130
+ end
131
+
132
+ # The Result to return. Without a channel it is `result` itself. With one,
133
+ # the rest of the channel is read, and the Result carries the {Kills} it
134
+ # names.
135
+ #
136
+ # The verdict matches a run without `--matrix`, which skips everything after
137
+ # the first failing serial test except the code around it (see
138
+ # {KillChannel}). So when a serial test has named a kill and the child then
139
+ # ended inside a region that run would skip (an exit, a crash or the
140
+ # timeout in a later test, class or group), the mutant is `killed`: the
141
+ # plain run never reached that point. An end anywhere else keeps the exit
142
+ # status, because the plain run reaches that code too: the rest of the
143
+ # failing test's class wrapper or group hooks, or the suite hooks. A kill in
144
+ # the parallel tests of a Minitest run also keeps it, because the stop
145
+ # cannot skip tests already queued.
146
+ #
147
+ # The row is complete only when the child ended before the timeout, sent a
148
+ # valid stream with both `start` and `end`, lost no line, and its kills
149
+ # agree with the verdict (a killed mutant names a killer; a survivor names
150
+ # none). An invalid stream promotes nothing, and its tests are dropped: an
151
+ # out-of-order line means the parent cannot tell which outcomes are real.
152
+ #
153
+ # @api private
154
+ # @param result [Mutineer::Result] the verdict from the exit status or the timeout.
155
+ # @param rd [IO, nil] the channel's read end.
156
+ # @param buffer [String, nil] bytes read so far.
157
+ # @param finished [Boolean] the child ended before the timeout.
158
+ # @return [Mutineer::Result]
159
+ def self.finish(result, rd, buffer, finished:)
160
+ return result unless rd
161
+
162
+ drain(rd, buffer)
163
+ report = KillChannel.parse(buffer)
164
+ return result.with(kills: Kills.new(killed_by: [], ran: [], complete: false)) if report.invalid
165
+
166
+ result = Result.killed if report.started && report.serial_kill && report.skipping.positive?
167
+ agrees = result.killed? ? report.killed.any? : result.survived? && report.killed.empty?
168
+ complete = finished && report.started && report.finished && report.lost.zero? && agrees
169
+ result.with(kills: Kills.new(killed_by: report.killed, ran: report.ran, complete: complete))
85
170
  end
86
171
 
87
172
  # Strategy 7a (default): write the whole mutated file and `load` it, which