fix: preserve image-list target proof after cache fingerprinting

This commit is contained in:
Robert Wojciechowski
2026-10-14 04:37:56 +00:00
parent efa5dd624c
commit f149cbdf4d
3 changed files with 160 additions and 3 deletions
+19 -3
View File
@@ -14,6 +14,20 @@ DEFAULT_ORPHAN_GRACE_SECONDS = 300.0
_LIFECYCLE_MARKER = object()
def _without_runtime_fingerprints(prompt: dict) -> dict:
"""Compare submitted structure without ComfyUI's per-node cache annotation.
PromptQueue snapshots the graph before IsChangedCache adds ``is_changed``
to executing nodes. Ignore only that top-level field, never inputs or nested
metadata, and leave both the queued and executing graphs untouched.
"""
return {
node_id: {key: value for key, value in node.items() if key != "is_changed"}
if isinstance(node, dict) else node
for node_id, node in prompt.items()
}
def has_safe_execution_targets(prompt_server: Any, prompt_id: str | None, prompt: dict) -> bool:
"""Prove outer roots from the running queue, not a forgeable prompt marker.
@@ -23,8 +37,9 @@ def has_safe_execution_targets(prompt_server: Any, prompt_id: str | None, prompt
if not prompt_id:
return False
from .execution_plan import prepare_image_list_execution
prepared, targets = prepare_image_list_execution(prompt)
if prepared != prompt or not targets:
execution_prompt = _without_runtime_fingerprints(prompt)
prepared, targets = prepare_image_list_execution(execution_prompt)
if prepared != execution_prompt or not targets:
return False
queue = getattr(prompt_server, "prompt_queue", None)
for name in ("get_current_queue_volatile", "get_current_queue"):
@@ -35,7 +50,8 @@ def has_safe_execution_targets(prompt_server: Any, prompt_id: str | None, prompt
running, _pending = reader()
for entry in running:
if (isinstance(entry, (tuple, list)) and len(entry) > 4
and entry[1] == prompt_id and entry[2] == prompt):
and entry[1] == prompt_id and isinstance(entry[2], dict)
and _without_runtime_fingerprints(entry[2]) == execution_prompt):
roots = entry[4]
return (isinstance(roots, (list, tuple))
and all(isinstance(root, str) for root in roots)
@@ -0,0 +1,60 @@
"""CPU-only proof against real PromptQueue and IsChangedCache; no services."""
import asyncio
from pathlib import Path
import sys
from types import SimpleNamespace
core = Path(sys.argv[1]).resolve()
root = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(core))
import comfy.cli_args
comfy.cli_args.args.cpu = True
import execution
import nodes
from comfy_execution.graph import DynamicPrompt
sys.path.insert(0, str(root))
from image_list.lifecycle import has_safe_execution_targets
class Source:
RETURN_TYPES = ('IMAGE',)
@classmethod
def INPUT_TYPES(cls):
return {'required': {'seed': ('INT',)}}
@classmethod
def IS_CHANGED(cls, seed):
return f'fingerprint-{seed}'
nodes.NODE_CLASS_MAPPINGS['ProofSource'] = Source
nodes.NODE_CLASS_MAPPINGS['ProofUtility'] = type('Utility', (), {'OUTPUT_NODE': True})
prompt = {
'source': {'class_type': 'ProofSource', 'inputs': {'seed': 7}},
'start': {'class_type': 'DistributedImageListStart', 'inputs': {'images': ['source', 0]}},
'op': {'class_type': 'ProofUtility', 'inputs': {'image': ['start', 0]}},
'end': {'class_type': 'DistributedImageListEnd', 'inputs': {
'images': ['op', 0], 'context': ['start', 3]}},
}
server = SimpleNamespace(queue_updated=lambda: None)
queue = execution.PromptQueue(server)
server.prompt_queue = queue
queue.put((0, 'owned', prompt, {}, ['end']))
item, _ = queue.get(timeout=0)
assert item[2] is prompt
snapshot = queue.get_current_queue_volatile()[0][0][2]
assert snapshot is not prompt
assert has_safe_execution_targets(server, 'owned', prompt)
cache = execution.IsChangedCache('owned', DynamicPrompt(prompt), None)
assert asyncio.run(cache.get('source')) == ['fingerprint-7']
assert prompt['source']['is_changed'] == ['fingerprint-7']
assert 'is_changed' not in snapshot['source']
assert has_safe_execution_targets(server, 'owned', prompt), 'core fingerprint invalidated safe roots'
prompt['source']['inputs']['seed'] = 8
assert not has_safe_execution_targets(server, 'owned', prompt), 'changed input accepted'
prompt['source']['inputs']['seed'] = 7
assert not has_safe_execution_targets(server, 'not-owned', prompt)
queue.get_current_queue_volatile()[0][0][4].append('op')
assert not has_safe_execution_targets(server, 'owned', prompt), 'unsafe output root accepted'
print('FINGERPRINT_PROOF_OK')
@@ -0,0 +1,81 @@
"""Execution-target proof must survive core cache annotations, not graph edits."""
import copy
import os
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
import pytest
from image_list.lifecycle import has_safe_execution_targets
from test_image_list_master_runtime import graph
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def proof(monkeypatch):
prompt = graph()
queued = copy.deepcopy(prompt)
entry = [0, 'owned', queued, {}, ['end']]
server = SimpleNamespace(prompt_queue=SimpleNamespace(
get_current_queue_volatile=lambda: ([entry], [])))
monkeypatch.setitem(sys.modules, 'nodes', SimpleNamespace(NODE_CLASS_MAPPINGS={
'Operation': type('Flagged', (), {'OUTPUT_NODE': True})}))
return prompt, queued, entry, server
@pytest.mark.parametrize('value', [['digest'], [None], float('nan'), {'nested': [1, 2]}])
@pytest.mark.parametrize('side', ['executing', 'queued', 'both'])
def test_top_level_fingerprints_do_not_invalidate_proof(proof, value, side):
prompt, queued, _, server = proof
if side in ('executing', 'both'):
prompt['source']['is_changed'] = value
if side in ('queued', 'both'):
queued['source']['is_changed'] = ['different queued fingerprint']
assert has_safe_execution_targets(server, 'owned', prompt)
# Comparisons must not strip or rewrite the caller's data.
if side in ('executing', 'both'):
assert prompt['source']['is_changed'] is value
if side in ('queued', 'both'):
assert queued['source']['is_changed'] == ['different queued fingerprint']
@pytest.mark.parametrize('change', ['input', 'link', 'class', 'node_id', 'nested_is_changed', 'other_metadata'])
def test_fingerprint_tolerance_does_not_hide_graph_changes(proof, change):
prompt, _, _, server = proof
prompt['source']['is_changed'] = ['digest']
if change == 'input':
prompt['source']['inputs']['seed'] = 2
elif change == 'link':
prompt['op']['inputs']['image'] = ['start', 1]
elif change == 'class':
prompt['source']['class_type'] = 'OtherSource'
elif change == 'node_id':
prompt['new_source'] = prompt.pop('source')
prompt['start']['inputs']['images'] = ['new_source', 0]
elif change == 'nested_is_changed':
prompt['source']['inputs']['is_changed'] = True
else:
prompt['source']['_meta'] = {'title': 'changed'}
assert not has_safe_execution_targets(server, 'owned', prompt)
@pytest.mark.parametrize('roots', [['op', 'end'], [], ['end', 'end'], None, [True]])
def test_fingerprints_never_grant_unscoped_or_invalid_roots(proof, roots):
prompt, _, entry, server = proof
prompt['source']['is_changed'] = ['digest']
entry[4] = roots
assert not has_safe_execution_targets(server, 'owned', prompt)
def test_real_core_queue_snapshot_and_fingerprint():
core = Path(os.environ.get('COMFYUI_SOURCE_ROOT', str(Path(sys.executable).parents[2])))
if not (core / 'execution.py').exists():
pytest.skip('real ComfyUI source not available beside this interpreter')
result = subprocess.run(
[sys.executable, str(ROOT / 'tests/helpers/image_list_fingerprint_proof.py'), str(core)],
text=True, capture_output=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
assert 'FINGERPRINT_PROOF_OK' in result.stdout