From 134be6a452f5474a3d68f8e7f177369a5a75f918 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 18:05:12 +0000 Subject: [PATCH 1/2] Fix infinite recursion in epochtable _compute_hash The make_hashable helper recursed into obj.__dict__ for arbitrary objects without tracking already-visited references. Objects with circular references (e.g. probe -> session -> probe) caused a RecursionError. Track seen object ids to break cycles. https://claude.ai/code/session_018x9bY9c9DVBpg4JoEDDheR --- src/ndi/epoch/epochset.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ndi/epoch/epochset.py b/src/ndi/epoch/epochset.py index 0f09531..827bb8d 100644 --- a/src/ndi/epoch/epochset.py +++ b/src/ndi/epoch/epochset.py @@ -121,17 +121,27 @@ def epochtable( def _compute_hash(self, epochtable: list[dict[str, Any]]) -> str: """Compute hash of epoch table for cache validation.""" - # Create a stable string representation + # Create a stable string representation; track seen object ids to + # avoid infinite recursion on circular references. + seen = set() + def make_hashable(obj): + obj_id = id(obj) + if obj_id in seen: + return "" if isinstance(obj, dict): + seen.add(obj_id) return tuple(sorted((k, make_hashable(v)) for k, v in obj.items())) elif isinstance(obj, list): + seen.add(obj_id) return tuple(make_hashable(x) for x in obj) elif isinstance(obj, np.ndarray): return tuple(obj.flatten().tolist()) elif hasattr(obj, "to_dict"): + seen.add(obj_id) return make_hashable(obj.to_dict()) elif hasattr(obj, "__dict__"): + seen.add(obj_id) return make_hashable(obj.__dict__) else: return obj From ab91500c62c3b3764e6c6052e4f5d80dd51734f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 18:09:25 +0000 Subject: [PATCH 2/2] Simplify _compute_hash to avoid __dict__ recursion entirely Instead of tracking visited objects with a seen set, remove the __dict__ fallback altogether. Objects like ndi_epoch_epoch already have to_dict() methods that exclude circular back-references (e.g. epochset_object), so we should prefer that path. For anything else, fall through to returning the object as-is (scalar) rather than blindly walking __dict__. https://claude.ai/code/session_018x9bY9c9DVBpg4JoEDDheR --- src/ndi/epoch/epochset.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/ndi/epoch/epochset.py b/src/ndi/epoch/epochset.py index 827bb8d..2300c99 100644 --- a/src/ndi/epoch/epochset.py +++ b/src/ndi/epoch/epochset.py @@ -121,28 +121,20 @@ def epochtable( def _compute_hash(self, epochtable: list[dict[str, Any]]) -> str: """Compute hash of epoch table for cache validation.""" - # Create a stable string representation; track seen object ids to - # avoid infinite recursion on circular references. - seen = set() - + # Create a stable string representation. Objects with to_dict() + # (like ndi_epoch_epoch) already exclude circular back-references, + # so we prefer that. For anything else, fall back to repr() + # rather than blindly walking __dict__, which can hit cycles + # (e.g. epoch -> epochset_object -> epoch table -> epoch). def make_hashable(obj): - obj_id = id(obj) - if obj_id in seen: - return "" if isinstance(obj, dict): - seen.add(obj_id) return tuple(sorted((k, make_hashable(v)) for k, v in obj.items())) - elif isinstance(obj, list): - seen.add(obj_id) + elif isinstance(obj, (list, tuple)): return tuple(make_hashable(x) for x in obj) elif isinstance(obj, np.ndarray): return tuple(obj.flatten().tolist()) elif hasattr(obj, "to_dict"): - seen.add(obj_id) return make_hashable(obj.to_dict()) - elif hasattr(obj, "__dict__"): - seen.add(obj_id) - return make_hashable(obj.__dict__) else: return obj