Skip to content

Commit f953a80

Browse files
committed
Warn when coverage.json is clobbered by a concurrent process
Record the time each JSONFormatter process loads as `PROCESS_START_TIME` and, before writing `coverage.json`, compare the existing file's `meta.timestamp` against it. If the file on disk was written *after* this process started, another test run likely produced it — warn that the overwrite will lose their data and point at `SimpleCov::ResultMerger`. The check is zero-cost for the common case (no existing file, or an older one from a prior sequential run) and has no state beyond the existing `meta.timestamp` field. Sequential re-runs do not warn: the prior file's timestamp predates the new process's start. parallel_tests-style setups, where workers share `PROCESS_START_TIME` but finish at different times, do warn once the later writers reach format time.
1 parent 43a5dc5 commit f953a80

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

lib/simplecov/formatter/json_formatter.rb

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@
22

33
require_relative "json_formatter/result_hash_formatter"
44
require "json"
5+
require "time"
56

67
module SimpleCov
78
module Formatter
89
class JSONFormatter
910
FILENAME = "coverage.json"
1011

12+
# Captured when the class loads, which is close enough to "when this
13+
# Ruby process started tracking coverage". Used to detect whether an
14+
# existing coverage.json was written by a sibling process running
15+
# concurrently (see #warn_if_concurrent_overwrite).
16+
PROCESS_START_TIME = Time.now
17+
private_constant :PROCESS_START_TIME
18+
1119
def initialize(silent: false)
1220
@silent = silent
1321
end
@@ -17,13 +25,38 @@ def self.build_hash(result)
1725
end
1826

1927
def format(result)
20-
json = JSON.pretty_generate(self.class.build_hash(result))
21-
File.write(File.join(SimpleCov.coverage_path, FILENAME), json)
28+
path = File.join(SimpleCov.coverage_path, FILENAME)
29+
warn_if_concurrent_overwrite(path)
30+
File.write(path, JSON.pretty_generate(self.class.build_hash(result)))
2231
puts output_message(result) unless @silent
2332
end
2433

2534
private
2635

36+
# Warns when the existing coverage.json has a timestamp newer than this
37+
# process's start time — a strong signal that a sibling test process
38+
# (e.g., parallel_tests) wrote it while we were running, and that our
39+
# write is about to clobber their data.
40+
def warn_if_concurrent_overwrite(path)
41+
existing_ts = existing_timestamp(path) or return
42+
return unless existing_ts > PROCESS_START_TIME
43+
44+
warn "simplecov: #{path} was written at #{existing_ts.iso8601} — after " \
45+
"this process started at #{PROCESS_START_TIME.iso8601}. Overwriting " \
46+
"likely loses coverage data from a concurrent test run. For " \
47+
"parallel test setups, use SimpleCov::ResultMerger or run a single " \
48+
"collation step after all workers finish."
49+
end
50+
51+
def existing_timestamp(path)
52+
return nil unless File.exist?(path)
53+
54+
timestamp = JSON.parse(File.read(path), symbolize_names: true).dig(:meta, :timestamp)
55+
timestamp && Time.iso8601(timestamp)
56+
rescue JSON::ParserError, ArgumentError
57+
nil
58+
end
59+
2760
def output_message(result)
2861
"JSON Coverage report generated for #{result.command_name} to #{SimpleCov.coverage_path}. " \
2962
"#{result.covered_lines} / #{result.total_lines} LOC (#{result.covered_percent.round(2)}%) covered."

spec/json_formatter_spec.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
# frozen_string_literal: true
22

33
require "helper"
4+
require "fileutils"
45

56
describe SimpleCov::Formatter::JSONFormatter do
67
subject { described_class.new(silent: true) }
78

89
let(:fixed_time) { Time.new(2024, 1, 1, 0, 0, 0, "+00:00") }
910

11+
# Prevent stale coverage.json from prior tests from triggering the
12+
# concurrent-overwrite warning.
13+
before { FileUtils.rm_f("tmp/coverage/coverage.json") }
14+
1015
let(:result) do
1116
res = SimpleCov::Result.new({
1217
source_fixture("json/sample.rb") => {"lines" => [
@@ -305,6 +310,57 @@
305310
expect(json_output).to eq(json_result("sample_groups"))
306311
end
307312
end
313+
314+
context "when an existing coverage.json was written after this process started" do
315+
let(:coverage_path) { "tmp/coverage/coverage.json" }
316+
let(:future_timestamp) { (Time.now + 3600).iso8601 }
317+
318+
before do
319+
FileUtils.mkdir_p("tmp/coverage")
320+
File.write(coverage_path, JSON.generate(meta: {timestamp: future_timestamp}))
321+
end
322+
323+
it "warns that a concurrent process may have written it" do
324+
stderr = capture_stderr { subject.format(result) }
325+
326+
expect(stderr).to include("simplecov:")
327+
expect(stderr).to include(future_timestamp)
328+
expect(stderr).to include("concurrent test run")
329+
end
330+
331+
it "still writes the new file" do
332+
capture_stderr { subject.format(result) }
333+
334+
expect(json_output.fetch("meta").fetch("timestamp")).to eq(fixed_time.iso8601)
335+
end
336+
end
337+
338+
context "when an existing coverage.json predates this process" do
339+
before do
340+
FileUtils.mkdir_p("tmp/coverage")
341+
past_timestamp = (Time.now - 3600).iso8601
342+
File.write("tmp/coverage/coverage.json", JSON.generate(meta: {timestamp: past_timestamp}))
343+
end
344+
345+
it "does not warn" do
346+
stderr = capture_stderr { subject.format(result) }
347+
348+
expect(stderr).to be_empty
349+
end
350+
end
351+
352+
context "when the existing coverage.json is malformed" do
353+
before do
354+
FileUtils.mkdir_p("tmp/coverage")
355+
File.write("tmp/coverage/coverage.json", "not-json")
356+
end
357+
358+
it "does not warn or raise" do
359+
stderr = capture_stderr { subject.format(result) }
360+
361+
expect(stderr).to be_empty
362+
end
363+
end
308364
end
309365

310366
def enable_branch_coverage

0 commit comments

Comments
 (0)