mutation_tester 1.4.2 → 1.5.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c743765bd0adb455bf1162627a35cb5b3232d45cfb83dca91ab139248a3acac1
4
- data.tar.gz: 71eb3f98c61271896c6c4cc2e3b9b69c62fee77d50b3d94c0237b0dac9eefd53
3
+ metadata.gz: e32ba3afbad23fb7f2d04c12f197c8e7d7af50d0eb537266f76e3bd957165110
4
+ data.tar.gz: b27a1b44965b42cb4498daaba8d221dcf820b71fd3e61f3b94c47a71c7cad41d
5
5
  SHA512:
6
- metadata.gz: 3fac687f4fd5496daced6e16cf30e3dc39a0816fc5be7fe9f37caae4a05d3e088bdf1f80ee5b23f72610741d767cd410de81c6fcec09b4456d2936549f32266c
7
- data.tar.gz: 13842db8fe228decfe6d3e165e5f80ea3c18af549e09051a3b12342f1ff3251ea5f81f8d375d1f0bdc5e76b6320f101ce791900016b8054630a3e0c2e14f3227
6
+ metadata.gz: 4a05a309477ff66005be2371bedd4b5ce4b3622a064f2013b3b62336dbe5d43af7c0e27526eee2ab186c07d9faef5fc8e3a568fe1bdda5e428c3142af19b0165
7
+ data.tar.gz: 3fd04d05930cf47e0a4fb15680531586dfd9183ecd77ec3040aae3e5397ca1cd0cef2ac68ea087acf87c0acdd43dd8f3a18a40a46f05d8c5fac9a545511988f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.1] - 2026-08-13
4
+
5
+ - Fixed load-time mutants losing per-worker database isolation in a parallel in-memory run with `--worker-env` and `--after-fork`. Mutants on load-time code (constants, class macros, `included do`) are decided file-based on purpose, but that file-based run acquires a fork-runner worker, and a worker booted inside a `Parallel` worker process inherited the parent process's value of the worker-env variable instead of the per-worker assignment: with 8 workers, every load-time mutant's test run hit the parent's database concurrently, races on shared rows failed tests unrelated to the mutant, and those failures were scored as kills. The same file could report 75% and 100% on consecutive runs with no error and no timeout in the output, while the spawn path and the pooled fork runner (which already carried the per-worker value) stayed stable. The fork execution path now passes the same per-worker env override the spawn path always used, and the worker applies it inside the forked test child before the spec loads, so every execution path resolves the same per-worker database. Reported in the field with exactly this signature: only class-macro mutants unstable, serial and fork-runner scores identical and stable.
6
+
7
+ ## [1.5.0] - 2026-08-08
8
+
9
+ - Added `--after-fork FILE` (and `config.after_fork_file` / the `MUTATION_TESTER_AFTER_FORK` environment variable): a Ruby file loaded inside each preloaded in-memory clone right after it forks and receives its per-worker environment, so the app can re-establish per-worker state such as its ActiveRecord connection. With `--worker-env` set, this keeps the `in_memory` runner available in parallel runs instead of falling back to `fork`: each clone now receives its own per-worker value of the variable through the existing per-clone environment plumbing and runs the after-fork file once, giving in-memory execution and per-worker database isolation at the same time. Failure modes stay loud: a clone whose after-fork file raises reports the error on stderr and is dropped, its worker deciding its share of mutants file-based, and a missing after-fork file makes the whole run fall back to file-based execution with a warning. A serial in-memory run (`-p 1`) has only one worker and now stays in memory with `--worker-env` even without the hook.
10
+ - A serial file-based run (`-p 1`) with `--worker-env` now decides each mutant in a shadow workspace instead of writing mutants into the real source file with a `.mutation_backup` alongside, so a process killed mid-run can no longer leave a mutated source file in the checkout. This combination occurs naturally when a caller uses one code path for both a parallel run and a serial re-run. Without `--worker-env` serial file-based runs keep the in-place strategy (the next run still restores a leftover backup automatically).
11
+
3
12
  ## [1.4.2] - 2026-08-05
4
13
 
5
14
  - Added `--spec-map 'PATTERN=>REPLACEMENT'`, a repeatable rule that builds the whole spec path from the whole source path with a regular expression, for the layouts the `{name}` template cannot express. `{name}` is the source path minus a leading `lib/`, so a template can only wrap it with a prefix and a suffix; every mapping that substitutes *inside* the path, after a variable-length prefix, was out of reach: `packs/identity/app/models/party.rb -> packs/identity/test/models/party_test.rb` (Packwerk / packs-rails), the same shape with Rails engines, and the plain Rails `app/ -> test/` rule. Because `--since` requires `--glob` and `--glob` maps through `--spec-glob`, such a layout previously lost the entire batch tier at once and had to be reimplemented in a wrapper script. The first matching rule wins and a source matching no rule falls back to `--spec-glob`, so one run can cover `app/` through a rule and `lib/` through the template. The flag works with `--glob`, a positional `FILE` list and `--staged`; a malformed rule is a usage error (exit code `2`) reported before any mutation runs.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mutation_tester (1.4.2)
4
+ mutation_tester (1.5.1)
5
5
  parallel (~> 1.20)
6
6
  parser (~> 3.3)
7
7
  rainbow (~> 3.0)
@@ -134,6 +134,18 @@ no fallback is possible.
134
134
  in a fresh fork of its own clone. If a pooled worker dies mid-run, that
135
135
  worker finishes its share of mutants through the file-based path with a
136
136
  warning; the other workers stay in memory.
137
+ - With `--worker-env` the preloaded clones would all share the primary
138
+ worker's already-established database connection, so a parallel run
139
+ (`-p N`, N > 1) skips the in-memory runner with a warning unless
140
+ `--after-fork FILE` is also given. With `--after-fork`, each clone
141
+ receives its per-worker value of the `--worker-env` variable and loads
142
+ `FILE` right after forking, and that file re-establishes the per-worker
143
+ state (typically the ActiveRecord connection), which keeps the whole run
144
+ in memory with per-worker database isolation. A serial run (`-p 1`) has
145
+ only one worker and stays in memory without any hook. A clone whose
146
+ after-fork file raises reports the error on stderr and is dropped, and
147
+ its worker falls back to the file-based path; a missing after-fork file
148
+ makes the whole run fall back with a warning.
137
149
  - Before any mutant runs, the runner re-applies the **unmutated** source in a
138
150
  probe child and runs the suite. If that probe fails (for example the file has
139
151
  top-level side effects that break on a second execution, or the class is
@@ -17,7 +17,7 @@ jobs:
17
17
  timeout-minutes: 30
18
18
  steps:
19
19
  - name: Checkout
20
- uses: actions/checkout@v4
20
+ uses: actions/checkout@v5
21
21
 
22
22
  - name: Set up Ruby
23
23
  uses: ruby/setup-ruby@v1
@@ -30,7 +30,7 @@ jobs:
30
30
 
31
31
  steps:
32
32
  - name: Checkout
33
- uses: actions/checkout@v4
33
+ uses: actions/checkout@v5
34
34
 
35
35
  - name: Set up Ruby
36
36
  uses: ruby/setup-ruby@v1
data/exe/mutation_test CHANGED
@@ -88,10 +88,14 @@ OptionParser.new do |opts|
88
88
  options[:strict_equality] = true
89
89
  end
90
90
 
91
- opts.on('--worker-env NAME', 'Set environment variable NAME to a distinct per-worker value before each parallel worker boots (parallel_tests TEST_ENV_NUMBER convention: worker 0 -> "", worker N -> N+1), so a parallel_tests-style database.yml selects a per-worker database. Provision the databases yourself (e.g. rake parallel:prepare). The in_memory runner cannot isolate a per-worker database, so this flag makes the runner fall back to fork.') do |name|
91
+ opts.on('--worker-env NAME', 'Set environment variable NAME to a distinct per-worker value before each parallel worker boots (parallel_tests TEST_ENV_NUMBER convention: worker 0 -> "", worker N -> N+1), so a parallel_tests-style database.yml selects a per-worker database. Provision the databases yourself (e.g. rake parallel:prepare). Without --after-fork a parallel in_memory run falls back to fork (preloaded clones share one database connection); pair it with --after-fork to keep the in_memory runner. A serial run (-p 1) decides mutants in a shadow workspace instead of mutating the checkout in place.') do |name|
92
92
  options[:worker_env] = name
93
93
  end
94
94
 
95
+ opts.on('--after-fork FILE', 'Ruby file loaded inside each preloaded in-memory clone right after it forks and receives its per-worker environment (see --worker-env), so the app can re-establish per-worker state such as its database connection (e.g. a file calling ActiveRecord::Base.establish_connection). With --worker-env set, this keeps the in_memory runner available in parallel runs.') do |file|
96
+ options[:after_fork] = file
97
+ end
98
+
95
99
  opts.on('-h', '--help', 'Show this help message') do
96
100
  puts opts
97
101
  exit
@@ -178,6 +182,7 @@ apply_configuration = lambda do
178
182
  config.timeout_factor = options[:timeout_factor] if options[:timeout_factor]
179
183
  config.timeout_policy = options[:timeout_policy] if options[:timeout_policy]
180
184
  config.worker_env_var = options[:worker_env] if options.key?(:worker_env)
185
+ config.after_fork_file = options[:after_fork] if options.key?(:after_fork)
181
186
  config.fail_fast = options.fetch(:fail_fast, false)
182
187
  config.mutation_types[:strict_equality] = true if options[:strict_equality]
183
188
 
@@ -19,7 +19,8 @@ module MutationTester
19
19
  number <= 0 ? '' : (number + 1).to_s
20
20
  end
21
21
 
22
- attr_reader :parallel_processes, :runner, :worker_env_var, :timeout, :timeout_factor, :timeout_policy
22
+ attr_reader :parallel_processes, :runner, :worker_env_var, :after_fork_file, :timeout, :timeout_factor,
23
+ :timeout_policy
23
24
 
24
25
  attr_accessor :baseline_duration,
25
26
  :baseline_timeout,
@@ -38,6 +39,7 @@ module MutationTester
38
39
  self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
39
40
  self.runner = ENV['MUTATION_TESTER_RUNNER'] || :auto
40
41
  self.worker_env_var = ENV['MUTATION_TESTER_WORKER_ENV']
42
+ self.after_fork_file = ENV['MUTATION_TESTER_AFTER_FORK']
41
43
  @timeout = DEFAULT_TIMEOUT
42
44
  @timeout_factor = DEFAULT_TIMEOUT_FACTOR
43
45
  @timeout_policy = :killed
@@ -136,6 +138,11 @@ module MutationTester
136
138
  @worker_env_var = normalized.empty? ? nil : normalized
137
139
  end
138
140
 
141
+ def after_fork_file=(value)
142
+ normalized = value.to_s.strip
143
+ @after_fork_file = normalized.empty? ? nil : File.expand_path(normalized)
144
+ end
145
+
139
146
  def worker_env_assignment(index)
140
147
  return nil unless @worker_env_var
141
148
 
@@ -70,6 +70,7 @@ supervise_child = lambda do |job, out, child_body|
70
70
  rescue Errno::EACCES, Errno::EPERM
71
71
  end
72
72
  Dir.chdir(job['chdir']) if job['chdir']
73
+ job['env']&.each { |name, value| ENV[name] = value }
73
74
  mirror_load_path.call(job['mirror_of'], job['chdir'])
74
75
  sink = File.open(job['log'] || File::NULL, 'w')
75
76
  sink.sync = true
@@ -238,6 +239,17 @@ spawn_clone = lambda do |request, out|
238
239
  out.close
239
240
  STDIN.reopen(File::NULL)
240
241
  request['env']&.each { |name, value| ENV[name] = value }
242
+ if request['after_fork']
243
+ begin
244
+ load(request['after_fork'])
245
+ rescue ScriptError, StandardError => e
246
+ clone_out.puts(JSON.generate(
247
+ 'event' => 'clone_error',
248
+ 'message' => "the after-fork file #{request['after_fork']} raised #{e.class}: #{e.message}; this clone is unavailable"
249
+ ))
250
+ raise
251
+ end
252
+ end
241
253
  serve.call(input, clone_out)
242
254
  end
243
255
  Process.detach(child)
@@ -39,10 +39,10 @@ module MutationTester
39
39
  refill_pool(pool_key(use_bundle_exec, framework), count, primary, env_for: env_for)
40
40
  end
41
41
 
42
- def prepare_in_memory_pool(count, primary)
42
+ def prepare_in_memory_pool(count, primary, env_for: nil, after_fork: nil)
43
43
  return [] unless available? && primary&.ready?
44
44
 
45
- refill_pool(IN_MEMORY_POOL_KEY, count, primary)
45
+ refill_pool(IN_MEMORY_POOL_KEY, count, primary, env_for: env_for, after_fork: after_fork)
46
46
  end
47
47
 
48
48
  def in_memory_pool_prepared?
@@ -116,13 +116,13 @@ module MutationTester
116
116
  Parallel.worker_number if defined?(Parallel) && Parallel.respond_to?(:worker_number)
117
117
  end
118
118
 
119
- def refill_pool(key, count, primary, env_for: nil)
119
+ def refill_pool(key, count, primary, env_for: nil, after_fork: nil)
120
120
  entry = (pool[key] ||= { owner: Process.pid, runners: [] })
121
121
  entry[:runners] = entry[:runners].each_with_index.map do |runner, index|
122
- runner&.ready? ? runner : primary.fork_clone(env: env_for&.call(index))
122
+ runner&.ready? ? runner : primary.fork_clone(env: env_for&.call(index), after_fork: after_fork)
123
123
  end
124
124
  entry[:runners].size.upto(count - 1) do |index|
125
- entry[:runners] << primary.fork_clone(env: env_for&.call(index))
125
+ entry[:runners] << primary.fork_clone(env: env_for&.call(index), after_fork: after_fork)
126
126
  end
127
127
  entry[:runners]
128
128
  end
@@ -154,7 +154,7 @@ module MutationTester
154
154
  end
155
155
 
156
156
  def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [], stop_on_first_failure: false,
157
- mirror_of: nil)
157
+ mirror_of: nil, env: nil)
158
158
  log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
159
159
  job = {
160
160
  spec: spec_file,
@@ -163,7 +163,8 @@ module MutationTester
163
163
  log: log&.path,
164
164
  args: args,
165
165
  stop_on_first_failure: stop_on_first_failure,
166
- mirror_of: mirror_of
166
+ mirror_of: mirror_of,
167
+ env: env
167
168
  }
168
169
  @job_writer.puts(JSON.generate(job))
169
170
  status = await_result(timeout)['status']
@@ -198,7 +199,7 @@ module MutationTester
198
199
  fail_worker
199
200
  end
200
201
 
201
- def fork_clone(env: nil)
202
+ def fork_clone(env: nil, after_fork: nil)
202
203
  return nil unless ready?
203
204
  return nil unless File.respond_to?(:mkfifo)
204
205
 
@@ -210,6 +211,7 @@ module MutationTester
210
211
 
211
212
  clone_request = { 'job' => job_path, 'events' => events_path }
212
213
  clone_request['env'] = env if env
214
+ clone_request['after_fork'] = after_fork if after_fork
213
215
  @job_writer.puts(JSON.generate('clone' => clone_request))
214
216
  event = read_event(monotonic_time + CLONE_TIMEOUT)
215
217
  return nil unless event.is_a?(Hash) && event['event'] == 'cloned'
@@ -264,6 +266,11 @@ module MutationTester
264
266
  next
265
267
  end
266
268
 
269
+ if event['event'] == 'clone_error'
270
+ warn("[MutationTester] #{event['message']}")
271
+ return false
272
+ end
273
+
267
274
  return event['event'] == 'ready'
268
275
  end
269
276
  end
@@ -56,16 +56,17 @@ module MutationTester
56
56
  elsif in_memory_first?
57
57
  run_in_memory_series(mutations, &progress_callback)
58
58
  elsif @config.parallel_processes == 1
59
- run_in_place_series(mutations, &progress_callback)
59
+ run_file_based_series(mutations, &progress_callback)
60
60
  else
61
61
  run_in_shadow_parallel(mutations, &progress_callback)
62
62
  end
63
63
  end
64
64
 
65
65
  def in_memory_first?
66
- return false if @config.worker_env_var
66
+ return false unless %i[in_memory auto].include?(@config.runner)
67
+ return true unless @config.worker_env_var
67
68
 
68
- %i[in_memory auto].include?(@config.runner)
69
+ @config.parallel_processes == 1 || !@config.after_fork_file.nil?
69
70
  end
70
71
 
71
72
  def run_in_memory_series(mutations, &progress_callback)
@@ -100,7 +101,12 @@ module MutationTester
100
101
  blocker = prepare_in_memory_execution
101
102
  return fall_back_to_parallel_file_based(blocker, mutations, &progress_callback) if blocker
102
103
 
103
- pool = ForkRunner.prepare_in_memory_pool([@config.parallel_processes, mutations.size].min, @in_memory_runner)
104
+ pool = ForkRunner.prepare_in_memory_pool(
105
+ [@config.parallel_processes, mutations.size].min,
106
+ @in_memory_runner,
107
+ env_for: worker_env_for,
108
+ after_fork: @config.after_fork_file
109
+ )
104
110
  if pool.compact.empty?
105
111
  return fall_back_to_parallel_file_based('the preloaded worker pool could not be cloned', mutations, &progress_callback)
106
112
  end
@@ -161,6 +167,28 @@ module MutationTester
161
167
  cleanup_shadow_workspaces
162
168
  end
163
169
 
170
+ def run_file_based_series(mutations, &progress_callback)
171
+ project_root = @config.worker_env_var ? discoverable_project_root : nil
172
+ return run_in_place_series(mutations, &progress_callback) unless project_root
173
+
174
+ run_in_shadow_series(mutations, project_root, &progress_callback)
175
+ end
176
+
177
+ def run_in_shadow_series(mutations, project_root, &progress_callback)
178
+ warn "[MutationTester] --worker-env #{@config.worker_env_var} is set, so the serial run decides each mutant in a shadow workspace and never mutates the checkout in place."
179
+ shadow_run_root
180
+ results = []
181
+ mutations.each_with_index do |mutation, index|
182
+ result = run_single_mutation(mutation, :shadow, project_root)
183
+ progress_callback.call(mutation, index + 1) if progress_callback
184
+ results << result
185
+ break if stop_early?(result)
186
+ end
187
+ results
188
+ ensure
189
+ cleanup_shadow_workspaces
190
+ end
191
+
164
192
  def run_in_place_series(mutations, &progress_callback)
165
193
  write_in_place_backup
166
194
  results = []
@@ -401,6 +429,9 @@ module MutationTester
401
429
 
402
430
  def prepare_in_memory_execution
403
431
  return 'Process.fork is not supported on this platform' unless ForkRunner.available?
432
+ if @config.after_fork_file && !File.exist?(@config.after_fork_file)
433
+ return "the after-fork file #{@config.after_fork_file} does not exist"
434
+ end
404
435
  if InMemoryLoader.load_time_defined_guard?(@original_content)
405
436
  return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
406
437
  end
@@ -451,7 +482,7 @@ module MutationTester
451
482
  offset_callback = progress_callback && lambda do |mutation, index|
452
483
  progress_callback.call(mutation, completed + index)
453
484
  end
454
- run_in_place_series(mutations, &offset_callback)
485
+ run_file_based_series(mutations, &offset_callback)
455
486
  end
456
487
 
457
488
  def fall_back_to_parallel_file_based(reason, mutations, &progress_callback)
@@ -470,8 +501,9 @@ module MutationTester
470
501
  def announce_worker_env_in_memory_opt_out
471
502
  return unless @config.worker_env_var
472
503
  return unless %i[in_memory auto].include?(@config.runner)
504
+ return if @config.parallel_processes == 1 || @config.after_fork_file
473
505
 
474
- warn "[MutationTester] --worker-env #{@config.worker_env_var} is set, so the in-memory runner is skipped (its clones share one preloaded database connection); using the fork runner for per-worker database isolation."
506
+ warn "[MutationTester] --worker-env #{@config.worker_env_var} is set without --after-fork, so the in-memory runner is skipped (its clones share one preloaded database connection); using the fork runner for per-worker database isolation. Pass --after-fork FILE to keep the in-memory runner and re-establish per-worker connections inside each clone."
475
507
  end
476
508
 
477
509
  def run_mutation_load_time(mutation, result, project_root)
@@ -51,7 +51,8 @@ module MutationTester
51
51
  capture: capture,
52
52
  args: filter_args,
53
53
  stop_on_first_failure: @stop_on_first_failure,
54
- mirror_of: mirror_of
54
+ mirror_of: mirror_of,
55
+ env: worker_env_overrides
55
56
  )
56
57
  end
57
58
  end
@@ -1,3 +1,3 @@
1
1
  module MutationTester
2
- VERSION = '1.4.2'.freeze
2
+ VERSION = '1.5.1'.freeze
3
3
  end