Enhance backend idle management and diagnostics in SwarmUI

- Updated the `wait_backend_idle` function to handle disabled backends, introducing a new failure condition and diagnostics collection for better error handling.
- Refactored the `install_swarm_comfy` script to include a new `configure_comfy_backend` function, streamlining backend configuration and enabling better management of backend states.
- Added tests to validate the new functionality for handling disabled backends and ensuring robust diagnostics, improving overall backend management.
This commit is contained in:
Leonid Pershin
2026-08-21 11:00:01 +03:00
parent f8bbde8ac0
commit a18074c985
3 changed files with 205 additions and 29 deletions
+18 -1
View File
@@ -207,21 +207,25 @@ def wait_backend_idle(
timeout: float = 2400.0, timeout: float = 2400.0,
poll_every: float = 15.0, poll_every: float = 15.0,
errored_fail_sec: float = 120.0, errored_fail_sec: float = 120.0,
disabled_fail_sec: float = 90.0,
) -> None: ) -> None:
"""Block until SwarmUI backends are ready (status=running, no queue). """Block until SwarmUI backends are ready (status=running, no queue).
Note: SwarmUI ``idle`` means suspended backends (cannot generate). Note: SwarmUI ``idle`` means suspended backends (cannot generate).
Ready-to-use is ``running``. Sustained ``errored`` fail-fasts (won't self-heal). Ready-to-use is ``running``. Sustained ``errored`` / ``disabled`` fail-fasts.
""" """
deadline = time.time() + timeout deadline = time.time() + timeout
log("жду ready backend (running)…") log("жду ready backend (running)…")
last = "" last = ""
errored_since: float | None = None errored_since: float | None = None
disabled_since: float | None = None
pretty = { pretty = {
"BUSY backend=loading (Comfy стартует)": "… Comfy стартует", "BUSY backend=loading (Comfy стартует)": "… Comfy стартует",
"BUSY backend=some_loading (Comfy стартует)": "… Comfy стартует (часть бэкендов)", "BUSY backend=some_loading (Comfy стартует)": "… Comfy стартует (часть бэкендов)",
"BUSY backend=empty (нужен first-install Comfy)": "… backend пуст — нужен install", "BUSY backend=empty (нужен first-install Comfy)": "… backend пуст — нужен install",
"BUSY backend=errored": "… backend errored — смотри journalctl -u swarmui", "BUSY backend=errored": "… backend errored — смотри journalctl -u swarmui",
"BUSY backend=disabled": "… backend disabled (пустой StartScript?)",
"BUSY backend=all_disabled": "… все backends disabled",
"READY backend=running": "backend ready (running)", "READY backend=running": "backend ready (running)",
"READY backend=idle (backends present, suspended)": "backend ready (idle/suspended)", "READY backend=idle (backends present, suspended)": "backend ready (idle/suspended)",
} }
@@ -256,6 +260,19 @@ def wait_backend_idle(
) )
else: else:
errored_since = None errored_since = None
if "BUSY backend=disabled" in line or "BUSY backend=all_disabled" in line:
now = time.time()
if disabled_since is None:
disabled_since = now
elif now - disabled_since >= disabled_fail_sec:
collect_swarm_diagnostics(cfg, host, log)
raise CloudError(
f"backend=disabled уже {int(now - disabled_since)} с"
"обычно пустой StartScript (нужен dlbackend/ComfyUI/main.py). "
"Диагностика выше. Server → Backends → Edit StartScript или re-run up."
)
else:
disabled_since = None
time.sleep(poll_every) time.sleep(poll_every)
collect_swarm_diagnostics(cfg, host, log) collect_swarm_diagnostics(cfg, host, log)
raise CloudError( raise CloudError(
+142 -21
View File
@@ -298,12 +298,88 @@ def list_backends(sid: str) -> dict:
return {"error": str(exc)} return {"error": str(exc)}
def add_comfy_selfstart(sid: str) -> dict: def comfy_start_script() -> str:
return post( """Relative StartScript Swarm expects (cwd=/opt/swarmui, dlbackend bind-mounted)."""
"/API/AddNewBackend", candidates = (
{"session_id": sid, "type_id": "comfyui_selfstart"}, "dlbackend/ComfyUI/main.py",
timeout=60.0, "dlbackend/comfy/ComfyUI/main.py",
"dlbackend/comfy/main.py",
) )
for rel in candidates:
if (SWARM_ROOT / rel).is_file() or (DATA / rel).is_file():
return rel
return "dlbackend/ComfyUI/main.py"
def edit_backend(sid: str, backend_id: int, *, title: str, settings: dict) -> dict:
return post(
"/API/EditBackend",
{
"session_id": sid,
"backend_id": backend_id,
"title": title,
"settings": settings,
},
timeout=120.0,
)
def toggle_backend(sid: str, backend_id: int, *, enabled: bool) -> dict:
return post(
"/API/ToggleBackend",
{
"session_id": sid,
"backend_id": int(backend_id),
"enabled": bool(enabled),
},
timeout=120.0,
)
def configure_comfy_backend(sid: str, backend_id: int) -> None:
"""Set StartScript after AddNewBackend (defaults are empty → stays disabled)."""
script = comfy_start_script()
settings = {
"StartScript": script,
"ExtraArgs": "",
"DisableInternalArgs": False,
"AutoUpdate": "false",
"UpdateManagedNodes": "false",
"FrontendVersion": "LatestSwarmValidated",
"EnablePreviews": "true",
"GPU_ID": "0",
"OverQueue": 1,
"AutoRestart": True,
}
print(f"EditBackend id={backend_id} StartScript={script}", flush=True)
try:
result = edit_backend(
sid, backend_id, title="ComfyUI Self-Starting", settings=settings
)
print(f"EditBackend: {result}", flush=True)
except Exception as exc:
print(f"EditBackend FAIL: {exc}", flush=True)
raise
try:
tog = toggle_backend(sid, backend_id, enabled=True)
print(f"ToggleBackend enabled: {tog}", flush=True)
except Exception as exc:
print(f"ToggleBackend WARN: {exc}", flush=True)
def _backend_entries(backends: dict) -> list[tuple[int, dict]]:
out: list[tuple[int, dict]] = []
if not isinstance(backends, dict):
return out
for key, val in backends.items():
if key in {"error"} or not isinstance(val, dict):
continue
try:
bid = int(val.get("id", key))
except (TypeError, ValueError):
continue
out.append((bid, val))
return out
def recover_empty_backends() -> str: def recover_empty_backends() -> str:
@@ -312,30 +388,64 @@ def recover_empty_backends() -> str:
if changed: if changed:
restart_swarmui_local() restart_swarmui_local()
bstat, _ = backend_status_detail() bstat, _ = backend_status_detail()
if bstat not in ("empty", "unknown") and not bstat.startswith("error:"): if bstat not in ("empty", "unknown", "disabled", "all_disabled") and not bstat.startswith(
"error:"
):
print(f"after sanitize: backend_status={bstat}", flush=True) print(f"after sanitize: backend_status={bstat}", flush=True)
return bstat return bstat
sid = get_session(time.time() + 60) sid = get_session(time.time() + 60)
backends = list_backends(sid) backends = list_backends(sid)
if isinstance(backends, dict) and any( entries = _backend_entries(backends)
isinstance(v, dict) and v.get("type") for v in backends.values()
):
bstat, _ = backend_status_detail()
return bstat
if not entries:
print("ListBackends empty — AddNewBackend comfyui_selfstart", flush=True) print("ListBackends empty — AddNewBackend comfyui_selfstart", flush=True)
try: try:
result = add_comfy_selfstart(sid) result = add_comfy_selfstart(sid)
print(f"AddNewBackend: {result}", flush=True) print(f"AddNewBackend: {result}", flush=True)
bid = int(result.get("id", 0))
except Exception as exc: except Exception as exc:
print(f"AddNewBackend FAIL: {exc}", flush=True) print(f"AddNewBackend FAIL: {exc}", flush=True)
return "empty" return "empty"
try:
configure_comfy_backend(sid, bid)
except Exception:
return "empty"
else:
# Backends exist but may lack StartScript / be disabled.
for bid, meta in entries:
settings = meta.get("settings") or {}
script = str(settings.get("StartScript") or "").strip()
enabled = bool(meta.get("enabled", True))
status = str(meta.get("status") or "").lower()
if not script or status in {"disabled", "errored", "waiting"}:
print(
f"fix backend id={bid} status={status} StartScript={script!r}",
flush=True,
)
try:
configure_comfy_backend(sid, bid)
except Exception as exc:
print(f"configure FAIL id={bid}: {exc}", flush=True)
# Default StartScript may be dlbackend/comfy/...; our tree is dlbackend/ComfyUI. deadline = time.time() + 180
time.sleep(2) last = "disabled"
bstat, _ = backend_status_detail() while time.time() < deadline:
return bstat time.sleep(5)
last, msg = backend_status_detail()
extra = f"{msg[:80]}" if msg else ""
print(f"recover poll: backend_status={last}{extra}", flush=True)
if last in ("running", "idle", "loading", "some_loading"):
return last
if last in ("disabled", "all_disabled"):
# One more enable attempt
sid2 = get_session(time.time() + 30)
for bid, _meta in _backend_entries(list_backends(sid2)):
try:
toggle_backend(sid2, bid, enabled=True)
except Exception:
pass
return last
def run_diagnostics() -> None: def run_diagnostics() -> None:
@@ -637,7 +747,9 @@ def main() -> int:
if bstat == "idle": if bstat == "idle":
print("backends present (idle/suspended) + skip install") print("backends present (idle/suspended) + skip install")
return 0 return 0
if bstat not in ("empty", "unknown", "errored") and not bstat.startswith("error:"): # disabled = empty StartScript / not usable — must recover, not skip.
need_recover = bstat in ("empty", "disabled", "all_disabled", "unknown")
if not need_recover and bstat != "errored" and not bstat.startswith("error:"):
if comfy_venv_ok(): if comfy_venv_ok():
print(f"backends present ({bstat}) + venv — skip install") print(f"backends present ({bstat}) + venv — skip install")
return 0 return 0
@@ -652,22 +764,31 @@ def main() -> int:
run_diagnostics() run_diagnostics()
return 1 return 1
if installed is True and comfy_venv_ok() and bstat not in ("empty", "errored"): if (
installed is True
and comfy_venv_ok()
and not need_recover
and bstat != "errored"
and not bstat.startswith("error:")
):
print("IsInstalled + venv — skip InstallConfirmWS (ждём ready отдельно)") print("IsInstalled + venv — skip InstallConfirmWS (ждём ready отдельно)")
return 0 return 0
if comfy_venv_ok() and bstat == "empty": if comfy_venv_ok() and need_recover:
print( print(
"backend empty при venv/IsInstalled — recover (sanitize FDS / AddNewBackend)…", f"backend {bstat} при venv — recover (sanitize FDS / StartScript / AddNewBackend)…",
flush=True, flush=True,
) )
bstat = recover_empty_backends() bstat = recover_empty_backends()
if bstat not in ("empty", "unknown") and not bstat.startswith("error:"): # disabled = empty StartScript / not usable — not a successful recover
ok_stats = ("running", "idle", "loading", "some_loading")
if bstat in ok_stats:
print(f"recovered empty → {bstat}") print(f"recovered empty → {bstat}")
return 0 return 0
print(f"recover incomplete → {bstat}", flush=True)
if installed is True: if installed is True:
print( print(
"WARN: всё ещё empty после recover — пробую InstallConfirmWS", "WARN: всё ещё не ready после recover — пробую InstallConfirmWS",
flush=True, flush=True,
) )
else: else:
+38
View File
@@ -41,6 +41,10 @@ def test_install_swarm_comfy_script_payload():
assert 'bstat == "errored"' in text assert 'bstat == "errored"' in text
assert "sanitize_backends_fds" in text or "recover_empty_backends" in text assert "sanitize_backends_fds" in text or "recover_empty_backends" in text
assert "AddNewBackend" in text assert "AddNewBackend" in text
assert "EditBackend" in text
assert "dlbackend/ComfyUI/main.py" in text
assert "configure_comfy_backend" in text
assert 'bstat in ("empty", "disabled", "all_disabled", "unknown")' in text
def test_wait_backend_idle_fail_fast_on_errored(monkeypatch): def test_wait_backend_idle_fail_fast_on_errored(monkeypatch):
@@ -72,6 +76,40 @@ def test_wait_backend_idle_fail_fast_on_errored(monkeypatch):
assert diag_calls["n"] == 1 assert diag_calls["n"] == 1
def test_wait_backend_idle_fail_fast_on_disabled(monkeypatch):
from gpu_rent.errors import CloudError
from gpu_rent import ready
class Cfg:
pass
def fake_ssh(*a, **k):
return "BUSY backend=disabled"
diag_calls = {"n": 0}
def fake_diag(*a, **k):
diag_calls["n"] += 1
return "DIAG ok"
monkeypatch.setattr(ready, "run_ssh", fake_ssh)
monkeypatch.setattr(ready, "collect_swarm_diagnostics", fake_diag)
try:
ready.wait_backend_idle(
Cfg(),
"1.2.3.4",
[].append,
timeout=600.0,
poll_every=0.01,
disabled_fail_sec=0.05,
)
assert False, "expected CloudError"
except CloudError as exc:
assert "disabled" in str(exc).lower()
assert "startscript" in str(exc).lower()
assert diag_calls["n"] == 1
def test_swarm_diag_script_covers_api_and_journal(): def test_swarm_diag_script_covers_api_and_journal():
from importlib.resources import files from importlib.resources import files