From 7d76a4447e382db4c5fac15618920f7a69fa9b35 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 02:36:40 +0100 Subject: [PATCH 01/13] Sanitize execution cache inputs --- comfy_execution/caching.py | 51 +++++++++++++++++++++++++++------- comfy_execution/graph_utils.py | 11 ++++++-- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 78212bde3..53d8e5bdb 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -51,17 +51,48 @@ class Unhashable: def __init__(self): self.value = float("NaN") -def to_hashable(obj): - # So that we don't infinitely recurse since frozenset and tuples - # are Sequences. - if isinstance(obj, (int, float, str, bool, bytes, type(None))): + +def _sanitize_signature_input(obj, depth=0, max_depth=32): + if depth >= max_depth: + return Unhashable() + + obj_type = type(obj) + if obj_type in (int, float, str, bool, bytes, type(None)): return obj - elif isinstance(obj, Mapping): - return frozenset([(to_hashable(k), to_hashable(v)) for k, v in sorted(obj.items())]) - elif isinstance(obj, Sequence): - return frozenset(zip(itertools.count(), [to_hashable(i) for i in obj])) + elif obj_type is dict: + sanitized = [] + for key in sorted(obj.keys(), key=lambda x: (type(x).__module__, type(x).__qualname__, repr(x))): + sanitized.append((_sanitize_signature_input(key, depth + 1, max_depth), + _sanitize_signature_input(obj[key], depth + 1, max_depth))) + return tuple(sanitized) + elif obj_type in (list, tuple): + return tuple(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) + elif obj_type in (set, frozenset): + sanitized_items = [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] + sanitized_items.sort(key=repr) + return tuple(sanitized_items) + else: + # Execution-cache signatures should be built from prompt-safe values. + # If a custom node injects a runtime object here, mark it unhashable so + # the node won't reuse stale cache entries across runs, but do not walk + # the foreign object and risk crashing on custom container semantics. + return Unhashable() + +def to_hashable(obj): + # 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 + # cache signature builder. + obj_type = type(obj) + if obj_type in (int, float, str, bool, bytes, type(None)): + return obj + elif obj_type is dict: + return frozenset([(to_hashable(k), to_hashable(v)) for k, v in sorted(obj.items(), key=lambda kv: repr(kv[0]))]) + elif obj_type in (list, tuple): + return frozenset(zip(itertools.count(), [to_hashable(i) for i in obj])) + elif obj_type in (set, frozenset): + return frozenset([to_hashable(i) for i in obj]) else: - # TODO - Support other objects like tensors? return Unhashable() class CacheKeySetID(CacheKeySet): @@ -123,7 +154,7 @@ class CacheKeySetInputSignature(CacheKeySet): ancestor_index = ancestor_order_mapping[ancestor_id] signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) else: - signature.append((key, inputs[key])) + signature.append((key, _sanitize_signature_input(inputs[key]))) return signature # This function returns a list of all ancestors of the given node. The order of the list is diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 496d2c634..75e4bc522 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -1,11 +1,16 @@ def is_link(obj): - if not isinstance(obj, list): + # Prompt links produced by the frontend / GraphBuilder are plain Python + # lists in the form [node_id, output_index]. Some custom-node paths can + # inject foreign runtime objects into prompt inputs during on-prompt graph + # rewriting or subgraph construction. Be strict here so cache signature + # building never tries to treat list-like proxy objects as links. + if type(obj) is not list: return False if len(obj) != 2: return False - if not isinstance(obj[0], str): + if type(obj[0]) is not str: return False - if not isinstance(obj[1], int) and not isinstance(obj[1], float): + if type(obj[1]) not in (int, float): return False return True From 2adde5a0e1f6abbb19c8aa4f3a71b93c7d47abf6 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 06:36:06 +0100 Subject: [PATCH 02/13] Keep container types in sanitizer --- comfy_execution/caching.py | 77 +++++++++++++++++++++++++++------- comfy_execution/graph_utils.py | 2 +- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 53d8e5bdb..d4bd2dee1 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -1,7 +1,6 @@ import asyncio import bisect import gc -import itertools import psutil import time import torch @@ -52,6 +51,37 @@ class Unhashable: self.value = float("NaN") +def _sanitized_sort_key(obj, depth=0, max_depth=32): + if depth >= max_depth: + return ("MAX_DEPTH",) + + obj_type = type(obj) + if obj_type is Unhashable: + return ("UNHASHABLE",) + elif obj_type in (int, float, str, bool, bytes, type(None)): + return (obj_type.__module__, obj_type.__qualname__, repr(obj)) + elif obj_type is dict: + items = [ + ( + _sanitized_sort_key(k, depth + 1, max_depth), + _sanitized_sort_key(v, depth + 1, max_depth), + ) + for k, v in obj.items() + ] + items.sort() + return ("dict", tuple(items)) + elif obj_type is list: + return ("list", tuple(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj)) + elif obj_type is tuple: + return ("tuple", tuple(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj)) + elif obj_type is set: + return ("set", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj))) + elif obj_type is frozenset: + return ("frozenset", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth) for i in obj))) + else: + return (obj_type.__module__, obj_type.__qualname__, "OPAQUE") + + def _sanitize_signature_input(obj, depth=0, max_depth=32): if depth >= max_depth: return Unhashable() @@ -60,17 +90,28 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): if obj_type in (int, float, str, bool, bytes, type(None)): return obj elif obj_type is dict: - sanitized = [] - for key in sorted(obj.keys(), key=lambda x: (type(x).__module__, type(x).__qualname__, repr(x))): - sanitized.append((_sanitize_signature_input(key, depth + 1, max_depth), - _sanitize_signature_input(obj[key], depth + 1, max_depth))) - return tuple(sanitized) - elif obj_type in (list, tuple): + sanitized_items = [ + ( + _sanitize_signature_input(key, depth + 1, max_depth), + _sanitize_signature_input(value, depth + 1, max_depth), + ) + for key, value in obj.items() + ] + sanitized_items.sort( + key=lambda kv: ( + _sanitized_sort_key(kv[0], depth + 1, max_depth), + _sanitized_sort_key(kv[1], depth + 1, max_depth), + ) + ) + return {key: value for key, value in sanitized_items} + elif obj_type is list: + return [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] + elif obj_type is tuple: return tuple(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) - elif obj_type in (set, frozenset): - sanitized_items = [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] - sanitized_items.sort(key=repr) - return tuple(sanitized_items) + elif obj_type is set: + return {_sanitize_signature_input(item, depth + 1, max_depth) for item in obj} + elif obj_type is frozenset: + return frozenset(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) else: # Execution-cache signatures should be built from prompt-safe values. # If a custom node injects a runtime object here, mark it unhashable so @@ -87,11 +128,15 @@ def to_hashable(obj): if obj_type in (int, float, str, bool, bytes, type(None)): return obj elif obj_type is dict: - return frozenset([(to_hashable(k), to_hashable(v)) for k, v in sorted(obj.items(), key=lambda kv: repr(kv[0]))]) - elif obj_type in (list, tuple): - return frozenset(zip(itertools.count(), [to_hashable(i) for i in obj])) - elif obj_type in (set, frozenset): - return frozenset([to_hashable(i) for i in obj]) + return ("dict", frozenset((to_hashable(k), to_hashable(v)) for k, v in obj.items())) + elif obj_type is list: + return ("list", tuple(to_hashable(i) for i in obj)) + elif obj_type is tuple: + return ("tuple", tuple(to_hashable(i) for i in obj)) + elif obj_type is set: + return ("set", frozenset(to_hashable(i) for i in obj)) + elif obj_type is frozenset: + return ("frozenset", frozenset(to_hashable(i) for i in obj)) else: return Unhashable() diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 75e4bc522..28f53ad95 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -10,7 +10,7 @@ def is_link(obj): return False if type(obj[0]) is not str: return False - if type(obj[1]) not in (int, float): + if type(obj[1]) is not int: return False return True From 39086890e23c1aa8fb6957a10e16a19bd5d51bc0 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 06:56:49 +0100 Subject: [PATCH 03/13] Fix sanitize_signature_input --- comfy_execution/caching.py | 3 +++ comfy_execution/graph_utils.py | 1 + 2 files changed, 4 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index d4bd2dee1..02c9028af 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -52,6 +52,7 @@ class Unhashable: def _sanitized_sort_key(obj, depth=0, max_depth=32): + """Build a deterministic sort key from sanitized built-in container content.""" if depth >= max_depth: return ("MAX_DEPTH",) @@ -83,6 +84,7 @@ def _sanitized_sort_key(obj, depth=0, max_depth=32): def _sanitize_signature_input(obj, depth=0, max_depth=32): + """Sanitize prompt-signature inputs without recursing into opaque runtime objects.""" if depth >= max_depth: return Unhashable() @@ -120,6 +122,7 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): return Unhashable() def to_hashable(obj): + """Convert prompt-safe built-in values into a stable hashable representation.""" # 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 diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 28f53ad95..573645c7c 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -1,4 +1,5 @@ def is_link(obj): + """Return True if obj is a plain prompt link of the form [node_id, output_index].""" # Prompt links produced by the frontend / GraphBuilder are plain Python # lists in the form [node_id, output_index]. Some custom-node paths can # inject foreign runtime objects into prompt inputs during on-prompt graph From 4d9516b909e6ad8cea68b306619594f117b77617 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 07:06:39 +0100 Subject: [PATCH 04/13] Fix caching sanitization logic --- comfy_execution/caching.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 02c9028af..b25673463 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -48,6 +48,7 @@ class CacheKeySet(ABC): class Unhashable: def __init__(self): + """Create a hashable sentinel value for unhashable prompt inputs.""" self.value = float("NaN") @@ -186,6 +187,7 @@ class CacheKeySetInputSignature(CacheKeySet): return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): + """Build the cache-signature fragment for a node's immediate inputs.""" if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. return [float("NaN")] From 880b51ac4f4bdd66b2d697a3dea48a1c66bdd27f Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 09:46:27 +0100 Subject: [PATCH 05/13] Harden to_hashable against cycles --- comfy_execution/caching.py | 36 +++++++++++++++---- .../execution_test/caching_hashable_test.py | 33 +++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 tests-unit/execution_test/caching_hashable_test.py 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) From 4b431ffc278d5be231d97ace5570b5a459bfb883 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 09:57:22 +0100 Subject: [PATCH 06/13] Add missing docstrings --- comfy_execution/caching.py | 2 ++ tests-unit/execution_test/caching_hashable_test.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index b690d03a8..d6561ab94 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -47,6 +47,8 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: + """Hashable sentinel used when an input cannot be safely represented.""" + def __init__(self): """Create a hashable sentinel value for unhashable prompt inputs.""" self.value = float("NaN") diff --git a/tests-unit/execution_test/caching_hashable_test.py b/tests-unit/execution_test/caching_hashable_test.py index 31d20de10..5613ac6c4 100644 --- a/tests-unit/execution_test/caching_hashable_test.py +++ b/tests-unit/execution_test/caching_hashable_test.py @@ -2,6 +2,7 @@ from comfy_execution.caching import Unhashable, to_hashable def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): + """Ensure self-referential built-in containers terminate as Unhashable.""" cyclic_list = [] cyclic_list.append(cyclic_list) @@ -13,6 +14,7 @@ def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): def test_to_hashable_returns_unhashable_when_max_depth_is_reached(): + """Ensure deeply nested built-in containers stop at the configured depth limit.""" nested = current = [] for _ in range(32): next_item = [] From 6728d4d4391ae42ab3a1e762a1acc501f2311ebd Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:11:04 +0100 Subject: [PATCH 07/13] Revert "Harden to_hashable against cycles" This reverts commit 880b51ac4f4bdd66b2d697a3dea48a1c66bdd27f. --- comfy_execution/caching.py | 36 ++++--------------- .../execution_test/caching_hashable_test.py | 35 ------------------ 2 files changed, 7 insertions(+), 64 deletions(-) delete mode 100644 tests-unit/execution_test/caching_hashable_test.py diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index d6561ab94..3e3bfcdab 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -124,14 +124,8 @@ 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, depth=0, max_depth=32, seen=None): +def to_hashable(obj): """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 @@ -139,32 +133,16 @@ def to_hashable(obj, depth=0, max_depth=32, seen=None): obj_type = type(obj) if obj_type in (int, float, str, bool, bytes, type(None)): return obj - - 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 dict: + return ("dict", frozenset((to_hashable(k), to_hashable(v)) for k, v in obj.items())) elif obj_type is list: - return ("list", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("list", tuple(to_hashable(i) for i in obj)) elif obj_type is tuple: - return ("tuple", tuple(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("tuple", tuple(to_hashable(i) for i in obj)) elif obj_type is set: - return ("set", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("set", frozenset(to_hashable(i) for i in obj)) elif obj_type is frozenset: - return ("frozenset", frozenset(to_hashable(i, depth + 1, max_depth, seen) for i in obj)) + return ("frozenset", frozenset(to_hashable(i) 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 deleted file mode 100644 index 5613ac6c4..000000000 --- a/tests-unit/execution_test/caching_hashable_test.py +++ /dev/null @@ -1,35 +0,0 @@ -from comfy_execution.caching import Unhashable, to_hashable - - -def test_to_hashable_returns_unhashable_for_cyclic_builtin_containers(): - """Ensure self-referential built-in containers terminate as Unhashable.""" - 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(): - """Ensure deeply nested built-in containers stop at the configured depth limit.""" - 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) From 3568b82b76e94485ab1ee26c845b7c12c466abfa Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:11:35 +0100 Subject: [PATCH 08/13] Revert "Add missing docstrings" This reverts commit 4b431ffc278d5be231d97ace5570b5a459bfb883. --- comfy_execution/caching.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 3e3bfcdab..b25673463 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -47,8 +47,6 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: - """Hashable sentinel used when an input cannot be safely represented.""" - def __init__(self): """Create a hashable sentinel value for unhashable prompt inputs.""" self.value = float("NaN") From 1af99b2e81c6ca3193f02535ed868159b5d13016 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:31:07 +0100 Subject: [PATCH 09/13] Update caching hash recursion --- comfy_execution/caching.py | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) 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() From 1451001f64818d2a47afea43695e30dfc41c7fcc Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 10:57:45 +0100 Subject: [PATCH 10/13] Add docstrings for cache signature hardening helpers --- comfy_execution/caching.py | 22 +++++++++++++++++----- comfy_execution/graph_utils.py | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index b690d03a8..07d9a2902 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -47,13 +47,14 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: + """Hashable sentinel for values that cannot be represented safely in cache keys.""" def __init__(self): - """Create a hashable sentinel value for unhashable prompt inputs.""" + """Initialize a sentinel that stays hashable while never comparing equal.""" self.value = float("NaN") def _sanitized_sort_key(obj, depth=0, max_depth=32): - """Build a deterministic sort key from sanitized built-in container content.""" + """Return a deterministic ordering key for sanitized built-in container content.""" if depth >= max_depth: return ("MAX_DEPTH",) @@ -85,7 +86,10 @@ def _sanitized_sort_key(obj, depth=0, max_depth=32): def _sanitize_signature_input(obj, depth=0, max_depth=32): - """Sanitize prompt-signature inputs without recursing into opaque runtime objects.""" + """Normalize signature inputs to safe built-in containers. + + Preserves built-in container type and replaces opaque runtime values with Unhashable(). + """ if depth >= max_depth: return Unhashable() @@ -123,7 +127,10 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): return Unhashable() def to_hashable(obj, depth=0, max_depth=32, seen=None): - """Convert prompt-safe built-in values into a stable hashable representation.""" + """Convert sanitized prompt inputs into a stable hashable representation. + + Preserves built-in container type and stops safely on cycles or excessive depth. + """ if depth >= max_depth: return Unhashable() @@ -182,6 +189,7 @@ class CacheKeySetID(CacheKeySet): self.subcache_keys[node_id] = (node_id, node["class_type"]) class CacheKeySetInputSignature(CacheKeySet): + """Cache-key strategy that hashes a node's immediate inputs plus ancestor references.""" def __init__(self, dynprompt, node_ids, is_changed_cache): super().__init__(dynprompt, node_ids, is_changed_cache) self.dynprompt = dynprompt @@ -201,6 +209,7 @@ class CacheKeySetInputSignature(CacheKeySet): self.subcache_keys[node_id] = (node_id, node["class_type"]) async def get_node_signature(self, dynprompt, node_id): + """Build the full cache signature for a node and its ordered ancestors.""" signature = [] ancestors, order_mapping = self.get_ordered_ancestry(dynprompt, node_id) signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping)) @@ -209,7 +218,10 @@ class CacheKeySetInputSignature(CacheKeySet): return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): - """Build the cache-signature fragment for a node's immediate inputs.""" + """Build the cache-signature fragment for a node's immediate inputs. + + Link inputs are reduced to ancestor references, while raw values are sanitized first. + """ if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. return [float("NaN")] diff --git a/comfy_execution/graph_utils.py b/comfy_execution/graph_utils.py index 573645c7c..57b4ef36e 100644 --- a/comfy_execution/graph_utils.py +++ b/comfy_execution/graph_utils.py @@ -1,5 +1,5 @@ def is_link(obj): - """Return True if obj is a plain prompt link of the form [node_id, output_index].""" + """Return whether obj is a plain prompt link of the form [node_id, output_index].""" # Prompt links produced by the frontend / GraphBuilder are plain Python # lists in the form [node_id, output_index]. Some custom-node paths can # inject foreign runtime objects into prompt inputs during on-prompt graph From 31ba844624153c9b5601e47c56e2f4e9e41f176d Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 12:04:31 +0100 Subject: [PATCH 11/13] Add cycle detection to signature input sanitization --- comfy_execution/caching.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 07d9a2902..6b0468730 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -85,22 +85,32 @@ def _sanitized_sort_key(obj, depth=0, max_depth=32): return (obj_type.__module__, obj_type.__qualname__, "OPAQUE") -def _sanitize_signature_input(obj, depth=0, max_depth=32): +def _sanitize_signature_input(obj, depth=0, max_depth=32, seen=None): """Normalize signature inputs to safe built-in containers. - Preserves built-in container type and replaces opaque runtime values with Unhashable(). + Preserves built-in container type, replaces opaque runtime values with + Unhashable(), and stops safely on cycles or excessive depth. """ if depth >= max_depth: return Unhashable() + if seen is None: + seen = set() + obj_type = type(obj) + if obj_type in (dict, list, tuple, set, frozenset): + obj_id = id(obj) + if obj_id in seen: + return Unhashable() + next_seen = seen | {obj_id} + if obj_type in (int, float, str, bool, bytes, type(None)): return obj elif obj_type is dict: sanitized_items = [ ( - _sanitize_signature_input(key, depth + 1, max_depth), - _sanitize_signature_input(value, depth + 1, max_depth), + _sanitize_signature_input(key, depth + 1, max_depth, next_seen), + _sanitize_signature_input(value, depth + 1, max_depth, next_seen), ) for key, value in obj.items() ] @@ -112,13 +122,13 @@ def _sanitize_signature_input(obj, depth=0, max_depth=32): ) return {key: value for key, value in sanitized_items} elif obj_type is list: - return [_sanitize_signature_input(item, depth + 1, max_depth) for item in obj] + return [_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj] elif obj_type is tuple: - return tuple(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) + return tuple(_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj) elif obj_type is set: - return {_sanitize_signature_input(item, depth + 1, max_depth) for item in obj} + return {_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj} elif obj_type is frozenset: - return frozenset(_sanitize_signature_input(item, depth + 1, max_depth) for item in obj) + return frozenset(_sanitize_signature_input(item, depth + 1, max_depth, next_seen) for item in obj) else: # Execution-cache signatures should be built from prompt-safe values. # If a custom node injects a runtime object here, mark it unhashable so From 17863f603a19c0290d7344543289b2f5e2a77eff Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 12:26:27 +0100 Subject: [PATCH 12/13] Add comprehensive docstrings for cache key helpers --- comfy_execution/caching.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 6b0468730..19480165e 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -16,6 +16,7 @@ NODE_CLASS_CONTAINS_UNIQUE_ID: Dict[str, bool] = {} def include_unique_id_in_input(class_type: str) -> bool: + """Return whether a node class includes UNIQUE_ID among its hidden inputs.""" if class_type in NODE_CLASS_CONTAINS_UNIQUE_ID: return NODE_CLASS_CONTAINS_UNIQUE_ID[class_type] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] @@ -23,27 +24,35 @@ def include_unique_id_in_input(class_type: str) -> bool: return NODE_CLASS_CONTAINS_UNIQUE_ID[class_type] class CacheKeySet(ABC): + """Base helper for building and storing cache keys for prompt nodes.""" def __init__(self, dynprompt, node_ids, is_changed_cache): + """Initialize cache-key storage for a dynamic prompt execution pass.""" self.keys = {} self.subcache_keys = {} @abstractmethod async def add_keys(self, node_ids): + """Populate cache keys for the provided node ids.""" raise NotImplementedError() def all_node_ids(self): + """Return the set of node ids currently tracked by this key set.""" return set(self.keys.keys()) def get_used_keys(self): + """Return the computed cache keys currently in use.""" return self.keys.values() def get_used_subcache_keys(self): + """Return the computed subcache keys currently in use.""" return self.subcache_keys.values() def get_data_key(self, node_id): + """Return the cache key for a node, if present.""" return self.keys.get(node_id, None) def get_subcache_key(self, node_id): + """Return the subcache key for a node, if present.""" return self.subcache_keys.get(node_id, None) class Unhashable: @@ -184,11 +193,14 @@ def to_hashable(obj, depth=0, max_depth=32, seen=None): return Unhashable() class CacheKeySetID(CacheKeySet): + """Cache-key strategy that keys nodes by node id and class type.""" def __init__(self, dynprompt, node_ids, is_changed_cache): + """Initialize identity-based cache keys for the supplied dynamic prompt.""" super().__init__(dynprompt, node_ids, is_changed_cache) self.dynprompt = dynprompt async def add_keys(self, node_ids): + """Populate identity-based keys for nodes that exist in the dynamic prompt.""" for node_id in node_ids: if node_id in self.keys: continue @@ -201,14 +213,17 @@ class CacheKeySetID(CacheKeySet): class CacheKeySetInputSignature(CacheKeySet): """Cache-key strategy that hashes a node's immediate inputs plus ancestor references.""" def __init__(self, dynprompt, node_ids, is_changed_cache): + """Initialize input-signature-based cache keys for the supplied dynamic prompt.""" super().__init__(dynprompt, node_ids, is_changed_cache) self.dynprompt = dynprompt self.is_changed_cache = is_changed_cache def include_node_id_in_input(self) -> bool: + """Return whether node ids should be included in computed input signatures.""" return False async def add_keys(self, node_ids): + """Populate input-signature-based keys for nodes in the dynamic prompt.""" for node_id in node_ids: if node_id in self.keys: continue @@ -254,12 +269,14 @@ class CacheKeySetInputSignature(CacheKeySet): # This function returns a list of all ancestors of the given node. The order of the list is # deterministic based on which specific inputs the ancestor is connected by. def get_ordered_ancestry(self, dynprompt, node_id): + """Return ancestors in deterministic traversal order and their index mapping.""" ancestors = [] order_mapping = {} self.get_ordered_ancestry_internal(dynprompt, node_id, ancestors, order_mapping) return ancestors, order_mapping def get_ordered_ancestry_internal(self, dynprompt, node_id, ancestors, order_mapping): + """Recursively collect ancestors in input order without revisiting prior nodes.""" if not dynprompt.has_node(node_id): return inputs = dynprompt.get_node(node_id)["inputs"] From 2bea0ee5d75961b5027e2edd20f785bd5a0e33dd Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 12:42:04 +0100 Subject: [PATCH 13/13] Simplify Unhashable sentinel implementation --- comfy_execution/caching.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 19480165e..cbf2e9de1 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -56,10 +56,8 @@ class CacheKeySet(ABC): return self.subcache_keys.get(node_id, None) class Unhashable: - """Hashable sentinel for values that cannot be represented safely in cache keys.""" - def __init__(self): - """Initialize a sentinel that stays hashable while never comparing equal.""" - self.value = float("NaN") + """Hashable identity sentinel for values that cannot be represented safely in cache keys.""" + pass def _sanitized_sort_key(obj, depth=0, max_depth=32):