Skip to content

Commit 5d5283e

Browse files
rozyczkoseventil
andauthored
More weakref fixes (#198)
Co-authored-by: Ales Kutsepau <72985238+seventil@users.noreply.github.com>
1 parent bd10653 commit 5d5283e

2 files changed

Lines changed: 60 additions & 15 deletions

File tree

src/easyscience/global_object/map.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ def __init__(self):
7474
# A dict with object names as keys and a list of their object types as values, with weak references
7575
self.__type_dict = {}
7676

77+
def _snapshot_items(self):
78+
"""Return a stable snapshot of __type_dict items.
79+
80+
Some callers iterate over __type_dict while other threads or
81+
weakref finalizers may modify it. Creating a list snapshot (with
82+
a retry loop) prevents RuntimeError: dictionary changed size during iteration.
83+
"""
84+
while True:
85+
try:
86+
return list(self.__type_dict.items())
87+
except RuntimeError:
88+
# Dict changed during snapshot creation, retry
89+
continue
90+
7791
def vertices(self) -> List[str]:
7892
"""Returns the vertices of a map.
7993
@@ -109,7 +123,15 @@ def returned_objs(self) -> List[str]:
109123

110124
def _nested_get(self, obj_type: str) -> List[str]:
111125
"""Access a nested object in root by key sequence."""
112-
return [key for key, item in self.__type_dict.items() if obj_type in item.type]
126+
# Create a stable snapshot of the dict items to avoid RuntimeError
127+
# when the dict is modified during iteration (e.g., by finalizers).
128+
while True:
129+
try:
130+
items = self._snapshot_items()
131+
return [key for key, item in items if obj_type in item.type]
132+
except RuntimeError:
133+
# In case the snapshot itself raises (very rare), retry
134+
continue
113135

114136
def get_item_by_key(self, item_id: str) -> object:
115137
if item_id in self._store:
@@ -143,10 +165,13 @@ def add_vertex(self, obj: object, obj_type: str = None):
143165
# but the finalizer hasn't run yet
144166
if name in self.__type_dict:
145167
del self.__type_dict[name]
168+
146169
self._store[name] = obj
147-
self.__type_dict[name] = _EntryList() # Add objects type to the list of types
148-
self.__type_dict[name].finalizer = weakref.finalize(self._store[name], self.prune, name)
149-
self.__type_dict[name].type = obj_type
170+
171+
entry_list = _EntryList()
172+
entry_list.finalizer = weakref.finalize(obj, self.prune, name)
173+
entry_list.type = obj_type
174+
self.__type_dict[name] = entry_list # Add objects type to the list of types
150175

151176
def add_edge(self, start_obj: object, end_obj: object):
152177
if start_obj.unique_name in self.__type_dict:
@@ -167,8 +192,11 @@ def __generate_edges(self) -> list:
167192
vertices
168193
"""
169194
edges = []
170-
for vertex in self.__type_dict:
171-
for neighbour in self.__type_dict[vertex]:
195+
# Iterate over a snapshot of items and snapshot neighbour lists to
196+
# avoid concurrent modification issues.
197+
for vertex, neighbours in self._snapshot_items():
198+
neighbours_snapshot = list(neighbours)
199+
for neighbour in neighbours_snapshot:
172200
if {neighbour, vertex} not in edges:
173201
edges.append({vertex, neighbour})
174202
return edges
@@ -190,12 +218,10 @@ def prune(self, key: str):
190218

191219
def find_isolated_vertices(self) -> list:
192220
"""returns a list of isolated vertices."""
193-
graph = self.__type_dict
194221
isolated = []
195-
for vertex in graph:
196-
print(isolated, vertex)
197-
if not graph[vertex]:
198-
isolated += [vertex]
222+
for vertex, neighbours in self._snapshot_items():
223+
if not list(neighbours):
224+
isolated.append(vertex)
199225
return isolated
200226

201227
def find_path(self, start_vertex: str, end_vertex: str, path=[]) -> list:
@@ -247,9 +273,10 @@ def reverse_route(self, end_vertex: str, start_vertex: Optional[str] = None) ->
247273
path_length = sys.maxsize
248274
optimum_path = []
249275
if start_vertex is None:
250-
# We now have to find where to begin.....
251-
for possible_start, vertices in self.__type_dict.items():
252-
if end_vertex in vertices:
276+
# We now have to find where to begin..... Iterate over a snapshot
277+
for possible_start, vertices in self._snapshot_items():
278+
vertices_snapshot = list(vertices)
279+
if end_vertex in vertices_snapshot:
253280
temp_path = self.find_path(possible_start, end_vertex)
254281
if len(temp_path) < path_length:
255282
path_length = len(temp_path)
@@ -270,7 +297,7 @@ def is_connected(self, vertices_encountered=None, start_vertex=None) -> bool:
270297
start_vertex = vertices[0]
271298
vertices_encountered.add(start_vertex)
272299
if len(vertices_encountered) != len(vertices):
273-
for vertex in graph[start_vertex]:
300+
for vertex in list(graph[start_vertex]):
274301
if vertex not in vertices_encountered and self.is_connected(vertices_encountered, vertex):
275302
return True
276303
else:

tests/unit_tests/global_object/test_map.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,24 @@ def test_find_type_unknown_object(self, clear):
241241
# When/Then
242242
result = global_object.map.find_type(unknown_obj)
243243
assert result is None
244+
245+
def test_returned_objs_access_safe_under_modification(self, clear):
246+
"""Ensure accessing returned_objs doesn't raise when entries change size during iteration."""
247+
objs = [ObjBase(name=f"race_{i}") for i in range(8)]
248+
# Mark all as returned
249+
for o in objs:
250+
global_object.map.change_type(o, 'returned')
251+
252+
# Repeatedly access returned_objs while deleting objects and forcing GC to
253+
# try to trigger concurrent modification. This used to raise RuntimeError.
254+
for _ in range(200):
255+
_ = global_object.map.returned_objs # should not raise
256+
if _ and objs:
257+
# delete one object and collect to trigger finalizer/prune
258+
del objs[0]
259+
gc.collect()
260+
# If we got here without exceptions, consider the access safe
261+
assert True
244262

245263
def test_reset_type(self, clear, base_object):
246264
"""Test resetting object type"""

0 commit comments

Comments
 (0)