Skip to content

Commit cb82f5f

Browse files
committed
Fix DescendantTracker.clear on Ruby 3.1
Previously I assumed it was useless, however I was wrong. The method is called by the reloader to give the illusion that the GC is precise. Meaning a class that will be unloaded is immediately made invisible without waiting for it to be garbage collected. This is easy to do up to Ruby 3.0 because `DescendantTracker` keeps a map of all tracked classes. However on 3.1 we need to use the inverse strategy, we keep a WeakMap of all the classes we cleared, and we filter the return value of `descendants` and `subclasses`. Since `clear` is private API and is only used when reloading is enabled, to reduce the performance impact in production mode, we entirely remove this behavior when `config.cache_classes` is enabled.
1 parent d0b53aa commit cb82f5f

File tree

5 files changed

+69
-31
lines changed

5 files changed

+69
-31
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def descendants
2020
ObjectSpace.each_object(singleton_class).reject do |k|
2121
k.singleton_class? || k == self
2222
end
23-
end unless ActiveSupport::RubyFeatures::CLASS_DESCENDANTS
23+
end unless ActiveSupport::RubyFeatures::CLASS_SUBCLASSES
2424

2525
# Returns an array with the direct children of +self+.
2626
#

activesupport/lib/active_support/descendants_tracker.rb

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,44 @@ def direct_descendants(klass)
1717
end
1818
end
1919

20-
if RubyFeatures::CLASS_DESCENDANTS
20+
@clear_disabled = false
21+
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
34+
def initialize
35+
@map = ObjectSpace::WeakMap.new
36+
end
37+
38+
def [](object)
39+
@map.key?(object.object_id)
40+
end
41+
42+
def []=(object, _present)
43+
@map[object_id] = object
44+
end
45+
end
46+
end
47+
2148
class << self
49+
def disable_clear! # :nodoc:
50+
unless @clear_disabled
51+
@clear_disabled = true
52+
remove_method(:subclasses)
53+
remove_method(:descendants)
54+
@@excluded_descendants = nil
55+
end
56+
end
57+
2258
def subclasses(klass)
2359
klass.subclasses
2460
end
@@ -27,19 +63,32 @@ def descendants(klass)
2763
klass.descendants
2864
end
2965

30-
def clear(only: nil) # :nodoc:
31-
# noop
66+
def clear(classes) # :nodoc:
67+
raise "DescendantsTracker.clear was disabled because config.cache_classes = true" 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
3275
end
3376

3477
def native? # :nodoc:
3578
true
3679
end
3780
end
3881

39-
unless RubyFeatures::CLASS_SUBCLASSES
40-
def subclasses
41-
descendants.select { |descendant| descendant.superclass == self }
42-
end
82+
def subclasses
83+
subclasses = super
84+
subclasses.reject! { |d| @@excluded_descendants[d] }
85+
subclasses
86+
end
87+
88+
def descendants
89+
descendants = super
90+
descendants.reject! { |d| @@excluded_descendants[d] }
91+
descendants
4392
end
4493

4594
def direct_descendants
@@ -53,6 +102,10 @@ def direct_descendants
53102
@@direct_descendants = {}
54103

55104
class << self
105+
def disable_clear! # :nodoc:
106+
@clear_disabled = true
107+
end
108+
56109
def subclasses(klass)
57110
descendants = @@direct_descendants[klass]
58111
descendants ? descendants.to_a : []
@@ -64,18 +117,15 @@ def descendants(klass)
64117
arr
65118
end
66119

67-
def clear(only: nil) # :nodoc:
68-
if only.nil?
69-
@@direct_descendants.clear
70-
return
71-
end
120+
def clear(classes) # :nodoc:
121+
raise "DescendantsTracker.clear was disabled because config.cache_classes = true" if @clear_disabled
72122

73123
@@direct_descendants.each do |klass, direct_descendants_of_klass|
74-
if only.member?(klass)
124+
if classes.member?(klass)
75125
@@direct_descendants.delete(klass)
76126
else
77127
direct_descendants_of_klass.reject! do |direct_descendant_of_class|
78-
only.member?(direct_descendant_of_class)
128+
classes.member?(direct_descendant_of_class)
79129
end
80130
end
81131
end

activesupport/lib/active_support/ruby_features.rb

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

33
module ActiveSupport
44
module RubyFeatures # :nodoc:
5-
CLASS_DESCENDANTS = Class.method_defined?(:descendants) # RUBY_VERSION >= "3.1"
65
CLASS_SUBCLASSES = Class.method_defined?(:subclasses) # RUBY_VERSION >= "3.1"
76
end
87
end

activesupport/test/descendants_tracker_test.rb

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ class DescendantsTrackerTest < ActiveSupport::TestCase
1111
@original_state.each { |k, v| @original_state[k] = v.dup }
1212
end
1313

14-
ActiveSupport::DescendantsTracker.clear
1514
eval <<~RUBY
1615
class Parent
1716
extend ActiveSupport::DescendantsTracker
@@ -90,17 +89,8 @@ class Grandchild2 < Child1
9089
end
9190
end
9291

93-
test ".clear deletes all state" do
94-
ActiveSupport::DescendantsTracker.clear
95-
if ActiveSupport::DescendantsTracker.class_variable_defined?(:@@direct_descendants)
96-
assert_empty ActiveSupport::DescendantsTracker.class_variable_get(:@@direct_descendants)
97-
end
98-
end
99-
100-
test ".clear(only) deletes the given classes only" do
101-
skip "Irrelevant for native Class#descendants" if ActiveSupport::DescendantsTracker.native?
102-
103-
ActiveSupport::DescendantsTracker.clear(only: Set[Child2, Grandchild1])
92+
test ".clear(classes) deletes the given classes only" do
93+
ActiveSupport::DescendantsTracker.clear(Set[Child2, Grandchild1])
10494

10595
assert_equal_sets [Child1, Grandchild2], Parent.descendants
10696
assert_equal_sets [Grandchild2], Child1.descendants

railties/lib/rails/application/finisher.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,7 @@ def self.complete(_state)
176176
initializer :set_clear_dependencies_hook, group: :all do |app|
177177
callback = lambda do
178178
# Order matters.
179-
ActiveSupport::DescendantsTracker.clear(
180-
only: ActiveSupport::Dependencies._autoloaded_tracked_classes
181-
)
179+
ActiveSupport::DescendantsTracker.clear(ActiveSupport::Dependencies._autoloaded_tracked_classes)
182180
ActiveSupport::Dependencies.clear
183181
end
184182

@@ -194,6 +192,7 @@ def self.complete(_state)
194192

195193
if config.cache_classes
196194
# No reloader
195+
ActiveSupport::DescendantsTracker.disable_clear!
197196
elsif config.reload_classes_only_on_change
198197
reloader = config.file_watcher.new(*watchable_args, &callback)
199198
reloaders << reloader

0 commit comments

Comments
 (0)