Problem
ActiveSupport's class_attribute keeps each class's value in closures behind methods on the singleton class (ClassAttribute.redefine). A subclass reads through its parent's singleton methods until its first write. That write calls define_method on the subclass's singleton class to give it its own reader and writer.
ActiveSupport::Callbacks::ClassMethods#set_callbacks writes and then reads in the same call:
self.__callbacks = __callbacks.dup # L948: first write defines the subclass's own reader/writer
callbacks_was = self.__callbacks[name.to_sym] # L951
self.__callbacks[name.to_sym] = callbacks # L955
With invokedynamic on, the reads on L951/L955 intermittently still return the parent's hash after L948 has defined the subclass's reader. The callback is stored in the parent, and the subclass's own copy doesn't get it.
We captured this during Rails eager loading, on the main thread, by wrapping set_callbacks. Right after the write, we read the hash three ways:
|
reader owner |
bind_call / send / public reader |
process_action chain |
| Child before L948 |
#<Class:ApplicationController> |
4488 / 4488 / 4488 |
parent's 9 |
Child after set_callbacks |
#<Class:ChildController> |
4612 / 4612 / 4612 |
parent's 9, missing the new callback |
ApplicationController after |
#<Class:ApplicationController> |
4488 / 4488 / 4488 |
parent's 9 + the child's callback |
The child owns a new hash (4612), and freshly resolved calls return it. But inside set_callbacks, which is hot and compiled by then, self.__callbacks returned 4488. A hot __callbacks.equal?(superclass.__callbacks) check in the same frame returned true, while the same check in cold code a moment later returned false.
The child's singleton class had modules extended into it from included. Class has ReloadedClassesFiltering prepended, as Rails does.
Impact
A controller's callbacks leak into its parent and into every sibling that loads afterwards, usually silently. It becomes visible when a later sibling calls skip_before_action for a callback that an earlier sibling's leaked skip already removed from the parent:
ArgumentError: Before process_action callback :some_filter has not been defined
activesupport-8.1.3.1/lib/active_support/callbacks.rb:798:in 'block in skip_callback'
zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb:171:in 'block in actual_eager_load_dir'
railties-8.1.3.1/lib/rails/application/finisher.rb:79:in 'block in Finisher'
Measurements
Same app, about 36 JRuby boots per CI run:
| Setup |
Runs |
Runs with a leak |
| JRuby 10.0.7.0 |
5 |
4 |
| JRuby 10.0.5.0 |
5 |
2 |
JRuby 10.0.7.0, -Xcompile.invokedynamic=false |
6 |
0 |
Reproduction
No standalone reproduction yet. Outside the app, activesupport/actionpack 8.1.3.1 on JRuby 10.0.7.0 didn't leak in 1,000+ rounds of controller subclasses using before_action/skip_before_action, modules extended from included, active_support/all, and concurrent class definition. That held under -X-C, the default JIT, and -Xjit.threshold=0 -Xjit.background=false. In the app this initializer catches it:
module DiagnoseCallbacks
protected
def set_callbacks(name, callbacks)
super.tap do
parent = superclass
if parent.respond_to?(:__callbacks) && __callbacks.equal?(parent.__callbacks)
reader = singleton_class.instance_method(:__class_attr___callbacks)
warn("#{inspect} wrote into #{parent.inspect}'s hash: owner #{reader.owner.inspect}, " \
"bind_call #{reader.bind_call(self).object_id}, send #{send(:__class_attr___callbacks).object_id}, " \
"public #{__callbacks.object_id}, parent #{parent.__callbacks.object_id}")
end
end
end
end
ActiveSupport.on_load(:action_controller_base) { singleton_class.prepend(DiagnoseCallbacks) }
Expected: once define_method adds the reader to the subclass's singleton class, every call site dispatches to it, as send, bind_call and cold call sites do.
Workaround
-Xcompile.invokedynamic=false
Environment Information
JRuby 10.0.7.0 (also 10.0.5.0)
Amazon Corretto 26.0.2
Rails 8.1.3.1
Zeitwerk 2.7.5
Problem
ActiveSupport's
class_attributekeeps each class's value in closures behind methods on the singleton class (ClassAttribute.redefine). A subclass reads through its parent's singleton methods until its first write. That write callsdefine_methodon the subclass's singleton class to give it its own reader and writer.ActiveSupport::Callbacks::ClassMethods#set_callbackswrites and then reads in the same call:With invokedynamic on, the reads on L951/L955 intermittently still return the parent's hash after L948 has defined the subclass's reader. The callback is stored in the parent, and the subclass's own copy doesn't get it.
We captured this during Rails eager loading, on the main thread, by wrapping
set_callbacks. Right after the write, we read the hash three ways:bind_call/send/ public readerprocess_actionchain#<Class:ApplicationController>set_callbacks#<Class:ChildController>ApplicationControllerafter#<Class:ApplicationController>The child owns a new hash (4612), and freshly resolved calls return it. But inside
set_callbacks, which is hot and compiled by then,self.__callbacksreturned 4488. A hot__callbacks.equal?(superclass.__callbacks)check in the same frame returned true, while the same check in cold code a moment later returned false.The child's singleton class had modules extended into it from
included.ClasshasReloadedClassesFilteringprepended, as Rails does.Impact
A controller's callbacks leak into its parent and into every sibling that loads afterwards, usually silently. It becomes visible when a later sibling calls
skip_before_actionfor a callback that an earlier sibling's leaked skip already removed from the parent:Measurements
Same app, about 36 JRuby boots per CI run:
-Xcompile.invokedynamic=falseReproduction
No standalone reproduction yet. Outside the app, activesupport/actionpack 8.1.3.1 on JRuby 10.0.7.0 didn't leak in 1,000+ rounds of controller subclasses using
before_action/skip_before_action, modules extended fromincluded,active_support/all, and concurrent class definition. That held under-X-C, the default JIT, and-Xjit.threshold=0 -Xjit.background=false. In the app this initializer catches it:Expected: once
define_methodadds the reader to the subclass's singleton class, every call site dispatches to it, assend,bind_calland cold call sites do.Workaround
-Xcompile.invokedynamic=falseEnvironment Information
JRuby 10.0.7.0 (also 10.0.5.0)
Amazon Corretto 26.0.2
Rails 8.1.3.1
Zeitwerk 2.7.5