fix(ml): comfyui_wait_result no sale prematuro en jobs de video/3D
Exige outputs no vacios (no solo status terminal) para dar por completado un prompt: en jobs pesados ComfyUI marca la entry de /history como terminada antes de poblar outputs, lo que devolvia un dict vacio mientras el job seguia en GPU. Ahora sigue sondeando hasta que los outputs aparecen o hasta agotar el timeout. Timeout default 180s -> 600s (cubre video/3D) y timeout HTTP por-request acotado a 30s. Firma y contrato de retorno intactos. Tests nuevos (mock urllib CI-safe + live opcional contra /history real): golden, regresion del bug, edge imagen corta, timeout y error. v1.0.0 -> 1.1.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,10 @@ name: comfyui_wait_result
|
|||||||
kind: function
|
kind: function
|
||||||
lang: py
|
lang: py
|
||||||
domain: ml
|
domain: ml
|
||||||
version: "1.0.0"
|
version: "1.1.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def comfyui_wait_result(prompt_id: str, server: str = \"127.0.0.1:8188\", timeout: float = 180.0, poll_interval: float = 1.0) -> dict"
|
signature: "def comfyui_wait_result(prompt_id: str, server: str = \"127.0.0.1:8188\", timeout: float = 600.0, poll_interval: float = 1.0) -> dict"
|
||||||
description: "Sondea GET /history/{prompt_id} hasta que un prompt ComfyUI completa (status.completed o status_str success/error) o se agota el timeout. Devuelve los outputs por nodo (node_id -> {images: [...]}). Polling como mecanismo principal (no WebSocket). Impura: HTTP GET en bucle + sleep, solo stdlib."
|
description: "Sondea GET /history/{prompt_id} hasta que un prompt ComfyUI completa con estado de exito (status.completed o status_str success) Y outputs ya poblados, o falla (status_str error), o se agota el timeout. Exige outputs no vacios para no salir prematuro en jobs de video/3D donde la entry de /history aparece marcada como terminada antes de poblar resultados. Devuelve los outputs por nodo (node_id -> {images: [...]}). Polling como mecanismo principal (no WebSocket). Impura: HTTP GET en bucle + sleep, solo stdlib."
|
||||||
tags: [comfyui, ml, image-generation, stable-diffusion, http, polling]
|
tags: [comfyui, ml, image-generation, stable-diffusion, http, polling]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
@@ -20,13 +20,13 @@ params:
|
|||||||
- name: server
|
- name: server
|
||||||
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
desc: "host:port del servidor ComfyUI sin esquema (default '127.0.0.1:8188')."
|
||||||
- name: timeout
|
- name: timeout
|
||||||
desc: "Maximo de segundos a esperar antes de lanzar TimeoutError."
|
desc: "Maximo de segundos a esperar antes de lanzar TimeoutError. Default amplio (600s) para cubrir video/3D; imagenes cortas retornan en cuanto los outputs estan listos."
|
||||||
- name: poll_interval
|
- name: poll_interval
|
||||||
desc: "Segundos entre sondeos de /history."
|
desc: "Segundos entre sondeos de /history."
|
||||||
output: "dict de outputs {node_id: {'images': [{'filename', 'subfolder', 'type'}, ...]}} tal como ComfyUI los expone en history[prompt_id]['outputs']. Para un txt2img, el nodo SaveImage ('9') trae el PNG. Puede contener otros tipos (gifs, texto) segun los nodos del workflow."
|
output: "dict de outputs {node_id: {'images': [{'filename', 'subfolder', 'type'}, ...]}} tal como ComfyUI los expone en history[prompt_id]['outputs']. Siempre no vacio en caso de exito. Para un txt2img, el nodo SaveImage ('9') trae el PNG. El video (SaveVideo) aparece bajo 'images' con 'animated':[True]. Puede contener otros tipos (gifs, texto) segun los nodos del workflow."
|
||||||
tested: false
|
tested: true
|
||||||
tests: []
|
tests: ["Golden: espera hasta que /history puebla outputs no vacios (entry tarda en aparecer)", "Regresion bug: 'done' con outputs vacios NO sale prematuro, espera a que aparezcan", "Edge: imagen corta ya completada en el primer sondeo retorna inmediato", "Edge: status.completed sin status_str success tambien vale si hay outputs", "Error: timeout genuino (prompt inexistente) lanza TimeoutError sin colgar", "Error: status_str 'error' lanza RuntimeError", "Live (skippable): job de video ya completado en /history real devuelve outputs"]
|
||||||
test_file_path: ""
|
test_file_path: "python/functions/ml/tests/test_comfyui_wait_result.py"
|
||||||
file_path: "python/functions/ml/comfyui_wait_result.py"
|
file_path: "python/functions/ml/comfyui_wait_result.py"
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,11 +63,25 @@ a paso) — es portable porque solo depende de HTTP, no de websocket-client.
|
|||||||
|
|
||||||
- Bloquea el hilo (sondea + duerme). Para varias generaciones en paralelo,
|
- Bloquea el hilo (sondea + duerme). Para varias generaciones en paralelo,
|
||||||
encola todas con submit y luego espera cada prompt_id, o usa hilos.
|
encola todas con submit y luego espera cada prompt_id, o usa hilos.
|
||||||
- El timeout por defecto (180s) puede quedarse corto en GPUs lentas o workflows
|
- El timeout por defecto es ahora 600s (antes 180s) para cubrir video/3D. Sigue
|
||||||
pesados (muchos steps, alta resolucion, upscalers). Sube `timeout` segun el
|
pudiendo quedarse corto en workflows muy pesados o GPUs lentas: sube `timeout`
|
||||||
caso. Lanza TimeoutError si se agota.
|
segun el caso. Lanza TimeoutError si se agota.
|
||||||
|
- Considera el job terminado solo cuando el estado es de exito **y** los outputs
|
||||||
|
estan poblados. Si ComfyUI marca la entry como terminada pero aun sin outputs
|
||||||
|
(ocurre en jobs de video/3D mientras la GPU sigue escribiendo), sigue
|
||||||
|
sondeando hasta que aparezcan o hasta agotar el timeout — no devuelve un dict
|
||||||
|
vacio prematuro. Corolario: un workflow sin nodo de guardado (no produce
|
||||||
|
outputs) agota el timeout en vez de devolver {}.
|
||||||
- Lanza RuntimeError si la ejecucion termina con status_str "error" (el detalle
|
- Lanza RuntimeError si la ejecucion termina con status_str "error" (el detalle
|
||||||
del fallo va en el mensaje) o si no se puede conectar al servidor.
|
del fallo va en el mensaje) o si no se puede conectar al servidor.
|
||||||
- Devuelve metadatos de los PNG (filename, subfolder, type), NO los bytes de la
|
- Devuelve metadatos de los PNG/MP4 (filename, subfolder, type), NO los bytes de
|
||||||
imagen. Los archivos quedan en la carpeta output/ del servidor; para leerlos
|
la imagen. Los archivos quedan en la carpeta output/ del servidor; para leerlos
|
||||||
desde otra maquina usa GET /view?filename=...&subfolder=...&type=output.
|
desde otra maquina usa GET /view?filename=...&subfolder=...&type=output.
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
- v1.1.0 (2026-06-24) — fix salida prematura en jobs de video/3D: ahora exige
|
||||||
|
outputs no vacios (no solo status terminal) para dar por completado, sigue
|
||||||
|
sondeando si la entry de /history aparece terminada pero sin outputs, sube el
|
||||||
|
timeout default 180s -> 600s y acota el timeout HTTP por-request a 30s.
|
||||||
|
Anadidos tests (mock urllib + live opcional). Documentado en report 0106.
|
||||||
|
|||||||
@@ -17,37 +17,55 @@ import urllib.request
|
|||||||
def comfyui_wait_result(
|
def comfyui_wait_result(
|
||||||
prompt_id: str,
|
prompt_id: str,
|
||||||
server: str = "127.0.0.1:8188",
|
server: str = "127.0.0.1:8188",
|
||||||
timeout: float = 180.0,
|
timeout: float = 600.0,
|
||||||
poll_interval: float = 1.0,
|
poll_interval: float = 1.0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Espera a que ComfyUI termine de ejecutar un prompt y devuelve sus outputs.
|
"""Espera a que ComfyUI termine de ejecutar un prompt y devuelve sus outputs.
|
||||||
|
|
||||||
Sondea GET /history/{prompt_id} cada poll_interval segundos hasta que
|
Sondea GET /history/{prompt_id} cada poll_interval segundos hasta que el
|
||||||
status.completed es True o status.status_str es "success"/"error", o hasta
|
prompt aparece en /history con un estado terminal de exito (status.completed
|
||||||
agotar el timeout.
|
True o status.status_str "success") **y** outputs ya poblados, o hasta que
|
||||||
|
falla (status_str "error"), o hasta agotar el timeout.
|
||||||
|
|
||||||
|
El requisito de outputs no vacios es deliberado: en jobs pesados (video, 3D)
|
||||||
|
ComfyUI puede exponer la entry de /history con el estado marcado como
|
||||||
|
terminado antes de haber poblado los outputs. Devolver en ese instante daba
|
||||||
|
un dict vacio mientras el job seguia en GPU (salida prematura). Por eso, si
|
||||||
|
el estado dice "terminado" pero los outputs aun no estan, se sigue sondeando
|
||||||
|
hasta que aparezcan o hasta agotar el timeout, que actua como red de
|
||||||
|
seguridad.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt_id: id devuelto por comfyui_submit_workflow.
|
prompt_id: id devuelto por comfyui_submit_workflow.
|
||||||
server: host:port del servidor ComfyUI (sin esquema).
|
server: host:port del servidor ComfyUI (sin esquema).
|
||||||
timeout: maximo de segundos a esperar antes de fallar.
|
timeout: maximo de segundos a esperar antes de fallar. El default amplio
|
||||||
|
(600s) cubre workflows largos de video/3D; para imagenes cortas el
|
||||||
|
retorno sigue siendo inmediato en cuanto los outputs estan listos.
|
||||||
poll_interval: segundos entre sondeos.
|
poll_interval: segundos entre sondeos.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict de outputs {node_id: {"images": [{filename, subfolder, type}, ...]}}
|
dict de outputs {node_id: {"images": [{filename, subfolder, type}, ...]}}
|
||||||
tal como ComfyUI los expone en history[prompt_id]["outputs"]. Puede
|
tal como ComfyUI los expone en history[prompt_id]["outputs"]. Puede
|
||||||
contener otros tipos de output (gifs, texto) segun los nodos del
|
contener otros tipos de output (gifs, texto, video bajo "images" con
|
||||||
workflow.
|
"animated") segun los nodos del workflow. Siempre no vacio en caso de
|
||||||
|
exito (un workflow sin nodo de guardado agotaria el timeout).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: si se agota el timeout sin que el prompt complete.
|
TimeoutError: si se agota el timeout sin que el prompt complete con
|
||||||
|
outputs (incluye el caso de un prompt_id que nunca aparece en
|
||||||
|
/history porque sigue en cola, no existe, o el workflow no produce
|
||||||
|
outputs).
|
||||||
RuntimeError: si la ejecucion termina con status_str "error", si no se
|
RuntimeError: si la ejecucion termina con status_str "error", si no se
|
||||||
puede conectar, o si la respuesta no es JSON valido.
|
puede conectar, o si la respuesta no es JSON valido.
|
||||||
"""
|
"""
|
||||||
url = f"http://{server}/history/{prompt_id}"
|
url = f"http://{server}/history/{prompt_id}"
|
||||||
|
# Timeout por-request acotado: una conexion colgada no debe bloquear todo el
|
||||||
|
# presupuesto global (que ahora puede ser de varios minutos).
|
||||||
|
req_timeout = min(timeout, 30.0) if timeout > 0 else 30.0
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
with urllib.request.urlopen(url, timeout=req_timeout) as resp:
|
||||||
hist = json.loads(resp.read())
|
hist = json.loads(resp.read())
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -67,8 +85,13 @@ def comfyui_wait_result(
|
|||||||
f"comfyui_wait_result: ejecucion fallo para {prompt_id}: "
|
f"comfyui_wait_result: ejecucion fallo para {prompt_id}: "
|
||||||
f"{json.dumps(status)[:500]}"
|
f"{json.dumps(status)[:500]}"
|
||||||
)
|
)
|
||||||
if status.get("completed") or status_str == "success":
|
done = bool(status.get("completed")) or status_str == "success"
|
||||||
return entry.get("outputs", {})
|
outputs = entry.get("outputs") or {}
|
||||||
|
# Solo se considera terminado cuando hay estado de exito Y outputs.
|
||||||
|
# "done" con outputs vacios = entry creada pero aun sin resultados:
|
||||||
|
# se sigue esperando (no salir prematuro).
|
||||||
|
if done and outputs:
|
||||||
|
return outputs
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Tests para comfyui_wait_result (funcion impura: polling de /history).
|
||||||
|
|
||||||
|
La mayoria mockea urllib.request.urlopen para correr sin GPU ni red (CI-safe).
|
||||||
|
Un test "live" extra valida contra el /history real de un job de video ya
|
||||||
|
completado, y se salta limpiamente si el servidor ComfyUI no responde o el job
|
||||||
|
ya no esta en el history (p. ej. servidor reiniciado).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
from ml.comfyui_wait_result import comfyui_wait_result
|
||||||
|
|
||||||
|
PID = "test-pid-1"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
"""Context manager minimo que imita la respuesta de urllib.request.urlopen."""
|
||||||
|
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._payload = json.dumps(payload).encode()
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _seq_urlopen(payloads):
|
||||||
|
"""Fake urlopen que devuelve cada payload en orden; repite el ultimo."""
|
||||||
|
state = {"i": 0}
|
||||||
|
|
||||||
|
def _open(url, timeout=None):
|
||||||
|
i = min(state["i"], len(payloads) - 1)
|
||||||
|
state["i"] += 1
|
||||||
|
return _FakeResp(payloads[i])
|
||||||
|
|
||||||
|
return _open
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(status_str="success", completed=True, outputs=None):
|
||||||
|
return {
|
||||||
|
PID: {
|
||||||
|
"status": {"status_str": status_str, "completed": completed, "messages": []},
|
||||||
|
"outputs": outputs if outputs is not None else {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Golden: espera a outputs no vacios en un job largo (entry tarda en aparecer) ---
|
||||||
|
def test_golden_espera_a_que_aparezcan_outputs():
|
||||||
|
payloads = [
|
||||||
|
{}, # aun en cola: entry ausente
|
||||||
|
{}, # sigue en cola
|
||||||
|
_entry(
|
||||||
|
outputs={
|
||||||
|
"79": {
|
||||||
|
"images": [
|
||||||
|
{"filename": "vid.mp4", "subfolder": "", "type": "output"}
|
||||||
|
],
|
||||||
|
"animated": [True],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
with mock.patch(
|
||||||
|
"ml.comfyui_wait_result.urllib.request.urlopen", _seq_urlopen(payloads)
|
||||||
|
), mock.patch("ml.comfyui_wait_result.time.sleep", lambda *_: None):
|
||||||
|
out = comfyui_wait_result(PID, timeout=5, poll_interval=0.001)
|
||||||
|
assert out, "debe devolver outputs no vacios"
|
||||||
|
assert out["79"]["images"][0]["filename"] == "vid.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Regresion del bug: 'done' con outputs vacios NO debe salir prematuro ---
|
||||||
|
def test_no_sale_prematuro_cuando_done_pero_outputs_vacios():
|
||||||
|
payloads = [
|
||||||
|
_entry(outputs={}), # estado terminal pero outputs aun vacios (caso del bug)
|
||||||
|
_entry(outputs={}), # sigue vacio mientras la GPU trabaja
|
||||||
|
_entry(outputs={"9": {"images": [{"filename": "img.png"}]}}), # ya poblado
|
||||||
|
]
|
||||||
|
with mock.patch(
|
||||||
|
"ml.comfyui_wait_result.urllib.request.urlopen", _seq_urlopen(payloads)
|
||||||
|
), mock.patch("ml.comfyui_wait_result.time.sleep", lambda *_: None):
|
||||||
|
out = comfyui_wait_result(PID, timeout=5, poll_interval=0.001)
|
||||||
|
assert out == {"9": {"images": [{"filename": "img.png"}]}}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Edge: imagen corta ya completada en el primer sondeo -> retorno inmediato ---
|
||||||
|
def test_imagen_corta_devuelve_en_primer_poll():
|
||||||
|
payloads = [_entry(outputs={"9": {"images": [{"filename": "apple.png"}]}})]
|
||||||
|
opener = _seq_urlopen(payloads)
|
||||||
|
with mock.patch(
|
||||||
|
"ml.comfyui_wait_result.urllib.request.urlopen", opener
|
||||||
|
), mock.patch("ml.comfyui_wait_result.time.sleep", lambda *_: None):
|
||||||
|
out = comfyui_wait_result(PID, timeout=5, poll_interval=0.001)
|
||||||
|
assert out["9"]["images"][0]["filename"] == "apple.png"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Edge: estado 'completed' sin status_str success tambien vale (si hay outputs) ---
|
||||||
|
def test_completed_sin_status_str_success():
|
||||||
|
payloads = [_entry(status_str=None, completed=True, outputs={"9": {"images": [{"filename": "x.png"}]}})]
|
||||||
|
with mock.patch(
|
||||||
|
"ml.comfyui_wait_result.urllib.request.urlopen", _seq_urlopen(payloads)
|
||||||
|
), mock.patch("ml.comfyui_wait_result.time.sleep", lambda *_: None):
|
||||||
|
out = comfyui_wait_result(PID, timeout=5, poll_interval=0.001)
|
||||||
|
assert out["9"]["images"][0]["filename"] == "x.png"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Error: timeout genuino (prompt nunca aparece) -> TimeoutError, no cuelga ---
|
||||||
|
def test_timeout_prompt_inexistente_lanza_timeout():
|
||||||
|
payloads = [{}] # entry siempre ausente
|
||||||
|
with mock.patch(
|
||||||
|
"ml.comfyui_wait_result.urllib.request.urlopen", _seq_urlopen(payloads)
|
||||||
|
):
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
comfyui_wait_result(PID, timeout=0.05, poll_interval=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Error: ejecucion fallida (status_str 'error') -> RuntimeError ---
|
||||||
|
def test_status_error_lanza_runtimeerror():
|
||||||
|
payloads = [
|
||||||
|
{
|
||||||
|
PID: {
|
||||||
|
"status": {
|
||||||
|
"status_str": "error",
|
||||||
|
"completed": False,
|
||||||
|
"messages": [["execution_error", {"exception_message": "OOM"}]],
|
||||||
|
},
|
||||||
|
"outputs": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
with mock.patch(
|
||||||
|
"ml.comfyui_wait_result.urllib.request.urlopen", _seq_urlopen(payloads)
|
||||||
|
), mock.patch("ml.comfyui_wait_result.time.sleep", lambda *_: None):
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
comfyui_wait_result(PID, timeout=5, poll_interval=0.001)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Live (opcional): job de video ya completado en el /history real ---
|
||||||
|
LIVE_SERVER = "127.0.0.1:8188"
|
||||||
|
LIVE_PID = "8a278988-8a94-4225-add3-88a406f7101c" # LTX T2V del report 0105
|
||||||
|
|
||||||
|
|
||||||
|
def _server_up(server=LIVE_SERVER):
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(f"http://{server}/history/__probe__", timeout=2).read()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _server_up(), reason="ComfyUI no responde en 127.0.0.1:8188")
|
||||||
|
def test_live_job_video_completado_devuelve_outputs():
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
h = json.loads(
|
||||||
|
urllib.request.urlopen(
|
||||||
|
f"http://{LIVE_SERVER}/history/{LIVE_PID}", timeout=3
|
||||||
|
).read()
|
||||||
|
)
|
||||||
|
if LIVE_PID not in h:
|
||||||
|
pytest.skip("job 8a278988 ya no esta en /history (servidor reiniciado)")
|
||||||
|
out = comfyui_wait_result(LIVE_PID, server=LIVE_SERVER, timeout=10)
|
||||||
|
assert out, "un job ya completado debe devolver outputs no vacios y rapido"
|
||||||
|
assert any(v.get("images") for v in out.values()), "debe traer la clave 'images'"
|
||||||
Reference in New Issue
Block a user