diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index b25673463..b690d03a8 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -122,8 +122,14 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): # the foreign object and risk crashing on custom container semantics. return Unhashable() -def to_hashable(obj): +def to_hashable(obj, depth=0, max_depth=32, seen=None): """Convert prompt-safe built-in values into a stable hashable representation.""" + if depth >= max_depth: + return Unhashable() + + if seen is None: + seen = set() + # Restrict recursion to plain built-in containers. Some custom nodes insert # runtime objects into prompt inputs for dynamic graph paths; walking those # objects as generic Mappings / Sequences is unsafe and can destabilize the @@ -131,16 +137,32 @@ def to_hashable(obj): obj_type = type(obj) if obj_type in (int, float, str, bool, bytes, type(None)): return obj - elif obj_type is dict: - return ("dict", frozenset((to_hashable(k), to_hashable(v)) for k, v in obj.items())) + + if obj_type in (dict, list, tuple, set, frozenset): + obj_id = id(obj) + if obj_id in seen: + return Unhashable() + seen = seen | {obj_id} + + if obj_type is dict: + return ( + "dict", + frozenset( + ( + to_hashable(k, depth + 1, max_depth, seen), + to_hashable(v, depth + 1, max_depth, seen), + ) + for k, v in obj.items() + ), + ) elif obj_type is list: - return ("list", tuple(to_hashable(i) for i in obj)) + return ("list", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is tuple: - return ("tuple", tuple(to_hashable(i) for i in obj)) + return ("tuple", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is set: - return ("set", frozenset(to_hashable(i) for i in obj)) + return ("set", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) elif obj_type is frozenset: - return ("frozenset", frozenset(to_hashable(i) for i in obj)) + return ("frozenset", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) else: return Unhashable() diff --git a/tests-unit/execution_test/caching_hashable_test.py b/tests-unit/execution_test/caching_hashable_test.py new file mode 100644 index 000000000..31d20de10 --- /dev/null +++ b/tests-unit/execution_test/caching_hashable_test.py @@ -0,0 +1,33 @@ +from comfy_execution.caching import Unhashable, to_hashable + + +def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): + cyclic_list = [] + cyclic_list.append(cyclic_list) + + result = to_hashable(cyclic_list) + + assert result[0] == "list" + assert len(result[1]) == 1 + assert isinstance(result[1][0], Unhashable) + + +def test_to_hashable_returns_unhashable_when_max_depth_is_reached(): + nested = current = [] + for _ in range(32): + next_item = [] + current.append(next_item) + current = next_item + + result = to_hashable(nested) + + depth = 0 + current = result + while isinstance(current, tuple): + assert current[0] == "list" + assert len(current[1]) == 1 + current = current[1][0] + depth += 1 + + assert depth == 32 + assert isinstance(current, Unhashable)