simplecov 1.0.2 → 1.1.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1425
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +46 -21
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +18 -9
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +12 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_adapter.rb +48 -16
  76. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  77. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  78. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  79. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  80. data/lib/simplecov/result_merger.rb +69 -42
  81. data/lib/simplecov/result_processing.rb +82 -43
  82. data/lib/simplecov/run_identity.rb +77 -0
  83. data/lib/simplecov/simulate_coverage.rb +35 -11
  84. data/lib/simplecov/source_file/method.rb +7 -1
  85. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  86. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  87. data/lib/simplecov/source_file/source_loader.rb +23 -7
  88. data/lib/simplecov/source_file/statistics.rb +24 -16
  89. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  90. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  91. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  92. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  93. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  94. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  95. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  96. data/lib/simplecov/version.rb +1 -1
  97. data/lib/simplecov.rb +11 -5
  98. data/schemas/coverage-v1.0.schema.json +2 -2
  99. data/schemas/coverage.schema.json +2 -2
  100. data/sig/simplecov.rbs +206 -67
  101. metadata +28 -14
  102. data/doc/alternate-formatters.md +0 -66
  103. data/doc/commercial-services.md +0 -25
  104. data/doc/editor-integration.md +0 -18
  105. data/lib/simplecov/combine/files_combiner.rb +0 -70
  106. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  107. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  110. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require "fileutils"
4
+ require_relative "coverage_json_writer"
5
5
  require "json"
6
- require "time"
7
6
 
8
7
  module SimpleCov
9
8
  module Formatter
@@ -11,7 +10,7 @@ module SimpleCov
11
10
  # standalone, alongside the HTML formatter, or by external tools that
12
11
  # consume SimpleCov output.
13
12
  class JSONFormatter < Base
14
- FILENAME = "coverage.json"
13
+ FILENAME = CoverageJSONWriter::FILENAME
15
14
 
16
15
  # `include_source:` defaults to `SimpleCov.source_in_json` (true
17
16
  # by default) so the historical payload shape is unchanged.
@@ -19,19 +18,12 @@ module SimpleCov
19
18
  # setting (the HTML formatter, which feeds the client-side
20
19
  # viewer) pass `include_source: true` explicitly.
21
20
  def self.build_hash(result, include_source: SimpleCov.source_in_json)
22
- ResultHashFormatter.new(result, include_source: include_source).format
21
+ ResultHashFormatter.format(result, include_source: include_source)
23
22
  end
24
23
 
25
24
  def format(result)
26
- FileUtils.mkdir_p(output_path)
27
- path = File.join(output_path, FILENAME)
28
- warn_if_concurrent_overwrite(path, result)
29
- File.write(path, JSON.pretty_generate(self.class.build_hash(result)))
30
- # stderr, not stdout: this is a status message, not the program's
31
- # output. Keeps the line out of pipelines like `rspec -f json`. And
32
- # $stderr.puts, not `warn`: a status line should not reach
33
- # `Warning.warn` hooks or vanish under `-W0` (see #1225).
34
- $stderr.puts output_message(result) unless @silent # rubocop:disable Style/StderrPuts
25
+ CoverageJSONWriter.write(output_path, self.class.build_hash(result), result)
26
+ emit_status(result)
35
27
  end
36
28
 
37
29
  private
@@ -43,41 +35,6 @@ module SimpleCov
43
35
  def entry_point_filename
44
36
  FILENAME
45
37
  end
46
-
47
- # Warns when the existing coverage.json has a timestamp newer than this
48
- # process's start time — a strong signal that a sibling test process
49
- # (e.g., parallel_tests) wrote it while we were running, and that our
50
- # write is about to clobber their data.
51
- def warn_if_concurrent_overwrite(path, result)
52
- start_time = SimpleCov.process_start_time or return
53
- existing = existing_meta(path) or return
54
- return unless existing[:timestamp] > start_time
55
-
56
- # The HTML formatter also writes coverage.json (it shares the file as
57
- # a side artifact), so when both formatters are configured the file we
58
- # find was just written by our own run, not a concurrent one. A
59
- # matching command_name means the same merged result, so there's
60
- # nothing to lose by overwriting. See issue #1171.
61
- return if existing[:command_name] == result.command_name
62
-
63
- warn "simplecov: #{path} was written at #{existing[:timestamp].iso8601} — after " \
64
- "this process started at #{start_time.iso8601}. Overwriting " \
65
- "likely loses coverage data from a concurrent test run. For " \
66
- "parallel test setups, use SimpleCov::ResultMerger or run a single " \
67
- "collation step after all workers finish."
68
- end
69
-
70
- def existing_meta(path)
71
- return nil unless File.exist?(path)
72
-
73
- meta = JSON.parse(File.read(path), symbolize_names: true)
74
- timestamp = meta.dig(:meta, :timestamp)
75
- return nil unless timestamp
76
-
77
- {timestamp: Time.iso8601(timestamp), command_name: meta.dig(:meta, :command_name)}
78
- rescue JSON::ParserError, ArgumentError
79
- nil
80
- end
81
38
  end
82
39
  end
83
40
  end
@@ -10,7 +10,7 @@ module SimpleCov
10
10
  module InstanceMethods
11
11
  def format(result)
12
12
  formatters.map do |formatter|
13
- formatter.new.format(result)
13
+ Formatter.format(formatter, result)
14
14
  rescue StandardError => e
15
15
  warn("Formatter #{formatter} failed with #{e.class}: #{e.message} (#{(_ = e.backtrace).first})")
16
16
  nil
@@ -8,16 +8,19 @@ module SimpleCov
8
8
  class SimpleFormatter
9
9
  # Takes a SimpleCov::Result and generates a string out of it
10
10
  def format(result)
11
- result.groups.map { |name, files| format_group(name, files) }.join
11
+ criterion = SimpleCov.coverage_statistics_key(SimpleCov.primary_coverage)
12
+ result.groups.map { |name, files| format_group(name, files, criterion) }.join
12
13
  end
13
14
 
14
15
  private
15
16
 
16
- def format_group(name, files)
17
+ def format_group(name, files, criterion)
17
18
  header = "Group: #{name}\n#{'=' * 40}\n"
18
- # `covered_percent` is nilable across criteria, but line stats are
19
- # always measured, so the no-argument call can't return nil here.
20
- body = files.map { |file| "#{file.filename} (coverage: #{(_ = file.covered_percent).floor(2)}%)\n" }.join
19
+ # The configured primary criterion is enabled, so every result file
20
+ # carries its corresponding statistic despite the nilable public API.
21
+ body = files.map do |file|
22
+ "#{file.filename} (coverage: #{(_ = file.covered_percent(criterion)).floor(2)}%)\n"
23
+ end.join
21
24
  "#{header}#{body}\n"
22
25
  end
23
26
  end
@@ -6,6 +6,18 @@ module SimpleCov
6
6
  # and can be wired up via `SimpleCov.formatter=`.
7
7
  # TODO: Documentation on how to build your own formatters
8
8
  module Formatter
9
+ # Formatters can be configured either as classes (instantiated
10
+ # fresh for every report) or as ready-built instances — the only
11
+ # way to reach constructor options like
12
+ # `HTMLFormatter.new(silent: true)`. See #1240.
13
+ def self.instance_for(formatter)
14
+ formatter.is_a?(Class) ? formatter.new : formatter
15
+ end
16
+
17
+ # Normalize a class or instance, then dispatch the result to it.
18
+ def self.format(formatter, result)
19
+ instance_for(formatter).format(result)
20
+ end
9
21
  end
10
22
  end
11
23
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # Semantic names owned by result processing rather than user configuration.
5
+ module GroupNames
6
+ UNGROUPED = "Ungrouped"
7
+
8
+ module_function
9
+
10
+ # Group names are Hash keys, report labels, and JSON object keys all at
11
+ # once, so they are normalized to Strings up front: a Symbol spelling
12
+ # (`group :Controllers`) means the String, and anything else has no
13
+ # sensible serialized form. Normalizing before `validate!` also keeps
14
+ # `group :Ungrouped` from slipping past the reservation below.
15
+ def normalize(group_name)
16
+ case group_name
17
+ when String then group_name
18
+ when Symbol then group_name.to_s
19
+ else
20
+ raise SimpleCov::ConfigurationError,
21
+ "Group names must be Strings, got #{group_name.inspect} (#{group_name.class})"
22
+ end
23
+ end
24
+
25
+ def validate!(group_names)
26
+ return group_names unless group_names.include?(UNGROUPED)
27
+
28
+ raise SimpleCov::ConfigurationError,
29
+ "#{UNGROUPED.inspect} is reserved for files that do not match a configured group"
30
+ end
31
+ end
32
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "json"
4
+ require_relative "atomic_file"
5
5
 
6
6
  module SimpleCov
7
7
  # Reads and writes coverage/.last_run.json — the previous run's coverage
@@ -18,17 +18,24 @@ module SimpleCov
18
18
  json = File.read(last_run_path)
19
19
  return nil if json.strip.empty?
20
20
 
21
- JSON.parse(json, symbolize_names: true)
21
+ parsed = JSON.parse(json, symbolize_names: true)
22
+ # The maximum_coverage_drop check digs into a Hash, so anything
23
+ # else in a corrupt or hand-edited file counts as "no previous
24
+ # run" rather than crashing the at_exit hook.
25
+ parsed.is_a?(Hash) ? parsed : invalid_last_run
26
+ rescue JSON::ParserError
27
+ invalid_last_run
22
28
  end
23
29
 
24
- # Write to a process-private temp file, then atomically rename, so a
25
- # concurrent reader (e.g. another parallel-tests worker checking
26
- # MaximumCoverageDrop) never sees a half-written file.
27
30
  def write(json)
28
- FileUtils.mkdir_p(SimpleCov.coverage_path)
29
- temp_path = "#{last_run_path}.#{Process.pid}.tmp"
30
- File.open(temp_path, "w") { |f| f.puts JSON.pretty_generate(json) }
31
- File.rename(temp_path, last_run_path)
31
+ AtomicFile.write(last_run_path, "#{JSON.pretty_generate(json)}\n")
32
+ end
33
+
34
+ private
35
+
36
+ def invalid_last_run
37
+ warn "[SimpleCov]: Warning! Parsing JSON content of .last_run.json failed, ignoring the previous run"
38
+ nil
32
39
  end
33
40
  end
34
41
  end
@@ -13,6 +13,16 @@ module SimpleCov
13
13
  COMMENT_LINE = /^\s*#/
14
14
  WHITESPACE_OR_COMMENT_LINE = Regexp.union(WHITESPACE_LINE, COMMENT_LINE)
15
15
 
16
+ # The leading `^(\s*)#` anchor is load-bearing beyond matching the marker:
17
+ # `classify_line` only reaches this for lines that already passed
18
+ # `whitespace_line?`, which is sound only while every marker is also a
19
+ # comment. Loosening this to match a trailing `x = 1 # :nocov:` would stop
20
+ # the toggle firing. `lines_classifier_spec.rb` pins the implication.
21
+ #
22
+ # The `/o` flag freezes the interpolated nocov token at this
23
+ # process's first classification; a `nocov_token` configured after
24
+ # coverage has classified a file would be silently ignored. Sound
25
+ # today because configuration always precedes classification.
16
26
  def self.no_cov_line
17
27
  /^(\s*)#(\s*)(:#{SimpleCov.current_nocov_token}:)/o
18
28
  end
@@ -34,21 +44,32 @@ module SimpleCov
34
44
  def classify(lines)
35
45
  lines = lines.to_a
36
46
  directive_disabled = directive_disabled_line_set(lines)
37
- skipping = false
47
+ # The `:nocov:` skip state lives in a one-slot box owned by this
48
+ # call, not in an ivar, so one classifier instance can serve
49
+ # concurrent `classify` calls without the toggles interleaving.
50
+ skip_state = [false]
38
51
 
39
52
  lines.map.with_index(1) do |line, line_number|
40
- skipping = !skipping if self.class.no_cov_line?(line)
41
- not_relevant_line?(line, line_number, skipping, directive_disabled) ? NOT_RELEVANT : RELEVANT
53
+ classify_line(line, line_number, directive_disabled, skip_state)
42
54
  end
43
55
  end
44
56
 
45
57
  private
46
58
 
47
- def not_relevant_line?(line, line_number, skipping, directive_disabled)
48
- skipping ||
49
- self.class.no_cov_line?(line) ||
50
- directive_disabled.include?(line_number) ||
51
- self.class.whitespace_line?(line)
59
+ # A `:nocov:` marker is itself a comment, so the cheap
60
+ # whitespace-or-comment test can gate the token match: a line of real
61
+ # code cannot be a marker, and no longer pays to be checked against
62
+ # one. That matters because this runs per line of every
63
+ # tracked-but-unloaded file, once in every process of a parallel run.
64
+ def classify_line(line, line_number, directive_disabled, skip_state)
65
+ if self.class.whitespace_line?(line)
66
+ skip_state[0] = !skip_state[0] if self.class.no_cov_line?(line)
67
+ NOT_RELEVANT
68
+ elsif skip_state[0] || directive_disabled.include?(line_number)
69
+ NOT_RELEVANT
70
+ else
71
+ RELEVANT
72
+ end
52
73
  end
53
74
 
54
75
  def directive_disabled_line_set(lines)
@@ -3,8 +3,11 @@
3
3
  # `~/.simplecov` was historically resolved via a three-step fallback chain
4
4
  # (HOME, then `Etc.getpwuid.dir`, then `~$USER`) for hostile container
5
5
  # environments circa 2017. Modern CRuby/JRuby/TruffleRuby all set HOME
6
- # reliably, so trust it and skip silently when it isn't there.
7
- if ENV.fetch("HOME", nil)
6
+ # reliably, so trust it and skip silently when it isn't there. An ENV
7
+ # check rather than Dir.home: JRuby raises from Dir.home with HOME
8
+ # unset, and `File.expand_path("~")` raises for a set-but-empty HOME
9
+ # (some container/CI images), which must not break `require "simplecov"`.
10
+ unless ENV.fetch("HOME", "").empty?
8
11
  # simplecov:disable — only fires when ~/.simplecov exists, which is
9
12
  # developer-machine-dependent (we can't rely on it for the dogfood).
10
13
  global_config_path = File.join(File.expand_path("~"), ".simplecov")
@@ -55,6 +55,23 @@ module SimpleCov
55
55
  def expected_worker_count
56
56
  1
57
57
  end
58
+
59
+ # The user's explicit `SimpleCov.parallel_tests false` opt-out,
60
+ # which every adapter's `active?` honors before its own
61
+ # detection.
62
+ def forced_off?
63
+ SimpleCov.parallel_tests == false
64
+ end
65
+
66
+ # Shared implementation of the PARALLEL_TEST_GROUPS convention
67
+ # both built-in adapters follow for `expected_worker_count`.
68
+ # Unset, empty, non-numeric, and non-positive values all mean 1:
69
+ # an unparseable value must not yield 0 workers, which would end
70
+ # the sibling wait before it started.
71
+ def parallel_test_groups_count
72
+ count = Integer(ENV.fetch("PARALLEL_TEST_GROUPS", ""), exception: false)
73
+ count&.positive? ? count : 1
74
+ end
58
75
  end
59
76
  end
60
77
  end
@@ -23,7 +23,7 @@ module SimpleCov
23
23
  class GenericAdapter < Base
24
24
  class << self
25
25
  def active?
26
- return false if SimpleCov.parallel_tests == false
26
+ return false if forced_off?
27
27
 
28
28
  ENV.key?("TEST_ENV_NUMBER")
29
29
  end
@@ -36,7 +36,7 @@ module SimpleCov
36
36
  end
37
37
 
38
38
  def expected_worker_count
39
- ENV["PARALLEL_TEST_GROUPS"]&.to_i || 1
39
+ parallel_test_groups_count
40
40
  end
41
41
  end
42
42
  end
@@ -16,7 +16,7 @@ module SimpleCov
16
16
  class ParallelTestsAdapter < Base
17
17
  class << self
18
18
  def active?
19
- return false if SimpleCov.parallel_tests == false
19
+ return false if forced_off?
20
20
 
21
21
  ensure_loaded
22
22
  # !! to coerce `defined?` (returns nil or "constant") to a proper bool.
@@ -50,7 +50,7 @@ module SimpleCov
50
50
  end
51
51
 
52
52
  def expected_worker_count
53
- ENV["PARALLEL_TEST_GROUPS"]&.to_i || 1
53
+ parallel_test_groups_count
54
54
  end
55
55
 
56
56
  # Auto-require `parallel_tests` when it's installed AND the env
@@ -77,7 +77,7 @@ module SimpleCov
77
77
  deadline = monotonic_time + parallel_wait_timeout
78
78
  tracker = {count: 0, since: monotonic_time}
79
79
  loop do
80
- seen = SimpleCov::ResultMerger.read_resultset.size
80
+ seen = current_parallel_worker_count
81
81
  return true if seen >= expected
82
82
  return true if native_wait && resultset_count_settled?(tracker, seen)
83
83
  return false if parallel_wait_timed_out?(deadline, expected, seen)
@@ -86,6 +86,11 @@ module SimpleCov
86
86
  end
87
87
  end
88
88
 
89
+ def current_parallel_worker_count
90
+ resultset = SimpleCov::ResultMerger.read_resultset
91
+ SimpleCov::ResultMerger.worker_identities_for_run(resultset, SimpleCov.run_id, SimpleCov.process_start_time).size
92
+ end
93
+
89
94
  # Track whether the resultset count has held steady (and positive) for
90
95
  # `PARALLEL_RESULTS_SETTLE` seconds. `tracker` carries the last count and
91
96
  # the time it last changed across poll iterations.
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ #
5
+ # Folds a list of resultset files into one merged coverage table across
6
+ # forked worker processes. Drives `SimpleCov.collate(..., processes: N)`.
7
+ #
8
+ # `ResultMerger.absorb_results` is a fold over N independent
9
+ # read-parse-combine steps, so it splits cleanly: each worker runs that
10
+ # same fold over a contiguous slice of the file list and ships the pair
11
+ # back over a pipe, and the parent combines the handful of per-worker
12
+ # pairs it gets back. Reading and parsing the shards — where a collate
13
+ # over a few hundred CI jobs spends most of its time — is what actually
14
+ # parallelises.
15
+ #
16
+ # The slices are contiguous and merged back in order, so the fold visits
17
+ # the resultsets in the order the serial fold visits them and the merged
18
+ # result is identical to `SimpleCov.collate`'s, not merely equivalent.
19
+ #
20
+ # Every failure path returns nil rather than a partial merge, so the caller
21
+ # can redo the fold serially: reporting coverage for a subset of the
22
+ # resultsets would silently understate it.
23
+ #
24
+ module ParallelResultMerger
25
+ module_function
26
+
27
+ #
28
+ # `ResultMerger.merge_and_store` across `processes` forked workers. One
29
+ # worker hands straight back to `ResultMerger`, so the default `collate`
30
+ # takes exactly the path it always has and never reaches this module's
31
+ # machinery at all.
32
+ #
33
+ def merge_and_store(*file_paths, processes:, ignore_timeout: false)
34
+ return ResultMerger.merge_and_store(*file_paths, ignore_timeout: ignore_timeout) if processes < 2
35
+
36
+ result = merge_results(*file_paths, processes: processes, ignore_timeout: ignore_timeout)
37
+ ResultMerger.store_result(result) if result
38
+ result
39
+ end
40
+
41
+ #
42
+ # `ResultMerger.merge_results` across `processes` forked workers, merging
43
+ # in this process instead whenever the fan-out did not produce a complete
44
+ # merge — a runtime that cannot fork, nothing worth splitting, or a worker
45
+ # that died. The result is the same either way; only the time it took to
46
+ # get there differs.
47
+ #
48
+ def merge_results(*file_paths, processes:, ignore_timeout: false)
49
+ tracked_files = Set.new
50
+ pair = absorb_results(file_paths, processes: processes, ignore_timeout: ignore_timeout,
51
+ tracked_files: tracked_files)
52
+ # A nil pair means nothing was fanned out at all, so the serial path
53
+ # is exactly `ResultMerger.merge_results` — delegate rather than
54
+ # re-implement its collector wiring.
55
+ return ResultMerger.merge_results(*file_paths, ignore_timeout: ignore_timeout) unless pair
56
+
57
+ command_names, coverage = pair
58
+ ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files)
59
+ end
60
+
61
+ #
62
+ # `ResultMerger.absorb_results` across at most `processes` forked
63
+ # workers: same arguments, same `[command_names, coverage]` return.
64
+ #
65
+ # The tracked paths a worker's slice carried come back with its payload
66
+ # rather than through a collector block, since the block a serial absorb
67
+ # takes would be mutating a Set in the wrong process.
68
+ #
69
+ # @return [Array(Array<String>, Hash), nil] the pair
70
+ # `ResultMerger.create_result` consumes, or nil when the work could not
71
+ # be fanned out and the caller should merge in this process instead.
72
+ #
73
+ def absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new)
74
+ # One worker folds the whole list anyway, and one file is a fold of
75
+ # one — in both cases the fork and the round trip are pure overhead.
76
+ return nil if processes < 2 || file_paths.size < 2
77
+ # The portable feature test, and the one the rest of the ecosystem uses.
78
+ # CRuby leaves `fork` undefined on Windows; JRuby and TruffleRuby cannot
79
+ # fork on the JVM and deliberately answer false here so libraries can
80
+ # detect that without rescuing an exception — TruffleRuby's compatibility
81
+ # guide names this as the correct check. They do still define
82
+ # `Kernel#fork` and raise `NotImplementedError` from it, so probing that
83
+ # instead would answer true and send them down the fan-out.
84
+ return nil unless Process.respond_to?(:fork)
85
+
86
+ fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files)
87
+ end
88
+
89
+ # Contiguous slices whose sizes differ by at most one, so no worker is
90
+ # left folding twice its share while the others idle. There are never
91
+ # more slices than files: asking for more processes than there are
92
+ # resultsets just gives one resultset per process.
93
+ def chunk(file_paths, processes)
94
+ groups = [processes, file_paths.size].min
95
+ base, remainder = file_paths.size.divmod(groups)
96
+ remaining = file_paths.dup
97
+
98
+ Array.new(groups) { |index| remaining.shift(base + (index < remainder ? 1 : 0)) }
99
+ end
100
+
101
+ # A `fork` that fails here raises, and is left to. `absorb_results` has
102
+ # already excluded the runtimes that never fork, so what remains is the OS
103
+ # refusing a process we expected to get — EAGAIN at RLIMIT_NPROC, ENOMEM
104
+ # under memory pressure. That says something is wrong with the machine
105
+ # rather than with the merge, and quietly absorbing it would hide it.
106
+ def fan_out(chunks, ignore_timeout:, tracked_files: Set.new)
107
+ workers = spawn_workers(chunks, ignore_timeout: ignore_timeout)
108
+ payloads = collect(workers)
109
+ return nil unless payloads
110
+
111
+ payloads.each { |(_pair, tracked)| tracked_files.merge(tracked) }
112
+ ResultMerger.merge_coverage(*payloads.map(&:first))
113
+ end
114
+
115
+ def spawn_workers(chunks, ignore_timeout:)
116
+ workers = [] #: Array[Hash[Symbol, untyped]]
117
+
118
+ chunks.each do |chunk|
119
+ workers << spawn_worker(chunk, ignore_timeout: ignore_timeout)
120
+ rescue StandardError
121
+ abandon(workers)
122
+ raise
123
+ end
124
+
125
+ workers
126
+ end
127
+
128
+ def abandon(workers)
129
+ workers.each do |worker|
130
+ worker[:reader].close
131
+ succeeded?(worker[:pid])
132
+ end
133
+ end
134
+
135
+ # `fork` itself can fail (EAGAIN under a process limit). The caller
136
+ # cleans up the workers it knows about, but this pipe is ours: the
137
+ # parent's writer end always closes, and the reader closes too when
138
+ # no child was spawned to feed it. Steep cannot type body locals
139
+ # inside an ensure, hence the ignore markers; the safe navigation
140
+ # keeps the cleanup well-defined when `IO.pipe` itself raised.
141
+ def spawn_worker(chunk, ignore_timeout:)
142
+ reader, writer = IO.pipe
143
+ pid = fork { run_in_child(reader, writer, chunk, ignore_timeout) }
144
+ {pid: pid, reader: reader}
145
+ ensure
146
+ # steep:ignore:start
147
+ writer&.close
148
+ reader&.close unless pid
149
+ # steep:ignore:end
150
+ end
151
+
152
+ # Everything the child does. `exit!` rather than `exit` because it must
153
+ # never fall through to the collating process's inherited `at_exit`
154
+ # handlers — SimpleCov's own report generation included.
155
+ def run_in_child(reader, writer, chunk, ignore_timeout)
156
+ reader.close
157
+ exit!(run_worker(chunk, writer, ignore_timeout: ignore_timeout))
158
+ end
159
+
160
+ # The body of a worker: merge the slice, ship it back, and report the exit
161
+ # status the child should terminate with. Kept free of the exit itself so
162
+ # it can be exercised in-process.
163
+ #
164
+ # The slice's tracked paths travel with the pair because the parent needs
165
+ # the union across every worker to know what nothing loaded.
166
+ def run_worker(chunk, writer, ignore_timeout:)
167
+ tracked_files = Set.new
168
+ pair = ResultMerger.absorb_results(chunk, ignore_timeout: ignore_timeout,
169
+ &ResultMerger::UnloadedFiles.collector(tracked_files))
170
+ Marshal.dump([pair, tracked_files.to_a], writer)
171
+ writer.close
172
+ 0
173
+ rescue StandardError => e
174
+ warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e.message}" if SimpleCov.print_errors
175
+ 1
176
+ end
177
+
178
+ # Deserializes on a thread per worker so every pipe is drained while the
179
+ # workers are still writing. A payload larger than the pipe buffer would
180
+ # otherwise block its worker mid-write, and the parent would block
181
+ # reaping a worker that can never finish.
182
+ #
183
+ # Returns nil if any worker failed, so the caller can fall back to the
184
+ # serial fold rather than report a subset of the resultsets as the whole.
185
+ def collect(workers)
186
+ payloads = drain(workers)
187
+ failed = workers.count { |worker| !succeeded?(worker[:pid]) }
188
+ return payloads if failed.zero? && payloads.all?
189
+
190
+ warn_about_failed_workers(failed, workers.size)
191
+ nil
192
+ ensure
193
+ workers.each { |worker| worker[:reader].close }
194
+ end
195
+
196
+ def drain(workers)
197
+ workers.map { |worker| Thread.new { read_payload(worker[:reader]) } }.map(&:value)
198
+ end
199
+
200
+ def read_payload(reader)
201
+ # The writer is a fork of this very process and the pipe never leaves
202
+ # it, so this is our own data coming back through our own kernel
203
+ # buffer, not input. A worker that died mid-write leaves the stream
204
+ # truncated, which Marshal reports by raising rather than returning.
205
+ # RBS types `Marshal.load`'s source as `_Source`, which IO satisfies
206
+ # structurally but not nominally.
207
+ # steep:ignore:start
208
+ Marshal.load(reader) # rubocop:disable Security/MarshalLoad
209
+ # steep:ignore:end
210
+ rescue StandardError
211
+ nil
212
+ end
213
+
214
+ def succeeded?(pid)
215
+ _pid, status = Process.wait2(pid)
216
+ status.success?
217
+ rescue SystemCallError
218
+ # Errno::ECHILD — nothing left to reap, so there is no status to judge
219
+ # this worker's slice by and we have to assume it did not finish.
220
+ false
221
+ end
222
+
223
+ def warn_about_failed_workers(failed, total)
224
+ return unless SimpleCov.print_errors
225
+
226
+ warn "[SimpleCov]: parallel merge did not complete (#{failed} of #{total} workers failed); " \
227
+ "merging the resultsets in this process instead."
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The clobber-prevention backstop: an empty process stands down instead
4
+ # of formatting over a fresher report a sibling produced. Split from
5
+ # exit_handling.rb, which orchestrates the at_exit flow that consults
6
+ # this. See issue #581.
7
+ module SimpleCov
8
+ class << self
9
+ # Returns true when our process has no coverage data to contribute
10
+ # (after the resultset merge) and a newer report already exists on
11
+ # disk. Typically fires when `SimpleCov.start` ran in a parent
12
+ # process that shelled out to the test runner. See issue #581.
13
+ def defer_to_existing_report?
14
+ return false unless existing_report_newer_than_us?
15
+
16
+ res = result
17
+ empty = res.nil? || res.files.empty?
18
+ warn_about_deferred_report if empty
19
+ empty
20
+ end
21
+
22
+ # `.last_run.json` only exists after fully successful runs, so alone
23
+ # it left the backstop inert when the child run failed — the case
24
+ # where clobbering its report hurts most. The report stamp is
25
+ # touched by every formatting process regardless of exit status. The
26
+ # rescue covers a file vanishing mid-at_exit (`rm -rf coverage`).
27
+ def existing_report_newer_than_us?
28
+ return false unless process_start_time
29
+
30
+ [SimpleCov::LastRun.last_run_path, SimpleCov::ReportStamp.path].any? do |path|
31
+ File.mtime(path) > process_start_time
32
+ rescue SystemCallError
33
+ false
34
+ end
35
+ end
36
+
37
+ def warn_about_deferred_report
38
+ return unless print_errors
39
+
40
+ ExitCodes.print_error SimpleCov::Color.colorize(
41
+ "Skipping SimpleCov report — this process tracked no application code and a newer " \
42
+ "report already exists at #{coverage_path}. This usually means SimpleCov.start ran in a " \
43
+ "parent process (e.g. a Rakefile or Rails' Bundler.require) that shelled out to the test " \
44
+ "runner. See https://github.com/simplecov-ruby/simplecov/issues/581.",
45
+ :yellow
46
+ )
47
+ end
48
+ end
49
+ end