fix: preserve image-list target proof after cache fingerprinting
This commit is contained in:
+19
-3
@@ -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
|
||||
Reference in New Issue
Block a user