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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. 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 framework differ" unless cached["inputs"] == compute_digest(inputs_only: true)
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
- #{abs_preload_paths.inspect}.each { |f| require f }
1032
- #{loads}
1033
- _passed = Minitest.run([])
1034
- $stderr.write(_report.string) unless _passed
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
- #{abs_preload_paths.inspect}.each { |f| require f }
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
- #{abs_preload_paths.inspect}.each { |f| require f }
1092
- _load = #{load_coverage_expression}
1093
- load #{absolute(test_path).inspect}
1094
- _passed = Minitest.run([])
1095
- $stderr.write(_report.string) unless _passed
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
- #{abs_preload_paths.inspect}.each { |f| require f }
1126
- _load = #{load_coverage_expression}
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. Kept in step with DaemonServer#sweep_temps.
37
- DAEMON_TEMP_GLOB = "{.,}mutineer_daemon*.rb"
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
- return [nil, worker_count, nil]
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
- count = client.database_count.to_i
177
- if count > 1 && worker_count > 1
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), app_root: config.project_root)
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: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
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
- # Schema for per-worker DB isolation. Sent when present; the daemon loads
424
- # it over a worker's copy of the test DB only when that copy is out of date.
425
- schema: schema_path(config),
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, :schema_path, :result_for,
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
- # How many database configs the booted test environment reported.
76
- # 1 when the daemon did not send a count.
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 [Integer]
79
- def database_count
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
- # Drain daemon stderr to the tool's stderr so child/boot errors are visible.
350
- # Tracked (not fire-and-forget) so close_io can reclaim it on quit/respawn;
351
- # the rescue swallows the benign EBADF/IOError raised when close_io closes
352
- # the pipe out from under an in-flight copy_stream.
353
- @drain = Thread.new do # rubocop:disable ThreadSafety/NewThread
354
- IO.copy_stream(@stderr, @errio)
355
- rescue IOError, Errno::EBADF
356
- nil
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
- @database_count = ready["database_count"] || 1
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
- @drain&.kill # stop the drain BEFORE closing its fd (avoids a copy_stream EBADF)
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