Skip to content

Commit 9bd3fb3

Browse files
committed
Stop rake parents from clobbering subprocess reports
Three layers of defense end a long-standing failure mode where a parent process — typically a Rakefile or a Rails app whose `Bundler.require` pulls in simplecov — auto-loaded `.simplecov`, started `Coverage`, then shelled out to the test runner. The subprocess wrote a correct report on its way out; the parent's `at_exit` hook then formatted its own empty result on top, wiping the data. First, `.simplecov` is now a pure configuration file. The autoloader wraps the load in `SimpleCov.with_dot_simplecov_autoload`, and any `SimpleCov.start` call inside the file is intercepted: its profile and block are applied via `initial_setup` (configuration only), and a one-time deprecation warning points users at moving the call into `spec_helper.rb` / `test_helper.rb`. No `Coverage.start`, no at_exit hook, no empty parent report. Second, `ResultMerger.store_result` now merges incoming entries with same-`command_name` entries whose timestamp is `>=` our `process_start_time`. Those entries can only have been written by a sibling test runner that finished while we were running, so combining coverage data preserves the subprocess's contribution even when an empty parent result is stored over it. Third, `SimpleCov.at_exit_behavior` defers entirely when our merged result has zero files and `coverage/.last_run.json` is fresher than this process. With the first two layers in place this rarely fires, but it catches degenerate cases (merging disabled, custom formatters that bypass the resultset) where the first two can't help. Resolves #581.
1 parent 7702ea4 commit 9bd3fb3

8 files changed

Lines changed: 321 additions & 19 deletions

File tree

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Metrics/BlockNesting:
5555
Max: 2
5656

5757
Metrics/ClassLength:
58-
Max: 300
58+
Max: 320
5959

6060
Metrics/ModuleLength:
6161
Description: Avoid modules longer than 100 lines of code.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Unreleased
1212
* HTML and JSON formatters now write the "Coverage report generated for X to Y" status line (and the per-criterion totals beneath it) to stderr instead of stdout. The message is a diagnostic, not the program's output, and routing it to stdout polluted pipelines like `rspec -f json`. Suppress it entirely with `silent: true` on the formatter; redirect with `2>&1` if you want the old behavior. See #1060.
1313

1414
## Deprecations
15+
* Calling `SimpleCov.start` from `.simplecov` is deprecated. Coverage tracking still begins for backward compatibility, but a one-time deprecation warning fires pointing the user at moving the call into `spec_helper.rb` / `test_helper.rb`; a future release will require the explicit `SimpleCov.start` from a test helper. The migration goes hand-in-hand with the bugfix below: once `SimpleCov.start` lives in the test helper, the parent process that auto-loads `.simplecov` never starts tracking and the empty-report-overwrite scenario can't arise. See #581.
1516
* `# :nocov:` toggle comments (and the configurable `SimpleCov.nocov_token` / `SimpleCov.skip_token`) are deprecated in favor of the new `# simplecov:disable` / `# simplecov:enable` directives. Each file that still uses `# :nocov:` emits a one-time deprecation warning to stderr at load time pointing at the recommended replacement, and any call to `SimpleCov.nocov_token` or `SimpleCov.skip_token` (getter or setter) likewise warns. The directive will be removed in a future release.
1617

1718
## Enhancements
@@ -35,6 +36,7 @@ Unreleased
3536
* `CommandGuesser` now appends the framework name to parallel test data (e.g. `"RSpec (1/2)"` instead of `"(1/2)"`)
3637

3738
## Bugfixes
39+
* Fix the parent-process / subprocess race where a Rakefile (or Rails `Bundler.require`) caused `.simplecov` to auto-load `SimpleCov.start` in the rake parent, which then shelled out to a test runner subprocess; the subprocess wrote a correct report, then the parent's `at_exit` would clobber it with an empty 0% report. Three layers of defense now apply: (1) `.simplecov` is treated as configuration only and no longer starts tracking from the parent (see Deprecations); (2) `ResultMerger.store_result` merges incoming entries with same-`command_name` entries that were written after our `process_start_time` instead of overwriting them; (3) `SimpleCov.at_exit_behavior` defers entirely when our merged result is empty and `coverage/.last_run.json` is fresher than this process. See #581.
3840
* Don't report misleading 100% branch/method coverage for files added via `track_files` that were never loaded. See #902
3941
* Fix HTML formatter tab bar layout: dark mode toggle no longer wraps onto two lines, and tabs connect seamlessly with the content panel
4042
* Allow `SimpleCov.root('/')` so files outside the conventional project root can be tracked (e.g. Docker layouts where code and tests are siblings at `/`). The root-prefix regex no longer doubles the separator (`//`), and `project_name` no longer crashes when `root` has no parent segment. The user-facing `:root_filter` profile and the unconditional `UselessResultsRemover` now share a single regex source instead of computing it independently. See #860.

README.md

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -260,29 +260,34 @@ Please check out the [Configuration] API documentation to find out what you can
260260

261261
## Using .simplecov for centralized config
262262

263-
If you use SimpleCov to merge multiple test suite results (e.g. Test/Unit and Cucumber) into a single report, you'd
264-
normally have to set up all your config options twice, once in `test_helper.rb` and once in `env.rb`.
265-
266-
To avoid this, you can place a file called `.simplecov` in your project root. You can then just leave the
267-
`require 'simplecov'` in each test setup helper (**at the top**) and move the `SimpleCov.start` code with all your
268-
custom config options into `.simplecov`:
263+
If you use SimpleCov to merge multiple test suite results (e.g. RSpec and Cucumber) into a single report, you'd
264+
normally have to repeat your filters / groups / profile in every test helper. To avoid that, place a `.simplecov`
265+
file at your project root with the shared configuration; each test helper then requires SimpleCov and explicitly
266+
starts it:
269267
270268
```ruby
271-
# test/test_helper.rb
269+
# .simplecov — configuration only
270+
SimpleCov.profiles.load 'rails'
271+
add_filter 'lib/generators'
272+
add_group 'Models', 'app/models'
273+
274+
# spec/spec_helper.rb
272275
require 'simplecov'
276+
SimpleCov.start
273277
274278
# features/support/env.rb
275279
require 'simplecov'
276-
277-
# .simplecov
278-
SimpleCov.start 'rails' do
279-
# any custom configs like groups and filters can be here at a central place
280-
end
280+
SimpleCov.start
281281
```
282282
283-
Using `.simplecov` rather than separately requiring SimpleCov multiple times is recommended if you are merging multiple
284-
test frameworks like Cucumber and RSpec that rely on each other, as invoking SimpleCov multiple times can cause coverage
285-
information to be lost.
283+
> **Note**: Calling `SimpleCov.start` directly from `.simplecov` is deprecated as of this release. Tracking
284+
> still begins for backward compatibility, but a one-time deprecation warning fires; a future release will
285+
> require the explicit `SimpleCov.start` from a test helper. Migrating prevents a long-standing bug where
286+
> `.simplecov` auto-loaded in a Rakefile or Rails' `Bundler.require` would leave an empty parent-process
287+
> report to overwrite the test subprocess's good one. See #581.
288+
289+
Using `.simplecov` rather than duplicating configuration in every test helper is recommended if you are merging
290+
multiple test frameworks like Cucumber and RSpec that rely on each other.
286291
287292
## Branch coverage (Ruby "~> 2.5")
288293
Add branch coverage measurement statistics to your results. Supported in CRuby versions 2.5+.

lib/simplecov.rb

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,40 @@ def external_at_exit?
6262
# Please check out the RDoc for SimpleCov::Configuration to find about available config options
6363
#
6464
def start(profile = nil, &)
65+
warn_about_start_in_dot_simplecov if @autoloading_dot_simplecov
66+
6567
initial_setup(profile, &)
6668
start_tracking
6769
install_at_exit_hook
6870
end
6971

72+
# @api private
73+
#
74+
# Mark the duration of a `.simplecov` auto-load so any `SimpleCov.start`
75+
# call inside the file can warn about the impending migration to a
76+
# config-only file. Tracking still begins for backward compatibility;
77+
# the warning is the cue to move `SimpleCov.start` into a test helper.
78+
# See #581.
79+
def with_dot_simplecov_autoload
80+
previous = @autoloading_dot_simplecov
81+
@autoloading_dot_simplecov = true
82+
yield
83+
ensure
84+
@autoloading_dot_simplecov = previous
85+
end
86+
87+
def warn_about_start_in_dot_simplecov
88+
return if @dot_simplecov_start_warned
89+
90+
@dot_simplecov_start_warned = true
91+
warn "[DEPRECATION] Calling `SimpleCov.start` from `.simplecov` is deprecated and will " \
92+
"be removed in a future release. `.simplecov` should contain configuration only; " \
93+
"move the `SimpleCov.start` call into your `spec_helper.rb` / `test_helper.rb`. " \
94+
"Coverage tracking still begins for backward compatibility, but a future release " \
95+
"will require the explicit `SimpleCov.start` from a test helper. " \
96+
"See https://github.com/simplecov-ruby/simplecov/issues/581."
97+
end
98+
7099
#
71100
# Install the at_exit hook that formats results and runs exit-code
72101
# checks. `SimpleCov.start` calls this automatically. Idempotent —
@@ -223,7 +252,48 @@ def at_exit_behavior
223252

224253
# If Coverage is no longer running (e.g. someone manually stopped it
225254
# or a test consumed the result) then don't run exit tasks.
226-
SimpleCov.run_exit_tasks! if Coverage.running?
255+
return unless Coverage.running?
256+
257+
# Stand down when we'd only clobber a fresher report. See
258+
# `defer_to_existing_report?` and issue #581.
259+
return if defer_to_existing_report?
260+
261+
SimpleCov.run_exit_tasks!
262+
end
263+
264+
# Returns true when our process has no coverage data to contribute
265+
# (after the resultset merge) and a newer report already exists on
266+
# disk. Typically fires when `SimpleCov.start` ran in a parent
267+
# process — e.g. a Rakefile or Rails' `Bundler.require` — that
268+
# shelled out to the test runner. Without this guard the parent's
269+
# empty result would overwrite the subprocess's report on the way
270+
# out. See https://github.com/simplecov-ruby/simplecov/issues/581.
271+
def defer_to_existing_report?
272+
return false unless existing_report_newer_than_us?
273+
274+
res = result
275+
empty = res.nil? || res.files.empty?
276+
warn_about_deferred_report if empty
277+
empty
278+
end
279+
280+
def existing_report_newer_than_us?
281+
return false unless process_start_time
282+
283+
last_run_path = File.join(coverage_path, ".last_run.json")
284+
File.exist?(last_run_path) && File.mtime(last_run_path) > process_start_time
285+
end
286+
287+
def warn_about_deferred_report
288+
return unless print_error_status
289+
290+
warn SimpleCov::Color.colorize(
291+
"Skipping SimpleCov report — this process tracked no application code and a newer " \
292+
"report already exists at #{coverage_path}. This usually means SimpleCov.start ran in a " \
293+
"parent process (e.g. a Rakefile or Rails' Bundler.require) that shelled out to the test " \
294+
"runner. See https://github.com/simplecov-ruby/simplecov/issues/581.",
295+
:yellow
296+
)
227297
end
228298

229299
# @api private

lib/simplecov/defaults.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@
3636
loop do
3737
filename = config_path.join(".simplecov")
3838
if filename.exist?
39-
begin
39+
# `.simplecov` is a configuration file; SimpleCov.start calls inside
40+
# it are intercepted and converted to configuration, with a deprecation
41+
# warning. See `SimpleCov.with_dot_simplecov_autoload` and issue #581.
42+
SimpleCov.with_dot_simplecov_autoload do
4043
load filename
4144
rescue LoadError, StandardError
4245
# simplecov:disable — only fires when .simplecov is unreadable

lib/simplecov/result_merger.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,34 @@ def store_result(result) # rubocop:disable Naming/PredicateMethod
155155

156156
# A single result only ever has one command_name, see `SimpleCov::Result#to_hash`
157157
command_name, data = result.to_hash.first
158-
new_resultset[command_name] = data
158+
new_resultset[command_name] = merged_entry(new_resultset[command_name], data)
159159

160160
atomic_write_resultset(new_resultset)
161161
end
162162
true
163163
end
164164

165+
# If an entry with the same command_name was written AFTER our process
166+
# started, a sibling test runner (typically a subprocess our parent
167+
# process shelled out to) wrote it. Combine coverage data rather than
168+
# overwriting, so an empty parent-process result doesn't clobber the
169+
# subprocess's real data. See https://github.com/simplecov-ruby/simplecov/issues/581.
170+
def merged_entry(existing, incoming)
171+
return incoming unless concurrent_runner_entry?(existing)
172+
173+
incoming.merge(
174+
"coverage" => Combine.combine(Combine::ResultsCombiner, existing["coverage"], incoming["coverage"])
175+
)
176+
end
177+
178+
def concurrent_runner_entry?(entry)
179+
return false unless entry.is_a?(Hash)
180+
181+
timestamp = entry["timestamp"]
182+
process_start = SimpleCov.process_start_time
183+
timestamp && process_start && timestamp.to_i >= process_start.to_i
184+
end
185+
165186
# Write to a temp file first, then atomically rename to avoid other
166187
# processes reading a truncated/incomplete .resultset.json.
167188
def atomic_write_resultset(resultset)

spec/result_merger_spec.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,63 @@
276276
described_class.store_result({})
277277
expect(described_class).to have_received(:synchronize_resultset)
278278
end
279+
280+
# See https://github.com/simplecov-ruby/simplecov/issues/581. When a parent
281+
# process (Rakefile, Rails Bundler.require) shells out to the test runner,
282+
# the subprocess writes its real result to the resultset and then the
283+
# parent's at_exit hook stores its own (empty) result under the same
284+
# command_name. Without merging, the parent overwrites the subprocess's
285+
# data; with the guard, the parent's incoming entry is combined with the
286+
# existing one so the subprocess's coverage survives.
287+
describe "merging same-command-name entries written by a concurrent runner" do
288+
let(:process_start) { Time.now }
289+
let(:subprocess_result) do
290+
SimpleCov::Result.new(
291+
{source_fixture("sample.rb") => {"lines" => [nil, 1, 1, 1, nil, nil, 1, 1, nil, nil]}},
292+
command_name: "RSpec"
293+
)
294+
end
295+
let(:parent_empty_result) { SimpleCov::Result.new({}, command_name: "RSpec") }
296+
297+
before { allow(SimpleCov).to receive(:process_start_time).and_return(process_start) }
298+
299+
it "merges parent's incoming entry into the subprocess's when newer than our process_start_time" do
300+
subprocess_result.created_at = process_start + 1 # subprocess finished after we started
301+
described_class.store_result(subprocess_result)
302+
303+
parent_empty_result.created_at = process_start + 2
304+
described_class.store_result(parent_empty_result)
305+
306+
merged = described_class.read_resultset.fetch("RSpec").fetch("coverage")
307+
expect(merged.keys).to contain_exactly(source_fixture("sample.rb"))
308+
end
309+
310+
it "still overwrites an older entry from a previous run (older than process_start)" do
311+
# A stale entry from a previous test run shouldn't be merged in — it's
312+
# not from a concurrent runner, just leftover state.
313+
stale = SimpleCov::Result.new(
314+
{source_fixture("sample.rb") => {"lines" => [nil, 1, 1, 1, nil, nil, 1, 1, nil, nil]}},
315+
command_name: "RSpec"
316+
)
317+
stale.created_at = process_start - 60
318+
described_class.store_result(stale)
319+
320+
parent_empty_result.created_at = process_start + 1
321+
described_class.store_result(parent_empty_result)
322+
323+
expect(described_class.read_resultset.fetch("RSpec").fetch("coverage")).to be_empty
324+
end
325+
326+
it "is a no-op when process_start_time is unset (e.g. SimpleCov.start was never called)" do
327+
allow(SimpleCov).to receive(:process_start_time).and_return(nil)
328+
329+
subprocess_result.created_at = Time.now
330+
described_class.store_result(subprocess_result)
331+
described_class.store_result(parent_empty_result)
332+
333+
expect(described_class.read_resultset.fetch("RSpec").fetch("coverage")).to be_empty
334+
end
335+
end
279336
end
280337

281338
describe ".resultset" do

0 commit comments

Comments
 (0)