diff --git a/lib/console1984/freezeable.rb b/lib/console1984/freezeable.rb index 3f68c21..5df9999 100644 --- a/lib/console1984/freezeable.rb +++ b/lib/console1984/freezeable.rb @@ -23,8 +23,19 @@ def self.included(base) base.extend ClassMethods # Flag to control manipulating instance data via instance_variable_get and instance_variable_set. - # true by default. - base.thread_mattr_accessor :prevent_instance_data_manipulation_after_freezing, default: true + # true by default. Stored as a singleton-class instance variable on the host so it survives + # +ActiveSupport::IsolatedExecutionState+ being cleared when Rails switches +isolation_level+ + # (e.g. the +:thread+ -> +:fiber+ flip the Rails 8.1 default triggers in +after_initialize+). + # A +mattr_accessor+ would leak the flag across the ancestor chain because +Console1984::Ext::Core::Object+ + # is included into +Object+; this storage keeps each host independent. + base.singleton_class.class_eval do + attr_writer :prevent_instance_data_manipulation_after_freezing + + define_method :prevent_instance_data_manipulation_after_freezing do + return @prevent_instance_data_manipulation_after_freezing if defined?(@prevent_instance_data_manipulation_after_freezing) + true + end + end end module ClassMethods diff --git a/test/freezeable_test.rb b/test/freezeable_test.rb new file mode 100644 index 0000000..eb018d7 --- /dev/null +++ b/test/freezeable_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class FreezeableTest < ActiveSupport::TestCase + test "prevent_instance_data_manipulation_after_freezing defaults to true" do + klass = Class.new { include Console1984::Freezeable } + + assert_equal true, klass.prevent_instance_data_manipulation_after_freezing + end + + test "prevent_instance_data_manipulation_after_freezing survives ActiveSupport::IsolatedExecutionState being cleared" do + # Reproduces the Rails 8.1 boot symptom: gem eager-load writes the opt-out under one + # +isolation_level+, then Rails switches the level in +after_initialize+ which clears + # the previous scope's storage. The write must survive. + klass = Class.new do + include Console1984::Freezeable + self.prevent_instance_data_manipulation_after_freezing = false + end + + ActiveSupport::IsolatedExecutionState.clear + + assert_equal false, klass.prevent_instance_data_manipulation_after_freezing + end + + test "prevent_instance_data_manipulation_after_freezing is independent per including host" do + one = Class.new { include Console1984::Freezeable } + two = Class.new { include Console1984::Freezeable } + + one.prevent_instance_data_manipulation_after_freezing = false + + assert_equal false, one.prevent_instance_data_manipulation_after_freezing + assert_equal true, two.prevent_instance_data_manipulation_after_freezing + end + + test "Console1984::Ext::Core::Object opt-out does not leak into other Freezeable hosts" do + # Object includes Console1984::Ext::Core::Object, which sets the flag to false. A class-variable-backed + # accessor would propagate that false through Ruby's ancestor chain to every descendant of Object, + # silently disabling the protection on every other Freezeable host. + assert_equal false, Console1984::Ext::Core::Object.prevent_instance_data_manipulation_after_freezing + assert_equal true, Console1984::Config.prevent_instance_data_manipulation_after_freezing + end +end