[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
      #=> [
        [#<Container:0x00007fc35843e388 @value="hello">],
        [#<Container:0x00007fc35843e388 @value="hello">, "hello"]
      ]

Co-authored-by: Yusuke Endoh <mame@ruby-lang.org>
This commit is contained in:
Daisuke Aritomo 2025-07-25 00:36:05 +09:00
parent d21e4e76c4
commit a7efcfbe93
2 changed files with 89 additions and 0 deletions

View file

@ -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
# #=> [
# [#<Container:0x00007fc35843e388 @value="hello">],
# [#<Container:0x00007fc35843e388 @value="hello">, "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

View file

@ -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