Skip to content

Commit 1aa2463

Browse files
committed
Filter reloaded classes in Class#subclasses and Class#descendants core exts
When calling `#descendants` on a non-reloadable class with reloadable descendants, it can return classes that were unloaded but not yet garbage collected. `DescendantsTracker` have been dealing with this for a long time but the core_ext versions of these methods were never made reloader aware. This also refactor `DescendantsTracker` to not be so different when there is no native `#subclasses` method.
1 parent 2fb72f5 commit 1aa2463

File tree

5 files changed

+145
-163
lines changed

5 files changed

+145
-163
lines changed

activesupport/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* `Class#subclasses` and `Class#descendants` now automatically filter reloaded classes.
2+
3+
Previously they could return old implementations of reloadable classes that have been
4+
dereferenced but not yet garbage collected.
5+
6+
They now automatically filter such classes like `DescendantTracker#subclasses` and
7+
`DescendantTracker#descendants`.
8+
9+
*Jean Boussier*
10+
111
* `Rails.error.report` now marks errors as reported to avoid reporting them twice.
212

313
In some cases, users might want to report errors explicitly with some extra context

activesupport/lib/active_support/core_ext/class/subclasses.rb

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "active_support/ruby_features"
4+
require "active_support/descendants_tracker"
45

56
class Class
67
if ActiveSupport::RubyFeatures::CLASS_SUBCLASSES
@@ -26,16 +27,18 @@ def descendants
2627
k.singleton_class? || k == self
2728
end
2829
end
30+
31+
# Returns an array with the direct children of +self+.
32+
#
33+
# class Foo; end
34+
# class Bar < Foo; end
35+
# class Baz < Bar; end
36+
#
37+
# Foo.subclasses # => [Bar]
38+
def subclasses
39+
descendants.select { |descendant| descendant.superclass == self }
40+
end
2941
end
3042

31-
# Returns an array with the direct children of +self+.
32-
#
33-
# class Foo; end
34-
# class Bar < Foo; end
35-
# class Baz < Bar; end
36-
#
37-
# Foo.subclasses # => [Bar]
38-
def subclasses
39-
descendants.select { |descendant| descendant.superclass == self }
40-
end unless ActiveSupport::RubyFeatures::CLASS_SUBCLASSES
43+
prepend ActiveSupport::DescendantsTracker::ReloadedClassesFiltering
4144
end

activesupport/lib/active_support/descendants_tracker.rb

Lines changed: 104 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -5,184 +5,122 @@
55

66
module ActiveSupport
77
# This module provides an internal implementation to track descendants
8-
# which is faster than iterating through ObjectSpace.
8+
# which is faster than iterating through +ObjectSpace+.
9+
#
10+
# However Ruby 3.1 provide a fast native +Class#subclasses+ method,
11+
# so if you know your code won't be executed on older rubies, including
12+
# +ActiveSupport::DescendantsTracker+ does not provide any benefit.
913
module DescendantsTracker
10-
class << self
11-
def direct_descendants(klass)
12-
ActiveSupport::Deprecation.warn(<<~MSG)
13-
ActiveSupport::DescendantsTracker.direct_descendants is deprecated and will be removed in Rails 7.1.
14-
Use ActiveSupport::DescendantsTracker.subclasses instead.
15-
MSG
16-
subclasses(klass)
17-
end
18-
end
19-
2014
@clear_disabled = false
2115

22-
if RubyFeatures::CLASS_SUBCLASSES
23-
@@excluded_descendants = if RUBY_ENGINE == "ruby"
24-
# On MRI `ObjectSpace::WeakMap` keys are weak references.
25-
# So we can simply use WeakMap as a `Set`.
26-
ObjectSpace::WeakMap.new
27-
else
28-
# On TruffleRuby `ObjectSpace::WeakMap` keys are strong references.
29-
# So we use `object_id` as a key and the actual object as a value.
30-
#
31-
# JRuby for now doesn't have Class#descendant, but when it will, it will likely
32-
# have the same WeakMap semantic than Truffle so we future proof this as much as possible.
33-
class WeakSet # :nodoc:
34-
def initialize
35-
@map = ObjectSpace::WeakMap.new
36-
end
16+
if RUBY_ENGINE == "ruby"
17+
# On MRI `ObjectSpace::WeakMap` keys are weak references.
18+
# So we can simply use WeakMap as a `Set`.
19+
class WeakSet < ObjectSpace::WeakMap # :nodoc:
20+
alias_method :to_a, :keys
3721

38-
def [](object)
39-
@map.key?(object.object_id)
40-
end
41-
42-
def []=(object, _present)
43-
@map[object.object_id] = object
44-
end
22+
def <<(object)
23+
self[object] = true
4524
end
46-
WeakSet.new
4725
end
48-
49-
class << self
50-
def disable_clear! # :nodoc:
51-
unless @clear_disabled
52-
@clear_disabled = true
53-
remove_method(:subclasses)
54-
@@excluded_descendants = nil
55-
end
26+
else
27+
# On TruffleRuby `ObjectSpace::WeakMap` keys are strong references.
28+
# So we use `object_id` as a key and the actual object as a value.
29+
#
30+
# JRuby for now doesn't have Class#descendant, but when it will, it will likely
31+
# have the same WeakMap semantic than Truffle so we future proof this as much as possible.
32+
class WeakSet # :nodoc:
33+
def initialize
34+
@map = ObjectSpace::WeakMap.new
5635
end
5736

58-
def subclasses(klass)
59-
klass.subclasses
37+
def [](object)
38+
@map.key?(object.object_id)
6039
end
40+
alias_method :include?, :[]
6141

62-
def descendants(klass)
63-
klass.descendants
42+
def []=(object, _present)
43+
@map[object.object_id] = object
6444
end
6545

66-
def clear(classes) # :nodoc:
67-
raise "DescendantsTracker.clear was disabled because config.enable_reloading is false" if @clear_disabled
68-
69-
classes.each do |klass|
70-
@@excluded_descendants[klass] = true
71-
klass.descendants.each do |descendant|
72-
@@excluded_descendants[descendant] = true
73-
end
74-
end
46+
def to_a
47+
@map.values
7548
end
7649

77-
def native? # :nodoc:
78-
true
50+
def <<(object)
51+
self[object] = true
7952
end
8053
end
54+
end
55+
@excluded_descendants = WeakSet.new
8156

57+
module ReloadedClassesFiltering # :nodoc:
8258
def subclasses
83-
subclasses = super
84-
subclasses.reject! { |d| @@excluded_descendants[d] }
85-
subclasses
59+
DescendantsTracker.reject!(super)
8660
end
8761

8862
def descendants
89-
subclasses.concat(subclasses.flat_map(&:descendants))
63+
DescendantsTracker.reject!(super)
9064
end
65+
end
9166

92-
def direct_descendants
93-
ActiveSupport::Deprecation.warn(<<~MSG)
94-
ActiveSupport::DescendantsTracker#direct_descendants is deprecated and will be removed in Rails 7.1.
95-
Use #subclasses instead.
96-
MSG
97-
subclasses
98-
end
99-
else
100-
@@direct_descendants = {}
67+
include ReloadedClassesFiltering
10168

102-
class << self
103-
def disable_clear! # :nodoc:
69+
class << self
70+
def disable_clear! # :nodoc:
71+
unless @clear_disabled
10472
@clear_disabled = true
73+
ReloadedClassesFiltering.remove_method(:subclasses)
74+
ReloadedClassesFiltering.remove_method(:descendants)
75+
@excluded_descendants = nil
10576
end
77+
end
10678

107-
def subclasses(klass)
108-
descendants = @@direct_descendants[klass]
109-
descendants ? descendants.to_a : []
110-
end
79+
def clear(classes) # :nodoc:
80+
raise "DescendantsTracker.clear was disabled because config.enable_reloading is false" if @clear_disabled
11181

112-
def descendants(klass)
113-
arr = []
114-
accumulate_descendants(klass, arr)
115-
arr
116-
end
117-
118-
def clear(classes) # :nodoc:
119-
raise "DescendantsTracker.clear was disabled because config.enable_reloading is false" if @clear_disabled
120-
121-
@@direct_descendants.each do |klass, direct_descendants_of_klass|
122-
if classes.member?(klass)
123-
@@direct_descendants.delete(klass)
124-
else
125-
direct_descendants_of_klass.reject! do |direct_descendant_of_class|
126-
classes.member?(direct_descendant_of_class)
127-
end
128-
end
82+
classes.each do |klass|
83+
@excluded_descendants << klass
84+
klass.descendants.each do |descendant|
85+
@excluded_descendants << descendant
12986
end
13087
end
131-
132-
def native? # :nodoc:
133-
false
134-
end
135-
136-
# This is the only method that is not thread safe, but is only ever called
137-
# during the eager loading phase.
138-
def store_inherited(klass, descendant)
139-
(@@direct_descendants[klass] ||= DescendantsArray.new) << descendant
140-
end
141-
142-
private
143-
def accumulate_descendants(klass, acc)
144-
if direct_descendants = @@direct_descendants[klass]
145-
direct_descendants.each do |direct_descendant|
146-
acc << direct_descendant
147-
accumulate_descendants(direct_descendant, acc)
148-
end
149-
end
150-
end
15188
end
15289

153-
def inherited(base)
154-
DescendantsTracker.store_inherited(self, base)
155-
super
90+
def reject!(classes) # :nodoc:
91+
if @excluded_descendants
92+
classes.reject! { |d| @excluded_descendants.include?(d) }
93+
end
94+
classes
15695
end
96+
end
15797

158-
def direct_descendants
159-
ActiveSupport::Deprecation.warn(<<~MSG)
160-
ActiveSupport::DescendantsTracker#direct_descendants is deprecated and will be removed in Rails 7.1.
161-
Use #subclasses instead.
162-
MSG
163-
DescendantsTracker.subclasses(self)
164-
end
98+
def descendants
99+
subclasses = self.subclasses
100+
subclasses.concat(subclasses.flat_map(&:descendants))
101+
end
165102

166-
def subclasses
167-
DescendantsTracker.subclasses(self)
168-
end
103+
if RubyFeatures::CLASS_SUBCLASSES
104+
class << self
105+
def subclasses(klass)
106+
klass.subclasses
107+
end
169108

170-
def descendants
171-
DescendantsTracker.descendants(self)
109+
def descendants(klass)
110+
klass.descendants
111+
end
172112
end
173-
113+
else
174114
# DescendantsArray is an array that contains weak references to classes.
115+
# Note: DescendantsArray is redundant with WeakSet, however WeakSet when used
116+
# on Ruby 2.7 or 3.0 can trigger a Ruby crash: https://bugs.ruby-lang.org/issues/18928
175117
class DescendantsArray # :nodoc:
176118
include Enumerable
177119

178120
def initialize
179121
@refs = []
180122
end
181123

182-
def initialize_copy(orig)
183-
@refs = @refs.dup
184-
end
185-
186124
def <<(klass)
187125
@refs << WeakRef.new(klass)
188126
end
@@ -213,6 +151,39 @@ def reject!
213151
end
214152
end
215153
end
154+
155+
@direct_descendants = {}
156+
157+
def inherited(base) # :nodoc:
158+
DescendantsTracker.store_inherited(self, base)
159+
super
160+
end
161+
162+
class << self
163+
def subclasses(klass)
164+
descendants = @direct_descendants[klass]
165+
descendants ? DescendantsTracker.reject!(descendants.to_a) : []
166+
end
167+
168+
def descendants(klass)
169+
subclasses = self.subclasses(klass)
170+
subclasses.concat(subclasses.flat_map { |k| descendants(k) })
171+
end
172+
173+
# This is the only method that is not thread safe, but is only ever called
174+
# during the eager loading phase.
175+
def store_inherited(klass, descendant) # :nodoc:
176+
(@direct_descendants[klass] ||= DescendantsArray.new) << descendant
177+
end
178+
end
179+
180+
def subclasses
181+
DescendantsTracker.subclasses(self)
182+
end
183+
184+
def descendants
185+
DescendantsTracker.descendants(self)
186+
end
216187
end
217188
end
218189
end

activesupport/test/core_ext/class_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative "../abstract_unit"
44
require "active_support/core_ext/class"
5+
require "active_support/descendants_tracker"
56
require "set"
67

78
class ClassTest < ActiveSupport::TestCase
@@ -37,4 +38,18 @@ def test_subclasses_excludes_singleton_classes
3738
klass = Parent.new.singleton_class
3839
assert_not Parent.subclasses.include?(klass), "subclasses should not include singleton classes"
3940
end
41+
42+
def test_subclasses_exclude_reloaded_classes
43+
subclass = Class.new(Parent)
44+
assert_includes Parent.subclasses, subclass
45+
ActiveSupport::DescendantsTracker.clear(Set[subclass])
46+
assert_not_includes Parent.subclasses, subclass
47+
end
48+
49+
def test_descendants_exclude_reloaded_classes
50+
subclass = Class.new(Parent)
51+
assert_includes Parent.descendants, subclass
52+
ActiveSupport::DescendantsTracker.clear(Set[subclass])
53+
assert_not_includes Parent.descendants, subclass
54+
end
4055
end

0 commit comments

Comments
 (0)