diff --git a/image_list/lifecycle.py b/image_list/lifecycle.py index 3c3d5d7..95f74d1 100644 --- a/image_list/lifecycle.py +++ b/image_list/lifecycle.py @@ -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) diff --git a/tests/helpers/image_list_fingerprint_proof.py b/tests/helpers/image_list_fingerprint_proof.py new file mode 100644 index 0000000..674d67c --- /dev/null +++ b/tests/helpers/image_list_fingerprint_proof.py @@ -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') diff --git a/tests/test_image_list_fingerprint_proof.py b/tests/test_image_list_fingerprint_proof.py new file mode 100644 index 0000000..c7cc48a --- /dev/null +++ b/tests/test_image_list_fingerprint_proof.py @@ -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