From a7efcfbe9325dd8a9541ccf4f638335e26ecc78a Mon Sep 17 00:00:00 2001 From: Daisuke Aritomo Date: Fri, 25 Jul 2025 00:36:05 +0900 Subject: [PATCH] [Feature #21028] ObjectSpace#find_paths_to_unshareable_objects Add a method to find paths to Ractor-unshareable objects which can be traced from an object. Example: class Container attr_reader :value def initialize(value) @value = value end end mutable_string = "hello" container = Container.new(mutable_string) pp ObjectSpace.find_paths_to_unshareable_objects(container).to_a #=> [ [#], [#, "hello"] ] Co-authored-by: Yusuke Endoh --- ext/objspace/lib/objspace.rb | 51 ++++++++++++++++++++++++++++++++++++ test/objspace/test_ractor.rb | 38 +++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/ext/objspace/lib/objspace.rb b/ext/objspace/lib/objspace.rb index 47873f5112..3c381af1be 100644 --- a/ext/objspace/lib/objspace.rb +++ b/ext/objspace/lib/objspace.rb @@ -132,4 +132,55 @@ module ObjectSpace return nil if output == :stdout ret end + + # call-seq: + # ObjectSpace.find_paths_to_unshareable_objects(obj) {|path| ... } -> nil + # ObjectSpace.find_paths_to_unshareable_objects(obj) -> enumerator + # + # Finds all unshareable objects reachable from +obj+. + # + # When called with a block, yields an array representing the path from +obj+ to + # each unshareable object found. The path includes all intermediate objects + # traversed, ending with the unshareable object itself. + # + # If +obj+ itself is shareable, no paths are yielded. + # + # Example: + # + # class Container + # attr_reader :value + # def initialize(value) + # @value = value + # end + # end + # + # mutable_string = "hello" + # container = Container.new(mutable_string) + # + # pp ObjectSpace.find_paths_to_unshareable_objects(container).to_a + # #=> [ + # [#], + # [#, "hello"] + # ] + def find_paths_to_unshareable_objects(obj) + return to_enum(__method__, obj) if !block_given? + + queue = [[obj, []]] + visited = Set.new + + while current = queue.shift + current_obj, current_path = current + visited.add(current_obj.object_id) + + if !Ractor.shareable?(current_obj) + yield current_path + [current_obj] + + ObjectSpace.reachable_objects_from(current_obj).each do |reachable| + if !reachable.is_a?(ObjectSpace::InternalObjectWrapper) && !visited.include?(reachable.object_id) + queue.push([reachable, current_path + [current_obj]]) + end + end + end + end + end end diff --git a/test/objspace/test_ractor.rb b/test/objspace/test_ractor.rb index eb3044cda3..2bcffed7a3 100644 --- a/test/objspace/test_ractor.rb +++ b/test/objspace/test_ractor.rb @@ -1,4 +1,5 @@ require "test/unit" +require "objspace" class TestObjSpaceRactor < Test::Unit::TestCase def test_tracing_does_not_crash @@ -52,4 +53,41 @@ class TestObjSpaceRactor < Test::Unit::TestCase ractors.each(&:join) RUBY end + + def test_find_paths_to_unshareable_objects + # Direct shareable object + assert_equal([], ObjectSpace.find_paths_to_unshareable_objects(1).to_a) + + # Direct unshareable object + assert_equal([["unfrozen"]], ObjectSpace.find_paths_to_unshareable_objects("unfrozen").to_a) + + # Hash containing unshareable object + obj = { a: 1, b: "frozen".freeze, c: "unfrozen" } + paths = ObjectSpace.find_paths_to_unshareable_objects(obj).to_a + assert_include(paths, [obj]) + assert_include(paths, [obj, "unfrozen"]) + + # Array containing unshareable object + obj = [1, 2, "unfrozen", "frozen".freeze] + paths = ObjectSpace.find_paths_to_unshareable_objects(obj).to_a + assert_include(paths, [obj]) + assert_include(paths, [obj, "unfrozen"]) + + # Custom class + klass = Class.new do + attr_accessor :value + end + obj = klass.new + obj.value = "unfrozen" + paths = ObjectSpace.find_paths_to_unshareable_objects(obj).to_a + assert_include(paths, [obj]) + assert_include(paths, [obj, "unfrozen"]) + + # Circular reference + obj1 = { name: "obj1" } + obj2 = { name: "obj2", ref: obj1 } + obj1[:ref] = obj2 + paths = ObjectSpace.find_paths_to_unshareable_objects(obj1).to_a + assert_include(paths, [obj1, obj2, "obj2"]) # does not circle back to obj1 + end end