mutineer 1.7.0 → 1.8.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 +432 -0
- data/README.md +157 -747
- data/lib/mutineer/baseline.rb +1 -1
- data/lib/mutineer/changed_lines.rb +2 -1
- data/lib/mutineer/cli.rb +119 -27
- data/lib/mutineer/concern_blocks.rb +168 -5
- data/lib/mutineer/config.rb +6 -0
- data/lib/mutineer/coverage_map.rb +92 -17
- data/lib/mutineer/daemon_backend.rb +75 -32
- data/lib/mutineer/daemon_client.rb +50 -17
- data/lib/mutineer/daemon_server.rb +92 -40
- data/lib/mutineer/external_backend.rb +150 -17
- data/lib/mutineer/isolation.rb +67 -25
- data/lib/mutineer/job_plan.rb +117 -33
- data/lib/mutineer/kill_channel.rb +24 -14
- data/lib/mutineer/migrate.rb +10 -8
- data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
- data/lib/mutineer/minitest_integration.rb +44 -14
- data/lib/mutineer/mutators/array_literal.rb +4 -2
- data/lib/mutineer/mutators/base.rb +22 -0
- data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
- data/lib/mutineer/mutators/chain_link.rb +6 -6
- data/lib/mutineer/mutators/condition_forcing.rb +6 -3
- data/lib/mutineer/mutators/operand_removal.rb +4 -2
- data/lib/mutineer/mutators/range_literal.rb +105 -4
- data/lib/mutineer/mutators/return_nil.rb +1 -0
- data/lib/mutineer/orphan_guard.rb +18 -6
- data/lib/mutineer/pairing.rb +62 -1
- data/lib/mutineer/project.rb +226 -12
- data/lib/mutineer/rails_worker_db.rb +200 -16
- data/lib/mutineer/redaction.rb +112 -11
- data/lib/mutineer/reporter.rb +109 -21
- data/lib/mutineer/result.rb +33 -5
- data/lib/mutineer/runner.rb +126 -18
- data/lib/mutineer/statement_lines.rb +14 -8
- data/lib/mutineer/subject.rb +13 -5
- data/lib/mutineer/test_runners/minitest.rb +2 -1
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/triage.rb +1 -1
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +2 -1
- data/skills/mutineer/SKILL.md +84 -0
- metadata +5 -1
|
@@ -37,6 +37,10 @@ module Mutineer
|
|
|
37
37
|
# The result is already in the pipe then; the wait only lets the reader
|
|
38
38
|
# thread run, even when the capture deadline has passed (#129).
|
|
39
39
|
RESULT_GRACE = 1
|
|
40
|
+
# The redaction module, by absolute path, for the subprocess scripts: they
|
|
41
|
+
# write a failing test's report, or a load error, to the inherited stderr,
|
|
42
|
+
# and either can quote DATABASE_URL.
|
|
43
|
+
REDACTION_PATH = File.expand_path("redaction.rb", __dir__)
|
|
40
44
|
|
|
41
45
|
attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
|
|
42
46
|
|
|
@@ -447,7 +451,7 @@ module Mutineer
|
|
|
447
451
|
raise CoverageFromError, "--coverage-from #{@coverage_from} is this run's own cache; give the run another --cache-dir"
|
|
448
452
|
end
|
|
449
453
|
|
|
450
|
-
cached = JSON.parse(File.read(@coverage_from))
|
|
454
|
+
cached = JSON.parse(File.read(@coverage_from, encoding: "UTF-8"))
|
|
451
455
|
reason = capture_mismatch(cached)
|
|
452
456
|
raise CoverageFromError, "--coverage-from #{@coverage_from} cannot be reused: #{reason}" if reason
|
|
453
457
|
|
|
@@ -467,7 +471,7 @@ module Mutineer
|
|
|
467
471
|
return "it has no source fingerprints; capture it again with this version and --rails/--boot"
|
|
468
472
|
end
|
|
469
473
|
return "its entries are not the shape a coverage cache saves; capture it again" unless well_formed_capture?(cached)
|
|
470
|
-
return "its tests, --require files, boot file, load paths or
|
|
474
|
+
return "its tests, --require files, boot file, load paths, framework or Minitest seed differ, or an older version made it; capture it again" unless cached["inputs"] == compute_digest(inputs_only: true)
|
|
471
475
|
|
|
472
476
|
fingerprints = source_fingerprints
|
|
473
477
|
missing = fingerprints.keys - cached["sources"].keys
|
|
@@ -1028,10 +1032,14 @@ module Mutineer
|
|
|
1028
1032
|
Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
|
|
1029
1033
|
Minitest.extensions << "mutineer_report"
|
|
1030
1034
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
+
begin
|
|
1036
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
1037
|
+
Minitest::Runnable.reset
|
|
1038
|
+
#{loads}
|
|
1039
|
+
#{load_failure_rescue}
|
|
1040
|
+
end
|
|
1041
|
+
_passed = Minitest.run(#{minitest_seed_args_expression})
|
|
1042
|
+
$stderr.write(#{redacted_expression("_report.string")}) unless _passed
|
|
1035
1043
|
exit(_passed ? 0 : 1)
|
|
1036
1044
|
RUBY
|
|
1037
1045
|
end
|
|
@@ -1053,14 +1061,58 @@ module Mutineer
|
|
|
1053
1061
|
end
|
|
1054
1062
|
RSpec::Core::Runner.disable_autorun!
|
|
1055
1063
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
1056
|
-
|
|
1064
|
+
begin
|
|
1065
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
1066
|
+
#{load_failure_rescue}
|
|
1067
|
+
end
|
|
1057
1068
|
_sink = StringIO.new
|
|
1058
1069
|
status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
|
|
1059
|
-
$stderr.write(_sink.string) unless status.zero?
|
|
1070
|
+
$stderr.write(#{redacted_expression("_sink.string")}) unless status.zero?
|
|
1060
1071
|
exit(status.zero? ? 0 : 1)
|
|
1061
1072
|
RUBY
|
|
1062
1073
|
end
|
|
1063
1074
|
|
|
1075
|
+
# Ruby source, for a subprocess script, that loads the redaction module
|
|
1076
|
+
# and returns the redacted value of `expression`.
|
|
1077
|
+
#
|
|
1078
|
+
# @api private
|
|
1079
|
+
# @param expression [String] Ruby source for the text to redact.
|
|
1080
|
+
# @return [String] Ruby script text.
|
|
1081
|
+
def redacted_expression(expression)
|
|
1082
|
+
"(require #{REDACTION_PATH.inspect}; Mutineer::Redaction.text(#{expression}))"
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# Ruby source, for a subprocess script, that closes a `begin` around the
|
|
1086
|
+
# project and test file loads. A load error then prints one redacted line
|
|
1087
|
+
# (Ruby's own printer would show DATABASE_URL raw), and the child still
|
|
1088
|
+
# exits 1, as it did when the error went uncaught. Under --verbose the
|
|
1089
|
+
# redacted backtrace follows; otherwise a hint says how to get it.
|
|
1090
|
+
#
|
|
1091
|
+
# @api private
|
|
1092
|
+
# @return [String] Ruby script text.
|
|
1093
|
+
def load_failure_rescue
|
|
1094
|
+
detail =
|
|
1095
|
+
if @verbose
|
|
1096
|
+
<<~RUBY.chomp
|
|
1097
|
+
(_e.backtrace || []).each { |_f| $stderr.puts("\t" + Mutineer::Redaction.text(_f)) }
|
|
1098
|
+
_cause = _e.cause
|
|
1099
|
+
while _cause
|
|
1100
|
+
$stderr.puts("[mutineer] caused by: " + Mutineer::Redaction.describe(_cause))
|
|
1101
|
+
_cause = _cause.cause
|
|
1102
|
+
end
|
|
1103
|
+
RUBY
|
|
1104
|
+
else
|
|
1105
|
+
'$stderr.puts("[mutineer] re-run with --verbose for the backtrace")'
|
|
1106
|
+
end
|
|
1107
|
+
<<~RUBY.chomp
|
|
1108
|
+
rescue ScriptError, StandardError => _e
|
|
1109
|
+
require #{REDACTION_PATH.inspect}
|
|
1110
|
+
$stderr.puts("[mutineer] load failed: " + Mutineer::Redaction.describe(_e))
|
|
1111
|
+
#{detail}
|
|
1112
|
+
exit 1
|
|
1113
|
+
RUBY
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1064
1116
|
# Builds the framework-specific subprocess script.
|
|
1065
1117
|
#
|
|
1066
1118
|
# @api private
|
|
@@ -1088,11 +1140,15 @@ module Mutineer
|
|
|
1088
1140
|
Minitest.extensions << "mutineer_report"
|
|
1089
1141
|
Coverage.start(lines: true, methods: true)
|
|
1090
1142
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1143
|
+
begin
|
|
1144
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
1145
|
+
_load = #{load_coverage_expression}
|
|
1146
|
+
Minitest::Runnable.reset
|
|
1147
|
+
load #{absolute(test_path).inspect}
|
|
1148
|
+
#{load_failure_rescue}
|
|
1149
|
+
end
|
|
1150
|
+
_passed = Minitest.run(#{minitest_seed_args_expression})
|
|
1151
|
+
$stderr.write(#{redacted_expression("_report.string")}) unless _passed
|
|
1096
1152
|
_coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
1097
1153
|
_result.puts JSON.generate("passed" => _passed == true, "coverage" => _coverage,
|
|
1098
1154
|
"load_coverage" => _load,
|
|
@@ -1101,6 +1157,18 @@ module Mutineer
|
|
|
1101
1157
|
RUBY
|
|
1102
1158
|
end
|
|
1103
1159
|
|
|
1160
|
+
# Selects the seed inside the generated child, after its tests and helpers
|
|
1161
|
+
# load. Choosing in the parent can ignore a helper's SEED assignment and
|
|
1162
|
+
# let a clean check pass in an order the mutant run never uses (#282).
|
|
1163
|
+
# The default comes from the same constant as the in-process runner; the
|
|
1164
|
+
# child needs no Mutineer require and the parent's environment is unchanged.
|
|
1165
|
+
#
|
|
1166
|
+
# @api private
|
|
1167
|
+
# @return [String] Ruby expression for the Minitest argument array.
|
|
1168
|
+
def minitest_seed_args_expression
|
|
1169
|
+
"ENV[\"SEED\"] ? [] : [\"--seed\", #{MinitestIntegration::PINNED_SEED.to_s.inspect}]"
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1104
1172
|
# Same coverage-JSON contract as the minitest path, but driven by RSpec:
|
|
1105
1173
|
# require rspec/core lazily, require the sources under Coverage, then run the
|
|
1106
1174
|
# one spec via RSpec::Core::Runner. The JSON goes to the result channel (see
|
|
@@ -1122,11 +1190,14 @@ module Mutineer
|
|
|
1122
1190
|
RSpec::Core::Runner.disable_autorun!
|
|
1123
1191
|
Coverage.start(lines: true, methods: true)
|
|
1124
1192
|
$LOAD_PATH.unshift(*#{abs_load_paths.inspect})
|
|
1125
|
-
|
|
1126
|
-
|
|
1193
|
+
begin
|
|
1194
|
+
#{abs_preload_paths.inspect}.each { |f| require f }
|
|
1195
|
+
_load = #{load_coverage_expression}
|
|
1196
|
+
#{load_failure_rescue}
|
|
1197
|
+
end
|
|
1127
1198
|
_sink = StringIO.new
|
|
1128
1199
|
_status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
|
|
1129
|
-
$stderr.write(_sink.string) unless _status.zero?
|
|
1200
|
+
$stderr.write(#{redacted_expression("_sink.string")}) unless _status.zero?
|
|
1130
1201
|
_coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
|
|
1131
1202
|
_result.puts JSON.generate("passed" => _status.zero?, "coverage" => _coverage,
|
|
1132
1203
|
"load_coverage" => _load,
|
|
@@ -1355,6 +1426,10 @@ module Mutineer
|
|
|
1355
1426
|
end
|
|
1356
1427
|
@load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
|
|
1357
1428
|
d.update("framework\0#{@framework}\0")
|
|
1429
|
+
# The seed sets the test order, and the order can change which lines run (#282).
|
|
1430
|
+
# This is the configured seed. A test helper that changes SEED is not
|
|
1431
|
+
# digested, like any other helper edit.
|
|
1432
|
+
d.update("seed\0#{MinitestIntegration.seed}\0") unless @framework == "rspec"
|
|
1358
1433
|
d.hexdigest
|
|
1359
1434
|
end
|
|
1360
1435
|
|
|
@@ -1448,7 +1523,7 @@ module Mutineer
|
|
|
1448
1523
|
def read_cache
|
|
1449
1524
|
return nil unless File.exist?(cache_path)
|
|
1450
1525
|
|
|
1451
|
-
JSON.parse(File.read(cache_path))
|
|
1526
|
+
JSON.parse(File.read(cache_path, encoding: "UTF-8"))
|
|
1452
1527
|
rescue JSON::ParserError
|
|
1453
1528
|
nil # corrupt cache: rebuild from scratch
|
|
1454
1529
|
end
|
|
@@ -33,8 +33,11 @@ module Mutineer
|
|
|
33
33
|
|
|
34
34
|
# The daemon's per-mutant tempfile, written into the source dir so
|
|
35
35
|
# require_relative resolves. The leading dot hides it from Zeitwerk (#253);
|
|
36
|
-
# older versions wrote it without one.
|
|
37
|
-
|
|
36
|
+
# older versions wrote it without one. Daemon files only, unlike the boot
|
|
37
|
+
# sweeps ({OrphanGuard::TEMP_BASENAME}): this sweep runs after the boot and
|
|
38
|
+
# waits up to {SWEEP_GRACE} while any file it matches is still owned, so a
|
|
39
|
+
# parallel in-process run's live files would add that wait to every daemon run.
|
|
40
|
+
DAEMON_TEMP_GLOB = "{.,}mutineer_daemon#{"[0-9]" * 8}-*.rb"
|
|
38
41
|
|
|
39
42
|
# Seconds the end-of-run sweep waits for an orphaned child to die. Its
|
|
40
43
|
# watchdog notices a dead daemon within {OrphanGuard::POLL}.
|
|
@@ -169,16 +172,15 @@ module Mutineer
|
|
|
169
172
|
raise
|
|
170
173
|
rescue DaemonBootError => e
|
|
171
174
|
warn_coverage_fallback("#{e.class}: #{e.message}")
|
|
172
|
-
|
|
175
|
+
# No daemon listed a Rails app's databases, so no second worker may
|
|
176
|
+
# share them. A --boot app without --rails has no database routing.
|
|
177
|
+
unknown = { "error" => "the first daemon did not boot" }
|
|
178
|
+
return [nil, config.rails ? worker_count_for(unknown, [], worker_count) : worker_count, nil]
|
|
173
179
|
end
|
|
174
180
|
|
|
175
181
|
begin
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
warn "[mutineer] this app has #{count} databases; Mutineer runs one worker. " \
|
|
179
|
-
"Parallel runs support one database."
|
|
180
|
-
worker_count = 1
|
|
181
|
-
end
|
|
182
|
+
# Raises ConfigError when shared_databases lists the primary database.
|
|
183
|
+
worker_count = worker_count_for(client.databases, config.shared_databases, worker_count)
|
|
182
184
|
# Raises DaemonBootError. Callers must not score mutants after this.
|
|
183
185
|
client.provision(worker_count)
|
|
184
186
|
[coverage_map_from_client(client, config), worker_count, client]
|
|
@@ -189,6 +191,52 @@ module Mutineer
|
|
|
189
191
|
end
|
|
190
192
|
end
|
|
191
193
|
|
|
194
|
+
# The worker count once the booted app's databases are known. Each worker
|
|
195
|
+
# copies only the primary database, so a second database forces one worker
|
|
196
|
+
# unless `shared_databases` lists it. Listing the primary is a config error.
|
|
197
|
+
# A failed lookup also forces one worker: a database nobody counted must
|
|
198
|
+
# never be shared by accident.
|
|
199
|
+
#
|
|
200
|
+
# @param databases [Hash, nil] the daemon's report: "names" and "primary",
|
|
201
|
+
# or "error" (with "primary" when it resolves). nil when the app has no
|
|
202
|
+
# worker-DB adapter.
|
|
203
|
+
# @param shared [Array<String>] the `shared_databases` config names.
|
|
204
|
+
# @param worker_count [Integer] the workers requested.
|
|
205
|
+
# @return [Integer] the workers to start.
|
|
206
|
+
# @raise [Mutineer::ConfigError] when `shared` lists the primary database.
|
|
207
|
+
def self.worker_count_for(databases, shared, worker_count)
|
|
208
|
+
return worker_count if databases.nil?
|
|
209
|
+
|
|
210
|
+
primary = databases["primary"]
|
|
211
|
+
if shared.include?(primary)
|
|
212
|
+
raise ConfigError, "shared_databases lists #{primary}, the database each worker copies. " \
|
|
213
|
+
"List only the other databases, the ones parallel workers may share."
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if databases["error"]
|
|
217
|
+
if worker_count > 1
|
|
218
|
+
warn "[mutineer] could not list this app's databases (#{databases["error"]}); Mutineer runs one worker."
|
|
219
|
+
end
|
|
220
|
+
return 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
names = Array(databases["names"])
|
|
224
|
+
unknown = shared - names
|
|
225
|
+
if unknown.any?
|
|
226
|
+
warn "[mutineer] shared_databases names no test database: #{unknown.join(", ")}. " \
|
|
227
|
+
"This app has #{names.join(", ")}."
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
unshared = names - shared
|
|
231
|
+
return worker_count unless unshared.size > 1 && worker_count > 1
|
|
232
|
+
|
|
233
|
+
outside = shared.empty? ? "" : " outside shared_databases"
|
|
234
|
+
warn "[mutineer] this app has #{unshared.size} databases#{outside}; Mutineer runs one worker. " \
|
|
235
|
+
"Parallel runs support one database. To run in parallel, list each one " \
|
|
236
|
+
"your tests never use under shared_databases: #{(unshared - [primary]).join(", ")}."
|
|
237
|
+
1
|
|
238
|
+
end
|
|
239
|
+
|
|
192
240
|
# Turn one daemon's coverage reply into a map. Does not start or stop the daemon.
|
|
193
241
|
#
|
|
194
242
|
# @param client [Mutineer::DaemonClient]
|
|
@@ -204,7 +252,7 @@ module Mutineer
|
|
|
204
252
|
failed_test_files: data["failed_test_files"] || [],
|
|
205
253
|
project_root: config.project_root,
|
|
206
254
|
failed_clean_tests: data["failed_clean_tests"]
|
|
207
|
-
))
|
|
255
|
+
), framework: config.framework)
|
|
208
256
|
end
|
|
209
257
|
|
|
210
258
|
unless data && !(data["map"] || {}).empty?
|
|
@@ -299,7 +347,8 @@ module Mutineer
|
|
|
299
347
|
threads = []
|
|
300
348
|
begin
|
|
301
349
|
worker_count.times do
|
|
302
|
-
clients << DaemonClient.new(boot: boot_config(config, abs_tests
|
|
350
|
+
clients << DaemonClient.new(boot: boot_config(config, abs_tests, parallel: true),
|
|
351
|
+
app_root: config.project_root)
|
|
303
352
|
clients.last.start
|
|
304
353
|
end
|
|
305
354
|
ensure
|
|
@@ -384,10 +433,15 @@ module Mutineer
|
|
|
384
433
|
# A :maybe mutant that cannot be placed stays no_coverage, as in-process.
|
|
385
434
|
sel && sel[0] == :maybe ? Result.no_coverage : Result.unplaceable
|
|
386
435
|
else
|
|
436
|
+
payload = { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) }
|
|
437
|
+
if subject.reload_hook_call
|
|
438
|
+
# #297: the daemon child places the hook-block def, as in-process (Runner.run).
|
|
439
|
+
payload["code"], hook_def = JobPlan.hook_mutant(mutated, mutation, subject, source)
|
|
440
|
+
payload["hook_def"] = hook_def.to_h { |key, value| [key.to_s, key == :name ? value.to_s : value] }
|
|
441
|
+
end
|
|
387
442
|
verdict = client.request(
|
|
388
443
|
id: req_id, worker: worker, timeout: config.daemon_timeout || DEFAULT_TIMEOUT,
|
|
389
|
-
payload:
|
|
390
|
-
tests: sel ? sel[1] : abs_tests
|
|
444
|
+
payload: payload, tests: sel ? sel[1] : abs_tests
|
|
391
445
|
)
|
|
392
446
|
# A survivor whose line ran at load, as in-process (Runner.run).
|
|
393
447
|
loaded = JobPlan.load_verdict(result_for(verdict), subject.file, mutation, subject, source, coverage_map)
|
|
@@ -407,8 +461,10 @@ module Mutineer
|
|
|
407
461
|
# @param coverage [Boolean] whether this daemon builds the coverage map.
|
|
408
462
|
# @param db_role [String, nil] "prepare" (copy databases on command),
|
|
409
463
|
# "worker" (lock only), or nil to pick from `coverage`.
|
|
464
|
+
# @param parallel [Boolean] whether this daemon is one of several workers.
|
|
465
|
+
# Only then does it check `shared_databases` after each mutant.
|
|
410
466
|
# @return [Hash] the boot config shipped to the daemon.
|
|
411
|
-
def self.boot_config(config, abs_tests, coverage: false, db_role: nil)
|
|
467
|
+
def self.boot_config(config, abs_tests, coverage: false, db_role: nil, parallel: false)
|
|
412
468
|
{
|
|
413
469
|
project_root: config.project_root,
|
|
414
470
|
boot: File.expand_path(config.boot || "config/environment", config.project_root),
|
|
@@ -420,9 +476,9 @@ module Mutineer
|
|
|
420
476
|
source_dirs: JobPlan.source_dirs(config), # so the daemon can sweep orphan mutant temps
|
|
421
477
|
framework: config.framework,
|
|
422
478
|
rails: config.rails,
|
|
423
|
-
#
|
|
424
|
-
#
|
|
425
|
-
|
|
479
|
+
# Databases the workers share. A parallel worker fails a mutant whose
|
|
480
|
+
# tests used one. One worker shares nothing, so it gets no names.
|
|
481
|
+
shared_databases: parallel ? config.shared_databases : [],
|
|
426
482
|
# Coverage narrowing. Only the short-lived map-building daemon starts
|
|
427
483
|
# Coverage (before boot); worker daemons boot with it OFF (no wasted
|
|
428
484
|
# instrumentation/memory across every mutant fork). `sources`/`tests` are the
|
|
@@ -438,19 +494,6 @@ module Mutineer
|
|
|
438
494
|
}
|
|
439
495
|
end
|
|
440
496
|
|
|
441
|
-
# Absolute path to the app's `db/schema.rb` if it exists, else nil. Each worker
|
|
442
|
-
# DB starts as a copy of the test DB; the daemon loads this file over the copy
|
|
443
|
-
# when the copy's schema differs. `structure.sql` apps get nil and keep the
|
|
444
|
-
# copy as it is.
|
|
445
|
-
#
|
|
446
|
-
# @param config [Mutineer::Config] the run config.
|
|
447
|
-
# @api private
|
|
448
|
-
# @return [String, nil] absolute schema path or nil.
|
|
449
|
-
def self.schema_path(config)
|
|
450
|
-
path = File.expand_path("db/schema.rb", config.project_root)
|
|
451
|
-
File.exist?(path) ? path : nil
|
|
452
|
-
end
|
|
453
|
-
|
|
454
497
|
# Map a daemon verdict string to a Result. The daemon reports the four
|
|
455
498
|
# run-time states it can decide; pre-fork states (skipped/no_coverage/…) are
|
|
456
499
|
# resolved tool-side before a request is ever sent.
|
|
@@ -471,7 +514,7 @@ module Mutineer
|
|
|
471
514
|
# tests drive directly: {boot_config} from the zero-dep suite and
|
|
472
515
|
# {build_coverage_map} from the daemon suite. Everything else is daemon-pipeline
|
|
473
516
|
# internals with no caller outside this file.
|
|
474
|
-
private_class_method :sweep_daemon_temps, :run_serial, :run_parallel, :job_result, :
|
|
475
|
-
:prepare_run, :coverage_map_from_client
|
|
517
|
+
private_class_method :sweep_daemon_temps, :run_serial, :run_parallel, :job_result, :result_for,
|
|
518
|
+
:prepare_run, :coverage_map_from_client, :worker_count_for
|
|
476
519
|
end
|
|
477
520
|
end
|
|
@@ -5,6 +5,7 @@ require "io/wait"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require_relative "external_backend"
|
|
7
7
|
require_relative "rails_run_env"
|
|
8
|
+
require_relative "redaction"
|
|
8
9
|
|
|
9
10
|
module Mutineer
|
|
10
11
|
# Raised when the daemon cannot be booted or is gone for good: a bad boot path, an
|
|
@@ -43,6 +44,9 @@ module Mutineer
|
|
|
43
44
|
# Seconds the daemon gets to boot the app and answer the handshake. A boot
|
|
44
45
|
# that never returns ends the run instead of hanging it (#101).
|
|
45
46
|
BOOT_TIMEOUT = 600
|
|
47
|
+
# Seconds close_io waits for the stderr drain to reach EOF before it stops
|
|
48
|
+
# the thread, so a last line without a newline is not lost.
|
|
49
|
+
DRAIN_GRACE = 1
|
|
46
50
|
# Bundler's marker for a variable that was unset before it activated.
|
|
47
51
|
BUNDLER_UNSET = "BUNDLER_ENVIRONMENT_PRESERVER_INTENTIONALLY_NIL"
|
|
48
52
|
|
|
@@ -72,13 +76,12 @@ module Mutineer
|
|
|
72
76
|
self
|
|
73
77
|
end
|
|
74
78
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
79
|
+
# The booted test environment's databases, as the daemon reported them:
|
|
80
|
+
# "names" and "primary", or "error" when the lookup failed. nil when the
|
|
81
|
+
# app has no worker-DB adapter.
|
|
77
82
|
#
|
|
78
|
-
# @return [
|
|
79
|
-
|
|
80
|
-
@database_count || 1
|
|
81
|
-
end
|
|
83
|
+
# @return [Hash, nil]
|
|
84
|
+
attr_reader :databases
|
|
82
85
|
|
|
83
86
|
# Ask the daemon to create `slots` worker databases. Raises when the copy
|
|
84
87
|
# fails or another run holds the database, so the caller stops before any
|
|
@@ -346,15 +349,14 @@ module Mutineer
|
|
|
346
349
|
"-r", DAEMON_PATH, "-e", "Mutineer::DaemonServer.run",
|
|
347
350
|
unsetenv_others: true, chdir: @app_root
|
|
348
351
|
)
|
|
349
|
-
#
|
|
350
|
-
#
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
end
|
|
352
|
+
# Replies are UTF-8 JSON, and the app's stderr is redacted as text: with
|
|
353
|
+
# LANG unset both pipes would read as US-ASCII (#303).
|
|
354
|
+
@stdout.set_encoding(Encoding::UTF_8)
|
|
355
|
+
@stderr.set_encoding(Encoding::UTF_8)
|
|
356
|
+
# Drain daemon stderr to the tool's stderr so child/boot errors are visible,
|
|
357
|
+
# redacted line by line (#288: the app's output goes through here too).
|
|
358
|
+
# Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn.
|
|
359
|
+
@drain = Thread.new { drain_stderr(@stderr) } # rubocop:disable ThreadSafety/NewThread
|
|
358
360
|
|
|
359
361
|
send_line(@boot)
|
|
360
362
|
read_line(BOOT_TIMEOUT)
|
|
@@ -373,7 +375,7 @@ module Mutineer
|
|
|
373
375
|
close_io
|
|
374
376
|
raise (timed_out ? DaemonBootTimeout : DaemonBootError), "daemon failed to boot under the app bundle: #{detail}"
|
|
375
377
|
end
|
|
376
|
-
@
|
|
378
|
+
@databases = ready["databases"]
|
|
377
379
|
end
|
|
378
380
|
|
|
379
381
|
# Respawn after a crash, up to MAX_RESTARTS, then hard-fail loudly.
|
|
@@ -413,6 +415,33 @@ module Mutineer
|
|
|
413
415
|
nil
|
|
414
416
|
end
|
|
415
417
|
|
|
418
|
+
# Copy each line of the daemon's stderr to the tool's stderr, redacted.
|
|
419
|
+
# A line is the unit the daemon and the app write, so a password is never
|
|
420
|
+
# split across two redaction calls. The line break is kept outside the
|
|
421
|
+
# redaction, so a line redaction hides as a whole still ends its line.
|
|
422
|
+
# After a write fails (EPIPE from a tool stderr that went away), the drain
|
|
423
|
+
# keeps reading and drops the lines, so the daemon never blocks on a full
|
|
424
|
+
# pipe. The outer rescue swallows the benign EBADF/IOError raised when
|
|
425
|
+
# close_io closes the pipe under an in-flight read, so the thread never
|
|
426
|
+
# ends with an error that close_io's join would re-raise.
|
|
427
|
+
#
|
|
428
|
+
# @param io [IO] the daemon's stderr pipe.
|
|
429
|
+
# @return [nil]
|
|
430
|
+
def drain_stderr(io)
|
|
431
|
+
writable = true
|
|
432
|
+
io.each_line do |line|
|
|
433
|
+
next unless writable
|
|
434
|
+
|
|
435
|
+
body = line.chomp("\n").scrub # an invalid byte must not stop the drain
|
|
436
|
+
@errio.write(Redaction.text(body), body.length < line.length ? "\n" : "")
|
|
437
|
+
rescue StandardError
|
|
438
|
+
writable = false
|
|
439
|
+
end
|
|
440
|
+
nil
|
|
441
|
+
rescue StandardError
|
|
442
|
+
nil
|
|
443
|
+
end
|
|
444
|
+
|
|
416
445
|
# Close the IPC pipes, stop the stderr-drain thread, and reap the daemon so a
|
|
417
446
|
# respawn or quit leaves no leaked fd, thread, or zombie.
|
|
418
447
|
#
|
|
@@ -425,7 +454,11 @@ module Mutineer
|
|
|
425
454
|
rescue Errno::ESRCH
|
|
426
455
|
nil # it exited between the check and the kill
|
|
427
456
|
end
|
|
428
|
-
|
|
457
|
+
# The drain holds a partial last line until EOF. Give it a moment to reach
|
|
458
|
+
# EOF now the daemon is dead, so a boot error with no final newline still
|
|
459
|
+
# prints; then stop it BEFORE closing its fd (avoids an EBADF mid-read).
|
|
460
|
+
@drain&.join(DRAIN_GRACE)
|
|
461
|
+
@drain&.kill
|
|
429
462
|
[@stdin, @stdout, @stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
|
|
430
463
|
@wait_thr&.join # reap the exited daemon so respawn/quit leaves no zombie
|
|
431
464
|
@stdin = @stdout = @stderr = @drain = @wait_thr = nil
|