Skip to content

Commit 846e5d5

Browse files
committed
CI fix
1 parent 52b1abd commit 846e5d5

File tree

4 files changed

+229
-6
lines changed

4 files changed

+229
-6
lines changed

temporalio/Steepfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ target :lib do
99

1010
ignore 'lib/temporalio/api', 'lib/temporalio/internal/bridge/api'
1111

12-
library 'uri'
12+
library 'uri', 'objspace'
1313

1414
configure_code_diagnostics do |hash|
1515
# TODO(cretz): Fix as more protos are generated

temporalio/test/gc_utils.rb

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# frozen_string_literal: true
2+
3+
require 'objspace'
4+
5+
module GCUtils
6+
class << self
7+
# Find one path from any GC root to the object with target_id.
8+
#
9+
# @return [Array<Object>, String] First value is array of path objects, second is root category.
10+
def find_retaining_path_to(target_id, max_depth: 12, max_visits: 250_000, category_whitelist: nil)
11+
roots = ObjectSpace.reachable_objects_from_root # {category_sym => [objs]}
12+
queue = []
13+
seen = {}
14+
parent = {} # child_id -> parent_id
15+
root_of = {} # obj_id -> root_category
16+
17+
roots.each do |category, objs|
18+
next if category_whitelist && !category_whitelist.include?(category)
19+
20+
objs.each do |o|
21+
id = o.__id__
22+
next if seen[id]
23+
24+
seen[id] = true
25+
parent[id] = nil
26+
root_of[id] = category
27+
queue << o
28+
end
29+
end
30+
31+
visits = 0
32+
depth = 0
33+
level_remaining = queue.length
34+
next_level = 0
35+
36+
found_leaf = nil
37+
38+
while !queue.empty? && visits < max_visits && depth <= max_depth
39+
cur = queue.shift
40+
level_remaining -= 1
41+
visits += 1
42+
43+
cid = cur.__id__
44+
if cid == target_id
45+
found_leaf = cid
46+
break
47+
end
48+
49+
children = begin
50+
ObjectSpace.reachable_objects_from(cur)
51+
rescue StandardError
52+
nil
53+
end
54+
55+
children&.each do |child|
56+
chid = child.__id__
57+
next if seen[chid]
58+
59+
seen[chid] = true
60+
parent[chid] = cid
61+
root_of[chid] ||= root_of[cid]
62+
queue << child
63+
next_level += 1
64+
end
65+
66+
next unless level_remaining.zero?
67+
68+
depth += 1
69+
level_remaining = next_level
70+
next_level = 0
71+
end
72+
73+
return [[], ''] unless found_leaf
74+
75+
# Reconstruct path
76+
ids = []
77+
i = found_leaf
78+
while i
79+
ids << i
80+
i = parent[i]
81+
end
82+
objs = ids.reverse.map do |id|
83+
ObjectSpace._id2ref(id)
84+
rescue StandardError
85+
id
86+
end
87+
[objs, root_of[ids.first]]
88+
end
89+
90+
# Print path on stdout.
91+
def print_annotated_path(path, root_category:)
92+
puts "Retaining path (len=#{path.length}) from ROOT[:#{root_category}] to target:"
93+
return if path.empty?
94+
95+
# First is the root
96+
puts " ROOT[:#{root_category}] #{describe_obj(path.first)}"
97+
# Then edges with labels
98+
(0...(path.length - 1)).each do |i|
99+
parent = path[i]
100+
child = path[i + 1]
101+
labels = edge_labels(parent, child)
102+
labels.each_with_index do |lab, j|
103+
arrow = (j.zero? ? ' └─' : ' •')
104+
puts "#{arrow} via #{lab}#{describe_obj(child)}"
105+
end
106+
end
107+
end
108+
109+
private
110+
111+
# Label HOW +parent+ holds a reference to +child+ (ivar name, constant, index, etc.).
112+
def edge_labels(parent, child)
113+
labels = []
114+
target = child
115+
116+
# 1) Instance variables (works for Class/Module too – class ivars are ivars on the Class object)
117+
if parent.respond_to?(:instance_variables)
118+
parent.instance_variables.each do |ivar|
119+
labels << "@#{ivar.to_s.delete('@')}" if parent.instance_variable_get(ivar).equal?(target)
120+
rescue StandardError
121+
# Ignore
122+
end
123+
end
124+
125+
# 2) Class variables on Module/Class
126+
if parent.is_a?(Module)
127+
parent.class_variables.each do |cvar|
128+
labels << cvar.to_s if parent.class_variable_get(cvar).equal?(target)
129+
rescue NameError
130+
# Ignore
131+
end
132+
end
133+
134+
# 3) Constants on Module/Class (avoid triggering autoload)
135+
if parent.is_a?(Module)
136+
parent.constants(false).each do |c|
137+
next if parent.respond_to?(:autoload?) && parent.autoload?(c)
138+
139+
if parent.const_defined?(c, false)
140+
v = parent.const_get(c, false)
141+
labels << "::#{c}" if v.equal?(target)
142+
end
143+
rescue NameError, LoadError
144+
# Ignore
145+
end
146+
end
147+
148+
# 4) Array elements
149+
if parent.is_a?(Array)
150+
parent.each_with_index do |v, i|
151+
labels << "[#{i}]" if v.equal?(target)
152+
end
153+
end
154+
155+
# 5) Hash entries (key or value)
156+
if parent.is_a?(Hash)
157+
parent.each do |k, v|
158+
labels << "{key #{k.inspect}}" if k.equal?(target)
159+
labels << "{value for #{k.inspect}}" if v.equal?(target)
160+
end
161+
end
162+
163+
# 6) Struct members
164+
if parent.is_a?(Struct)
165+
parent.members.each do |m|
166+
labels << ".#{m}" if parent[m].equal?(target)
167+
rescue StandardError
168+
# Ignore
169+
end
170+
end
171+
172+
# 7) Fallback for VM internals
173+
if labels.empty?
174+
begin
175+
labels << '(internal)' if parent.is_a?(ObjectSpace::InternalObjectWrapper)
176+
rescue StandardError
177+
# Ignore
178+
end
179+
end
180+
181+
labels.empty? ? ['(unknown edge)'] : labels
182+
end
183+
184+
def describe_obj(obj)
185+
cls = (obj.is_a?(Module) ? obj : obj.class)
186+
"#<#{cls} 0x#{obj.__id__.to_s(16)}>"
187+
rescue StandardError
188+
obj.inspect
189+
end
190+
end
191+
end

temporalio/test/sig/gc_utils.rbs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module GCUtils
2+
def self.find_retaining_path_to: (
3+
Integer target_id,
4+
?max_depth: Integer,
5+
?max_visits: Integer,
6+
?category_whitelist: Array[String]?
7+
) -> [Array[untyped], String]
8+
9+
def self.print_annotated_path: (Array[untyped] path, root_category: String) -> void
10+
11+
def self.edge_labels: (untyped parent, untyped child) -> Array[String]
12+
13+
def self.describe_obj: (untyped obj) -> String
14+
end

temporalio/test/worker_workflow_test.rb

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require 'base64_codec'
4+
require 'gc_utils'
45
require 'net/http'
56
require 'temporalio/client'
67
require 'temporalio/testing'
@@ -1912,9 +1913,10 @@ def test_fail_workflow_payload_converter
19121913
class ConfirmGarbageCollectWorkflow < Temporalio::Workflow::Definition
19131914
@initialized_count = 0
19141915
@finalized_count = 0
1916+
@weak_instance = nil
19151917

19161918
class << self
1917-
attr_accessor :initialized_count, :finalized_count
1919+
attr_accessor :initialized_count, :finalized_count, :weak_instance
19181920

19191921
def create_finalizer
19201922
proc { @finalized_count += 1 }
@@ -1923,6 +1925,7 @@ def create_finalizer
19231925

19241926
def initialize
19251927
self.class.initialized_count += 1
1928+
self.class.weak_instance = WeakRef.new(self)
19261929
ObjectSpace.define_finalizer(self, self.class.create_finalizer)
19271930
end
19281931

@@ -1932,6 +1935,13 @@ def execute
19321935
end
19331936

19341937
def test_confirm_garbage_collect
1938+
major, minor = RUBY_VERSION.split('.').take(2).map(&:to_i)
1939+
skip('Only Ruby 3.4+ has predictable eager GC') if major != 3 || minor < 4
1940+
1941+
# This test confirms the workflow instance is reliably GC'd when workflow/worker done. To confirm the test fails
1942+
# when there is still an instance, make a "strong_instance" singleton attribute and assign "self" to it in
1943+
# initialize and confirm this calls flunk later.
1944+
19351945
execute_workflow(ConfirmGarbageCollectWorkflow) do |handle|
19361946
# Wait until it is started
19371947
assert_eventually { assert handle.fetch_history_events.any?(&:workflow_task_completed_event_attributes) }
@@ -1940,10 +1950,18 @@ def test_confirm_garbage_collect
19401950
assert_equal 0, ConfirmGarbageCollectWorkflow.finalized_count
19411951
end
19421952

1943-
# Now with worker shutdown, GC and confirm finalized
1944-
assert_eventually do
1945-
GC.start
1946-
assert_equal 1, ConfirmGarbageCollectWorkflow.finalized_count
1953+
# Perform a GC and confirm gone
1954+
GC.start
1955+
begin
1956+
# Access instance and assert that it fails when a method is called on it as expected
1957+
instance = ConfirmGarbageCollectWorkflow.weak_instance.__getobj__
1958+
1959+
# Print out the path still holding it
1960+
path, cat = GCUtils.find_retaining_path_to(instance.object_id, max_depth: 12)
1961+
GCUtils.print_annotated_path(path, root_category: cat)
1962+
flunk
1963+
rescue WeakRef::RefError
1964+
# Expected
19471965
end
19481966
end
19491967

0 commit comments

Comments
 (0)