From 7d76a4447e382db4c5fac15618920f7a69fa9b35 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sat, 14 Mar 2026 02:36:40 +0100 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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): From aceaa5e5798b53dcdc5a0c584d70b45de25083d7 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 02:32:25 +0100 Subject: [PATCH 14/24] fail closed on ambiguous container ordering in cache signatures --- comfy_execution/caching.py | 304 +++++++++++++++------- tests-unit/execution_test/caching_test.py | 176 +++++++++++++ 2 files changed, 380 insertions(+), 100 deletions(-) create mode 100644 tests-unit/execution_test/caching_test.py diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index cbf2e9de1..1ca1edcc0 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -60,136 +60,239 @@ class Unhashable: pass -def _sanitized_sort_key(obj, depth=0, max_depth=32): +_PRIMITIVE_SIGNATURE_TYPES = (int, float, str, bool, bytes, type(None)) +_CONTAINER_SIGNATURE_TYPES = (dict, list, tuple, set, frozenset) +_MAX_SIGNATURE_DEPTH = 32 +_MAX_SIGNATURE_CONTAINER_VISITS = 10_000 + + +def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None): """Return a deterministic ordering key for sanitized built-in container content.""" if depth >= max_depth: return ("MAX_DEPTH",) + if active is None: + active = set() + if memo is None: + memo = {} + obj_type = type(obj) if obj_type is Unhashable: return ("UNHASHABLE",) - elif obj_type in (int, float, str, bool, bytes, type(None)): + elif obj_type in _PRIMITIVE_SIGNATURE_TYPES: 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: + elif obj_type not in _CONTAINER_SIGNATURE_TYPES: return (obj_type.__module__, obj_type.__qualname__, "OPAQUE") + obj_id = id(obj) + if obj_id in memo: + return memo[obj_id] + if obj_id in active: + return ("CYCLE",) -def _sanitize_signature_input(obj, depth=0, max_depth=32, seen=None): + active.add(obj_id) + try: + if obj_type is dict: + items = [ + ( + _sanitized_sort_key(k, depth + 1, max_depth, active, memo), + _sanitized_sort_key(v, depth + 1, max_depth, active, memo), + ) + for k, v in obj.items() + ] + items.sort() + result = ("dict", tuple(items)) + elif obj_type is list: + result = ("list", tuple(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj)) + elif obj_type is tuple: + result = ("tuple", tuple(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj)) + elif obj_type is set: + result = ("set", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj))) + else: + result = ("frozenset", tuple(sorted(_sanitized_sort_key(i, depth + 1, max_depth, active, memo) for i in obj))) + finally: + active.discard(obj_id) + + memo[obj_id] = result + return result + + +def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None): """Normalize signature inputs to safe built-in containers. Preserves built-in container type, replaces opaque runtime values with - Unhashable(), and stops safely on cycles or excessive depth. + Unhashable(), stops safely on cycles or excessive depth, and memoizes + repeated built-in substructures so shared DAG-like inputs do not explode + into repeated recursive work. """ if depth >= max_depth: return Unhashable() - if seen is None: - seen = set() + if active is None: + active = set() + if memo is None: + memo = {} + if budget is None: + budget = {"remaining": _MAX_SIGNATURE_CONTAINER_VISITS} 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)): + if obj_type in _PRIMITIVE_SIGNATURE_TYPES: return obj - elif obj_type is dict: - sanitized_items = [ - ( - _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() - ] - 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, next_seen) for item in obj] - elif obj_type is tuple: - 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, next_seen) for item in obj} - elif obj_type is frozenset: - 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 - # the node won't reuse stale cache entries across runs, but do not walk - # the foreign object and risk crashing on custom container semantics. + if obj_type not in _CONTAINER_SIGNATURE_TYPES: return Unhashable() -def to_hashable(obj, depth=0, max_depth=32, seen=None): + obj_id = id(obj) + if obj_id in memo: + return memo[obj_id] + if obj_id in active: + return Unhashable() + + budget["remaining"] -= 1 + if budget["remaining"] < 0: + return Unhashable() + + active.add(obj_id) + try: + if obj_type is dict: + sort_memo = {} + sanitized_items = [ + ( + _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), + _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), + ) + for key, value in obj.items() + ] + ordered_items = [ + ( + ( + _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), + _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), + ), + (key, value), + ) + for key, value in sanitized_items + ] + ordered_items.sort(key=lambda item: item[0]) + + result = Unhashable() + for index in range(1, len(ordered_items)): + previous_sort_key, previous_item = ordered_items[index - 1] + current_sort_key, current_item = ordered_items[index] + if previous_sort_key == current_sort_key and previous_item != current_item: + break + else: + result = {key: value for _, (key, value) in ordered_items} + elif obj_type is list: + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + elif obj_type is tuple: + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + elif obj_type is set: + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + else: + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + finally: + active.discard(obj_id) + + memo[obj_id] = result + return result + + +def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): """Convert sanitized prompt inputs into a stable hashable representation. - Preserves built-in container type and stops safely on cycles or excessive depth. + The input is expected to already be sanitized to plain built-in containers, + but this function still fails safe for anything unexpected. Traversal is + iterative and memoized so shared built-in substructures do not trigger + exponential re-walks during cache-key construction. """ - 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 - # cache signature builder. obj_type = type(obj) - if obj_type in (int, float, str, bool, bytes, type(None)): + if obj_type in _PRIMITIVE_SIGNATURE_TYPES or obj_type is Unhashable: 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 list: - 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, depth + 1, max_depth, seen) for i in obj)) - elif obj_type is set: - 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, depth + 1, max_depth, seen) for i in obj)) - else: + if obj_type not in _CONTAINER_SIGNATURE_TYPES: return Unhashable() + memo = {} + active = set() + sort_memo = {} + processed = 0 + stack = [(obj, False)] + + def resolve_value(value): + """Resolve a child value from the completed memo table when available.""" + value_type = type(value) + if value_type in _PRIMITIVE_SIGNATURE_TYPES or value_type is Unhashable: + return value + return memo.get(id(value), Unhashable()) + + def resolve_unordered_values(current, container_tag): + """Resolve a set-like container or fail closed if ordering is ambiguous.""" + ordered_items = [ + (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) + for item in current + ] + ordered_items.sort(key=lambda item: item[0]) + + for index in range(1, len(ordered_items)): + previous_key, previous_value = ordered_items[index - 1] + current_key, current_value = ordered_items[index] + if previous_key == current_key and previous_value != current_value: + return Unhashable() + + return (container_tag, tuple(value for _, value in ordered_items)) + + while stack: + current, expanded = stack.pop() + current_type = type(current) + + if current_type in _PRIMITIVE_SIGNATURE_TYPES or current_type is Unhashable: + continue + if current_type not in _CONTAINER_SIGNATURE_TYPES: + memo[id(current)] = Unhashable() + continue + + current_id = id(current) + if current_id in memo: + continue + + if expanded: + active.discard(current_id) + if current_type is dict: + memo[current_id] = ( + "dict", + tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), + ) + elif current_type is list: + memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) + elif current_type is tuple: + memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) + elif current_type is set: + memo[current_id] = resolve_unordered_values(current, "set") + else: + memo[current_id] = resolve_unordered_values(current, "frozenset") + continue + + if current_id in active: + memo[current_id] = Unhashable() + continue + + processed += 1 + if processed > max_nodes: + return Unhashable() + + active.add(current_id) + stack.append((current, True)) + if current_type is dict: + items = list(current.items()) + for key, value in reversed(items): + stack.append((value, False)) + stack.append((key, False)) + else: + items = list(current) + for item in reversed(items): + stack.append((item, False)) + + return memo.get(id(obj), 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): @@ -238,6 +341,7 @@ class CacheKeySetInputSignature(CacheKeySet): signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping)) for ancestor_id in ancestors: signature.append(await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping)) + signature = _sanitize_signature_input(signature) return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py new file mode 100644 index 000000000..2f088722e --- /dev/null +++ b/tests-unit/execution_test/caching_test.py @@ -0,0 +1,176 @@ +"""Unit tests for cache-signature sanitization and hash conversion hardening.""" + +import asyncio +import importlib +import sys +import types + +import pytest + + +class _DummyNode: + """Minimal node stub used to satisfy cache-signature class lookups.""" + + @staticmethod + def INPUT_TYPES(): + """Return a minimal empty input schema for unit tests.""" + return {"required": {}} + + +class _FakeDynPrompt: + """Small DynamicPrompt stand-in with only the methods these tests need.""" + + def __init__(self, nodes_by_id): + """Store test nodes by id.""" + self._nodes_by_id = nodes_by_id + + def has_node(self, node_id): + """Return whether the fake prompt contains the requested node.""" + return node_id in self._nodes_by_id + + def get_node(self, node_id): + """Return the stored node payload for the requested id.""" + return self._nodes_by_id[node_id] + + +class _FakeIsChangedCache: + """Async stub for `is_changed` lookups used by cache-key generation.""" + + def __init__(self, values): + """Store canned `is_changed` responses keyed by node id.""" + self._values = values + + async def get(self, node_id): + """Return the canned `is_changed` value for a node.""" + return self._values[node_id] + + +class _OpaqueValue: + """Hashable opaque object used to exercise fail-closed unordered hashing paths.""" + + +def _contains_unhashable(value, unhashable_type): + """Return whether a nested built-in structure contains an Unhashable sentinel.""" + if isinstance(value, unhashable_type): + return True + + value_type = type(value) + if value_type is dict: + return any( + _contains_unhashable(key, unhashable_type) or _contains_unhashable(item, unhashable_type) + for key, item in value.items() + ) + if value_type in (list, tuple, set, frozenset): + return any(_contains_unhashable(item, unhashable_type) for item in value) + return False + + +@pytest.fixture +def caching_module(monkeypatch): + """Import `comfy_execution.caching` with lightweight stub dependencies.""" + torch_module = types.ModuleType("torch") + psutil_module = types.ModuleType("psutil") + nodes_module = types.ModuleType("nodes") + nodes_module.NODE_CLASS_MAPPINGS = {} + graph_module = types.ModuleType("comfy_execution.graph") + + class DynamicPrompt: + """Placeholder graph type so the caching module can import cleanly.""" + + pass + + graph_module.DynamicPrompt = DynamicPrompt + + monkeypatch.setitem(sys.modules, "torch", torch_module) + monkeypatch.setitem(sys.modules, "psutil", psutil_module) + monkeypatch.setitem(sys.modules, "nodes", nodes_module) + monkeypatch.setitem(sys.modules, "comfy_execution.graph", graph_module) + monkeypatch.delitem(sys.modules, "comfy_execution.caching", raising=False) + + module = importlib.import_module("comfy_execution.caching") + module = importlib.reload(module) + return module, nodes_module + + +def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_module): + """Shared built-in substructures should sanitize without collapsing to Unhashable.""" + caching, _ = caching_module + shared = [{"value": 1}, {"value": 2}] + + sanitized = caching._sanitize_signature_input([shared, shared]) + + assert isinstance(sanitized, list) + assert sanitized[0] == sanitized[1] + assert sanitized[0][0]["value"] == 1 + assert sanitized[0][1]["value"] == 2 + + +def test_to_hashable_handles_shared_builtin_substructures(caching_module): + """Repeated sanitized content should hash stably for shared substructures.""" + caching, _ = caching_module + shared = [{"value": 1}, {"value": 2}] + + sanitized = caching._sanitize_signature_input([shared, shared]) + hashable = caching.to_hashable(sanitized) + + assert hashable[0] == "list" + assert hashable[1][0] == hashable[1][1] + assert hashable[1][0][0] == "list" + + +def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(caching_module): + """Ambiguous dict sort ties should fail closed instead of depending on input order.""" + caching, _ = caching_module + ambiguous = { + _OpaqueValue(): _OpaqueValue(), + _OpaqueValue(): _OpaqueValue(), + } + + sanitized = caching._sanitize_signature_input(ambiguous) + + assert isinstance(sanitized, caching.Unhashable) + + +@pytest.mark.parametrize( + "container_factory", + [ + set, + frozenset, + ], +) +def test_to_hashable_fails_closed_for_ambiguous_unordered_values(caching_module, container_factory): + """Ambiguous unordered values should fail closed instead of depending on iteration order.""" + caching, _ = caching_module + container = container_factory({_OpaqueValue(), _OpaqueValue()}) + + hashable = caching.to_hashable(container) + + assert isinstance(hashable, caching.Unhashable) + + +def test_get_node_signature_sanitizes_full_signature(caching_module, monkeypatch): + """Recursive `is_changed` payloads should be sanitized inside the full node signature.""" + caching, nodes_module = caching_module + monkeypatch.setitem(nodes_module.NODE_CLASS_MAPPINGS, "UnitTestNode", _DummyNode) + + is_changed_value = [] + is_changed_value.append(is_changed_value) + + dynprompt = _FakeDynPrompt( + { + "node": { + "class_type": "UnitTestNode", + "inputs": {"value": 5}, + } + } + ) + key_set = caching.CacheKeySetInputSignature( + dynprompt, + ["node"], + _FakeIsChangedCache({"node": is_changed_value}), + ) + + signature = asyncio.run(key_set.get_node_signature(dynprompt, "node")) + + assert signature[0] == "list" + assert _contains_unhashable(signature, caching.Unhashable) From 117afbc1d7cfd9129af317e45b4cfdf39ed8ac6f Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 02:55:39 +0100 Subject: [PATCH 15/24] Add docstrings and harden signature --- comfy_execution/caching.py | 131 +++++++++++++--------- tests-unit/execution_test/caching_test.py | 51 +++++++++ 2 files changed, 132 insertions(+), 50 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 1ca1edcc0..2169dda9a 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -154,42 +154,57 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti active.add(obj_id) try: if obj_type is dict: - sort_memo = {} - sanitized_items = [ - ( - _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), - _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), - ) - for key, value in obj.items() - ] - ordered_items = [ - ( + try: + sort_memo = {} + sanitized_items = [ ( - _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), - _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), - ), - (key, value), - ) - for key, value in sanitized_items - ] - ordered_items.sort(key=lambda item: item[0]) + _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), + _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), + ) + for key, value in obj.items() + ] + ordered_items = [ + ( + ( + _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), + _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), + ), + (key, value), + ) + for key, value in sanitized_items + ] + ordered_items.sort(key=lambda item: item[0]) - result = Unhashable() - for index in range(1, len(ordered_items)): - previous_sort_key, previous_item = ordered_items[index - 1] - current_sort_key, current_item = ordered_items[index] - if previous_sort_key == current_sort_key and previous_item != current_item: - break - else: - result = {key: value for _, (key, value) in ordered_items} + result = Unhashable() + for index in range(1, len(ordered_items)): + previous_sort_key, previous_item = ordered_items[index - 1] + current_sort_key, current_item = ordered_items[index] + if previous_sort_key == current_sort_key and previous_item != current_item: + break + else: + result = {key: value for _, (key, value) in ordered_items} + except RuntimeError: + result = Unhashable() elif obj_type is list: - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + try: + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + except RuntimeError: + result = Unhashable() elif obj_type is tuple: - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + try: + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + except RuntimeError: + result = Unhashable() elif obj_type is set: - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + try: + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + except RuntimeError: + result = Unhashable() else: - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + try: + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + except RuntimeError: + result = Unhashable() finally: active.discard(obj_id) @@ -226,11 +241,14 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): def resolve_unordered_values(current, container_tag): """Resolve a set-like container or fail closed if ordering is ambiguous.""" - ordered_items = [ - (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) - for item in current - ] - ordered_items.sort(key=lambda item: item[0]) + try: + ordered_items = [ + (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) + for item in current + ] + ordered_items.sort(key=lambda item: item[0]) + except RuntimeError: + return Unhashable() for index in range(1, len(ordered_items)): previous_key, previous_value = ordered_items[index - 1] @@ -256,19 +274,22 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): if expanded: active.discard(current_id) - if current_type is dict: - memo[current_id] = ( - "dict", - tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), - ) - elif current_type is list: - memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) - elif current_type is tuple: - memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) - elif current_type is set: - memo[current_id] = resolve_unordered_values(current, "set") - else: - memo[current_id] = resolve_unordered_values(current, "frozenset") + try: + if current_type is dict: + memo[current_id] = ( + "dict", + tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), + ) + elif current_type is list: + memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) + elif current_type is tuple: + memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) + elif current_type is set: + memo[current_id] = resolve_unordered_values(current, "set") + else: + memo[current_id] = resolve_unordered_values(current, "frozenset") + except RuntimeError: + memo[current_id] = Unhashable() continue if current_id in active: @@ -282,12 +303,22 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): active.add(current_id) stack.append((current, True)) if current_type is dict: - items = list(current.items()) + try: + items = list(current.items()) + except RuntimeError: + memo[current_id] = Unhashable() + active.discard(current_id) + continue for key, value in reversed(items): stack.append((value, False)) stack.append((key, False)) else: - items = list(current) + try: + items = list(current) + except RuntimeError: + memo[current_id] = Unhashable() + active.discard(current_id) + continue for item in reversed(items): stack.append((item, False)) diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 2f088722e..c9892304a 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -105,6 +105,35 @@ def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_m assert sanitized[0][1]["value"] == 2 +@pytest.mark.parametrize( + "container_factory", + [ + lambda marker: [marker], + lambda marker: (marker,), + lambda marker: {marker}, + lambda marker: frozenset({marker}), + lambda marker: {marker: "value"}, + ], +) +def test_sanitize_signature_input_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): + """Traversal RuntimeError should degrade sanitization to Unhashable.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + + def raising_sanitize(obj, *args, **kwargs): + """Raise a traversal RuntimeError for the marker value and delegate otherwise.""" + if obj is marker: + raise RuntimeError("container changed during iteration") + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", raising_sanitize) + + sanitized = original(container_factory(marker)) + + assert isinstance(sanitized, caching.Unhashable) + + def test_to_hashable_handles_shared_builtin_substructures(caching_module): """Repeated sanitized content should hash stably for shared substructures.""" caching, _ = caching_module @@ -118,6 +147,28 @@ def test_to_hashable_handles_shared_builtin_substructures(caching_module): assert hashable[1][0][0] == "list" +@pytest.mark.parametrize( + "container_factory", + [ + set, + frozenset, + ], +) +def test_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): + """Traversal RuntimeError should degrade unordered hash conversion to Unhashable.""" + caching, _ = caching_module + + def raising_sort_key(obj, *args, **kwargs): + """Raise a traversal RuntimeError while unordered values are canonicalized.""" + raise RuntimeError("container changed during iteration") + + monkeypatch.setattr(caching, "_sanitized_sort_key", raising_sort_key) + + hashable = caching.to_hashable(container_factory({"value"})) + + assert isinstance(hashable, caching.Unhashable) + + def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(caching_module): """Ambiguous dict sort ties should fail closed instead of depending on input order.""" caching, _ = caching_module From fadd79ad489f8d76d7c1d18d5511cbf70dff1080 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 03:29:59 +0100 Subject: [PATCH 16/24] Fix nondeterministic set signing --- comfy_execution/caching.py | 15 +++++--- tests-unit/execution_test/caching_test.py | 42 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 2169dda9a..08f3f436b 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -155,13 +155,14 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti try: if obj_type is dict: try: + items = list(obj.items()) sort_memo = {} sanitized_items = [ ( _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), ) - for key, value in obj.items() + for key, value in items ] ordered_items = [ ( @@ -187,22 +188,26 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti result = Unhashable() elif obj_type is list: try: - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj] + items = list(obj) + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items] except RuntimeError: result = Unhashable() elif obj_type is tuple: try: - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + items = list(obj) + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) except RuntimeError: result = Unhashable() elif obj_type is set: try: - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj} + items = list(obj) + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items} except RuntimeError: result = Unhashable() else: try: - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in obj) + items = list(obj) + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) except RuntimeError: result = Unhashable() finally: diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index c9892304a..6313faed1 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -105,6 +105,48 @@ def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_m assert sanitized[0][1]["value"] == 2 +def test_sanitize_signature_input_snapshots_list_before_recursing(caching_module, monkeypatch): + """List sanitization should read a point-in-time snapshot before recursive descent.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + values = [marker, 2] + + def mutating_sanitize(obj, *args, **kwargs): + """Mutate the live list during recursion to verify snapshot-based traversal.""" + if obj is marker: + values[1] = 3 + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + + sanitized = original(values) + + assert isinstance(sanitized, list) + assert sanitized[1] == 2 + + +def test_sanitize_signature_input_snapshots_dict_before_recursing(caching_module, monkeypatch): + """Dict sanitization should read a point-in-time snapshot before recursive descent.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + values = {"first": marker, "second": 2} + + def mutating_sanitize(obj, *args, **kwargs): + """Mutate the live dict during recursion to verify snapshot-based traversal.""" + if obj is marker: + values["second"] = 3 + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + + sanitized = original(values) + + assert isinstance(sanitized, dict) + assert sanitized["second"] == 2 + + @pytest.mark.parametrize( "container_factory", [ From 9feb26928cd755d751a04e3f6e46c12485a57be5 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 04:31:32 +0100 Subject: [PATCH 17/24] Change signature cache to bail early --- comfy_execution/caching.py | 45 ++++++++++++---- tests-unit/execution_test/caching_test.py | 65 ++++++++++++++++------- 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 08f3f436b..73b67f8ab 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -66,6 +66,12 @@ _MAX_SIGNATURE_DEPTH = 32 _MAX_SIGNATURE_CONTAINER_VISITS = 10_000 +def _mark_signature_tainted(taint_state): + """Record that signature sanitization hit a fail-closed condition.""" + if taint_state is not None: + taint_state["tainted"] = True + + def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None): """Return a deterministic ordering key for sanitized built-in container content.""" if depth >= max_depth: @@ -117,15 +123,20 @@ def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=Non return result -def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None): +def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None, taint_state=None): """Normalize signature inputs to safe built-in containers. Preserves built-in container type, replaces opaque runtime values with - Unhashable(), stops safely on cycles or excessive depth, and memoizes - repeated built-in substructures so shared DAG-like inputs do not explode - into repeated recursive work. + Unhashable(), stops safely on cycles or excessive depth, memoizes repeated + built-in substructures so shared DAG-like inputs do not explode into + repeated recursive work, and optionally records when sanitization had to + fail closed anywhere in the traversed structure. """ + if taint_state is not None and taint_state.get("tainted"): + return Unhashable() + if depth >= max_depth: + _mark_signature_tainted(taint_state) return Unhashable() if active is None: @@ -139,16 +150,19 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti if obj_type in _PRIMITIVE_SIGNATURE_TYPES: return obj if obj_type not in _CONTAINER_SIGNATURE_TYPES: + _mark_signature_tainted(taint_state) return Unhashable() obj_id = id(obj) if obj_id in memo: return memo[obj_id] if obj_id in active: + _mark_signature_tainted(taint_state) return Unhashable() budget["remaining"] -= 1 if budget["remaining"] < 0: + _mark_signature_tainted(taint_state) return Unhashable() active.add(obj_id) @@ -159,8 +173,8 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti sort_memo = {} sanitized_items = [ ( - _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget), - _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget), + _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget, taint_state), + _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget, taint_state), ) for key, value in items ] @@ -181,34 +195,40 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti previous_sort_key, previous_item = ordered_items[index - 1] current_sort_key, current_item = ordered_items[index] if previous_sort_key == current_sort_key and previous_item != current_item: + _mark_signature_tainted(taint_state) break else: result = {key: value for _, (key, value) in ordered_items} except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() elif obj_type is list: try: items = list(obj) - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items] + result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items] except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() elif obj_type is tuple: try: items = list(obj) - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) + result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() elif obj_type is set: try: items = list(obj) - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items} + result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items} except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() else: try: items = list(obj) - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget) for item in items) + result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) except RuntimeError: + _mark_signature_tainted(taint_state) result = Unhashable() finally: active.discard(obj_id) @@ -377,7 +397,10 @@ class CacheKeySetInputSignature(CacheKeySet): signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping)) for ancestor_id in ancestors: signature.append(await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping)) - signature = _sanitize_signature_input(signature) + taint_state = {"tainted": False} + signature = _sanitize_signature_input(signature, taint_state=taint_state) + if taint_state["tainted"]: + return Unhashable() return to_hashable(signature) async def get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping): diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 6313faed1..390efe87b 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -49,22 +49,6 @@ class _OpaqueValue: """Hashable opaque object used to exercise fail-closed unordered hashing paths.""" -def _contains_unhashable(value, unhashable_type): - """Return whether a nested built-in structure contains an Unhashable sentinel.""" - if isinstance(value, unhashable_type): - return True - - value_type = type(value) - if value_type is dict: - return any( - _contains_unhashable(key, unhashable_type) or _contains_unhashable(item, unhashable_type) - for key, item in value.items() - ) - if value_type in (list, tuple, set, frozenset): - return any(_contains_unhashable(item, unhashable_type) for item in value) - return False - - @pytest.fixture def caching_module(monkeypatch): """Import `comfy_execution.caching` with lightweight stub dependencies.""" @@ -105,6 +89,43 @@ def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_m assert sanitized[0][1]["value"] == 2 +def test_sanitize_signature_input_marks_tainted_on_opaque_values(caching_module): + """Opaque values should mark the containing signature as tainted.""" + caching, _ = caching_module + taint_state = {"tainted": False} + + sanitized = caching._sanitize_signature_input(["safe", object()], taint_state=taint_state) + + assert isinstance(sanitized, list) + assert taint_state["tainted"] is True + assert isinstance(sanitized[1], caching.Unhashable) + + +def test_sanitize_signature_input_stops_descending_after_taint(caching_module, monkeypatch): + """Once tainted, later recursive calls should return immediately without deeper descent.""" + caching, _ = caching_module + original = caching._sanitize_signature_input + marker = object() + marker_seen = False + + def tracking_sanitize(obj, *args, **kwargs): + """Track whether recursion reaches the nested marker after tainting.""" + nonlocal marker_seen + if obj is marker: + marker_seen = True + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_sanitize_signature_input", tracking_sanitize) + + taint_state = {"tainted": False} + sanitized = original([object(), [marker]], taint_state=taint_state) + + assert isinstance(sanitized, list) + assert taint_state["tainted"] is True + assert marker_seen is False + assert isinstance(sanitized[1], caching.Unhashable) + + def test_sanitize_signature_input_snapshots_list_before_recursing(caching_module, monkeypatch): """List sanitization should read a point-in-time snapshot before recursive descent.""" caching, _ = caching_module @@ -241,10 +262,15 @@ def test_to_hashable_fails_closed_for_ambiguous_unordered_values(caching_module, assert isinstance(hashable, caching.Unhashable) -def test_get_node_signature_sanitizes_full_signature(caching_module, monkeypatch): - """Recursive `is_changed` payloads should be sanitized inside the full node signature.""" +def test_get_node_signature_returns_top_level_unhashable_for_tainted_signature(caching_module, monkeypatch): + """Tainted full signatures should fail closed before `to_hashable()` runs.""" caching, nodes_module = caching_module monkeypatch.setitem(nodes_module.NODE_CLASS_MAPPINGS, "UnitTestNode", _DummyNode) + monkeypatch.setattr( + caching, + "to_hashable", + lambda *_args, **_kwargs: pytest.fail("to_hashable should not run for tainted signatures"), + ) is_changed_value = [] is_changed_value.append(is_changed_value) @@ -265,5 +291,4 @@ def test_get_node_signature_sanitizes_full_signature(caching_module, monkeypatch signature = asyncio.run(key_set.get_node_signature(dynprompt, "node")) - assert signature[0] == "list" - assert _contains_unhashable(signature, caching.Unhashable) + assert isinstance(signature, caching.Unhashable) From 0b512198e896179019a85bfe3171f1cb076510bb Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 05:41:39 +0100 Subject: [PATCH 18/24] Adopt single-pass signature hashing --- comfy_execution/caching.py | 167 +++++++++++----------- tests-unit/execution_test/caching_test.py | 105 +++++++------- 2 files changed, 131 insertions(+), 141 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 73b67f8ab..f1b5227db 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -64,12 +64,13 @@ _PRIMITIVE_SIGNATURE_TYPES = (int, float, str, bool, bytes, type(None)) _CONTAINER_SIGNATURE_TYPES = (dict, list, tuple, set, frozenset) _MAX_SIGNATURE_DEPTH = 32 _MAX_SIGNATURE_CONTAINER_VISITS = 10_000 +_FAILED_SIGNATURE = object() -def _mark_signature_tainted(taint_state): - """Record that signature sanitization hit a fail-closed condition.""" - if taint_state is not None: - taint_state["tainted"] = True +def _primitive_signature_sort_key(obj): + """Return a deterministic ordering key for primitive signature values.""" + obj_type = type(obj) + return ("primitive", obj_type.__module__, obj_type.__qualname__, repr(obj)) def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None): @@ -123,21 +124,10 @@ def _sanitized_sort_key(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=Non return result -def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None, taint_state=None): - """Normalize signature inputs to safe built-in containers. - - Preserves built-in container type, replaces opaque runtime values with - Unhashable(), stops safely on cycles or excessive depth, memoizes repeated - built-in substructures so shared DAG-like inputs do not explode into - repeated recursive work, and optionally records when sanitization had to - fail closed anywhere in the traversed structure. - """ - if taint_state is not None and taint_state.get("tainted"): - return Unhashable() - +def _signature_to_hashable_impl(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, active=None, memo=None, budget=None): + """Canonicalize signature inputs directly into their final hashable form.""" if depth >= max_depth: - _mark_signature_tainted(taint_state) - return Unhashable() + return _FAILED_SIGNATURE if active is None: active = set() @@ -148,93 +138,102 @@ def _sanitize_signature_input(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, acti obj_type = type(obj) if obj_type in _PRIMITIVE_SIGNATURE_TYPES: - return obj - if obj_type not in _CONTAINER_SIGNATURE_TYPES: - _mark_signature_tainted(taint_state) - return Unhashable() + return obj, _primitive_signature_sort_key(obj) + if obj_type is Unhashable or obj_type not in _CONTAINER_SIGNATURE_TYPES: + return _FAILED_SIGNATURE obj_id = id(obj) if obj_id in memo: return memo[obj_id] if obj_id in active: - _mark_signature_tainted(taint_state) - return Unhashable() + return _FAILED_SIGNATURE budget["remaining"] -= 1 if budget["remaining"] < 0: - _mark_signature_tainted(taint_state) - return Unhashable() + return _FAILED_SIGNATURE active.add(obj_id) try: if obj_type is dict: try: items = list(obj.items()) - sort_memo = {} - sanitized_items = [ - ( - _sanitize_signature_input(key, depth + 1, max_depth, active, memo, budget, taint_state), - _sanitize_signature_input(value, depth + 1, max_depth, active, memo, budget, taint_state), - ) - for key, value in items - ] - ordered_items = [ - ( - ( - _sanitized_sort_key(key, depth + 1, max_depth, memo=sort_memo), - _sanitized_sort_key(value, depth + 1, max_depth, memo=sort_memo), - ), - (key, value), - ) - for key, value in sanitized_items - ] - ordered_items.sort(key=lambda item: item[0]) + except RuntimeError: + return _FAILED_SIGNATURE - result = Unhashable() - for index in range(1, len(ordered_items)): - previous_sort_key, previous_item = ordered_items[index - 1] - current_sort_key, current_item = ordered_items[index] - if previous_sort_key == current_sort_key and previous_item != current_item: - _mark_signature_tainted(taint_state) - break - else: - result = {key: value for _, (key, value) in ordered_items} - except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() - elif obj_type is list: + ordered_items = [] + for key, value in items: + key_result = _signature_to_hashable_impl(key, depth + 1, max_depth, active, memo, budget) + if key_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + value_result = _signature_to_hashable_impl(value, depth + 1, max_depth, active, memo, budget) + if value_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + key_value, key_sort = key_result + value_value, value_sort = value_result + ordered_items.append((((key_sort, value_sort)), (key_value, value_value))) + + ordered_items.sort(key=lambda item: item[0]) + for index in range(1, len(ordered_items)): + previous_sort_key, previous_item = ordered_items[index - 1] + current_sort_key, current_item = ordered_items[index] + if previous_sort_key == current_sort_key and previous_item != current_item: + return _FAILED_SIGNATURE + + value = ("dict", tuple(item for _, item in ordered_items)) + sort_key = ("dict", tuple(sort_key for sort_key, _ in ordered_items)) + elif obj_type is list or obj_type is tuple: try: items = list(obj) - result = [_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items] except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() - elif obj_type is tuple: - try: - items = list(obj) - result = tuple(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) - except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() - elif obj_type is set: - try: - items = list(obj) - result = {_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items} - except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() + return _FAILED_SIGNATURE + + child_results = [] + for item in items: + child_result = _signature_to_hashable_impl(item, depth + 1, max_depth, active, memo, budget) + if child_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + child_results.append(child_result) + + container_tag = "list" if obj_type is list else "tuple" + value = (container_tag, tuple(child for child, _ in child_results)) + sort_key = (container_tag, tuple(child_sort for _, child_sort in child_results)) else: try: items = list(obj) - result = frozenset(_sanitize_signature_input(item, depth + 1, max_depth, active, memo, budget, taint_state) for item in items) except RuntimeError: - _mark_signature_tainted(taint_state) - result = Unhashable() + return _FAILED_SIGNATURE + + ordered_items = [] + for item in items: + child_result = _signature_to_hashable_impl(item, depth + 1, max_depth, active, memo, budget) + if child_result is _FAILED_SIGNATURE: + return _FAILED_SIGNATURE + child_value, child_sort = child_result + ordered_items.append((child_sort, child_value)) + + ordered_items.sort(key=lambda item: item[0]) + for index in range(1, len(ordered_items)): + previous_sort_key, previous_value = ordered_items[index - 1] + current_sort_key, current_value = ordered_items[index] + if previous_sort_key == current_sort_key and previous_value != current_value: + return _FAILED_SIGNATURE + + container_tag = "set" if obj_type is set else "frozenset" + value = (container_tag, tuple(child_value for _, child_value in ordered_items)) + sort_key = (container_tag, tuple(child_sort for child_sort, _ in ordered_items)) finally: active.discard(obj_id) - memo[obj_id] = result - return result + memo[obj_id] = (value, sort_key) + return memo[obj_id] + + +def _signature_to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): + """Build the final cache-signature representation in one fail-closed pass.""" + result = _signature_to_hashable_impl(obj, budget={"remaining": max_nodes}) + if result is _FAILED_SIGNATURE: + return Unhashable() + return result[0] def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): @@ -397,11 +396,7 @@ class CacheKeySetInputSignature(CacheKeySet): signature.append(await self.get_immediate_node_signature(dynprompt, node_id, order_mapping)) for ancestor_id in ancestors: signature.append(await self.get_immediate_node_signature(dynprompt, ancestor_id, order_mapping)) - taint_state = {"tainted": False} - signature = _sanitize_signature_input(signature, taint_state=taint_state) - if taint_state["tainted"]: - return Unhashable() - return to_hashable(signature) + return _signature_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. @@ -424,7 +419,7 @@ class CacheKeySetInputSignature(CacheKeySet): ancestor_index = ancestor_order_mapping[ancestor_id] signature.append((key,("ANCESTOR", ancestor_index, ancestor_socket))) else: - signature.append((key, _sanitize_signature_input(inputs[key]))) + signature.append((key, 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/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 390efe87b..a7dffcee0 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -1,4 +1,4 @@ -"""Unit tests for cache-signature sanitization and hash conversion hardening.""" +"""Unit tests for cache-signature canonicalization hardening.""" import asyncio import importlib @@ -76,96 +76,91 @@ def caching_module(monkeypatch): return module, nodes_module -def test_sanitize_signature_input_handles_shared_builtin_substructures(caching_module): - """Shared built-in substructures should sanitize without collapsing to Unhashable.""" +def test_signature_to_hashable_handles_shared_builtin_substructures(caching_module): + """Shared built-in substructures should canonicalize without collapsing to Unhashable.""" caching, _ = caching_module shared = [{"value": 1}, {"value": 2}] - sanitized = caching._sanitize_signature_input([shared, shared]) + signature = caching._signature_to_hashable([shared, shared]) - assert isinstance(sanitized, list) - assert sanitized[0] == sanitized[1] - assert sanitized[0][0]["value"] == 1 - assert sanitized[0][1]["value"] == 2 + assert signature[0] == "list" + assert signature[1][0] == signature[1][1] + assert signature[1][0][0] == "list" + assert signature[1][0][1][0] == ("dict", (("value", 1),)) + assert signature[1][0][1][1] == ("dict", (("value", 2),)) -def test_sanitize_signature_input_marks_tainted_on_opaque_values(caching_module): - """Opaque values should mark the containing signature as tainted.""" +def test_signature_to_hashable_fails_closed_on_opaque_values(caching_module): + """Opaque values should collapse the full signature to Unhashable immediately.""" caching, _ = caching_module - taint_state = {"tainted": False} - sanitized = caching._sanitize_signature_input(["safe", object()], taint_state=taint_state) + signature = caching._signature_to_hashable(["safe", object()]) - assert isinstance(sanitized, list) - assert taint_state["tainted"] is True - assert isinstance(sanitized[1], caching.Unhashable) + assert isinstance(signature, caching.Unhashable) -def test_sanitize_signature_input_stops_descending_after_taint(caching_module, monkeypatch): - """Once tainted, later recursive calls should return immediately without deeper descent.""" +def test_signature_to_hashable_stops_descending_after_failure(caching_module, monkeypatch): + """Once canonicalization fails, later recursive descent should stop immediately.""" caching, _ = caching_module - original = caching._sanitize_signature_input + original = caching._signature_to_hashable_impl marker = object() marker_seen = False - def tracking_sanitize(obj, *args, **kwargs): - """Track whether recursion reaches the nested marker after tainting.""" + def tracking_canonicalize(obj, *args, **kwargs): + """Track whether recursion reaches the nested marker after failure.""" nonlocal marker_seen if obj is marker: marker_seen = True return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", tracking_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", tracking_canonicalize) - taint_state = {"tainted": False} - sanitized = original([object(), [marker]], taint_state=taint_state) + signature = caching._signature_to_hashable([object(), [marker]]) - assert isinstance(sanitized, list) - assert taint_state["tainted"] is True + assert isinstance(signature, caching.Unhashable) assert marker_seen is False - assert isinstance(sanitized[1], caching.Unhashable) -def test_sanitize_signature_input_snapshots_list_before_recursing(caching_module, monkeypatch): - """List sanitization should read a point-in-time snapshot before recursive descent.""" +def test_signature_to_hashable_snapshots_list_before_recursing(caching_module, monkeypatch): + """List canonicalization should read a point-in-time snapshot before recursive descent.""" caching, _ = caching_module - original = caching._sanitize_signature_input - marker = object() + original = caching._signature_to_hashable_impl + marker = ("marker",) values = [marker, 2] - def mutating_sanitize(obj, *args, **kwargs): + def mutating_canonicalize(obj, *args, **kwargs): """Mutate the live list during recursion to verify snapshot-based traversal.""" if obj is marker: values[1] = 3 return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", mutating_canonicalize) - sanitized = original(values) + signature = caching._signature_to_hashable(values) - assert isinstance(sanitized, list) - assert sanitized[1] == 2 + assert signature == ("list", (("tuple", ("marker",)), 2)) + assert values[1] == 3 -def test_sanitize_signature_input_snapshots_dict_before_recursing(caching_module, monkeypatch): - """Dict sanitization should read a point-in-time snapshot before recursive descent.""" +def test_signature_to_hashable_snapshots_dict_before_recursing(caching_module, monkeypatch): + """Dict canonicalization should read a point-in-time snapshot before recursive descent.""" caching, _ = caching_module - original = caching._sanitize_signature_input - marker = object() + original = caching._signature_to_hashable_impl + marker = ("marker",) values = {"first": marker, "second": 2} - def mutating_sanitize(obj, *args, **kwargs): + def mutating_canonicalize(obj, *args, **kwargs): """Mutate the live dict during recursion to verify snapshot-based traversal.""" if obj is marker: values["second"] = 3 return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", mutating_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", mutating_canonicalize) - sanitized = original(values) + signature = caching._signature_to_hashable(values) - assert isinstance(sanitized, dict) - assert sanitized["second"] == 2 + assert signature == ("dict", (("first", ("tuple", ("marker",))), ("second", 2))) + assert values["second"] == 3 @pytest.mark.parametrize( @@ -178,31 +173,31 @@ def test_sanitize_signature_input_snapshots_dict_before_recursing(caching_module lambda marker: {marker: "value"}, ], ) -def test_sanitize_signature_input_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): - """Traversal RuntimeError should degrade sanitization to Unhashable.""" +def test_signature_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, container_factory): + """Traversal RuntimeError should degrade canonicalization to Unhashable.""" caching, _ = caching_module - original = caching._sanitize_signature_input + original = caching._signature_to_hashable_impl marker = object() - def raising_sanitize(obj, *args, **kwargs): + def raising_canonicalize(obj, *args, **kwargs): """Raise a traversal RuntimeError for the marker value and delegate otherwise.""" if obj is marker: raise RuntimeError("container changed during iteration") return original(obj, *args, **kwargs) - monkeypatch.setattr(caching, "_sanitize_signature_input", raising_sanitize) + monkeypatch.setattr(caching, "_signature_to_hashable_impl", raising_canonicalize) - sanitized = original(container_factory(marker)) + signature = caching._signature_to_hashable(container_factory(marker)) - assert isinstance(sanitized, caching.Unhashable) + assert isinstance(signature, caching.Unhashable) def test_to_hashable_handles_shared_builtin_substructures(caching_module): - """Repeated sanitized content should hash stably for shared substructures.""" + """The legacy helper should still hash sanitized built-ins stably when used directly.""" caching, _ = caching_module shared = [{"value": 1}, {"value": 2}] - sanitized = caching._sanitize_signature_input([shared, shared]) + sanitized = [shared, shared] hashable = caching.to_hashable(sanitized) assert hashable[0] == "list" @@ -232,7 +227,7 @@ def test_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, c assert isinstance(hashable, caching.Unhashable) -def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(caching_module): +def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): """Ambiguous dict sort ties should fail closed instead of depending on input order.""" caching, _ = caching_module ambiguous = { @@ -240,7 +235,7 @@ def test_sanitize_signature_input_fails_closed_for_ambiguous_dict_ordering(cachi _OpaqueValue(): _OpaqueValue(), } - sanitized = caching._sanitize_signature_input(ambiguous) + sanitized = caching._signature_to_hashable(ambiguous) assert isinstance(sanitized, caching.Unhashable) From a6624a9afd2494bc3fdd05df5778c49699802c9a Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 07:09:24 +0100 Subject: [PATCH 19/24] Unify signature sanitize and hash --- comfy_execution/caching.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index f1b5227db..caa5d4a48 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -252,6 +252,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): memo = {} active = set() + snapshots = {} sort_memo = {} processed = 0 stack = [(obj, False)] @@ -263,12 +264,12 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return value return memo.get(id(value), Unhashable()) - def resolve_unordered_values(current, container_tag): + def resolve_unordered_values(current_items, container_tag): """Resolve a set-like container or fail closed if ordering is ambiguous.""" try: ordered_items = [ (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) - for item in current + for item in current_items ] ordered_items.sort(key=lambda item: item[0]) except RuntimeError: @@ -300,18 +301,33 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): active.discard(current_id) try: if current_type is dict: + items = snapshots.pop(current_id, None) + if items is None: + items = list(current.items()) memo[current_id] = ( "dict", - tuple((resolve_value(k), resolve_value(v)) for k, v in current.items()), + tuple((resolve_value(k), resolve_value(v)) for k, v in items), ) elif current_type is list: - memo[current_id] = ("list", tuple(resolve_value(item) for item in current)) + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = ("list", tuple(resolve_value(item) for item in items)) elif current_type is tuple: - memo[current_id] = ("tuple", tuple(resolve_value(item) for item in current)) + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = ("tuple", tuple(resolve_value(item) for item in items)) elif current_type is set: - memo[current_id] = resolve_unordered_values(current, "set") + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = resolve_unordered_values(items, "set") else: - memo[current_id] = resolve_unordered_values(current, "frozenset") + items = snapshots.pop(current_id, None) + if items is None: + items = list(current) + memo[current_id] = resolve_unordered_values(items, "frozenset") except RuntimeError: memo[current_id] = Unhashable() continue @@ -329,6 +345,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): if current_type is dict: try: items = list(current.items()) + snapshots[current_id] = items except RuntimeError: memo[current_id] = Unhashable() active.discard(current_id) @@ -339,6 +356,7 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): else: try: items = list(current) + snapshots[current_id] = items except RuntimeError: memo[current_id] = Unhashable() active.discard(current_id) From 24fdbb9aca552f000486185136b8372a00be457f Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 07:30:18 +0100 Subject: [PATCH 20/24] Replace sanitize hash two pass --- comfy_execution/caching.py | 14 +++++++------- tests-unit/execution_test/caching_test.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index caa5d4a48..130b0dd5e 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -170,17 +170,17 @@ def _signature_to_hashable_impl(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, ac return _FAILED_SIGNATURE key_value, key_sort = key_result value_value, value_sort = value_result - ordered_items.append((((key_sort, value_sort)), (key_value, value_value))) + ordered_items.append((key_sort, value_sort, key_value, value_value)) - ordered_items.sort(key=lambda item: item[0]) + ordered_items.sort(key=lambda item: (item[0], item[1])) for index in range(1, len(ordered_items)): - previous_sort_key, previous_item = ordered_items[index - 1] - current_sort_key, current_item = ordered_items[index] - if previous_sort_key == current_sort_key and previous_item != current_item: + previous_key_sort = ordered_items[index - 1][0] + current_key_sort = ordered_items[index][0] + if previous_key_sort == current_key_sort: return _FAILED_SIGNATURE - value = ("dict", tuple(item for _, item in ordered_items)) - sort_key = ("dict", tuple(sort_key for sort_key, _ in ordered_items)) + value = ("dict", tuple((key_value, value_value) for _, _, key_value, value_value in ordered_items)) + sort_key = ("dict", tuple((key_sort, value_sort) for key_sort, value_sort, _, _ in ordered_items)) elif obj_type is list or obj_type is tuple: try: items = list(obj) diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index a7dffcee0..9e813eec7 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -240,6 +240,28 @@ def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_ assert isinstance(sanitized, caching.Unhashable) +def test_signature_to_hashable_fails_closed_on_dict_key_sort_collisions_even_with_distinct_values(caching_module, monkeypatch): + """Different values must not mask dict key-sort collisions during canonicalization.""" + caching, _ = caching_module + original = caching._signature_to_hashable_impl + key_a = object() + key_b = object() + + def colliding_key_canonicalize(obj, *args, **kwargs): + """Force two distinct raw keys to share the same canonical sort key.""" + if obj is key_a: + return ("key-a", ("COLLIDE",)) + if obj is key_b: + return ("key-b", ("COLLIDE",)) + return original(obj, *args, **kwargs) + + monkeypatch.setattr(caching, "_signature_to_hashable_impl", colliding_key_canonicalize) + + sanitized = caching._signature_to_hashable({key_a: 1, key_b: 2}) + + assert isinstance(sanitized, caching.Unhashable) + + @pytest.mark.parametrize( "container_factory", [ From dbed5a1b52ba5ce80dd938a446fb513971114885 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 07:39:10 +0100 Subject: [PATCH 21/24] Replace sanitize and hash passes --- comfy_execution/caching.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 130b0dd5e..02e36ec68 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -417,9 +417,11 @@ class CacheKeySetInputSignature(CacheKeySet): return _signature_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 immediate cache-signature fragment for a node. - Link inputs are reduced to ancestor references, while raw values are sanitized first. + Link inputs are reduced to ancestor references here, while non-link + values are appended as-is. Full canonicalization happens later in + `get_node_signature()` via `_signature_to_hashable()`. """ if not dynprompt.has_node(node_id): # This node doesn't exist -- we can't cache it. From f1d91a4c8cdb1dad6b0363ac8d8951ff08fdcf13 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 16:14:23 +0100 Subject: [PATCH 22/24] Prevent canonicalizing is_changed --- comfy_execution/caching.py | 18 +++++++++- tests-unit/execution_test/caching_test.py | 44 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 02e36ec68..0342d7ec2 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -67,6 +67,22 @@ _MAX_SIGNATURE_CONTAINER_VISITS = 10_000 _FAILED_SIGNATURE = object() +def _shallow_is_changed_signature(value): + """Sanitize execution-time `is_changed` values without deep recursion.""" + value_type = type(value) + if value_type in _PRIMITIVE_SIGNATURE_TYPES: + return value + if value_type is list or value_type is tuple: + try: + items = tuple(value) + except RuntimeError: + return Unhashable() + if all(type(item) in _PRIMITIVE_SIGNATURE_TYPES for item in items): + container_tag = "is_changed_list" if value_type is list else "is_changed_tuple" + return (container_tag, items) + return Unhashable() + + def _primitive_signature_sort_key(obj): """Return a deterministic ordering key for primitive signature values.""" obj_type = type(obj) @@ -429,7 +445,7 @@ class CacheKeySetInputSignature(CacheKeySet): node = dynprompt.get_node(node_id) class_type = node["class_type"] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] - signature = [class_type, await self.is_changed_cache.get(node_id)] + signature = [class_type, _shallow_is_changed_signature(await self.is_changed_cache.get(node_id))] if self.include_node_id_in_input() or (hasattr(class_def, "NOT_IDEMPOTENT") and class_def.NOT_IDEMPOTENT) or include_unique_id_in_input(class_type): signature.append(node_id) inputs = node["inputs"] diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index 9e813eec7..c86359aa9 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -309,3 +309,47 @@ def test_get_node_signature_returns_top_level_unhashable_for_tainted_signature(c signature = asyncio.run(key_set.get_node_signature(dynprompt, "node")) assert isinstance(signature, caching.Unhashable) + + +def test_shallow_is_changed_signature_accepts_primitive_lists(caching_module): + """Primitive-only `is_changed` lists should stay hashable without deep descent.""" + caching, _ = caching_module + + sanitized = caching._shallow_is_changed_signature([1, "two", None, True]) + + assert sanitized == ("is_changed_list", (1, "two", None, True)) + + +def test_shallow_is_changed_signature_fails_closed_on_nested_containers(caching_module): + """Nested containers from `is_changed` should be rejected immediately.""" + caching, _ = caching_module + + sanitized = caching._shallow_is_changed_signature([1, ["nested"]]) + + assert isinstance(sanitized, caching.Unhashable) + + +def test_get_immediate_node_signature_marks_recursive_is_changed_unhashable(caching_module, monkeypatch): + """Recursive `is_changed` payloads should be cut off before signature canonicalization.""" + caching, nodes_module = caching_module + monkeypatch.setitem(nodes_module.NODE_CLASS_MAPPINGS, "UnitTestNode", _DummyNode) + + is_changed_value = [] + is_changed_value.append(is_changed_value) + dynprompt = _FakeDynPrompt( + { + "node": { + "class_type": "UnitTestNode", + "inputs": {"value": 5}, + } + } + ) + key_set = caching.CacheKeySetInputSignature( + dynprompt, + ["node"], + _FakeIsChangedCache({"node": is_changed_value}), + ) + + signature = asyncio.run(key_set.get_immediate_node_signature(dynprompt, "node", {})) + + assert isinstance(signature[1], caching.Unhashable) From 4c5f82971eb7f33dc7ebccde24534d3b7b07d98a Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 16:44:25 +0100 Subject: [PATCH 23/24] Restrict is_changed canonicalization --- comfy_execution/caching.py | 23 +++++++++++++++++----- tests-unit/execution_test/caching_test.py | 24 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 0342d7ec2..ca8b15918 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -246,7 +246,10 @@ def _signature_to_hashable_impl(obj, depth=0, max_depth=_MAX_SIGNATURE_DEPTH, ac def _signature_to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): """Build the final cache-signature representation in one fail-closed pass.""" - result = _signature_to_hashable_impl(obj, budget={"remaining": max_nodes}) + try: + result = _signature_to_hashable_impl(obj, budget={"remaining": max_nodes}) + except RuntimeError: + return Unhashable() if result is _FAILED_SIGNATURE: return Unhashable() return result[0] @@ -320,10 +323,20 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): items = snapshots.pop(current_id, None) if items is None: items = list(current.items()) - memo[current_id] = ( - "dict", - tuple((resolve_value(k), resolve_value(v)) for k, v in items), - ) + ordered_items = [ + (_sanitized_sort_key(k, memo=sort_memo), resolve_value(k), resolve_value(v)) + for k, v in items + ] + ordered_items.sort(key=lambda item: item[0]) + for index in range(1, len(ordered_items)): + if ordered_items[index - 1][0] == ordered_items[index][0]: + memo[current_id] = Unhashable() + break + else: + memo[current_id] = ( + "dict", + tuple((key, value) for _, key, value in ordered_items), + ) elif current_type is list: items = snapshots.pop(current_id, None) if items is None: diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index c86359aa9..c96f40722 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -205,6 +205,17 @@ def test_to_hashable_handles_shared_builtin_substructures(caching_module): assert hashable[1][0][0] == "list" +def test_to_hashable_canonicalizes_dict_insertion_order(caching_module): + """Dicts with the same content should hash identically regardless of insertion order.""" + caching, _ = caching_module + + first = {"b": 2, "a": 1} + second = {"a": 1, "b": 2} + + assert caching.to_hashable(first) == ("dict", (("a", 1), ("b", 2))) + assert caching.to_hashable(first) == caching.to_hashable(second) + + @pytest.mark.parametrize( "container_factory", [ @@ -227,6 +238,19 @@ def test_to_hashable_fails_closed_on_runtimeerror(caching_module, monkeypatch, c assert isinstance(hashable, caching.Unhashable) +def test_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): + """Ambiguous dict key ordering should fail closed instead of using insertion order.""" + caching, _ = caching_module + ambiguous = { + _OpaqueValue(): 1, + _OpaqueValue(): 2, + } + + hashable = caching.to_hashable(ambiguous) + + assert isinstance(hashable, caching.Unhashable) + + def test_signature_to_hashable_fails_closed_for_ambiguous_dict_ordering(caching_module): """Ambiguous dict sort ties should fail closed instead of depending on input order.""" caching, _ = caching_module From 088778c35d4e53dd4c81bb2fc07f7a9999103e5c Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 17:06:20 +0100 Subject: [PATCH 24/24] Stop canonicalizing is_changed --- comfy_execution/caching.py | 21 +++++++++++++++++++-- tests-unit/execution_test/caching_test.py | 9 +++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index ca8b15918..d3a7ec52a 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -283,6 +283,10 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): return value return memo.get(id(value), Unhashable()) + def is_failed(value): + """Return whether a resolved child value represents failed canonicalization.""" + return type(value) is Unhashable + def resolve_unordered_values(current_items, container_tag): """Resolve a set-like container or fail closed if ordering is ambiguous.""" try: @@ -290,6 +294,8 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): (_sanitized_sort_key(item, memo=sort_memo), resolve_value(item)) for item in current_items ] + if any(is_failed(value) for _, value in ordered_items): + return Unhashable() ordered_items.sort(key=lambda item: item[0]) except RuntimeError: return Unhashable() @@ -327,6 +333,9 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): (_sanitized_sort_key(k, memo=sort_memo), resolve_value(k), resolve_value(v)) for k, v in items ] + if any(is_failed(key) or is_failed(value) for _, key, value in ordered_items): + memo[current_id] = Unhashable() + continue ordered_items.sort(key=lambda item: item[0]) for index in range(1, len(ordered_items)): if ordered_items[index - 1][0] == ordered_items[index][0]: @@ -341,12 +350,20 @@ def to_hashable(obj, max_nodes=_MAX_SIGNATURE_CONTAINER_VISITS): items = snapshots.pop(current_id, None) if items is None: items = list(current) - memo[current_id] = ("list", tuple(resolve_value(item) for item in items)) + resolved_items = tuple(resolve_value(item) for item in items) + if any(is_failed(item) for item in resolved_items): + memo[current_id] = Unhashable() + else: + memo[current_id] = ("list", resolved_items) elif current_type is tuple: items = snapshots.pop(current_id, None) if items is None: items = list(current) - memo[current_id] = ("tuple", tuple(resolve_value(item) for item in items)) + resolved_items = tuple(resolve_value(item) for item in items) + if any(is_failed(item) for item in resolved_items): + memo[current_id] = Unhashable() + else: + memo[current_id] = ("tuple", resolved_items) elif current_type is set: items = snapshots.pop(current_id, None) if items is None: diff --git a/tests-unit/execution_test/caching_test.py b/tests-unit/execution_test/caching_test.py index c96f40722..943f72586 100644 --- a/tests-unit/execution_test/caching_test.py +++ b/tests-unit/execution_test/caching_test.py @@ -205,6 +205,15 @@ def test_to_hashable_handles_shared_builtin_substructures(caching_module): assert hashable[1][0][0] == "list" +def test_to_hashable_fails_closed_for_ordered_container_with_opaque_child(caching_module): + """Ordered containers should fail closed when a child cannot be canonicalized.""" + caching, _ = caching_module + + result = caching.to_hashable([object()]) + + assert isinstance(result, caching.Unhashable) + + def test_to_hashable_canonicalizes_dict_insertion_order(caching_module): """Dicts with the same content should hash identically regardless of insertion order.""" caching, _ = caching_module