ComfyUI/tests-unit/server_test/test_prompt_metadata.py
dante01yoon 5396b4fe67 Propagate workflow_id via per-prompt metadata registry (FE-745)
PR #13684 added workflow_id directly to ~9 dict literals across execution.py,
progress.py and main.py, along with executor.workflow_id and
server.last_workflow_id state. It was reverted because the execution layer
should not know about workflow concepts and because a finally-clear race
emitted workflow_id=None on the terminal "executing" frame.

Instead, register per-prompt metadata on PromptServer at submission time
and merge it onto outbound WebSocket payloads inside send_sync. The merge
keys off prompt_id (already present on every execution event), so
execution.py stays workflow-agnostic. Metadata is unregistered in main.py's
queue loop AFTER the terminal executing send, which structurally removes
the race.

- New comfy_execution/metadata.py: PromptMetadata TypedDict +
  build_prompt_metadata + merge_prompt_metadata helpers.
- PromptServer: prompt_metadata registry (lock-protected), register on
  post_prompt, merge in send_sync, expose get_prompt_metadata.
- jobs.py: extracted extract_workflow_id with strict isinstance guards;
  _extract_job_metadata delegates.
- main.py: try/finally around the queue iteration; unregister after the
  terminal "executing: {node: None}" send.
- execution.py PromptQueue: drop registry entries on wipe_queue /
  delete_queue_item so cancellations don't leak.
- progress.py: look up workflow_id from the server registry for the
  per-node nested copies and the binary preview metadata, matching #13684's
  wire shape so the frontend needs no changes.
- Tests: tests-unit/server_test/test_prompt_metadata.py covers the merge,
  the passthrough cases (no prompt_id, unknown prompt_id, binary payloads),
  and the terminal-frame race regression.
2026-05-19 17:16:11 +09:00

111 lines
4.6 KiB
Python

"""Tests for the per-prompt metadata registry used to propagate ``workflow_id``
through WebSocket events without coupling ``execution.py`` to workflow-level
concepts.
The registry is a dict on ``PromptServer`` (keyed by ``prompt_id``) registered
at submission time (``post_prompt``), merged onto outbound payloads in
``send_sync`` via ``merge_prompt_metadata``, and dropped in ``main.py`` *after*
the terminal ``executing: {node: None}`` send so the final frame carries the
same ``workflow_id`` as the rest of the prompt.
"""
import threading
import pytest
from comfy_execution.metadata import (
build_prompt_metadata,
merge_prompt_metadata,
)
@pytest.fixture
def registry():
return {}
@pytest.fixture
def lock():
return threading.Lock()
class TestBuildPromptMetadata:
def test_returns_workflow_id_when_present(self):
extra_data = {"extra_pnginfo": {"workflow": {"id": "wf-1"}}}
assert build_prompt_metadata(extra_data) == {"workflow_id": "wf-1"}
def test_empty_when_workflow_id_missing(self):
assert build_prompt_metadata({}) == {}
assert build_prompt_metadata({"extra_pnginfo": {}}) == {}
assert build_prompt_metadata({"extra_pnginfo": {"workflow": {}}}) == {}
def test_empty_when_workflow_id_not_a_non_empty_string(self):
assert build_prompt_metadata({"extra_pnginfo": {"workflow": {"id": ""}}}) == {}
assert build_prompt_metadata({"extra_pnginfo": {"workflow": {"id": 42}}}) == {}
assert build_prompt_metadata({"extra_pnginfo": {"workflow": {"id": None}}}) == {}
def test_empty_on_non_dict_input(self):
assert build_prompt_metadata(None) == {}
assert build_prompt_metadata("not a dict") == {}
class TestMergeMetadata:
"""``merge_prompt_metadata`` is the transparent injection point used by
``PromptServer.send_sync``. Event payload fields win on conflict so the
executor can never be overridden by stale registry data, and binary payloads
(the preview tuple) pass through untouched."""
def test_merges_workflow_id_when_prompt_id_known(self, registry, lock):
registry["p1"] = {"workflow_id": "wf-1"}
merged = merge_prompt_metadata(registry, lock, {"node": "n1", "prompt_id": "p1"})
assert merged == {"node": "n1", "prompt_id": "p1", "workflow_id": "wf-1"}
def test_passthrough_when_prompt_id_unknown(self, registry, lock):
merged = merge_prompt_metadata(registry, lock, {"node": "n1", "prompt_id": "missing"})
assert merged == {"node": "n1", "prompt_id": "missing"}
def test_passthrough_when_no_prompt_id(self, registry, lock):
registry["p1"] = {"workflow_id": "wf-1"}
merged = merge_prompt_metadata(registry, lock, {"status": {"queue_remaining": 0}})
assert merged == {"status": {"queue_remaining": 0}}
def test_passthrough_for_non_dict_payload(self, registry, lock):
registry["p1"] = {"workflow_id": "wf-1"}
binary = (b"image-bytes", {"prompt_id": "p1"})
assert merge_prompt_metadata(registry, lock, binary) is binary
def test_event_payload_wins_over_registered_metadata(self, registry, lock):
registry["p1"] = {"workflow_id": "wf-registered"}
merged = merge_prompt_metadata(
registry, lock, {"prompt_id": "p1", "workflow_id": "wf-caller"}
)
assert merged["workflow_id"] == "wf-caller"
class TestRaceRegressionForTerminalExecutingFrame:
"""Regression for the PR #13684 finally-clear race.
In the reverted PR, the executor's ``finally`` cleared ``last_workflow_id``
before ``main.py`` emitted the post-completion ``executing: {node: None}``
frame — so that terminal frame shipped ``workflow_id=None``.
With the registry approach, ``main.py`` unregisters *after* the terminal
send, so the merge sees the registered metadata. This test simulates that
ordering to lock in the contract.
"""
def test_terminal_executing_frame_includes_workflow_id(self, registry, lock):
registry["p1"] = {"workflow_id": "wf-1"}
# main.py emits the terminal frame BEFORE unregistering.
terminal = merge_prompt_metadata(registry, lock, {"node": None, "prompt_id": "p1"})
registry.pop("p1", None) # main.py's finally: unregister_prompt_metadata
assert terminal == {"node": None, "prompt_id": "p1", "workflow_id": "wf-1"}
# After unregister, any straggler events emitted by extensions after
# completion are no longer decorated. Verifies the registry is actually
# released, not just shadowed.
straggler = merge_prompt_metadata(registry, lock, {"node": None, "prompt_id": "p1"})
assert "workflow_id" not in straggler