mutineer-mongoid 2.0.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 +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- 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 +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- metadata +143 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "io/wait"
|
|
5
|
+
require "open3"
|
|
6
|
+
require_relative "external_backend"
|
|
7
|
+
|
|
8
|
+
module Mutineer
|
|
9
|
+
# Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
|
|
10
|
+
# app error, a failed handshake, a spawn the OS refused, or MAX_RESTARTS crashes.
|
|
11
|
+
# It means "stop the run" — a backend that scored the remaining mutants against a
|
|
12
|
+
# dead daemon would report a score covering a fraction of the work. The CLI maps it
|
|
13
|
+
# to a runtime error (exit 1).
|
|
14
|
+
class DaemonBootError < StandardError; end
|
|
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
|
+
|
|
21
|
+
# Tool-side handle for the app-side daemon.
|
|
22
|
+
#
|
|
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.
|
|
29
|
+
# If the daemon dies mid-run it respawns (bounded) and marks the in-flight
|
|
30
|
+
# mutant `error` rather than corrupting the run. Reuses the cleaned-env spawn
|
|
31
|
+
# and stderr-drain proven in the spike driver and the spawn discipline of
|
|
32
|
+
# ExternalBackend.
|
|
33
|
+
class DaemonClient
|
|
34
|
+
# Absolute path to the daemon entry, loaded app-side by `-r` (bypasses the bundle).
|
|
35
|
+
DAEMON_PATH = File.expand_path("daemon_server.rb", __dir__)
|
|
36
|
+
# How many times to respawn a crashing daemon before aborting the run.
|
|
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"
|
|
47
|
+
|
|
48
|
+
# @param boot [Hash] boot config sent to the daemon: project_root, boot,
|
|
49
|
+
# load_paths, framework, rails.
|
|
50
|
+
# @param app_root [String] directory to spawn the daemon in (the app root).
|
|
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.
|
|
54
|
+
# @param gemfile [String, nil] BUNDLE_GEMFILE for the app's bundle (nil = app_root/Gemfile).
|
|
55
|
+
# @param errio [IO] where daemon stderr is drained.
|
|
56
|
+
def initialize(boot:, app_root:, ruby_version: nil, gemfile: nil, errio: $stderr)
|
|
57
|
+
@boot = boot
|
|
58
|
+
@app_root = app_root
|
|
59
|
+
@ruby_version = ruby_version
|
|
60
|
+
@gemfile = gemfile || File.join(app_root, "Gemfile")
|
|
61
|
+
@errio = errio
|
|
62
|
+
@restarts = 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Spawn the daemon and complete the ready handshake. Raises DaemonBootError on
|
|
66
|
+
# failure (surfaced by the CLI as a clean runtime error, not a hang).
|
|
67
|
+
#
|
|
68
|
+
# @return [self]
|
|
69
|
+
def start
|
|
70
|
+
spawn_daemon
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Run one mutant: ship the payload + covering tests, return the verdict string.
|
|
75
|
+
# On a daemon crash (EOF/dead pipe) respawn (bounded) and return `"error"` for
|
|
76
|
+
# this mutant. Never a wrong verdict, never a wedged run.
|
|
77
|
+
#
|
|
78
|
+
# @param id [Integer] request id (echoed back for ordering safety).
|
|
79
|
+
# @param payload [Hash] mutated ruby under the "code" key, path under "source_file".
|
|
80
|
+
# @param tests [Array<String>] covering test file paths.
|
|
81
|
+
# @param timeout [Numeric] per-mutant wall-clock timeout (seconds).
|
|
82
|
+
# @param worker [Integer] worker slot; the daemon routes the fork to
|
|
83
|
+
# `<db>-<worker>` for isolation. Defaults to 0 (serial).
|
|
84
|
+
# @return [String] one of survived/killed/error/timeout.
|
|
85
|
+
def request(id:, payload:, tests:, timeout:, worker: 0)
|
|
86
|
+
# close_io nils the pipes, so a client whose respawn never completed would
|
|
87
|
+
# otherwise fail per-mutant forever (NoMethodError on nil) and let the backend
|
|
88
|
+
# score every remaining mutant against nothing. Deadness is a property of the
|
|
89
|
+
# client, not of whichever exception happened to escape.
|
|
90
|
+
raise DaemonBootError, "daemon is not running" if @stdin.nil?
|
|
91
|
+
|
|
92
|
+
# A crash can surface on the WRITE (daemon died idle between requests →
|
|
93
|
+
# Errno::EPIPE) as well as the read (EOF), so guard both: either way, respawn
|
|
94
|
+
# for future mutants and score THIS one error (re-running a crash-causing
|
|
95
|
+
# mutant could loop). Never let a dead pipe abort the whole run.
|
|
96
|
+
reply =
|
|
97
|
+
begin
|
|
98
|
+
send_line("id" => id, "worker" => worker, "payload" => payload, "tests" => tests, "timeout" => timeout)
|
|
99
|
+
read_line(timeout + REPLY_GRACE)
|
|
100
|
+
rescue Errno::EPIPE, IOError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
return reply["verdict"] if reply && reply["id"] == id
|
|
104
|
+
|
|
105
|
+
restart!
|
|
106
|
+
"error"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Ask the daemon to build the coverage map app-side and return it. One-shot
|
|
110
|
+
# control message (no id). On success, returns
|
|
111
|
+
# `{"map"=>..., "failed_test_files"=>..., "failed_clean_tests"=>...}`.
|
|
112
|
+
# On coverage-build failure, returns
|
|
113
|
+
# `{"map"=>{}, "failed_test_files"=>[], "error"=>...}`.
|
|
114
|
+
# Returns nil if the daemon vanished. The caller then falls back to running
|
|
115
|
+
# the full test set (no narrowing) rather than mis-scoring, except a red
|
|
116
|
+
# unmutated suite which aborts.
|
|
117
|
+
#
|
|
118
|
+
# @return [Hash, nil] the coverage payload, or nil on a dead pipe.
|
|
119
|
+
def coverage
|
|
120
|
+
send_line("cmd" => "coverage")
|
|
121
|
+
read_line
|
|
122
|
+
rescue Errno::EPIPE, IOError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Graceful shutdown; leaves no orphaned daemon/child.
|
|
127
|
+
#
|
|
128
|
+
# @return [void]
|
|
129
|
+
def quit
|
|
130
|
+
return unless @stdin
|
|
131
|
+
|
|
132
|
+
send_line("cmd" => "quit") rescue nil # rubocop:disable Style/RescueModifier
|
|
133
|
+
@wait_thr&.join(REPLY_GRACE) # a wedged daemon is killed by close_io
|
|
134
|
+
ensure
|
|
135
|
+
close_io
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
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}]
|
|
146
|
+
def app_env
|
|
147
|
+
env = restored_user_env
|
|
148
|
+
scrub_managed_ruby_bins!(env)
|
|
149
|
+
env.delete("BUNDLER_SETUP")
|
|
150
|
+
env["BUNDLE_GEMFILE"] = @gemfile
|
|
151
|
+
env["RBENV_VERSION"] = @ruby_version if @ruby_version
|
|
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
|
+
# Inside Bundler, an unsaved value belongs to the tool. Standalone
|
|
180
|
+
# invocation has no saved keys: preserve the caller's application paths.
|
|
181
|
+
unless restored.empty?
|
|
182
|
+
%w[GEM_HOME GEM_PATH BUNDLE_PATH BUNDLE_WITHOUT BUNDLER_VERSION].each do |key|
|
|
183
|
+
env.delete(key) unless restored.include?(key)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
env
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Drop rbenv and asdf version bins so shims can select the app Ruby.
|
|
190
|
+
# Leave those bins in place when no shim directory exists, so +bundle+
|
|
191
|
+
# stays on PATH. Leave chruby bins in place.
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
# @param env [Hash{String => String}]
|
|
195
|
+
# @return [void]
|
|
196
|
+
def scrub_managed_ruby_bins!(env)
|
|
197
|
+
parts = env["PATH"].to_s.split(File::PATH_SEPARATOR)
|
|
198
|
+
rbenv = rbenv_shim_dirs(env)
|
|
199
|
+
asdf = asdf_shim_dirs(env)
|
|
200
|
+
dropped_rbenv = false
|
|
201
|
+
dropped_asdf = false
|
|
202
|
+
kept = parts.reject do |part|
|
|
203
|
+
if rbenv_version_bin?(part, env) && rbenv.any?
|
|
204
|
+
dropped_rbenv = true
|
|
205
|
+
elsif asdf_version_bin?(part, env) && asdf.any?
|
|
206
|
+
dropped_asdf = true
|
|
207
|
+
else
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
return if kept.size == parts.size
|
|
212
|
+
|
|
213
|
+
shims = []
|
|
214
|
+
shims.concat(rbenv) if dropped_rbenv
|
|
215
|
+
shims.concat(asdf) if dropped_asdf
|
|
216
|
+
env["PATH"] = (shims + kept).uniq.join(File::PATH_SEPARATOR)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Existing rbenv shim directory. +RBENV_ROOT+ wins. Otherwise the home
|
|
220
|
+
# directory's <tt>.rbenv</tt> is used.
|
|
221
|
+
#
|
|
222
|
+
# @api private
|
|
223
|
+
# @param env [Hash{String => String}]
|
|
224
|
+
# @return [Array<String>]
|
|
225
|
+
def rbenv_shim_dirs(env)
|
|
226
|
+
root = rbenv_root(env)
|
|
227
|
+
return [] if root.nil?
|
|
228
|
+
|
|
229
|
+
dir = File.join(root, "shims")
|
|
230
|
+
File.directory?(dir) ? [dir] : []
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# rbenv install root. +RBENV_ROOT+ wins over <tt>~/.rbenv</tt>.
|
|
234
|
+
#
|
|
235
|
+
# @api private
|
|
236
|
+
# @param env [Hash{String => String}]
|
|
237
|
+
# @return [String, nil]
|
|
238
|
+
def rbenv_root(env)
|
|
239
|
+
root = env["RBENV_ROOT"]
|
|
240
|
+
return File.expand_path(root) if root && !root.empty?
|
|
241
|
+
|
|
242
|
+
home = env["HOME"]
|
|
243
|
+
return nil if home.nil? || home.empty?
|
|
244
|
+
|
|
245
|
+
File.join(home, ".rbenv")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Existing asdf shim directories. A custom install uses +ASDF_DATA_DIR+.
|
|
249
|
+
#
|
|
250
|
+
# @api private
|
|
251
|
+
# @param env [Hash{String => String}]
|
|
252
|
+
# @return [Array<String>]
|
|
253
|
+
def asdf_shim_dirs(env)
|
|
254
|
+
dirs = []
|
|
255
|
+
data = env["ASDF_DATA_DIR"]
|
|
256
|
+
dirs << File.join(data, "shims") if data && !data.empty?
|
|
257
|
+
home = env["HOME"]
|
|
258
|
+
dirs << File.join(home, ".asdf", "shims") if home && !home.empty?
|
|
259
|
+
dirs.select { |dir| File.directory?(dir) }.uniq
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# True when `part` is a version bin under the active rbenv root.
|
|
263
|
+
#
|
|
264
|
+
# @api private
|
|
265
|
+
# @param part [String] one PATH entry.
|
|
266
|
+
# @param env [Hash{String => String}]
|
|
267
|
+
# @return [Boolean]
|
|
268
|
+
def rbenv_version_bin?(part, env)
|
|
269
|
+
root = rbenv_root(env)
|
|
270
|
+
return false if root.nil?
|
|
271
|
+
|
|
272
|
+
prefix = File.join(root, "versions")
|
|
273
|
+
File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# True when `part` is an asdf Ruby version bin under ~/.asdf or +ASDF_DATA_DIR+.
|
|
277
|
+
#
|
|
278
|
+
# @api private
|
|
279
|
+
# @param part [String] one PATH entry.
|
|
280
|
+
# @param env [Hash{String => String}]
|
|
281
|
+
# @return [Boolean]
|
|
282
|
+
def asdf_version_bin?(part, env)
|
|
283
|
+
return true if part.match?(%r{/\.asdf/installs/ruby/[^/]+/bin/?\z})
|
|
284
|
+
|
|
285
|
+
data = env["ASDF_DATA_DIR"]
|
|
286
|
+
return false if data.nil? || data.empty?
|
|
287
|
+
|
|
288
|
+
prefix = File.join(File.expand_path(data), "installs", "ruby")
|
|
289
|
+
File.expand_path(part).match?(%r{\A#{Regexp.escape(prefix)}/[^/]+/bin/?\z})
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# True when this daemon boots a Rails app.
|
|
293
|
+
#
|
|
294
|
+
# @api private
|
|
295
|
+
# @return [Boolean]
|
|
296
|
+
def rails_boot?
|
|
297
|
+
@boot[:rails] || @boot["rails"]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Spawn the daemon under the app bundle and complete the ready handshake.
|
|
301
|
+
#
|
|
302
|
+
# @return [void]
|
|
303
|
+
# @raise [Mutineer::DaemonBootError] when the daemon fails to boot.
|
|
304
|
+
def spawn_daemon
|
|
305
|
+
# Plain `bundle exec ruby`, NOT `rbenv exec`, which would break CI and any
|
|
306
|
+
# non-rbenv setup. An explicit ruby_version replaces RBENV_VERSION.
|
|
307
|
+
# With no argument, a pin already in the environment stays. That pin is
|
|
308
|
+
# often the Ruby that started the tool. `.ruby-version` applies only
|
|
309
|
+
# when no pin is set. rbenv and asdf version bins leave PATH when a shim
|
|
310
|
+
# directory exists. chruby bins stay.
|
|
311
|
+
# Everything up to the handshake is terminal, not one mutant's problem: a spawn
|
|
312
|
+
# the OS refuses (EMFILE/ENOMEM under --jobs N, ENOENT when `bundle` does not
|
|
313
|
+
# resolve) and a daemon that dies before accepting the boot payload (EPIPE on
|
|
314
|
+
# the write) both leave a client that cannot recover. Raise the class that ends
|
|
315
|
+
# the run — a SystemCallError would reach the CLI as a usage error (exit 2).
|
|
316
|
+
ready =
|
|
317
|
+
begin
|
|
318
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(
|
|
319
|
+
app_env, "bundle", "exec", "ruby",
|
|
320
|
+
"-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
|
|
321
|
+
unsetenv_others: true, chdir: @app_root
|
|
322
|
+
)
|
|
323
|
+
# Drain daemon stderr to the tool's stderr so child/boot errors are visible.
|
|
324
|
+
# Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
|
|
325
|
+
# the rescue swallows the benign EBADF/IOError raised when close_io closes
|
|
326
|
+
# the pipe out from under an in-flight copy_stream.
|
|
327
|
+
@drain = Thread.new do # rubocop:disable ThreadSafety/NewThread
|
|
328
|
+
IO.copy_stream(@stderr, @errio)
|
|
329
|
+
rescue IOError, Errno::EBADF
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
send_line(@boot)
|
|
334
|
+
read_line(BOOT_TIMEOUT)
|
|
335
|
+
rescue SystemCallError, IOError => e
|
|
336
|
+
close_io
|
|
337
|
+
raise DaemonBootError, "daemon could not be started: #{e.class}: #{e.message}"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
unless ready && ready["ready"]
|
|
341
|
+
detail =
|
|
342
|
+
if ready && ready["error"] then ready["error"]
|
|
343
|
+
elsif @timed_out then "the daemon did not finish booting within #{BOOT_TIMEOUT}s"
|
|
344
|
+
else "daemon exited before the handshake"
|
|
345
|
+
end
|
|
346
|
+
timed_out = @timed_out
|
|
347
|
+
close_io
|
|
348
|
+
raise (timed_out ? DaemonBootTimeout : DaemonBootError), "daemon failed to boot under the app bundle: #{detail}"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.
|
|
353
|
+
def restart!
|
|
354
|
+
close_io
|
|
355
|
+
@restarts += 1
|
|
356
|
+
if @restarts > MAX_RESTARTS
|
|
357
|
+
raise DaemonBootError, "daemon crashed or stopped answering #{@restarts} times; aborting the run"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
cause = @timed_out ? "stopped answering" : "crashed"
|
|
361
|
+
@errio.puts("[mutineer] daemon #{cause} — respawning (#{@restarts}/#{MAX_RESTARTS})")
|
|
362
|
+
spawn_daemon
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Write one JSON object as a line to the daemon.
|
|
366
|
+
#
|
|
367
|
+
# @param obj [Hash] the message to encode.
|
|
368
|
+
# @return [void]
|
|
369
|
+
def send_line(obj)
|
|
370
|
+
@stdin.puts(JSON.generate(obj))
|
|
371
|
+
@stdin.flush
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Read one JSON reply line; nil on EOF/dead pipe, or when no line arrives
|
|
375
|
+
# within `timeout` seconds (caller treats either as a crash).
|
|
376
|
+
#
|
|
377
|
+
# @param timeout [Numeric, nil] seconds to wait; nil waits for the reply.
|
|
378
|
+
# @return [Hash, nil]
|
|
379
|
+
def read_line(timeout = nil)
|
|
380
|
+
@timed_out = timeout && !@stdout.wait_readable(timeout)
|
|
381
|
+
return nil if @timed_out
|
|
382
|
+
|
|
383
|
+
line = @stdout.gets
|
|
384
|
+
line && JSON.parse(line.strip)
|
|
385
|
+
rescue IOError, Errno::EPIPE, JSON::ParserError
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Close the IPC pipes, stop the stderr-drain thread, and reap the daemon so a
|
|
390
|
+
# respawn or quit leaves no leaked fd, thread, or zombie.
|
|
391
|
+
#
|
|
392
|
+
# @return [void]
|
|
393
|
+
def close_io
|
|
394
|
+
# A wedged daemon may never read the closed stdin, so the reap below would
|
|
395
|
+
# wait forever: kill it first. An exited daemon makes this a no-op.
|
|
396
|
+
begin
|
|
397
|
+
Process.kill(:KILL, @wait_thr.pid) if @wait_thr&.alive?
|
|
398
|
+
rescue Errno::ESRCH
|
|
399
|
+
nil # it exited between the check and the kill
|
|
400
|
+
end
|
|
401
|
+
@drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
|
|
402
|
+
[@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
|
|
403
|
+
@wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
|
|
404
|
+
@stdin = @stdout = @stderr = @drain = @wait_thr = nil
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|