Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions comfy/comfy_types/node_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ class HiddenInputTypeDict(TypedDict):
"""EXTRA_PNGINFO is a dictionary that will be copied into the metadata of any .png files saved. Custom nodes can store additional information in this dictionary for saving (or as a way to communicate with a downstream node)."""
dynprompt: NotRequired[Literal["DYNPROMPT"]]
"""DYNPROMPT is an instance of comfy_execution.graph.DynamicPrompt. It differs from PROMPT in that it may mutate during the course of execution in response to Node Expansion."""
prompt_id: NotRequired[Literal["PROMPT_ID"]]
"""PROMPT_ID is the unique identifier of the current prompt/job being executed. Useful for associating progress updates with specific jobs."""


class InputTypeDict(TypedDict):
Expand Down
1 change: 1 addition & 0 deletions comfy_api/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Default server capabilities
SERVER_FEATURE_FLAGS: dict[str, Any] = {
"supports_preview_metadata": True,
"supports_progress_text_metadata": True,
"max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes
"extension": {"manager": {"supports_v4": True}},
"node_replacements": True,
Expand Down
22 changes: 21 additions & 1 deletion comfy_api/latest/_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -1282,9 +1282,16 @@ class V3Data(TypedDict):
'When True, the value of the dynamic input will be in the format (value, path_key).'

class HiddenHolder:
"""Holds hidden input values resolved during node execution.

Hidden inputs are special values automatically provided by the execution
engine (e.g., node ID, prompt data, authentication tokens) rather than
being connected by the user in the graph.
"""
def __init__(self, unique_id: str, prompt: Any,
extra_pnginfo: Any, dynprompt: Any,
auth_token_comfy_org: str, api_key_comfy_org: str, **kwargs):
auth_token_comfy_org: str, api_key_comfy_org: str,
prompt_id: str = None, **kwargs):
self.unique_id = unique_id
"""UNIQUE_ID is the unique identifier of the node, and matches the id property of the node on the client side. It is commonly used in client-server communications (see messages)."""
self.prompt = prompt
Expand All @@ -1297,13 +1304,23 @@ def __init__(self, unique_id: str, prompt: Any,
"""AUTH_TOKEN_COMFY_ORG is a token acquired from signing into a ComfyOrg account on frontend."""
self.api_key_comfy_org = api_key_comfy_org
"""API_KEY_COMFY_ORG is an API Key generated by ComfyOrg that allows skipping signing into a ComfyOrg account on frontend."""
self.prompt_id = prompt_id
"""PROMPT_ID is the unique identifier of the current prompt/job being executed."""

def __getattr__(self, key: str):
'''If hidden variable not found, return None.'''
return None

@classmethod
def from_dict(cls, d: dict | None):
"""Create a HiddenHolder from a dictionary of hidden input values.

Args:
d: Dictionary mapping Hidden enum values to their resolved values.

Returns:
A new HiddenHolder instance with values populated from the dict.
"""
if d is None:
d = {}
return cls(
Expand All @@ -1313,6 +1330,7 @@ def from_dict(cls, d: dict | None):
dynprompt=d.get(Hidden.dynprompt, None),
auth_token_comfy_org=d.get(Hidden.auth_token_comfy_org, None),
api_key_comfy_org=d.get(Hidden.api_key_comfy_org, None),
prompt_id=d.get(Hidden.prompt_id, None),
)

@classmethod
Expand All @@ -1335,6 +1353,8 @@ class Hidden(str, Enum):
"""AUTH_TOKEN_COMFY_ORG is a token acquired from signing into a ComfyOrg account on frontend."""
api_key_comfy_org = "API_KEY_COMFY_ORG"
"""API_KEY_COMFY_ORG is an API Key generated by ComfyOrg that allows skipping signing into a ComfyOrg account on frontend."""
prompt_id = "PROMPT_ID"
"""PROMPT_ID is the unique identifier of the current prompt/job being executed. Useful for associating progress updates with specific jobs."""


@dataclass
Expand Down
16 changes: 15 additions & 1 deletion comfy_api_nodes/util/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from comfy import utils
from comfy_api.latest import IO
from comfy_execution.utils import get_executing_context
from server import PromptServer

from . import request_logger
Expand Down Expand Up @@ -440,6 +441,17 @@ def _display_text(
status: str | int | None = None,
price: float | None = None,
) -> None:
"""Send a progress text message to the client for display on a node.

Assembles status, price, and text lines, then sends them via WebSocket.
Automatically retrieves the current prompt_id from the execution context.

Args:
node_cls: The ComfyNode class sending the progress text.
text: Optional text content to display.
status: Optional status string or code to display.
price: Optional price in dollars to display as credits.
"""
display_lines: list[str] = []
if status:
display_lines.append(f"Status: {status.capitalize() if isinstance(status, str) else status}")
Expand All @@ -450,7 +462,9 @@ def _display_text(
if text is not None:
display_lines.append(text)
if display_lines:
PromptServer.instance.send_progress_text("\n".join(display_lines), get_node_id(node_cls))
ctx = get_executing_context()
prompt_id = ctx.prompt_id if ctx is not None else None
PromptServer.instance.send_progress_text("\n".join(display_lines), get_node_id(node_cls), prompt_id=prompt_id)


def _display_time_progress(
Expand Down
4 changes: 2 additions & 2 deletions comfy_extras/nodes_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ def define_schema(cls):
IO.Int.Output(display_name="height"),
IO.Int.Output(display_name="batch_size"),
],
hidden=[IO.Hidden.unique_id],
hidden=[IO.Hidden.unique_id, IO.Hidden.prompt_id],
)

@classmethod
Expand All @@ -578,7 +578,7 @@ def execute(cls, image) -> IO.NodeOutput:

# Send progress text to display size on the node
if cls.hidden.unique_id:
PromptServer.instance.send_progress_text(f"width: {width}, height: {height}\n batch size: {batch_size}", cls.hidden.unique_id)
PromptServer.instance.send_progress_text(f"width: {width}, height: {height}\n batch size: {batch_size}", cls.hidden.unique_id, prompt_id=cls.hidden.prompt_id)

return IO.NodeOutput(width, height, batch_size)

Expand Down
8 changes: 6 additions & 2 deletions execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def recursive_debug_dump(self):

SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_comfy_org", "api_key_comfy_org")

def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}):
def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}, prompt_id=None):
is_v3 = issubclass(class_def, _ComfyNodeInternal)
v3_data: io.V3Data = {}
hidden_inputs_v3 = {}
Expand Down Expand Up @@ -197,6 +197,8 @@ def mark_missing():
hidden_inputs_v3[io.Hidden.auth_token_comfy_org] = extra_data.get("auth_token_comfy_org", None)
if io.Hidden.api_key_comfy_org.name in hidden:
hidden_inputs_v3[io.Hidden.api_key_comfy_org] = extra_data.get("api_key_comfy_org", None)
if io.Hidden.prompt_id.name in hidden:
hidden_inputs_v3[io.Hidden.prompt_id] = prompt_id
else:
if "hidden" in valid_inputs:
h = valid_inputs["hidden"]
Expand All @@ -213,6 +215,8 @@ def mark_missing():
input_data_all[x] = [extra_data.get("auth_token_comfy_org", None)]
if h[x] == "API_KEY_COMFY_ORG":
input_data_all[x] = [extra_data.get("api_key_comfy_org", None)]
if h[x] == "PROMPT_ID":
input_data_all[x] = [prompt_id]
v3_data["hidden_inputs"] = hidden_inputs_v3
return input_data_all, missing_keys, v3_data

Expand Down Expand Up @@ -470,7 +474,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
has_subgraph = False
else:
get_progress_state().start_progress(unique_id)
input_data_all, missing_keys, v3_data = get_input_data(inputs, class_def, unique_id, execution_list, dynprompt, extra_data)
input_data_all, missing_keys, v3_data = get_input_data(inputs, class_def, unique_id, execution_list, dynprompt, extra_data, prompt_id=prompt_id)
if server.client_id is not None:
server.last_node_id = display_node_id
server.send_sync("executing", { "node": unique_id, "display_node": display_node_id, "prompt_id": prompt_id }, server.client_id)
Expand Down
42 changes: 38 additions & 4 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,13 +1267,47 @@ def trigger_on_prompt(self, json_data):
return json_data

def send_progress_text(
self, text: Union[bytes, bytearray, str], node_id: str, sid=None
self,
text: Union[bytes, bytearray, str],
node_id: str,
prompt_id: Optional[str] = None,
sid=None,
):
"""Send a progress text message to the client via WebSocket.

Encodes the text as a binary message with length-prefixed node_id. When
the client supports the ``supports_progress_text_metadata`` feature flag,
the prompt_id is always prepended as a length-prefixed field (empty string
when None) to ensure consistent binary framing.

Args:
text: The progress text content to send.
node_id: The unique identifier of the node sending the progress.
prompt_id: Optional prompt/job identifier to associate the message with.
sid: Optional session ID to target a specific client.
"""
if isinstance(text, str):
text = text.encode("utf-8")
node_id_bytes = str(node_id).encode("utf-8")

# Pack the node_id length as a 4-byte unsigned integer, followed by the node_id bytes
message = struct.pack(">I", len(node_id_bytes)) + node_id_bytes + text
# Auto-resolve sid to the currently executing client
target_sid = sid if sid is not None else self.client_id

# When client supports the new format, always send
# [prompt_id_len][prompt_id][node_id_len][node_id][text]
# even when prompt_id is None (encoded as zero-length string)
if feature_flags.supports_feature(
self.sockets_metadata, target_sid, "supports_progress_text_metadata"
):
prompt_id_bytes = (prompt_id or "").encode("utf-8")
message = (
struct.pack(">I", len(prompt_id_bytes))
+ prompt_id_bytes
+ struct.pack(">I", len(node_id_bytes))
+ node_id_bytes
+ text
)
else:
message = struct.pack(">I", len(node_id_bytes)) + node_id_bytes + text

self.send_sync(BinaryEventTypes.TEXT, message, sid)
self.send_sync(BinaryEventTypes.TEXT, message, target_sid)
Comment on lines 1269 to +1313
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the default socket lookup prompt-scoped before parallel execution.

Line 1294 still resolves target_sid from the single shared self.client_id. Once two prompts execute concurrently, whichever prompt updates that field last wins, so progress text for another prompt can be delivered to the wrong client; if it is unset, Line 1313 also falls back to the broadcast path. The new prompt_id metadata only helps if the frame reaches the correct socket, so this fallback should resolve from a prompt-scoped client mapping or require callers to pass sid explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 1269 - 1313, The code currently uses self.client_id
as the default target_sid which races between concurrent prompts; change
send_progress_text so when sid is None it first tries a prompt-scoped lookup
(e.g. target_sid = self.prompt_client_map.get(prompt_id) when prompt_id is
provided) and only if that returns None fall back to self.client_id, and ensure
callers that expect broadcast must pass sid explicitly; update
send_progress_text (and create/maintain self.prompt_client_map if not present)
to use this prompt-scoped resolution before using self.client_id and avoid
relying on a single shared client_id for per-prompt routing.

Loading