Files
gpu-rent/src/gpu_rent/remote/install_swarm_comfy.py
T
Leonid Pershin f8bbde8ac0 Enhance backend recovery and diagnostics in installation and tuning scripts
- Improved the `install_swarm_comfy` function to handle empty backend states more effectively, introducing recovery mechanisms and enhanced logging for better visibility.
- Updated the `tune_swarm_perf` function to always sanitize backend FDS corruption, ensuring consistent performance tuning.
- Added new tests to validate the functionality of backend recovery and FDS sanitization, ensuring robustness in backend management.
2026-08-21 10:54:02 +03:00

718 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Headless SwarmUI first-install: ComfyUI backend via InstallConfirmWS.
Runs on the VM (stdlib only). Idempotent: skips when backends exist or
dlbackend/ComfyUI venv is already present and IsInstalled.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import socket
import struct
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
SWARM = "http://127.0.0.1:7801"
DATA = Path("/mnt/swarm_data")
SWARM_ROOT = Path(os.environ.get("SWARM_ROOT") or "/opt/swarmui")
DLBACKEND = DATA / "dlbackend"
COMFY_ROOT = DLBACKEND / "ComfyUI"
COMFY_VENV = COMFY_ROOT / "venv" / "bin" / "python"
SETTINGS = DATA / "Data" / "Settings.fds"
BACKENDS_FDS = DATA / "Data" / "Backends.fds"
# Comfy clone + torch can take 2040+ min on a cold disk.
INSTALL_TIMEOUT = float(os.environ.get("GPU_RENT_COMFY_INSTALL_TIMEOUT") or "3600")
def fmt_bytes(n: float) -> str:
n = float(n)
for unit, div in (("GB", 1024**3), ("MB", 1024**2), ("KB", 1024), ("B", 1)):
if n >= div or unit == "B":
if unit == "B":
return f"{int(n)}B"
return f"{n / div:.1f}{unit}"
return f"{n:.0f}B"
def progress_bar(done: int, total: int | None, *, width: int = 22) -> str:
if not total or total <= 0:
return ""
pct = min(100.0, 100.0 * done / total)
filled = min(width, max(0, int(width * done / total)))
bar = "#" * filled + "-" * (width - filled)
return f"[{bar}] {pct:5.1f}% "
def dir_size_bytes(root: Path) -> int:
if not root.is_dir():
return 0
try:
out = subprocess.check_output(
["du", "-sb", str(root)],
text=True,
stderr=subprocess.DEVNULL,
timeout=15,
)
return int(out.split()[0])
except (subprocess.CalledProcessError, FileNotFoundError, OSError, ValueError, IndexError, subprocess.TimeoutExpired):
pass
total = 0
try:
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
try:
total += (Path(dirpath) / name).stat().st_size
except OSError:
continue
except OSError:
return total
return total
def detect_stage() -> str:
"""Best-effort stage while Linux comfy-install-linux.sh runs (no WS bytes)."""
if not COMFY_ROOT.is_dir():
return "ожидание clone ComfyUI"
if not (COMFY_ROOT / ".git").is_dir() and not (COMFY_ROOT / "main.py").is_file():
return "git clone ComfyUI…"
if not COMFY_VENV.is_file():
if (COMFY_ROOT / "venv").is_dir():
return "создание venv…"
return "ComfyUI clone ok → venv"
# Prefer filesystem check — importing torch every second is too heavy.
torch_dirs = list((COMFY_ROOT / "venv").glob("lib/python*/site-packages/torch"))
if not torch_dirs:
# pip may still be downloading into cache / tmp
return "pip install torch (CUDA)…"
req = COMFY_ROOT / "requirements.txt"
if req.is_file():
# Heuristic: if common deps missing, still on requirements.
missing = False
for pkg in ("aiohttp", "einops", "safetensors"):
if not list((COMFY_ROOT / "venv").glob(f"lib/python*/site-packages/{pkg}*")):
missing = True
break
if missing:
return "pip install requirements…"
return "Comfy deps почти готовы…"
def _journal_comfy_lines(since_sec: float = 90.0) -> list[str]:
"""Recent swarmui journal lines that look like comfy/pip progress."""
try:
out = subprocess.check_output(
[
"journalctl",
"-u",
"swarmui",
"--no-pager",
"-o",
"cat",
f"--since={int(max(1, since_sec))} seconds ago",
],
text=True,
stderr=subprocess.DEVNULL,
timeout=8,
)
except (subprocess.CalledProcessError, FileNotFoundError, OSError, subprocess.TimeoutExpired):
return []
keys = (
"comfy",
"torch",
"Downloading",
"install",
"Requirement",
"Collecting",
"Cloning",
"Making venv",
"Installation completed",
"pip",
)
lines: list[str] = []
for raw in out.splitlines():
line = raw.strip()
if not line or len(line) < 8:
continue
low = line.lower()
if any(k.lower() in low for k in keys):
# Strip noisy prefixes
if "[ComfyUI Install" in line or "STDOUT" in line or "pip" in low or "torch" in low:
lines.append(line[-180:])
return lines[-6:]
def post(path: str, payload: dict, timeout: float = 15.0) -> dict:
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{SWARM}{path}",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def wait_http(deadline: float) -> None:
last = ""
while time.time() < deadline:
try:
req = urllib.request.Request(f"{SWARM}/", method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
if getattr(resp, "status", 200) == 200:
return
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc:
last = str(exc)[:160]
time.sleep(2)
raise SystemExit(f"SwarmUI HTTP not up: {last}")
def get_session(deadline: float) -> str:
last = ""
while time.time() < deadline:
try:
data = post("/API/GetNewSession", {})
sid = str(data.get("session_id") or "")
if sid:
return sid
last = "no session_id"
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, json.JSONDecodeError) as exc:
last = str(exc)[:160]
time.sleep(2)
raise SystemExit(f"SwarmUI session unavailable: {last}")
def backend_status() -> str:
try:
sid = str(post("/API/GetNewSession", {}).get("session_id") or "")
if not sid:
return "unknown"
data = post("/API/GetCurrentStatus", {"session_id": sid})
be = data.get("backend_status") or {}
return str(be.get("status") or "unknown").lower()
except Exception as exc:
return f"error:{exc}"
def backend_status_detail() -> tuple[str, str]:
"""Return (status, message) from GetCurrentStatus."""
try:
sid = str(post("/API/GetNewSession", {}).get("session_id") or "")
if not sid:
return "unknown", ""
data = post("/API/GetCurrentStatus", {"session_id": sid})
be = data.get("backend_status") or {}
return (
str(be.get("status") or "unknown").lower(),
str(be.get("message") or "").strip(),
)
except Exception as exc:
return f"error:{exc}", ""
def restart_backends(sid: str, *, which: str = "all") -> dict:
"""POST /API/RestartBackends — recovers ERRORED Comfy self-start."""
return post(
"/API/RestartBackends",
{"session_id": sid, "backend": which},
timeout=180.0,
)
def recover_errored_backends(*, wait_sec: float = 180.0) -> str:
"""RestartBackends then poll until not errored (or timeout). Returns last status."""
bstat, msg = backend_status_detail()
print(
f"backend errored — RestartBackends(all)"
+ (f" ({msg[:120]})" if msg else ""),
flush=True,
)
sid = get_session(time.time() + 60)
try:
result = restart_backends(sid)
print(f"RestartBackends: {result}", flush=True)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, json.JSONDecodeError) as exc:
print(f"RestartBackends FAIL: {exc}", flush=True)
return bstat
deadline = time.time() + wait_sec
last = "errored"
while time.time() < deadline:
time.sleep(8)
last, msg2 = backend_status_detail()
extra = f" — {msg2[:100]}" if msg2 else ""
print(f"after restart: backend_status={last}{extra}", flush=True)
if last != "errored" and not last.startswith("error:"):
return last
return last
def sanitize_backends_fds() -> bool:
"""Repair ExtraArgs: \\x --flag (FDS empty + appended flag). Return True if changed."""
path = DATA / "Data" / "Backends.fds"
if not path.is_file():
return False
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
new, n = re.subn(r"^(\s*ExtraArgs:\s*)\\x(\s+)", r"\1", text, flags=re.M)
if not n:
return False
path.write_text(new, encoding="utf-8")
print(f"sanitized Backends.fds ExtraArgs \\x corruption ({n} line(s))", flush=True)
return True
def restart_swarmui_local() -> None:
print("systemctl restart swarmui (после fix Backends.fds)", flush=True)
try:
subprocess.run(
["sudo", "-n", "systemctl", "restart", "swarmui"],
check=False,
timeout=120,
)
except (OSError, subprocess.TimeoutExpired) as exc:
print(f"WARN restart swarmui: {exc}", flush=True)
wait_http(time.time() + 180)
def list_backends(sid: str) -> dict:
try:
return post(
"/API/ListBackends",
{"session_id": sid, "nonreal": False, "full_data": False},
timeout=30.0,
)
except Exception as exc:
return {"error": str(exc)}
def add_comfy_selfstart(sid: str) -> dict:
return post(
"/API/AddNewBackend",
{"session_id": sid, "type_id": "comfyui_selfstart"},
timeout=60.0,
)
def recover_empty_backends() -> str:
"""When API says empty but venv/IsInstalled exist — fix FDS and/or AddNewBackend."""
changed = sanitize_backends_fds()
if changed:
restart_swarmui_local()
bstat, _ = backend_status_detail()
if bstat not in ("empty", "unknown") and not bstat.startswith("error:"):
print(f"after sanitize: backend_status={bstat}", flush=True)
return bstat
sid = get_session(time.time() + 60)
backends = list_backends(sid)
if isinstance(backends, dict) and any(
isinstance(v, dict) and v.get("type") for v in backends.values()
):
bstat, _ = backend_status_detail()
return bstat
print("ListBackends empty — AddNewBackend comfyui_selfstart", flush=True)
try:
result = add_comfy_selfstart(sid)
print(f"AddNewBackend: {result}", flush=True)
except Exception as exc:
print(f"AddNewBackend FAIL: {exc}", flush=True)
return "empty"
# Default StartScript may be dlbackend/comfy/...; our tree is dlbackend/ComfyUI.
time.sleep(2)
bstat, _ = backend_status_detail()
return bstat
def run_diagnostics() -> None:
"""Best-effort: prefer uploaded swarm_diag.py, else journalctl snippet."""
diag = Path("/tmp/gpu-rent-swarm_diag.py")
if diag.is_file():
try:
subprocess.run(
[sys.executable, str(diag)],
check=False,
timeout=90,
)
return
except (OSError, subprocess.TimeoutExpired) as exc:
print(f"diag script fail: {exc}", flush=True)
print("=== fallback journalctl -u swarmui -n 60 ===", flush=True)
try:
out = subprocess.check_output(
["journalctl", "-u", "swarmui", "-n", "60", "--no-pager"],
text=True,
stderr=subprocess.STDOUT,
timeout=30,
errors="replace",
)
print(out, flush=True)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
print(f"journalctl fail: {exc}", flush=True)
def settings_is_installed() -> bool | None:
if not SETTINGS.is_file():
return None
try:
text = SETTINGS.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for line in text.splitlines():
if "IsInstalled" in line:
low = line.lower()
if "true" in low:
return True
if "false" in low:
return False
return None
def comfy_venv_ok() -> bool:
return COMFY_VENV.is_file() and os.access(COMFY_VENV, os.X_OK)
def _recv_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("websocket closed")
buf += chunk
return buf
def _ws_handshake(host: str, port: int, path: str) -> socket.socket:
key = base64.b64encode(os.urandom(16)).decode("ascii")
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
).encode("ascii")
sock = socket.create_connection((host, port), timeout=30)
sock.sendall(req)
# Read headers until blank line
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
sock.close()
raise ConnectionError("no WS handshake response")
data += chunk
if len(data) > 65536:
sock.close()
raise ConnectionError("WS handshake too large")
head = data.split(b"\r\n\r\n", 1)[0].decode("latin-1", errors="replace")
if "101" not in head.split("\r\n", 1)[0]:
sock.close()
raise ConnectionError(f"WS handshake failed: {head.splitlines()[0]}")
expect = base64.b64encode(
hashlib.sha1(
(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")
).digest()
).decode("ascii")
if expect not in head:
# Some servers omit echoing exact key in proxies; 101 is enough.
pass
sock.settimeout(120.0)
return sock
def _ws_send_text(sock: socket.socket, text: str) -> None:
payload = text.encode("utf-8")
mask = os.urandom(4)
header = bytearray([0x81]) # FIN + text
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", n))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", n))
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
sock.sendall(header + masked)
def _ws_recv_text(sock: socket.socket) -> str | None:
"""Return next text frame payload, or None on close."""
while True:
hdr = _recv_exact(sock, 2)
opcode = hdr[0] & 0x0F
masked = bool(hdr[1] & 0x80)
length = hdr[1] & 0x7F
if length == 126:
length = struct.unpack("!H", _recv_exact(sock, 2))[0]
elif length == 127:
length = struct.unpack("!Q", _recv_exact(sock, 8))[0]
mask_key = _recv_exact(sock, 4) if masked else b""
raw = _recv_exact(sock, length) if length else b""
if masked:
raw = bytes(b ^ mask_key[i % 4] for i, b in enumerate(raw))
if opcode == 0x8: # close
return None
if opcode == 0x9: # ping → pong
# build pong
frame = bytearray([0x8A, 0x80 | len(raw)])
m = os.urandom(4)
frame.extend(m)
frame.extend(bytes(b ^ m[i % 4] for i, b in enumerate(raw)))
sock.sendall(frame)
continue
if opcode in (0x1, 0x0): # text / continuation
return raw.decode("utf-8", errors="replace")
# ignore binary / other
def run_install(sid: str) -> None:
print("SwarmUI InstallConfirmWS: backend=comfyui models=none …", flush=True)
sock = _ws_handshake("127.0.0.1", 7801, "/API/InstallConfirmWS")
payload = {
"session_id": sid,
"theme": "modern_dark",
"installed_for": "just_self",
"backend": "comfyui",
"models": "none",
"install_amd": False,
"language": "en",
"make_shortcut": False,
}
_ws_send_text(sock, json.dumps(payload))
deadline = time.time() + INSTALL_TIMEOUT
last_info = ""
last_journal: set[str] = set()
stop = threading.Event()
t0 = time.monotonic()
size0 = dir_size_bytes(DLBACKEND)
last_size = size0
last_size_t = t0
# WS byte progress (Windows 7z / model downloads); Linux script usually silent.
ws_prog: dict = {"done": 0, "total": 0, "steps": 0, "total_steps": 0, "t": 0.0}
def emit_progress_line(*, final: bool = False) -> None:
nonlocal last_size, last_size_t
now = time.monotonic()
elapsed = max(now - t0, 0.001)
size = dir_size_bytes(DLBACKEND)
dt = max(now - last_size_t, 0.001)
speed = (size - last_size) / dt if size >= last_size else 0.0
last_size, last_size_t = size, now
stage = detect_stage()
grown = max(0, size - size0)
parts = [
f"[installer] {stage}",
f"dlbackend={fmt_bytes(size)}",
f"+{fmt_bytes(grown)}",
f"{fmt_bytes(speed)}/s",
f"{int(elapsed)}s",
]
done = int(ws_prog.get("done") or 0)
total = int(ws_prog.get("total") or 0)
if done > 0 or total > 0:
bar = progress_bar(done, total if total > 0 else None)
steps = ws_prog.get("steps")
total_steps = ws_prog.get("total_steps")
parts.insert(
1,
f"step {steps}/{total_steps} {bar}{fmt_bytes(done)}"
+ (f"/{fmt_bytes(total)}" if total else ""),
)
line = " · ".join(parts)
if final:
print(line, flush=True)
else:
print(line, end="\r", flush=True)
def monitor() -> None:
while not stop.wait(1.0):
emit_progress_line()
# Surface interesting journal lines as real log lines (newline).
for jline in _journal_comfy_lines(120.0):
if jline in last_journal:
continue
last_journal.add(jline)
# End in-place progress then print journal snippet.
print(flush=True)
print(f" … {jline}", flush=True)
mon = threading.Thread(target=monitor, name="comfy-install-progress", daemon=True)
mon.start()
try:
while time.time() < deadline:
try:
sock.settimeout(max(5.0, min(30.0, deadline - time.time())))
msg = _ws_recv_text(sock)
except (TimeoutError, socket.timeout):
continue
if msg is None:
raise SystemExit("InstallConfirmWS closed without success")
try:
data = json.loads(msg)
except json.JSONDecodeError:
print(f"ws raw: {msg[:200]}", flush=True)
continue
if data.get("error"):
raise SystemExit(f"InstallConfirmWS error: {data['error']}")
if data.get("info"):
info = str(data["info"])
if info != last_info:
print(flush=True) # break progress \r line
print(f"[installer] {info}", flush=True)
last_info = info
if "progress" in data:
try:
ws_prog["done"] = int(data.get("progress") or 0)
ws_prog["total"] = int(data.get("total") or 0)
ws_prog["steps"] = int(data.get("steps") or 0)
ws_prog["total_steps"] = int(data.get("total_steps") or 0)
ws_prog["t"] = time.monotonic()
except (TypeError, ValueError):
pass
# Byte tick from Swarm (rare on Linux) — refresh bar now.
if ws_prog["done"] or ws_prog["total"]:
emit_progress_line()
if data.get("success"):
stop.set()
print(flush=True)
emit_progress_line(final=True)
print("InstallConfirmWS: success", flush=True)
return
raise SystemExit(f"InstallConfirmWS timeout after {int(INSTALL_TIMEOUT)}s")
finally:
stop.set()
try:
sock.close()
except OSError:
pass
mon.join(timeout=2.0)
def main() -> int:
# WorkingDirectory for comfy-install-linux.sh is Swarm root when launched by
# Installation.cs; InstallConfirmWS handles that for us.
wait_http(time.time() + 180)
bstat, bmsg = backend_status_detail()
print(
f"backend_status={bstat} venv={'yes' if comfy_venv_ok() else 'no'} "
f"IsInstalled={settings_is_installed()}"
+ (f" msg={bmsg[:160]}" if bmsg else ""),
flush=True,
)
# Errored backends are registered but dead — restart, don't skip as "present".
if bstat == "errored":
bstat = recover_errored_backends(wait_sec=180.0)
if bstat == "errored" or bstat.startswith("error:"):
print(
"FAIL: backend still errored after RestartBackends — collecting diag",
file=sys.stderr,
)
run_diagnostics()
return 1
if bstat in ("running", "idle", "loading", "some_loading") and comfy_venv_ok():
print(f"recovered to {bstat} — skip InstallConfirmWS")
return 0
# Backends already registered (healthy / loading / suspended) + venv → skip.
if bstat == "idle":
print("backends present (idle/suspended) + skip install")
return 0
if bstat not in ("empty", "unknown", "errored") and not bstat.startswith("error:"):
if comfy_venv_ok():
print(f"backends present ({bstat}) + venv — skip install")
return 0
installed = settings_is_installed()
if installed is True and not comfy_venv_ok() and bstat == "empty":
print(
"WARN: Settings IsInstalled=true but backends empty and no Comfy venv. "
"Open SwarmUI → Server → Backends and add ComfyUI Self-Starting, "
"or delete Data/Settings.fds IsInstalled and re-run up."
)
run_diagnostics()
return 1
if installed is True and comfy_venv_ok() and bstat not in ("empty", "errored"):
print("IsInstalled + venv — skip InstallConfirmWS (ждём ready отдельно)")
return 0
if comfy_venv_ok() and bstat == "empty":
print(
"backend empty при venv/IsInstalled — recover (sanitize FDS / AddNewBackend)…",
flush=True,
)
bstat = recover_empty_backends()
if bstat not in ("empty", "unknown") and not bstat.startswith("error:"):
print(f"recovered empty → {bstat}")
return 0
if installed is True:
print(
"WARN: всё ещё empty после recover — пробую InstallConfirmWS",
flush=True,
)
else:
print("venv есть, IsInstalled=false — запускаю InstallConfirmWS")
marker = DATA / ".gpu-rent-comfy-installing"
try:
marker.write_text(f"{int(time.time())}\n", encoding="utf-8")
except OSError:
pass
try:
sid = get_session(time.time() + 120)
run_install(sid)
finally:
try:
marker.unlink(missing_ok=True)
except OSError:
pass
# Confirm outcome
time.sleep(3)
bstat2 = backend_status()
print(
f"after install: backend_status={bstat2} "
f"venv={'yes' if comfy_venv_ok() else 'no'}"
)
if bstat2 == "errored":
bstat2 = recover_errored_backends(wait_sec=120.0)
if bstat2 == "errored":
print("FAIL: install done but backend errored", file=sys.stderr)
run_diagnostics()
return 1
if not comfy_venv_ok() and bstat2 == "empty":
print("FAIL: install finished but still empty / no venv", file=sys.stderr)
run_diagnostics()
return 1
return 0
if __name__ == "__main__":
# Ensure Installation.cs cwd paths resolve if anything shells out relative —
# InstallConfirmWS itself cds via WorkingDirectory of the service.
try:
os.chdir(str(SWARM_ROOT))
except OSError:
pass
raise SystemExit(main())