Enhance ComfyUI installation script with progress tracking and status detection

- Added functions for formatting byte sizes, displaying progress bars, and calculating directory sizes to improve user feedback during installation.
- Implemented a stage detection mechanism to provide real-time updates on the installation process of ComfyUI.
- Enhanced logging to capture recent journal lines related to ComfyUI installation, improving visibility into the installation status.
- Updated tests to verify the presence of new functionality in the installation script, ensuring robustness and reliability.
This commit is contained in:
Leonid Pershin
2026-08-21 09:28:45 +03:00
parent f7ba915e74
commit a622265bb5
2 changed files with 205 additions and 14 deletions
+202 -14
View File
@@ -13,7 +13,9 @@ import json
import os import os
import socket import socket
import struct import struct
import subprocess
import sys import sys
import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -22,12 +24,131 @@ from pathlib import Path
SWARM = "http://127.0.0.1:7801" SWARM = "http://127.0.0.1:7801"
DATA = Path("/mnt/swarm_data") DATA = Path("/mnt/swarm_data")
SWARM_ROOT = Path(os.environ.get("SWARM_ROOT") or "/opt/swarmui") SWARM_ROOT = Path(os.environ.get("SWARM_ROOT") or "/opt/swarmui")
COMFY_VENV = DATA / "dlbackend" / "ComfyUI" / "venv" / "bin" / "python" DLBACKEND = DATA / "dlbackend"
COMFY_ROOT = DLBACKEND / "ComfyUI"
COMFY_VENV = COMFY_ROOT / "venv" / "bin" / "python"
SETTINGS = DATA / "Data" / "Settings.fds" SETTINGS = DATA / "Data" / "Settings.fds"
# Comfy clone + torch can take 2040+ min on a cold disk. # Comfy clone + torch can take 2040+ min on a cold disk.
INSTALL_TIMEOUT = float(os.environ.get("GPU_RENT_COMFY_INSTALL_TIMEOUT") or "3600") 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: def post(path: str, payload: dict, timeout: float = 15.0) -> dict:
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
@@ -201,7 +322,7 @@ def _ws_recv_text(sock: socket.socket) -> str | None:
def run_install(sid: str) -> None: def run_install(sid: str) -> None:
print("SwarmUI InstallConfirmWS: backend=comfyui models=none …") print("SwarmUI InstallConfirmWS: backend=comfyui models=none …", flush=True)
sock = _ws_handshake("127.0.0.1", 7801, "/API/InstallConfirmWS") sock = _ws_handshake("127.0.0.1", 7801, "/API/InstallConfirmWS")
payload = { payload = {
"session_id": sid, "session_id": sid,
@@ -216,44 +337,111 @@ def run_install(sid: str) -> None:
_ws_send_text(sock, json.dumps(payload)) _ws_send_text(sock, json.dumps(payload))
deadline = time.time() + INSTALL_TIMEOUT deadline = time.time() + INSTALL_TIMEOUT
last_info = "" 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: try:
while time.time() < deadline: while time.time() < deadline:
try: try:
sock.settimeout(max(5.0, min(120.0, deadline - time.time()))) sock.settimeout(max(5.0, min(30.0, deadline - time.time())))
msg = _ws_recv_text(sock) msg = _ws_recv_text(sock)
except (TimeoutError, socket.timeout): except (TimeoutError, socket.timeout):
print("… install still running (ws idle)")
continue continue
if msg is None: if msg is None:
raise SystemExit("InstallConfirmWS closed without success") raise SystemExit("InstallConfirmWS closed without success")
try: try:
data = json.loads(msg) data = json.loads(msg)
except json.JSONDecodeError: except json.JSONDecodeError:
print(f"ws raw: {msg[:200]}") print(f"ws raw: {msg[:200]}", flush=True)
continue continue
if data.get("error"): if data.get("error"):
raise SystemExit(f"InstallConfirmWS error: {data['error']}") raise SystemExit(f"InstallConfirmWS error: {data['error']}")
if data.get("info"): if data.get("info"):
info = str(data["info"]) info = str(data["info"])
if info != last_info: if info != last_info:
print(f"[installer] {info}") print(flush=True) # break progress \r line
print(f"[installer] {info}", flush=True)
last_info = info last_info = info
if "progress" in data and data.get("progress"): if "progress" in data:
steps = data.get("steps") try:
total_steps = data.get("total_steps") ws_prog["done"] = int(data.get("progress") or 0)
print( ws_prog["total"] = int(data.get("total") or 0)
f"[installer] progress step={steps}/{total_steps} " ws_prog["steps"] = int(data.get("steps") or 0)
f"bytes={data.get('progress')}/{data.get('total')}" 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"): if data.get("success"):
print("InstallConfirmWS: success") stop.set()
print(flush=True)
emit_progress_line(final=True)
print("InstallConfirmWS: success", flush=True)
return return
raise SystemExit(f"InstallConfirmWS timeout after {int(INSTALL_TIMEOUT)}s") raise SystemExit(f"InstallConfirmWS timeout after {int(INSTALL_TIMEOUT)}s")
finally: finally:
stop.set()
try: try:
sock.close() sock.close()
except OSError: except OSError:
pass pass
mon.join(timeout=2.0)
def main() -> int: def main() -> int:
+3
View File
@@ -25,6 +25,9 @@ def test_install_swarm_comfy_script_payload():
assert '"backend": "comfyui"' in text assert '"backend": "comfyui"' in text
assert '"models": "none"' in text assert '"models": "none"' in text
assert "modern_dark" in text assert "modern_dark" in text
assert "detect_stage" in text
assert "dlbackend=" in text
assert 'end="\\r"' in text or "end=\"\\r\"" in text
def test_verify_gpu_env_fail_fast_empty_dlbackend(monkeypatch): def test_verify_gpu_env_fail_fast_empty_dlbackend(monkeypatch):