Skip to content
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

Exclude stubbed classes from subclasses after teardown #1570

Closed
wants to merge 21 commits into from

Conversation

GCorbel
Copy link

@GCorbel GCorbel commented Feb 20, 2024

This fix #1568.

Stubbed classes are excluded from parent subclasses after each spec.

The original issue :

begin
  require "bundler/inline"
rescue LoadError => e
  $stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler"
  raise e
end

gemfile(true) do
  source "https://rubygems.org"

  gem "rspec", "3.12.0" # Activate the gem and version you are reporting the issue against.
  gem 'rspec-mocks', '3.12.6'
end

puts "Ruby version is: #{RUBY_VERSION}" # Ruby version is: 3.1.4

class Something; end

describe 'Test' do
  before(:each) do
    class A < Something; end
    stub_const('B', Class.new(Something))
  end

  it 'something' do
    puts Something.subclasses # => [B, A]
  end

  it 'something else' do
    puts Something.subclasses # => [B, B, A]
    # Only one occurence of B should be listed
  end
end

Now, Something.subclasses always return [B, A].

Copy link
Member

@pirj pirj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s nice that you’ve found a reliable solution.
I’m skeptical, however, to include it for everyone.
There might be some performance impact, and since this can introduce flakiness due to the GC race condition and an exception is not reassuring.

Still, this should be kept as a reference for those who experience issues with subclasses.

lib/rspec/mocks.rb Outdated Show resolved Hide resolved
@@ -101,6 +101,23 @@ def self.with_temporary_scope
end
end

@@excluded_subclasses = []

def self.excluded_subclasses
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to getobj?
There’s a potential race condition, and the weakref itself should quack just like the object itself (including the === probably to allow to remove from the list.
https://stackoverflow.com/questions/69185508/ruby-weakref-has-implicit-race-condition

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, otherwise the substraction wasn't working.

I changed to rescue RefError.

@excluded_subclasses = []

def self.excluded_subclasses
@excluded_subclasses.select(&:weakref_alive?).map do |ref|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just do this in the actual method, then you don't have to build the list.

Copy link
Author

@GCorbel GCorbel Feb 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean with ?

diff --git a/lib/rspec/mocks.rb b/lib/rspec/mocks.rb
index a82055dd..ad697b59 100644
--- a/lib/rspec/mocks.rb
+++ b/lib/rspec/mocks.rb
@@ -104,11 +104,7 @@ module RSpec
     @excluded_subclasses = []
 
     def self.excluded_subclasses
-      @excluded_subclasses.select(&:weakref_alive?).map do |ref|
-        ref.__getobj__
-      rescue RefError
-        nil
-      end.compact
+      @excluded_subclasses
     end
 
     def self.exclude_subclass(constant)
@@ -117,7 +113,11 @@ module RSpec
 
     module ExcludeClassesFromSubclasses
       def subclasses
-        super - RSpec::Mocks.excluded_subclasses
+        super - RSpec::Mocks.excluded_subclasses.select(&:weakref_alive?).map do |ref|
+          ref.__getobj__
+        rescue RefError
+          nil
+        end.compact
       end
     end
     Class.prepend(ExcludeClassesFromSubclasses)

Copy link
Member

@JonRowe JonRowe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just placing a block on this as I don't want it merged without final say so @pirj I'm concerned about the require.

@GCorbel
Copy link
Author

GCorbel commented Feb 21, 2024

I’m skeptical, however, to include it for everyone. There might be some performance impact, and since this can introduce flakiness due to the GC race condition and an exception is not reassuring.

I understand your worries. I would like to find a better solution but wasn't able.

Maybe we can use a config as excluded_stubbed_const_from_subclasses or enable it by default and do a config as thread_safe_garabage_collection ?

@GCorbel
Copy link
Author

GCorbel commented Feb 21, 2024

The more I think, the more I'm in favor of a config flag. Maybe a warning message that say the flag have to be switched should be displayed when there is an error.

@pirj
Copy link
Member

pirj commented Feb 21, 2024

I support your idea of opting in for this with a flag.

@pirj
Copy link
Member

pirj commented Feb 21, 2024

We could conditionally require weakref if this option is on.

@GCorbel
Copy link
Author

GCorbel commented Feb 21, 2024

Should we have the option on by default ?

@pirj
Copy link
Member

pirj commented Feb 21, 2024

No. But we’re working in RSpec 4, and we can think about enabling it there unless performance considerations come up.

Copy link
Author

@GCorbel GCorbel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored, added a config flag and specs. I tried to respect the existing code. Let me know what you think.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took RSpec::Mocks::MarshalExtension for example.

@GCorbel
Copy link
Author

GCorbel commented Feb 29, 2024

I change to run specs only for Ruby 3.1 and ahead as in https://github.com/rails/rails/blob/v7.0.8/activesupport/lib/active_support/ruby_features.rb

@GCorbel
Copy link
Author

GCorbel commented Mar 4, 2024

Sorry, my latest commit was completely broke. I fixed it.

@GCorbel
Copy link
Author

GCorbel commented Mar 4, 2024

I fixed the code for Ruby <= 2.4


Class.class_eval do
undef subclasses_with_rspec_mocks
alias subclasses subclasses_without_rspec_mocks
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rubocop triggers this warning :

lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb:47:11: W: Lint/DuplicateMethods: Method RSpec::Mocks::ExcludeStubbedClassesFromSubclasses::Class#subclasses is defined at both lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb:38 and lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb:47.
          alias subclasses subclasses_without_rspec_mocks
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

It's wrong because the method is undefined at line 46. I disabled this warning.

@GCorbel
Copy link
Author

GCorbel commented Mar 5, 2024

The CI pass except for "ruby-head" which is not to my commit

@GCorbel
Copy link
Author

GCorbel commented Mar 6, 2024

I fixed a flap on specs. Now the CI should pass.

@GCorbel
Copy link
Author

GCorbel commented Mar 6, 2024

I finally succeeded to install Ruby 3.4-dev locally and confirm that the error on the CI is not related to the PR. It also fail on the main branch.

@GCorbel GCorbel requested review from JonRowe and pirj March 6, 2024 17:15
@GCorbel
Copy link
Author

GCorbel commented Mar 25, 2024

Hello, I don't want to bother you but is it possible to have a quick review, at least on the global behavior? I'm waiting for this to be able to move forward on the project I'm working on.

Copy link
Member

@pirj pirj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please accept my apologies for the wait. The code is mostly good, thanks!

I’d love to hear what @JonRowe says.
Do we need a benchmark to measure the overhead in reset calls?

lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb Outdated Show resolved Hide resolved
lib/rspec/mocks.rb Show resolved Hide resolved
lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb Outdated Show resolved Hide resolved
lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb Outdated Show resolved Hide resolved
@GCorbel
Copy link
Author

GCorbel commented Mar 26, 2024

Please accept my apologies for the wait. The code is mostly good, thanks!

No problem, it's open source. You don't even have to reply.

Copy link
Member

@pirj pirj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks solid, thank you! 🙌

Just a few optional, subjective and cosmetic notes - please feel free to ignore.

def exclude_subclass(constant)
require 'weakref' unless defined?(::WeakRef)

@excluded_subclasses ||= []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in a few places. Might be it belongs to enable!, too?

end

def excluded_subclasses
require 'weakref' unless defined?(::WeakRef)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok to move this line into enable!?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it's a problem but now we can do RSpec::Mocks::ExcludeStubbedClassesFromSubclasses.excluded_subclasses without ExcludeStubbedClassesFromSubclasses.enable! first. If we move this line to enable!, it will raise an error.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you wish, I can do :

diff --git a/lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb b/lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb
index bb72101e..129ff0d2 100644
--- a/lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb
+++ b/lib/rspec/mocks/exclude_stubbed_classes_from_subclasses.rb
@@ -8,6 +8,8 @@ module RSpec
         def enable!
           return unless RUBY_VERSION >= "3.1"
           return if Class.respond_to?(:subclasses_with_rspec_mocks)
+          @excluded_subclasses = []
+          require 'weakref' unless defined?(::WeakRef)
 
           Class.class_eval do
             def subclasses_with_rspec_mocks
@@ -32,9 +34,6 @@ module RSpec
         end
 
         def excluded_subclasses
-          require 'weakref' unless defined?(::WeakRef)
-
-          @excluded_subclasses ||= []
           @excluded_subclasses.select(&:weakref_alive?).map do |ref|
             begin
               ref.__getobj__
@@ -45,9 +44,6 @@ module RSpec
         end
 
         def exclude_subclass(constant)
-          require 'weakref' unless defined?(::WeakRef)
-
-          @excluded_subclasses ||= []
           @excluded_subclasses << ::WeakRef.new(constant)
         end
       end
diff --git a/spec/rspec/mocks/exclude_stubbed_classes_from_subclasses_spec.rb b/spec/rspec/mocks/exclude_stubbed_classes_from_subclasses_spec.rb
index 3b5ded94..16454922 100644
--- a/spec/rspec/mocks/exclude_stubbed_classes_from_subclasses_spec.rb
+++ b/spec/rspec/mocks/exclude_stubbed_classes_from_subclasses_spec.rb
@@ -63,6 +63,10 @@ if RUBY_VERSION >= '3.1'
         end
 
         describe '.excluded_subclasses' do
+          before do
+            described_class.enable!
+          end
+
           it 'returns excluded subclasses' do
             subclass = Class.new
             described_class.exclude_subclass(subclass)
@@ -75,14 +79,12 @@ if RUBY_VERSION >= '3.1'
             described_class.exclude_subclass(subclass)
 
             subclass = nil
-
             GC.start
 
             expect(described_class.excluded_subclasses).to eq([])
           end
 
           it 'does not return excluded subclasses that raises a ::WeakRef::RefError' do
-            require 'weakref'
             subclass = double(:weakref_alive? => true)
             described_class.instance_variable_set(:@excluded_subclasses, [subclass])
 


@excluded_subclasses ||= []
@excluded_subclasses.select(&:weakref_alive?).map do |ref|
begin # rubocop:disable Style/RedundantBegin
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It slipped my mind, isn’t it possible to squash that so that rescue is on the map itself?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, nevermind, Ruby 1.8 probably doesn’t support that. Do you mind making a note to indicate that?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the comment because the CI was failing because of that (I don't have the same behavior locally with the same version of Ruby/Rubocop).

@GCorbel
Copy link
Author

GCorbel commented May 14, 2024

@JonRowe can you give a look at this request ?

@JonRowe
Copy link
Member

JonRowe commented Sep 27, 2024

I'd like to apologise for leaving this hanging for so long, its been on my review list for ages but I just haven't been able to get around to it, I had a couple of concerns about weak ref and other such behaviour but following on from the discussion in #1568 I'm going to close this. Sorry to have wasted your time.

@JonRowe JonRowe closed this Sep 27, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

stub_const doesn't clear Class#subclasses
3 participants