Files
speedyrulzandClaude Fable 5.1 44cfb46a72 fix: settle unacknowledged submissions by asking the worker about the run itself
Second round of review follow-ups for the next-free-GPU dispatcher.

Ambiguous acceptance is no longer inferred from aggregate queue depth. The
dispatcher asks the worker about the specific run: a prompt carrying one
of its job ids in the worker's running or pending queue, or in its recent
history, means the run was accepted and its result is collected (or
reported) there; only after consecutive checks find no trace of it is the
original prompt restored. A worker that cannot be asked keeps the run on
hold. The same question decides when a held delegate prompt must be queued
because the worker finished (or lost) the run without delivering.

An eagerly dispatched run whose acknowledgement was lost is now collected
after the worker delivers: the record remembers that no master prompt was
ever queued for it, so acceptance queues its collector instead of treating
it like a collector that is already executing.

Cancellation now reaches the queue-insertion boundary: queue_prompt_payload
takes an insert_if guard that is checked under the queue mutex right before
the item is put, after validation has yielded. The delegate requeue passes
the run's cancellation flag, so a clear or delete that lands during
validation refuses the insertion instead of being discovered afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 19:50:59 -04:00

212 lines
7.5 KiB
Python

"""
Async helper utilities for ComfyUI-Distributed.
"""
import asyncio
import contextlib
import threading
import time
import uuid
import execution
import server
from typing import Optional, Any, Coroutine
from .network import get_server_loop
def run_async_in_server_loop(coro: Coroutine, timeout: Optional[float] = None) -> Any:
"""
Run async coroutine in server's event loop and wait for result.
This is useful when you need to run async code from a synchronous context
but want to use the server's existing event loop instead of creating a new one.
Args:
coro: The coroutine to run
timeout: Optional timeout in seconds
Returns:
The result of the coroutine
Raises:
TimeoutError: If the operation times out
Exception: Any exception raised by the coroutine
"""
event = threading.Event()
result = None
error = None
async def wrapper():
nonlocal result, error
try:
result = await coro
except Exception as e:
error = e
finally:
event.set()
# Schedule on server's event loop
loop = get_server_loop()
asyncio.run_coroutine_threadsafe(wrapper(), loop)
# Wait for completion
if not event.wait(timeout):
raise TimeoutError(f"Async operation timed out after {timeout} seconds")
if error:
raise error
return result
prompt_server = server.PromptServer.instance
def _summarize_node_errors(node_errors: dict) -> str:
if not isinstance(node_errors, dict) or not node_errors:
return ""
parts = []
for node_id, entry in node_errors.items():
if not isinstance(entry, dict):
continue
class_type = str(entry.get("class_type") or "UnknownNode")
for err in entry.get("errors", []):
if not isinstance(err, dict):
continue
message = str(err.get("message") or "validation error")
details = str(err.get("details") or "").strip()
parts.append(
f"{class_type}#{node_id}: {message}{f' ({details})' if details else ''}"
)
if len(parts) >= 5:
return " | ".join(parts)
return " | ".join(parts)
class QueueInsertionCancelled(RuntimeError):
"""The prompt was valid but its insertion was refused by ``insert_if``."""
cancelled_insertion = True
class PromptValidationError(RuntimeError):
"""Raised when a prompt fails ComfyUI validation with structured context."""
def __init__(self, error_payload, node_errors=None):
payload = error_payload if isinstance(error_payload, dict) else {
"type": "prompt_validation_failed",
"message": str(error_payload),
"details": "",
"extra_info": {},
}
self.validation_error = dict(payload)
self.node_errors = node_errors if isinstance(node_errors, dict) else {}
if self.node_errors:
details = str(self.validation_error.get("details") or "").strip()
if not details:
summary = _summarize_node_errors(self.node_errors)
if summary:
self.validation_error["details"] = summary
merged = dict(self.validation_error)
if self.node_errors:
merged["node_errors"] = self.node_errors
super().__init__(f"Invalid prompt: {merged}")
def validate_execution_targets(prompt, targets):
"""Reject malformed explicit roots instead of falling back to all outputs."""
if (not isinstance(targets, list) or not targets
or any(not isinstance(node_id, str) or not node_id or node_id not in prompt
for node_id in targets)
or len(set(targets)) != len(targets)):
raise PromptValidationError({
"type": "invalid_partial_execution_targets",
"message": "partial_execution_targets must be a non-empty list of unique existing node IDs",
"details": "", "extra_info": {},
})
return list(targets)
async def queue_prompt_payload(
prompt_obj,
workflow_meta=None,
client_id=None,
include_queue_metadata=False,
*,
partial_execution_targets=None,
prompt_id=None,
insert_if=None,
):
"""Validate and queue a prompt, preserving any explicitly requested roots.
``prompt_id`` lets a caller re-queue a prompt under an id it already
handed out (the load-balance dispatcher swaps a waiting prompt for its
delegate form without changing the id clients track).
``insert_if`` is checked under the queue mutex right before insertion,
after validation has yielded to the event loop; when it returns false
the prompt is not queued and ``QueueInsertionCancelled`` is raised.
"""
targets = (validate_execution_targets(prompt_obj, partial_execution_targets)
if partial_execution_targets is not None else None)
payload = {"prompt": prompt_obj}
if targets is not None:
payload["partial_execution_targets"] = list(targets)
payload = prompt_server.trigger_on_prompt(payload)
prompt = payload["prompt"]
if targets is not None:
# Hooks may replace the envelope or mutate its target list. The caller's
# requested roots remain authoritative, but must still exist afterwards.
validate_execution_targets(prompt, targets)
prompt_id = str(prompt_id) if prompt_id else str(uuid.uuid4())
valid = await execution.validate_prompt(prompt_id, prompt, targets)
if not valid[0]:
error_payload = valid[1] if len(valid) > 1 else "Prompt outputs failed validation"
node_errors = valid[3] if len(valid) > 3 else {}
raise PromptValidationError(error_payload, node_errors)
if targets is not None:
node_errors = valid[3] if len(valid) > 3 else {}
target_errors = {
node_id: entry for node_id, entry in node_errors.items()
if node_id in targets or (isinstance(entry, dict)
and set(entry.get("dependent_outputs", [])) & set(targets))
}
if set(valid[2]) != set(targets) or target_errors:
raise PromptValidationError({
"type": "partial_execution_targets_failed_validation",
"message": "Not all requested execution targets passed validation",
"details": "Refusing to queue a subset or additional output roots",
"extra_info": {"requested_targets": targets},
}, node_errors)
extra_data = {"create_time": int(time.time() * 1000)}
if workflow_meta:
extra_data.setdefault("extra_pnginfo", {})["workflow"] = workflow_meta
if client_id:
extra_data["client_id"] = client_id
sensitive = {}
for key in getattr(execution, "SENSITIVE_EXTRA_DATA_KEYS", []):
if key in extra_data:
sensitive[key] = extra_data.pop(key)
prompt_queue = prompt_server.prompt_queue
mutex = getattr(prompt_queue, "mutex", None)
with (mutex if mutex is not None else contextlib.nullcontext()):
if insert_if is not None and not insert_if():
raise QueueInsertionCancelled(f"Prompt {prompt_id} was cancelled before it could be queued")
number = getattr(prompt_server, "number", 0)
prompt_server.number = number + 1
prompt_queue_item = (number, prompt_id, prompt, extra_data, valid[2], sensitive)
prompt_queue.put(prompt_queue_item)
if include_queue_metadata:
return {
"prompt_id": prompt_id,
"number": number,
"node_errors": {},
}
return prompt_id