From f1d91a4c8cdb1dad6b0363ac8d8951ff08fdcf13 Mon Sep 17 00:00:00 2001 From: xmarre Date: Sun, 15 Mar 2026 16:14:23 +0100 Subject: [PATCH] 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)