mutineer 0.11.2 → 0.11.4

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.
@@ -4,19 +4,23 @@ require "json"
4
4
  require "open3"
5
5
 
6
6
  module Mutineer
7
- # Raised when the daemon cannot be booted (bad boot path, app error, or it dies on
8
- # the handshake). The CLI maps it to a runtime error.
7
+ # Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
8
+ # app error, a failed handshake, a spawn the OS refused, or MAX_RESTARTS crashes.
9
+ # It means "stop the run" — a backend that scored the remaining mutants against a
10
+ # dead daemon would report a score covering a fraction of the work. The CLI maps it
11
+ # to a runtime error (exit 1).
9
12
  class DaemonBootError < StandardError; end
10
13
 
11
- # #26/#27 Phase 2a — the TOOL-side handle for the app-side daemon.
14
+ # Tool-side handle for the app-side daemon.
12
15
  #
13
- # Spawns `daemon_server.rb` UNDER THE APP'S BUNDLE/RUBY (cleaned env so the gem's
14
- # bundler context never leaks; the daemon file is loaded by absolute path with
15
- # `-r`, which bypasses the app bundle that has no mutineer), completes the ready
16
- # handshake, then ships per-mutant payloads and reads structured verdicts. If the
17
- # daemon dies mid-run it respawns (bounded) and marks the in-flight mutant `error`
18
- # rather than corrupting the run. Reuses the cleaned-env spawn + stderr-drain proven
19
- # in the spike driver and the spawn discipline of ExternalBackend.
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.
20
+ # If the daemon dies mid-run it respawns (bounded) and marks the in-flight
21
+ # mutant `error` rather than corrupting the run. Reuses the cleaned-env spawn
22
+ # and stderr-drain proven in the spike driver and the spawn discipline of
23
+ # ExternalBackend.
20
24
  class DaemonClient
21
25
  # Absolute path to the daemon entry, loaded app-side by `-r` (bypasses the bundle).
22
26
  DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
@@ -49,16 +53,22 @@ module Mutineer
49
53
 
50
54
  # Run one mutant: ship the payload + covering tests, return the verdict string.
51
55
  # On a daemon crash (EOF/dead pipe) respawn (bounded) and return `"error"` for
52
- # this mutant never a wrong verdict, never a wedged run.
56
+ # this mutant. Never a wrong verdict, never a wedged run.
53
57
  #
54
58
  # @param id [Integer] request id (echoed back for ordering safety).
55
59
  # @param payload [Hash] {"code" => mutated ruby, "source_file" => path}.
56
60
  # @param tests [Array<String>] covering test file paths.
57
61
  # @param timeout [Numeric] per-mutant wall-clock timeout (seconds).
58
62
  # @param worker [Integer] worker slot; the daemon routes the fork to
59
- # `<db>-<worker>` (#26 isolation). Defaults to 0 (serial in U5).
63
+ # `<db>-<worker>` for isolation. Defaults to 0 (serial).
60
64
  # @return [String] one of survived/killed/error/timeout.
61
65
  def request(id:, payload:, tests:, timeout:, worker: 0)
66
+ # close_io nils the pipes, so a client whose respawn never completed would
67
+ # otherwise fail per-mutant forever (NoMethodError on nil) and let the backend
68
+ # score every remaining mutant against nothing. Deadness is a property of the
69
+ # client, not of whichever exception happened to escape.
70
+ raise DaemonBootError, "daemon is not running" if @stdin.nil?
71
+
62
72
  # A crash can surface on the WRITE (daemon died idle between requests →
63
73
  # Errno::EPIPE) as well as the read (EOF), so guard both: either way, respawn
64
74
  # for future mutants and score THIS one error (re-running a crash-causing
@@ -76,10 +86,11 @@ module Mutineer
76
86
  "error"
77
87
  end
78
88
 
79
- # #26/U7: ask the daemon to build the coverage map app-side and return it.
80
- # One-shot control message (no id). Returns `{"map"=>..., "failed_test_files"=>...}`
81
- # (possibly with an `"error"`), or nil if the daemon vanished the caller then
82
- # falls back to running the full test set (no narrowing) rather than mis-scoring.
89
+ # Ask the daemon to build the coverage map app-side and return it. One-shot
90
+ # control message (no id). Returns `{"map"=>..., "failed_test_files"=>...}`
91
+ # (possibly with an `"error"`), or nil if the daemon vanished. The caller then
92
+ # falls back to running the full test set (no narrowing) rather than
93
+ # mis-scoring.
83
94
  #
84
95
  # @return [Hash, nil] the coverage payload, or nil on a dead pipe.
85
96
  def coverage
@@ -103,8 +114,8 @@ module Mutineer
103
114
 
104
115
  private
105
116
 
106
- # Cleaned environment for the app bundle: strip the gem's bundler/Ruby context so
107
- # `bundle exec` resolves the APP's Gemfile under the requested Ruby.
117
+ # Cleaned environment for the app bundle: strip the gem's bundler/Ruby context
118
+ # so `bundle exec` resolves the APP's Gemfile under the requested Ruby.
108
119
  def app_env
109
120
  env = ENV.to_h.reject { |k, _| k.start_with?("BUNDLE_", "RUBY", "GEM_") }
110
121
  env["BUNDLE_GEMFILE"] = @gemfile
@@ -118,25 +129,38 @@ module Mutineer
118
129
  # @return [void]
119
130
  # @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
120
131
  def spawn_daemon
121
- # Plain `bundle exec ruby` NOT `rbenv exec`, which would break CI and any
122
- # non-rbenv setup. When bundler/ruby are rbenv shims, the RBENV_VERSION carried
123
- # in app_env still selects the app's Ruby; otherwise the active Ruby is used.
124
- @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
125
- app_env, "bundle", "exec", "ruby",
126
- "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run", chdir: @app_root
127
- )
128
- # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
129
- # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn; the
130
- # rescue swallows the benign EBADF/IOError raised when close_io closes the pipe
131
- # out from under an in-flight copy_stream.
132
- @drain = Thread.new do # rubocop:disable ThreadSafety/NewThread
133
- IO.copy_stream(@stderr, @errio)
134
- rescue IOError, Errno::EBADF
135
- nil
136
- end
132
+ # Plain `bundle exec ruby`, NOT `rbenv exec`, which would break CI and any
133
+ # non-rbenv setup. When bundler/ruby are rbenv shims, the RBENV_VERSION
134
+ # carried in app_env still selects the app's Ruby; otherwise the active
135
+ # Ruby is used.
136
+ # Everything up to the handshake is terminal, not one mutant's problem: a spawn
137
+ # the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
138
+ # resolve) and a daemon that dies before accepting the boot payload (EPIPE on
139
+ # the write) both leave a client that cannot recover. Raise the class that ends
140
+ # the run a SystemCallError would reach the CLI as a usage error (exit 2).
141
+ ready =
142
+ begin
143
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
144
+ app_env, "bundle", "exec", "ruby",
145
+ "-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run", chdir: @app_root
146
+ )
147
+ # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
148
+ # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
149
+ # the rescue swallows the benign EBADF/IOError raised when close_io closes
150
+ # the pipe out from under an in-flight copy_stream.
151
+ @drain = Thread.new do # rubocop:disable ThreadSafety/NewThread
152
+ IO.copy_stream(@stderr, @errio)
153
+ rescue IOError, Errno::EBADF
154
+ nil
155
+ end
156
+
157
+ send_line(@boot)
158
+ read_line
159
+ rescue SystemCallError, IOError => e
160
+ close_io
161
+ raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
162
+ end
137
163
 
138
- send_line(@boot)
139
- ready = read_line
140
164
  unless ready && ready["ready"]
141
165
  detail = ready && ready["error"] ? ready["error"] : "daemon exited before the handshake"
142
166
  close_io
@@ -4,19 +4,20 @@ require "json"
4
4
  require "tempfile"
5
5
 
6
6
  module Mutineer
7
- # #26/#27 Phase 2a — the app-side daemon (persistent worker).
7
+ # App-side daemon (persistent worker).
8
8
  #
9
9
  # Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
10
10
  # `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
11
- # requests over stdin/stdout as newline-delimited JSON. For each request it FORKS
12
- # a child that loads the mutated source text the tool sent, runs the covering
13
- # tests, and exits with a status the parent decodes into a verdict.
11
+ # requests over stdin/stdout as newline-delimited JSON. For each request it
12
+ # FORKS a child that loads the mutated source text the tool sent, runs the
13
+ # covering tests, and exits with a status the parent decodes into a verdict.
14
14
  #
15
- # HARD CONSTRAINT (KTD-2/R4): this file must be loadable WITHOUT Prism or the rest
16
- # of mutineer the app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
17
- # mutineer. So it requires ONLY stdlib + the app's own boot file; it re-implements
18
- # the fork/timeout/decode loop rather than requiring `isolation.rb` (which pulls in
19
- # Prism). All parsing/mutation happened tool-side; the daemon only `load`s text.
15
+ # HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
16
+ # mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
17
+ # mutineer. So it requires ONLY stdlib + the app's own boot file; it
18
+ # re-implements the fork/timeout/decode loop rather than requiring
19
+ # `isolation.rb` (which pulls in Prism). All parsing/mutation happened
20
+ # tool-side; the daemon only `load`s text.
20
21
  #
21
22
  # Protocol (one JSON object per line, both directions):
22
23
  # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
@@ -27,16 +28,18 @@ module Mutineer
27
28
  # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
28
29
  # quit in : {"cmd":"quit"}
29
30
  #
30
- # Worker isolation (#26/U5): when the app is Rails, each fork is routed to its own
31
- # database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so concurrent
32
- # workers can't clobber each other's transactional fixtures. `worker` defaults to 0
33
- # (serial). SQLite this pass; Postgres provisioning is U10.
31
+ # Worker isolation: when the app is Rails, each fork is routed to its own
32
+ # database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so
33
+ # concurrent workers cannot clobber each other's transactional fixtures.
34
+ # `worker` defaults to 0 (serial). SQLite this pass; Postgres provisioning is
35
+ # not yet implemented.
34
36
  #
35
- # Verdict mapping (KTD-5, Phase-2a honest limit): child exit 0=survived (suite
36
- # passed), 1=killed (suite failed), 2=error (child raised AROUND the test load,
37
- # boot, or worker-DB routing failure); parent-detected timeout. Tagging an in-TEST DB
38
- # error (one fired inside a test body, vs at routing time) as `error` rather than
39
- # `killed` is a U6 concern — only observable under the concurrent gate.
37
+ # Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite
38
+ # failed), 2=error (child raised AROUND the test: load, boot, or worker-DB
39
+ # routing failure); parent-detected timeout. Tagging an in-test DB error (one
40
+ # fired inside a test body, vs at routing time) as `error` rather than
41
+ # `killed` is only observable under the concurrent gate and is not yet
42
+ # implemented.
40
43
  module DaemonServer
41
44
  # Poll interval (seconds) for the per-fork deadline wait loop.
42
45
  POLL = 0.02
@@ -65,16 +68,16 @@ module Mutineer
65
68
  begin
66
69
  req = JSON.parse(line)
67
70
  rescue JSON::ParserError => e
68
- # A corrupt line has no id to address a reply to (and the client only ever
69
- # sends valid JSON, so it can't be a pending request) log and read on
70
- # rather than write an unaddressable verdict onto the channel.
71
+ # A corrupt line has no id to address a reply to (and the client only
72
+ # ever sends valid JSON, so it cannot be a pending request). Log and
73
+ # read on rather than write an unaddressable verdict onto the channel.
71
74
  @errio.puts("[daemon] dropped unparseable line: #{e.message}")
72
75
  next
73
76
  end
74
77
  break if req["cmd"] == "quit"
75
78
 
76
- # #26/U7: build the coverage map app-side and ship it to the tool, which
77
- # then selects covering tests per mutant. One-shot control message.
79
+ # Build the coverage map app-side and ship it to the tool, which then
80
+ # selects covering tests per mutant. One-shot control message.
78
81
  if req["cmd"] == "coverage"
79
82
  output.puts(JSON.generate(build_coverage_map))
80
83
  output.flush
@@ -88,8 +91,8 @@ module Mutineer
88
91
 
89
92
  private
90
93
 
91
- # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded and
92
- # inherited by every fork. Never requires mutineer.
94
+ # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
95
+ # and inherited by every fork. Never requires mutineer.
93
96
  def boot!(cfg)
94
97
  @cfg = cfg
95
98
  @framework = cfg.fetch("framework", "minitest")
@@ -97,30 +100,30 @@ module Mutineer
97
100
  Dir.chdir(cfg["project_root"]) if cfg["project_root"]
98
101
  ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
99
102
  Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
100
- # #26/U7: start Coverage BEFORE the app loads, so booted source lines are
101
- # instrumented the map build (build_via_fork) forks this booted parent.
103
+ # Start Coverage BEFORE the app loads, so booted source lines are
104
+ # instrumented. The map build (build_via_fork) forks this booted parent.
102
105
  if cfg["coverage"]
103
106
  require "coverage"
104
107
  Coverage.start(lines: true)
105
108
  end
106
109
  # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
107
- # source dir BEFORE the app boots Zeitwerk would otherwise choke on the
110
+ # source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
108
111
  # tempfile's non-constant name during autoload setup.
109
112
  sweep_temps
110
113
  require File.expand_path(cfg["boot"]) if cfg["boot"]
111
114
  setup_worker_db(cfg) if cfg["rails"]
112
115
  rescue Exception => e # rubocop:disable Lint/RescueException
113
- # Boot failed (bad boot path, app error) tell the client and exit so it can
114
- # surface a clean error rather than hang on the handshake.
116
+ # Boot failed (bad boot path, app error). Tell the client and exit so it
117
+ # can surface a clean error rather than hang on the handshake.
115
118
  @output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
116
119
  @output.flush
117
120
  exit!(1)
118
121
  end
119
122
 
120
- # Load the per-worker DB adapter app-side (sibling gem file, by relative path so
121
- # it bypasses the app bundle like this daemon itself). No-op unless the app has
122
- # ActiveRecord. Records the adapter + schema path so each fork can route to its
123
- # own database (#26 isolation, U5). SQLite-only this pass; a non-SQLite config
123
+ # Load the per-worker DB adapter app-side (sibling gem file, by relative path
124
+ # so it bypasses the app bundle, like this daemon itself). No-op unless the
125
+ # app has ActiveRecord. Records the adapter + schema path so each fork can
126
+ # route to its own database. SQLite-only this pass; a non-SQLite config
124
127
  # raises in the fork and reads as `error`, never a mis-routed verdict.
125
128
  def setup_worker_db(cfg)
126
129
  require_relative "rails_worker_db"
@@ -134,11 +137,11 @@ module Mutineer
134
137
  @worker_db = nil
135
138
  end
136
139
 
137
- # #26/U7: build the coverage map app-side (Coverage was started at boot) and
138
- # return it as `{map, failed_test_files}` for the tool to select covering tests.
139
- # Capture forks route to worker 0's DB (isolated, serial). On any failure return
140
- # an empty map + an error string the tool then falls back to the full test set
141
- # rather than mis-scoring everything as no_coverage.
140
+ # Build the coverage map app-side (Coverage was started at boot) and return
141
+ # it as `{map, failed_test_files}` for the tool to select covering tests.
142
+ # Capture forks route to worker 0's DB (isolated, serial). On any failure
143
+ # return an empty map + an error string. The tool then falls back to the
144
+ # full test set rather than mis-scoring everything as no_coverage.
142
145
  def build_coverage_map
143
146
  require_relative "coverage_map"
144
147
  root = @cfg["project_root"] || Dir.pwd
@@ -153,9 +156,9 @@ module Mutineer
153
156
  { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
154
157
  end
155
158
 
156
- # Fork-safety hook for coverage capture: route each capture fork to worker 0's
157
- # isolated DB (captures run serially, so one worker is enough). Nil when the app
158
- # has no worker-DB adapter (non-Rails) capture then runs as before.
159
+ # Fork-safety hook for coverage capture: route each capture fork to worker
160
+ # 0's isolated DB (captures run serially, so one worker is enough). Nil when
161
+ # the app has no worker-DB adapter (non-Rails). Capture then runs as before.
159
162
  def coverage_after_fork
160
163
  return nil unless @worker_db
161
164
 
@@ -190,16 +193,16 @@ module Mutineer
190
193
  verdict = wait_verdict(pid, timeout)
191
194
  # Mark ready only when the child finished cleanly after schema load
192
195
  # (killed/survived). Timeout can interrupt mid-load_schema; error is a
193
- # routing failure both leave the slot unready so the next fork reloads.
196
+ # routing failure. Both leave the slot unready so the next fork reloads.
194
197
  @schema_ready[worker] = true if schema_for_fork && %w[killed survived].include?(verdict)
195
- # A SIGKILLed timeout child skipped its Tempfile unlink sweep the orphan so
196
- # it can't outlive the run or trip Zeitwerk on a later fork.
198
+ # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
199
+ # so it cannot outlive the run or trip Zeitwerk on a later fork.
197
200
  sweep_temps if verdict == "timeout"
198
201
  { "id" => req["id"], "verdict" => verdict }
199
202
  end
200
203
 
201
204
  # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
202
- # SIGKILL path can't run the child's ensure). Mirrors Runner.sweep_orphans.
205
+ # SIGKILL path cannot run the child's ensure). Mirrors Runner.sweep_orphans.
203
206
  def sweep_temps
204
207
  @source_dirs.to_a.each do |dir|
205
208
  Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
@@ -209,12 +212,12 @@ module Mutineer
209
212
  end
210
213
 
211
214
  # Single-waiter deadline loop (mirrors Isolation.run and
212
- # ExternalBackend.wait_with_timeout, re-implemented here because Isolation pulls
213
- # in Prism which is forbidden app-side). NOTE: this is the 3rd copy of the
214
- # waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline a fix to the
215
- # kill/reap/decode logic must be applied to all three in lockstep. SIGKILL the
216
- # child's process group past the deadline; a signalled child (nil exitstatus) is
217
- # `error`.
215
+ # ExternalBackend.wait_with_timeout, re-implemented here because Isolation
216
+ # pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
217
+ # the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
218
+ # the kill/reap/decode logic must be applied to all three in lockstep.
219
+ # SIGKILL the child's process group past the deadline; a signalled child
220
+ # (nil exitstatus) is `error`.
218
221
  def wait_verdict(pid, timeout)
219
222
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
220
223
  loop do
@@ -242,14 +245,15 @@ module Mutineer
242
245
  end
243
246
  end
244
247
 
245
- # Write the tool-built mutated text beside the real source and `load` it
246
- # reopening the mutated class/method in THIS child only. It goes in the source
247
- # file's directory (like Isolation.apply_whole_file) so a `require_relative` in
248
- # the mutated source resolves against its real neighbours — writing it to the
249
- # tmpdir would LoadError on such files and score a spurious `error` that
250
- # diverges from the in-process path. The Zeitwerk hazard (a stray `.rb` in an
251
- # autoload dir) is handled by the boot/timeout `sweep_temps`, not by relocating
252
- # the file. Same path for reload (whole file) and redefine (wrapped snippet).
248
+ # Write the tool-built mutated text beside the real source and `load` it,
249
+ # reopening the mutated class/method in THIS child only. It goes in the
250
+ # source file's directory (like Isolation.apply_whole_file) so a
251
+ # `require_relative` in the mutated source resolves against its real
252
+ # neighbours. Writing it to the tmpdir would LoadError on such files and
253
+ # score a spurious `error` that diverges from the in-process path. The
254
+ # Zeitwerk hazard (a stray `.rb` in an autoload dir) is handled by the
255
+ # boot/timeout `sweep_temps`, not by relocating the file. Same path for
256
+ # reload (whole file) and redefine (wrapped snippet).
253
257
  def apply_payload(payload)
254
258
  dir = File.dirname(File.expand_path(payload.fetch("source_file")))
255
259
  Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
@@ -260,7 +264,7 @@ module Mutineer
260
264
  end
261
265
 
262
266
  # Load the covering test files and run them; 0 = all passed (survived),
263
- # 1 = a failure/error (killed). Minitest only in 2a (rspec is a later unit).
267
+ # 1 = a failure/error (killed). Minitest only; rspec is not yet on this path.
264
268
  def run_tests(tests)
265
269
  raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"
266
270
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # Raised when a source file's backup already exists as FileSwap.with begins
5
- # a second mutineer run is racing on the same file (the backup path is shared
4
+ # Raised when a source file's backup already exists as FileSwap.with begins.
5
+ # A second mutineer run is racing on the same file (the backup path is shared
6
6
  # and unlocked). Aborting beats silently leaving the tree mutated.
7
7
  class ConcurrentRunError < StandardError
8
8
  def initialize(backup)
@@ -11,11 +11,11 @@ module Mutineer
11
11
  end
12
12
  end
13
13
 
14
- # #27 (U2): apply one whole-file mutant to the REAL source path for the external
14
+ # Apply one whole-file mutant to the REAL source path for the external
15
15
  # (`--test-command`) backend, and guarantee the original is restored on every
16
- # exit path. A separate `bundle exec` subprocess has its own VM and cannot see an
17
- # in-process `load`, so the mutant must live on disk while its suite runs — which
18
- # makes leaving the file mutated the one genuinely dangerous failure mode.
16
+ # exit path. A separate `bundle exec` subprocess has its own VM and cannot see
17
+ # an in-process `load`, so the mutant must live on disk while its suite runs,
18
+ # which makes leaving the file mutated the one genuinely dangerous failure mode.
19
19
  #
20
20
  # Defense in depth, mirroring the tempfile-orphan discipline
21
21
  # (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
@@ -39,12 +39,13 @@ module Mutineer
39
39
  # @return [Object] the block's return value.
40
40
  def self.with(source_file, mutated)
41
41
  backup = source_file + BACKUP_SUFFIX
42
- # A backup already on disk means either a prior hard-killed run (restore_orphans
43
- # should have healed it at startup) or a SECOND mutineer run racing us on the
44
- # same file. The backup path is shared and unlocked, so proceeding would let
45
- # us capture the other run's mutant AS the "original" and permanently mutate
46
- # the tree. Refuse loudly rather than silently corrupt — and do it BEFORE
47
- # `created` is set, so the ensure below never touches a backup we don't own.
42
+ # A backup already on disk means either a prior hard-killed run
43
+ # (restore_orphans should have healed it at startup) or a SECOND mutineer
44
+ # run racing us on the same file. The backup path is shared and unlocked, so
45
+ # proceeding would let us capture the other run's mutant AS the "original"
46
+ # and permanently mutate the tree. Refuse loudly rather than silently
47
+ # corrupt, and do it BEFORE `created` is set, so the ensure below never
48
+ # touches a backup we don't own.
48
49
  raise ConcurrentRunError, backup if File.exist?(backup)
49
50
 
50
51
  original = File.binread(source_file)
@@ -74,11 +75,11 @@ module Mutineer
74
75
  backup_bytes = File.binread(backup)
75
76
  if !File.exist?(source_file)
76
77
  # A real user file that merely ends in our suffix, with no sibling to
77
- # restore leave it untouched (never create a file from it).
78
+ # restore. Leave it untouched (never create a file from it).
78
79
  next
79
80
  elsif File.binread(source_file) == backup_bytes
80
- # Redundant backup (e.g. a crash between restore and unlink): nothing to
81
- # heal, just clear the orphan so the next run doesn't see a false race.
81
+ # Redundant backup (e.g. a crash between restore and unlink): nothing
82
+ # to heal, just clear the orphan so the next run does not see a false race.
82
83
  File.unlink(backup)
83
84
  else
84
85
  File.binwrite(source_file, backup_bytes)
@@ -1,33 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutineer
4
- # #26/#27 Phase 2b (U5) — per-worker database isolation for the daemon path.
4
+ # Per-worker database isolation for the daemon path.
5
5
  #
6
- # Loaded APP-SIDE by {DaemonServer} (a sibling gem file, pulled in by absolute path
7
- # so it bypasses the app bundle the same trick {DaemonClient} uses to run
8
- # `daemon_server.rb` under a bundle that has no mutineer). It uses the app's OWN
9
- # already-booted ActiveRecord and NEVER `require "active_record"` (R10/KTD-8): every
6
+ # Loaded APP-SIDE by {DaemonServer} (a sibling gem file, pulled in by absolute
7
+ # path so it bypasses the app bundle, the same trick {DaemonClient} uses to run
8
+ # `daemon_server.rb` under a bundle that has no mutineer). It uses the app's
9
+ # OWN already-booted ActiveRecord and NEVER `require "active_record"`: every
10
10
  # method that touches AR first confirms {available?}, so the daemon core stays
11
11
  # framework-agnostic and the gem keeps its zero-runtime-dependency promise.
12
12
  #
13
- # Isolation model (KTD-7): each parallel worker gets its OWN database so concurrent
14
- # forks can't clobber each other's transactional fixtures (the measured #26
15
- # corruption). {after_fork} runs inside a freshly-forked child and points that
16
- # child's connection at the worker's database BEFORE any test loads; transactional
17
- # fixtures then repopulate that isolated database per test.
13
+ # Isolation model: each parallel worker gets its OWN database so concurrent
14
+ # forks cannot clobber each other's transactional fixtures. {after_fork} runs
15
+ # inside a freshly-forked child and points that child's connection at the
16
+ # worker's database BEFORE any test loads; transactional fixtures then
17
+ # repopulate that isolated database per test.
18
18
  #
19
- # Scope: this pass ships the **SQLite** adapter (per-worker file, hermetic,
20
- # spike-proven). Postgres per-worker DBs (`CREATE DATABASE <db>-<worker>`) are not
21
- # implemented yet; a non-SQLite config raises a clear NotImplementedError rather
22
- # than silently mis-routing.
19
+ # Scope: SQLite adapter only (per-worker file, hermetic). Postgres per-worker
20
+ # DBs (`CREATE DATABASE <db>-<worker>`) are not implemented yet; a non-SQLite
21
+ # config raises a clear NotImplementedError rather than silently mis-routing.
23
22
  #
24
- # Honest limit (KTD-5): routing failures surface as `error` via {verify_connection!}.
25
- # Re-raising an AR error that fires *inside a test body* past Minitest (so an in-test
26
- # DB failure is `error`, not `killed`) is only observable under concurrent load and
27
- # is deferred to U6 with the parallel gate — noted, not silently skipped.
23
+ # Routing failures surface as `error` via {verify_connection!}. Tagging an
24
+ # in-test DB failure as `error` (not `killed`) is only observable under
25
+ # concurrent load and is not yet implemented.
28
26
  module RailsWorkerDb
29
- # True when the app has ActiveRecord loaded the only condition under which any
30
- # other method here may touch AR. Never triggers an autoload/require of AR itself.
27
+ # True when the app has ActiveRecord loaded. The only condition under which
28
+ # any other method here may touch AR. Never triggers an autoload/require of
29
+ # AR itself.
31
30
  #
32
31
  # @return [Boolean]
33
32
  def self.available?
@@ -35,8 +34,9 @@ module Mutineer
35
34
  end
36
35
 
37
36
  # Derive a per-worker database path from a base path by inserting `-<worker>`
38
- # before the extension. Pure string transform (no AR) so it is unit-testable in
39
- # the zero-dep suite. `storage/test.sqlite3`, worker 1 -> `storage/test-1.sqlite3`.
37
+ # before the extension. Pure string transform (no AR) so it is unit-testable
38
+ # in the zero-dep suite. `storage/test.sqlite3`, worker 1 ->
39
+ # `storage/test-1.sqlite3`.
40
40
  #
41
41
  # @param database [String] the base database path.
42
42
  # @param worker [Integer] the worker slot index (0..N-1).
@@ -47,9 +47,9 @@ module Mutineer
47
47
  end
48
48
 
49
49
  # Build the AR connection config for one worker by copying the app's current
50
- # (default test) config and swapping in the per-worker database path. SQLite only
51
- # this pass a non-SQLite adapter raises so the SQLite-first scope fails loud
52
- # instead of mis-routing (Postgres is U10).
50
+ # (default test) config and swapping in the per-worker database path. SQLite
51
+ # only this pass: a non-SQLite adapter raises so the SQLite-first scope fails
52
+ # loud instead of mis-routing.
53
53
  #
54
54
  # @param worker [Integer] the worker slot index.
55
55
  # @return [Hash] a symbol-keyed AR configuration hash for the worker database.
@@ -57,16 +57,15 @@ module Mutineer
57
57
  def self.worker_db_config(worker)
58
58
  hash = ActiveRecord::Base.connection_db_config.configuration_hash
59
59
  adapter = hash[:adapter].to_s
60
- # U10 seam: the config-SHAPING below (per_worker_config) is already
61
- # adapter-general — it derives correct SQLite *and* Postgres worker-DB names.
62
- # What's gated is runtime PROVISIONING: SQLite files are created on connect,
63
- # but Postgres needs an explicit `CREATE DATABASE` per worker (U10). Until that
64
- # lands, refuse non-SQLite loudly rather than route to a database that doesn't
65
- # exist until Postgres worker creation is implemented.
60
+ # Config shaping (per_worker_config) is already adapter-general: it derives
61
+ # correct SQLite and Postgres worker-DB names. What is gated is runtime
62
+ # provisioning: SQLite files are created on connect, but Postgres needs an
63
+ # explicit `CREATE DATABASE` per worker. Until that lands, refuse non-SQLite
64
+ # loudly rather than route to a database that does not exist.
66
65
  unless adapter.start_with?("sqlite")
67
66
  raise NotImplementedError,
68
67
  "worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
69
- "Postgres per-worker provisioning is U10 (#26/#35) use a SQLite test DB, or drop --jobs."
68
+ "Postgres per-worker provisioning is not yet supported. Use a SQLite test DB, or drop --jobs."
70
69
  end
71
70
 
72
71
  per_worker_config(hash, worker)
@@ -74,10 +73,10 @@ module Mutineer
74
73
 
75
74
  # Pure config-shaping (no AR): given a connection config hash, return the
76
75
  # per-worker variant with its database swapped to the worker's own name.
77
- # Adapter-general SQLite (`storage/test.sqlite3` `storage/test-<w>.sqlite3`)
78
- # and Postgres (`myapp_test` `myapp_test-<w>`, Rails `parallelize` naming) both
79
- # fall out of {worker_database_path}. Extracted + unit-tested so the Postgres
80
- # SHAPE is proven ready for U10 without a live database.
76
+ # Adapter-general: SQLite (`storage/test.sqlite3` -> `storage/test-<w>.sqlite3`)
77
+ # and Postgres (`myapp_test` -> `myapp_test-<w>`, Rails `parallelize` naming)
78
+ # both fall out of {worker_database_path}. Extracted and unit-tested so the
79
+ # Postgres shape is proven ready without a live database.
81
80
  #
82
81
  # @param config_hash [Hash] a connection config hash (symbol or string keys).
83
82
  # @param worker [Integer] the worker slot index.
@@ -94,11 +93,12 @@ module Mutineer
94
93
  hash.merge(database: worker_database_path(database, worker))
95
94
  end
96
95
 
97
- # Child-side (after fork): route this process's ActiveRecord at the worker's own
98
- # database and confirm it is reachable, so a routing failure reads as `error`
99
- # (via the daemon's child rescue) rather than a false verdict. Loads the schema
100
- # into the worker database when a schema path is given (idempotent — schema.rb
101
- # runs with `force: true`), covering a fresh worker file.
96
+ # Child-side (after fork): route this process's ActiveRecord at the worker's
97
+ # own database and confirm it is reachable, so a routing failure reads as
98
+ # `error` (via the daemon's child rescue) rather than a false verdict. Loads
99
+ # the schema into the worker database when a schema path is given
100
+ # (idempotent: schema.rb runs with `force: true`), covering a fresh worker
101
+ # file.
102
102
  #
103
103
  # @param worker [Integer] the worker slot index.
104
104
  # @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
@@ -126,8 +126,9 @@ module Mutineer
126
126
  $stdout = original
127
127
  end
128
128
 
129
- # Force a round-trip to the freshly-routed connection so a broken route fails HERE
130
- # (→ `error`) instead of later masquerading as a test failure (→ false `killed`).
129
+ # Force a round-trip to the freshly-routed connection so a broken route fails
130
+ # HERE (→ `error`) instead of later masquerading as a test failure
131
+ # (→ false `killed`).
131
132
  #
132
133
  # @return [void]
133
134
  def self.verify_connection!