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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +475 -0
- data/README.md +224 -28
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +117 -20
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +533 -100
- data/lib/mutineer/daemon_backend.rb +51 -33
- data/lib/mutineer/daemon_client.rb +212 -22
- data/lib/mutineer/daemon_server.rb +74 -27
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +92 -6
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +75 -11
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +344 -21
- data/lib/mutineer/rails_worker_db.rb +83 -9
- data/lib/mutineer/reporter.rb +306 -53
- data/lib/mutineer/result.rb +73 -5
- data/lib/mutineer/runner.rb +123 -242
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +282 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +2 -0
- 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
|
-
|
|
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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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
|
|
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 {
|
|
44
|
+
# source map, and the {JobPlan.collect_jobs} extras.
|
|
48
45
|
def self.execute(config, operator_classes)
|
|
49
|
-
jobs, ignored_results, source_map, extras =
|
|
50
|
-
jobs =
|
|
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
|
-
|
|
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.
|
|
95
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
250
|
-
# no fork. No map (build failed) → run the full --test set
|
|
251
|
-
# narrowed).
|
|
252
|
-
sel = 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
|
-
|
|
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
|
|
270
|
-
#
|
|
271
|
-
# whether this
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
#
|
|
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.
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
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
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
|
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
|
-
#
|
|
121
|
-
#
|
|
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 =
|
|
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"]
|
|
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.
|
|
137
|
-
#
|
|
138
|
-
# Ruby
|
|
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",
|
|
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 =
|
|
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
|
-
@
|
|
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
|
|
196
|
-
|
|
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
|