-
Notifications
You must be signed in to change notification settings - Fork 553
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix merging race conditions #570
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,25 +17,31 @@ def resultset_writelock | |
File.join(SimpleCov.coverage_path, ".resultset.json.lock") | ||
end | ||
|
||
# Loads the cached resultset from JSON and returns it as a Hash | ||
# Loads the cached resultset from JSON and returns it as a Hash, | ||
# caching it for subsequent accesses. | ||
def resultset | ||
if stored_data | ||
begin | ||
JSON.parse(stored_data) | ||
rescue | ||
@resultset ||= begin | ||
data = stored_data | ||
if data | ||
begin | ||
JSON.parse(data) || {} | ||
rescue | ||
{} | ||
end | ||
else | ||
{} | ||
end | ||
else | ||
{} | ||
end | ||
end | ||
|
||
# Returns the contents of the resultset cache as a string or if the file is missing or empty nil | ||
def stored_data | ||
return unless File.exist?(resultset_path) | ||
data = File.read(resultset_path) | ||
return if data.nil? || data.length < 2 | ||
data | ||
synchronize_resultset do | ||
return unless File.exist?(resultset_path) | ||
data = File.read(resultset_path) | ||
return if data.nil? || data.length < 2 | ||
data | ||
end | ||
end | ||
|
||
# Gets the resultset hash and re-creates all included instances | ||
|
@@ -76,8 +82,9 @@ def merged_result | |
|
||
# Saves the given SimpleCov::Result in the resultset cache | ||
def store_result(result) | ||
File.open(resultset_writelock, "w+") do |f| | ||
f.flock(File::LOCK_EX) | ||
synchronize_resultset do | ||
# Ensure we have the latest, in case it was already cached | ||
clear_resultset | ||
new_set = resultset | ||
command_name, data = result.to_hash.first | ||
new_set[command_name] = data | ||
|
@@ -87,6 +94,28 @@ def store_result(result) | |
end | ||
true | ||
end | ||
|
||
# Ensure only one process is reading or writing the resultset at any | ||
# given time | ||
def synchronize_resultset | ||
# make it reentrant | ||
return yield if defined?(@resultset_locked) && @resultset_locked | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does, but it issues a ruby warning There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also still not sure what making it reentrant here means and accomplishes, but reentrancy also has been a long time ago for me. The way I see it, the block we are given is now also called when the result set is already locked. Doesn't this effectively circumvent the lock? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, so the idea is that if we call The reason we need to do this is we want to make both reads and writes acquire a lock so that neither one does anything unsafe. And in order to safely do a write, we 1. first need to read and 2. we need to ensure nobody else writes between our read and our write, otherwise we might lose data. So to accomplish that, write does There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Which works as we are just one process, got it, thanks for taking the time to explain! :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All of that said, do you happen to know of a way to test this? We could probably extract synchronize_resultset and yse that to write some specs around it which might be worthwhile, not sure. I just see code that I have questions about and often feel better, the better that code is tested :D There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, I have a couple ideas, I'll tweak the PR with some more tests |
||
|
||
begin | ||
@resultset_locked = true | ||
File.open(resultset_writelock, "w+") do |f| | ||
f.flock(File::LOCK_EX) | ||
yield | ||
end | ||
ensure | ||
@resultset_locked = false | ||
end | ||
end | ||
|
||
# Clear out the previously cached .resultset | ||
def clear_resultset | ||
@resultset = nil | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
require "helper" | ||
|
||
if SimpleCov.usable? | ||
describe SimpleCov do | ||
describe ".result" do | ||
before do | ||
SimpleCov.clear_result | ||
allow(Coverage).to receive(:result).once.and_return({}) | ||
end | ||
|
||
context "with merging disabled" do | ||
before do | ||
allow(SimpleCov).to receive(:use_merging).once.and_return(false) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. couldn't we say There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i suppose, if we set it back. this feels safer IMO, since we're not changing instance variables of the SimpleCov class, which may have side effects in later specs There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hm, fair point thanks! 👍 |
||
end | ||
|
||
context "when not running" do | ||
before do | ||
allow(SimpleCov).to receive(:running).and_return(false) | ||
end | ||
|
||
it "returns nil" do | ||
expect(SimpleCov.result).to be_nil | ||
end | ||
end | ||
|
||
context "when running" do | ||
before do | ||
allow(SimpleCov).to receive(:running).and_return(true, false) | ||
end | ||
|
||
it "uses the result from Coverage" do | ||
expect(Coverage).to receive(:result).once.and_return(__FILE__ => [0, 1]) | ||
expect(SimpleCov.result.filenames).to eq [__FILE__] | ||
end | ||
|
||
it "adds not-loaded-files" do | ||
expect(SimpleCov).to receive(:add_not_loaded_files).once.and_return({}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as this is basically ourselves, I'd think rather testing the effect that |
||
SimpleCov.result | ||
end | ||
|
||
it "doesn't store the current coverage" do | ||
expect(SimpleCov::ResultMerger).not_to receive(:store_result) | ||
SimpleCov.result | ||
end | ||
|
||
it "doesn't merge the result" do | ||
expect(SimpleCov::ResultMerger).not_to receive(:merged_result) | ||
SimpleCov.result | ||
end | ||
|
||
it "caches its result" do | ||
result = SimpleCov.result | ||
expect(SimpleCov.result).to be(result) | ||
end | ||
end | ||
end | ||
|
||
context "with merging enabled" do | ||
let(:the_merged_result) { double } | ||
|
||
before do | ||
allow(SimpleCov).to receive(:use_merging).once.and_return(true) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. couldn't we just do |
||
allow(SimpleCov::ResultMerger).to receive(:store_result).once | ||
allow(SimpleCov::ResultMerger).to receive(:merged_result).once.and_return(the_merged_result) | ||
end | ||
|
||
context "when not running" do | ||
before do | ||
allow(SimpleCov).to receive(:running).and_return(false) | ||
end | ||
|
||
it "merges the result" do | ||
expect(SimpleCov.result).to be(the_merged_result) | ||
end | ||
end | ||
|
||
context "when running" do | ||
before do | ||
allow(SimpleCov).to receive(:running).and_return(true, false) | ||
end | ||
|
||
it "uses the result from Coverage" do | ||
expect(Coverage).to receive(:result).once.and_return({}) | ||
SimpleCov.result | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. seems like we might wanna assert the return value? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a little weird here because That said, I tweaked the similar spec on line 31 (non merging scenario) to show that the Coverage data does indeed get used |
||
end | ||
|
||
it "adds not-loaded-files" do | ||
expect(SimpleCov).to receive(:add_not_loaded_files).once.and_return({}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as add_not_loaded_files is on the thing under test I thinkw e mgiht opt to test what it does not that it gets called, wdyt? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, well i'd say There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not wanting to stub a bunch of file stuff is a perfectly fine reason for stubbing early 👍 I somehow thought the effects would be easier to test, but with this I much prefer the current stub. True, maybe a refactoring for another object but I'd have to dig deeper to see what scope would make sense there. |
||
SimpleCov.result | ||
end | ||
|
||
it "stores the current coverage" do | ||
expect(SimpleCov::ResultMerger).to receive(:store_result).once | ||
SimpleCov.result | ||
end | ||
|
||
it "merges the result" do | ||
expect(SimpleCov.result).to be(the_merged_result) | ||
end | ||
|
||
it "caches its result" do | ||
result = SimpleCov.result | ||
expect(SimpleCov.result).to be(result) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was ostensibly added for rubocop, but rubocop and ruby don't complain about it now, so ¯_(ツ)_/¯
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably was solved some time when
result?
was introduced/use or so