Update documentation and CLI behavior for GPU management
- Clarified the behavior of `Ctrl+C` and `Ctrl+D` in the README and other documentation, specifying that `Ctrl+C` only stops the tunnel while keeping the GPU active, and `Ctrl+D` stops the GPU while preserving disk data. - Enhanced the CLI documentation to reflect these changes, ensuring users understand the implications of these commands during GPU operations. - Improved the handling of data bindings and remounting logic in the codebase to prevent issues with empty model tabs in the UI. - Added tests to validate the new command behaviors and ensure proper documentation alignment.
This commit is contained in:
@@ -124,6 +124,9 @@ def render_access_panel(
|
||||
cmds.add_row("открыть LLM", "gpu-rent open --llm")
|
||||
cmds.add_row("hold killer", "gpu-rent hold")
|
||||
cmds.add_row("стоп GPU", "gpu-rent stop")
|
||||
if tunneled:
|
||||
cmds.add_row("Ctrl+C", "туннель off, GPU жив")
|
||||
cmds.add_row("Ctrl+D", "stop GPU, диски остаются")
|
||||
if not tunneled:
|
||||
cmds.add_row("туннель", "gpu-rent tunnel --open")
|
||||
|
||||
@@ -217,6 +220,7 @@ def print_access_card(
|
||||
for line in mcp_snippet_lines(cfg):
|
||||
log(line)
|
||||
log("hold: gpu-rent hold | stop: gpu-rent stop")
|
||||
log("Ctrl+C — туннель off, GPU жив | Ctrl+D — stop GPU")
|
||||
log("")
|
||||
return
|
||||
from gpu_rent.term import console as default_console
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ def _die(exc: BaseException) -> None:
|
||||
err(str(exc))
|
||||
hint = (
|
||||
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop "
|
||||
"(Ctrl+C на туннеле GPU не гасит)"
|
||||
"(Ctrl+C на туннеле GPU не гасит; Ctrl+D гасит)"
|
||||
)
|
||||
msg = str(exc)
|
||||
if "gpu-rent status" not in msg and "Дальше:" not in msg:
|
||||
@@ -809,7 +809,7 @@ def logs(
|
||||
def tunnel(
|
||||
open_browser: bool = typer.Option(False, "--open", help="Открыть браузер на 17801"),
|
||||
) -> None:
|
||||
"""SSH localhost:17801 -> VM :7801. Ctrl+C закрывает туннель, GPU оставляет."""
|
||||
"""SSH localhost:17801 -> VM :7801. Ctrl+C — туннель off; Ctrl+D — stop GPU."""
|
||||
try:
|
||||
cfg = load_config(require_auth=True)
|
||||
state = load_state()
|
||||
@@ -1052,7 +1052,7 @@ def resize_data(gb: int = typer.Option(..., "--gb", help="Новый разме
|
||||
|
||||
watchdog_app = typer.Typer(
|
||||
help=(
|
||||
"Локальный сервис: если туннель умер без Ctrl+C / stop — "
|
||||
"Локальный сервис: если туннель умер без Ctrl+C / Ctrl+D / stop — "
|
||||
"через grace удалить compute. Не путать с idle-killer на VM."
|
||||
),
|
||||
no_args_is_help=True,
|
||||
|
||||
@@ -422,7 +422,8 @@ def dry_run_plan(checks: list[Check]) -> list[str]:
|
||||
f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин",
|
||||
"₽: в API нет — смотри панель; диск 24/7 даже после stop",
|
||||
f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801",
|
||||
"gpu-rent up --yes создаст GPU + SwarmUI и откроет туннель :17801 (Ctrl+C не гасит GPU)",
|
||||
"gpu-rent up --yes создаст GPU + SwarmUI и откроет туннель :17801 "
|
||||
"(Ctrl+C не гасит GPU, Ctrl+D гасит)",
|
||||
"только облако без туннеля: gpu-rent up --yes --no-tunnel",
|
||||
]
|
||||
flavor = next((c.detail for c in checks if c.name == "flavor" and c.ok), None)
|
||||
|
||||
@@ -271,7 +271,7 @@ def install_watchdog(
|
||||
log(
|
||||
f"local-watchdog установлен ({platform}): тик каждые {interval} мин. "
|
||||
f"Grace {grace_seconds() // 60} мин после смерти процесса туннеля → stop. "
|
||||
f"Ctrl+C на туннеле GPU не гасит. "
|
||||
f"Ctrl+C на туннеле GPU не гасит; Ctrl+D — stop. "
|
||||
"При SELECTEL_API_TOKEN — toast каждые BALANCE_NOTIFY_STEP_RUB ₽ (дефолт 200)."
|
||||
)
|
||||
return marker
|
||||
|
||||
+157
-90
@@ -28,7 +28,7 @@ from gpu_rent.manifests import (
|
||||
repo_dirname,
|
||||
repo_matches_runtime,
|
||||
)
|
||||
from gpu_rent.ssh_ops import put_text, remote_exists, run_python, run_ssh
|
||||
from gpu_rent.ssh_ops import put_text, remote_exists, run_python, run_script_sudo, run_ssh
|
||||
from gpu_rent.sync_files import pull_tree, push_tree
|
||||
|
||||
Log = Callable[[str], None]
|
||||
@@ -109,6 +109,26 @@ def tune_swarm_perf(cfg: Config, host: str, log: Log) -> bool:
|
||||
return "RESTART_SWARMUI=1" in out
|
||||
|
||||
|
||||
def ensure_data_binds(
|
||||
cfg: Config, host: str, log: Log, *, stop_swarm: bool = True
|
||||
) -> None:
|
||||
"""Re-bind Models/Data/Output/dlbackend without lazy umount.
|
||||
|
||||
``umount -l`` while Comfy holds files makes the Models tab go empty later
|
||||
(dropdown still shows the last checkpoint).
|
||||
"""
|
||||
env = None if stop_swarm else {"GPU_RENT_STOP_SWARM": "0"}
|
||||
run_script_sudo(
|
||||
cfg,
|
||||
host,
|
||||
_pkg_text("ensure_binds.sh"),
|
||||
remote_path="/tmp/gpu-rent-ensure_binds.sh",
|
||||
timeout=180,
|
||||
env=env,
|
||||
log=log,
|
||||
)
|
||||
|
||||
|
||||
def ensure_swarm_comfy_installed(cfg: Config, host: str, log: Log) -> None:
|
||||
"""Headless Comfy install / recover errored backends before ready wait."""
|
||||
# Diag script next to install so recover-fail can subprocess it on the VM.
|
||||
@@ -212,13 +232,11 @@ def seed_autocomplete(cfg: Config, host: str, log: Log) -> bool:
|
||||
dest = f"{dest_dir}/{cfg.autocomplete_filename}"
|
||||
meta_path = f"{dest}.gpu-rent-meta.json"
|
||||
blob = _github_blob(cfg)
|
||||
if blob is None:
|
||||
return False
|
||||
if blob.get("error"):
|
||||
if blob and blob.get("error"):
|
||||
log(str(blob["error"]))
|
||||
return False
|
||||
sha = str(blob.get("sha") or "")
|
||||
download_url = str(blob.get("download_url") or "")
|
||||
github_ok = isinstance(blob, dict) and not blob.get("error")
|
||||
sha = str(blob.get("sha") or "") if github_ok else ""
|
||||
download_url = str(blob.get("download_url") or "") if github_ok else ""
|
||||
old_sha = ""
|
||||
if remote_exists(cfg, host, meta_path):
|
||||
raw = run_ssh(cfg, host, f"cat {meta_path}", check=False)
|
||||
@@ -226,54 +244,52 @@ def seed_autocomplete(cfg: Config, host: str, log: Log) -> bool:
|
||||
old_sha = str(json.loads(raw).get("github_blob_sha") or "")
|
||||
except json.JSONDecodeError:
|
||||
old_sha = ""
|
||||
changed = sha != old_sha or not remote_exists(cfg, host, dest)
|
||||
changed = bool(github_ok) and (sha != old_sha or not remote_exists(cfg, host, dest))
|
||||
if changed:
|
||||
if not download_url:
|
||||
log("GitHub не дал download_url")
|
||||
return False
|
||||
log(f"качаю {cfg.autocomplete_filename}")
|
||||
run_ssh(
|
||||
cfg,
|
||||
host,
|
||||
"mkdir -p {dir} && curl -fsSL -o {part} {url} && mv {part} {dest}".format(
|
||||
dir=shlex.quote(dest_dir),
|
||||
part=shlex.quote(dest + ".partial"),
|
||||
url=shlex.quote(download_url),
|
||||
dest=shlex.quote(dest),
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
meta = {
|
||||
"repo": cfg.autocomplete_github_repo,
|
||||
"path": cfg.autocomplete_github_path,
|
||||
"ref": cfg.autocomplete_github_ref,
|
||||
"github_blob_sha": sha,
|
||||
"filename": cfg.autocomplete_filename,
|
||||
"fetched_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
"settings_applied": True,
|
||||
}
|
||||
put_text(cfg, host, meta_path, json.dumps(meta, indent=2) + "\n")
|
||||
changed = False
|
||||
else:
|
||||
log(f"качаю {cfg.autocomplete_filename}")
|
||||
run_ssh(
|
||||
cfg,
|
||||
host,
|
||||
"mkdir -p {dir} && curl -fsSL -o {part} {url} && mv {part} {dest}".format(
|
||||
dir=shlex.quote(dest_dir),
|
||||
part=shlex.quote(dest + ".partial"),
|
||||
url=shlex.quote(download_url),
|
||||
dest=shlex.quote(dest),
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
meta = {
|
||||
"repo": cfg.autocomplete_github_repo,
|
||||
"path": cfg.autocomplete_github_path,
|
||||
"ref": cfg.autocomplete_github_ref,
|
||||
"github_blob_sha": sha,
|
||||
"filename": cfg.autocomplete_filename,
|
||||
"fetched_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
"settings_applied": False,
|
||||
}
|
||||
put_text(cfg, host, meta_path, json.dumps(meta, indent=2) + "\n")
|
||||
settings = f"{DATA}/Data/Settings.fds"
|
||||
applied = False
|
||||
if remote_exists(cfg, host, meta_path):
|
||||
raw = run_ssh(cfg, host, f"cat {meta_path}", check=False)
|
||||
try:
|
||||
applied = bool(json.loads(raw).get("settings_applied"))
|
||||
except json.JSONDecodeError:
|
||||
applied = False
|
||||
if not applied:
|
||||
_merge_autocomplete_into_settings(cfg, host, settings, cfg.autocomplete_filename, log)
|
||||
if remote_exists(cfg, host, meta_path):
|
||||
try:
|
||||
meta_obj = json.loads(
|
||||
run_ssh(cfg, host, f"cat {meta_path}", check=False)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
meta_obj = {}
|
||||
if isinstance(meta_obj, dict):
|
||||
meta_obj["settings_applied"] = True
|
||||
put_text(cfg, host, meta_path, json.dumps(meta_obj, indent=2) + "\n")
|
||||
return changed
|
||||
settings_status = "noop"
|
||||
if remote_exists(cfg, host, dest):
|
||||
settings_status = _merge_autocomplete_into_settings(
|
||||
cfg, host, settings, cfg.autocomplete_filename, log
|
||||
)
|
||||
if settings_status in ("changed", "already", "skip_user"):
|
||||
if remote_exists(cfg, host, meta_path):
|
||||
try:
|
||||
meta_obj = json.loads(
|
||||
run_ssh(cfg, host, f"cat {meta_path}", check=False)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
meta_obj = {}
|
||||
if isinstance(meta_obj, dict):
|
||||
meta_obj["settings_applied"] = True
|
||||
put_text(cfg, host, meta_path, json.dumps(meta_obj, indent=2) + "\n")
|
||||
return changed or settings_status == "changed"
|
||||
|
||||
|
||||
_AUTOCOMPLETE_MERGE_PY = r'''
|
||||
@@ -335,6 +351,77 @@ def sync_is_installed(text: str) -> tuple[str, str | None]:
|
||||
return text, None
|
||||
|
||||
|
||||
def set_autocomplete_source(text: str, fname: str) -> tuple[str, str]:
|
||||
"""Fill AutoComplete.Source when empty; keep a user-chosen non-empty file."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
ac_idx = None
|
||||
ac_indent = ""
|
||||
for i, line in enumerate(lines):
|
||||
m = re.match(r"^([ \t]*)AutoComplete:\s*$", line)
|
||||
if m:
|
||||
ac_idx = i
|
||||
ac_indent = m.group(1)
|
||||
break
|
||||
if ac_idx is not None:
|
||||
child = ac_indent + " "
|
||||
src_idx = None
|
||||
src_val = None
|
||||
end = ac_idx + 1
|
||||
while end < len(lines):
|
||||
raw = lines[end]
|
||||
if raw.strip() == "":
|
||||
end += 1
|
||||
continue
|
||||
if (
|
||||
raw.startswith(ac_indent)
|
||||
and len(raw.rstrip("\n")) > len(ac_indent)
|
||||
and raw[len(ac_indent)] in " \t"
|
||||
):
|
||||
sm = re.match(r"^[ \t]*Source:\s*(.*)$", raw)
|
||||
if sm:
|
||||
src_idx = end
|
||||
src_val = sm.group(1).strip()
|
||||
end += 1
|
||||
continue
|
||||
break
|
||||
if src_val in ("\\x", "x"):
|
||||
src_val = ""
|
||||
if src_val == fname:
|
||||
return text, f"ALREADY AutoComplete.Source={fname}"
|
||||
if src_val:
|
||||
return text, f"SKIP_USER AutoComplete.Source={src_val}"
|
||||
src_line = f"{child}Source: {fname}\n"
|
||||
if src_idx is not None:
|
||||
nl = "\n" if lines[src_idx].endswith("\n") else ""
|
||||
lines[src_idx] = f"{child}Source: {fname}{nl}"
|
||||
return "".join(lines), f"CHANGED patched AutoComplete.Source={fname}"
|
||||
lines.insert(ac_idx + 1, src_line)
|
||||
return "".join(lines), f"CHANGED inserted AutoComplete.Source={fname}"
|
||||
if re.search(r"^DefaultUser:\s*$", text, re.M):
|
||||
new = re.sub(
|
||||
r"^(DefaultUser:\s*\n)",
|
||||
(
|
||||
r"\1 AutoComplete:\n"
|
||||
f" Source: {fname}\n"
|
||||
" EscapeParens: true\n"
|
||||
),
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
return new, f"CHANGED inserted AutoComplete under DefaultUser Source={fname}"
|
||||
ac_block = (
|
||||
"DefaultUser:\n"
|
||||
" AutoComplete:\n"
|
||||
f" Source: {fname}\n"
|
||||
" EscapeParens: true\n"
|
||||
)
|
||||
return (
|
||||
text.rstrip() + "\n\n" + ac_block,
|
||||
f"CHANGED appended DefaultUser.AutoComplete Source={fname}",
|
||||
)
|
||||
|
||||
|
||||
p = Path(os.environ.get("GPU_RENT_SETTINGS_FDS") or "/mnt/swarm_data/Data/Settings.fds")
|
||||
fname = (os.environ.get("GPU_RENT_AUTOCOMPLETE_FILE") or "").strip()
|
||||
if not fname:
|
||||
@@ -358,7 +445,7 @@ if not p.is_file():
|
||||
prefix = installed_prefix if comfy_on_disk() else ""
|
||||
p.write_text(prefix + ac_block, encoding="utf-8")
|
||||
extra = "+IsInstalled" if prefix else "без IsInstalled (первый Comfy install)"
|
||||
print(f"created Settings.fds AutoComplete.Source={fname} {extra}")
|
||||
print(f"CHANGED created Settings.fds AutoComplete.Source={fname} {extra}")
|
||||
raise SystemExit(0)
|
||||
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
@@ -368,40 +455,10 @@ if note:
|
||||
print(note)
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if re.search(rf"^\s*Source:\s*{re.escape(fname)}\s*$", text, re.M):
|
||||
print(f"AutoComplete.Source already {fname}")
|
||||
raise SystemExit(0)
|
||||
|
||||
# Replace Source line if AutoComplete section exists
|
||||
new, n = re.subn(
|
||||
r"(^[ \t]*Source:\s*).*$",
|
||||
rf"\1{fname}",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
if n and "AutoComplete" in text:
|
||||
p.write_text(new, encoding="utf-8")
|
||||
print(f"patched AutoComplete.Source={fname}")
|
||||
raise SystemExit(0)
|
||||
|
||||
if re.search(r"^DefaultUser:\s*$", text, re.M):
|
||||
new = re.sub(
|
||||
r"^(DefaultUser:\s*\n)",
|
||||
(
|
||||
r"\1 AutoComplete:\n"
|
||||
f" Source: {fname}\n"
|
||||
" EscapeParens: true\n"
|
||||
),
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
p.write_text(new, encoding="utf-8")
|
||||
print(f"inserted AutoComplete under DefaultUser Source={fname}")
|
||||
else:
|
||||
p.write_text(text.rstrip() + "\n\n" + ac_block, encoding="utf-8")
|
||||
print(f"appended DefaultUser.AutoComplete Source={fname}")
|
||||
text, src_note = set_autocomplete_source(text, fname)
|
||||
print(src_note)
|
||||
if src_note.startswith("CHANGED"):
|
||||
p.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
||||
'''
|
||||
|
||||
|
||||
@@ -548,8 +605,8 @@ print("CHANGED" if changed else "NOOP")
|
||||
|
||||
def _merge_autocomplete_into_settings(
|
||||
cfg: Config, host: str, settings_path: str, filename: str, log: Log
|
||||
) -> None:
|
||||
"""Patch AutoComplete.Source in Settings.fds without wiping the rest."""
|
||||
) -> str:
|
||||
"""Patch AutoComplete.Source. Returns changed|already|skip_user|noop."""
|
||||
out = run_python(
|
||||
cfg,
|
||||
host,
|
||||
@@ -562,9 +619,19 @@ def _merge_autocomplete_into_settings(
|
||||
"GPU_RENT_AUTOCOMPLETE_FILE": filename,
|
||||
},
|
||||
)
|
||||
status = "noop"
|
||||
for line in (out or "").splitlines():
|
||||
if line.strip():
|
||||
log(line.strip())
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
log(line)
|
||||
if line.startswith("CHANGED"):
|
||||
status = "changed"
|
||||
elif line.startswith("ALREADY") and status != "changed":
|
||||
status = "already"
|
||||
elif line.startswith("SKIP_USER") and status != "changed":
|
||||
status = "skip_user"
|
||||
return status
|
||||
|
||||
|
||||
def ensure_settings_is_installed(cfg: Config, host: str, log: Log) -> bool:
|
||||
|
||||
+21
-1
@@ -137,7 +137,27 @@ if want_swarm:
|
||||
})
|
||||
|
||||
if want_ollama:
|
||||
ok, detail = http_ok("http://127.0.0.1:11434/api/tags")
|
||||
try:
|
||||
req = urllib.request.Request("http://127.0.0.1:11434/api/tags", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
payload = json.loads(raw)
|
||||
models = payload.get("models") if isinstance(payload, dict) else None
|
||||
names = []
|
||||
if isinstance(models, list):
|
||||
for m in models:
|
||||
if isinstance(m, dict) and m.get("name"):
|
||||
names.append(str(m["name"]))
|
||||
elif isinstance(m, str) and m.strip():
|
||||
names.append(m.strip())
|
||||
if names:
|
||||
preview = ", ".join(names[:3])
|
||||
extra = "" if len(names) <= 3 else f" +{len(names) - 3}"
|
||||
ok, detail = True, f"{len(names)} models ({preview}{extra})"
|
||||
else:
|
||||
ok, detail = False, "Ollama up, 0 models — Assistent dropdown empty; ollama pull"
|
||||
except Exception as exc:
|
||||
ok, detail = False, str(exc)[:160]
|
||||
checks.append({
|
||||
"name": "ollama",
|
||||
"ok": ok,
|
||||
|
||||
@@ -76,7 +76,12 @@ ensure_bind() {
|
||||
return 0
|
||||
fi
|
||||
log "bind remount ${dst} (было: ${src_mnt:-?})"
|
||||
umount "$dst" 2>/dev/null || umount -l "$dst" 2>/dev/null || true
|
||||
umount "$dst" 2>/dev/null || true
|
||||
if findmnt "$dst" >/dev/null 2>&1; then
|
||||
# umount -l + bind on a live path empties Models later when FDs close.
|
||||
log "WARN ${dst} busy — оставляю текущий mount, без umount -l"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
mount --bind "$src" "$dst" || log "WARN: mount --bind ${src} → ${dst} failed"
|
||||
if ! grep -Fq " ${dst} " /etc/fstab; then
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Remount SwarmUI data binds. Call after swarmui is stopped (or we stop it).
|
||||
#
|
||||
# Do NOT umount -l and immediately mount --bind on the same path while SwarmUI
|
||||
# still has files open: when those FDs close, the lazy mount vanishes and
|
||||
# /opt/swarmui/Models becomes an empty boot-disk folder. The Generate dropdown
|
||||
# still shows the last model (JS), the Models tab lists disk → empty.
|
||||
set -uo pipefail
|
||||
|
||||
DATA="${DATA_ROOT:-/mnt/swarm_data}"
|
||||
SWARM="${SWARM_ROOT:-/opt/swarmui}"
|
||||
STOP="${GPU_RENT_STOP_SWARM:-1}"
|
||||
|
||||
log() { echo "[gpu-rent-binds] $*" >&2; }
|
||||
|
||||
count_weights() {
|
||||
local dir="$1"
|
||||
find "$dir" -type f \( -name '*.safetensors' -o -name '*.ckpt' -o -name '*.sft' \) 2>/dev/null | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
if [[ "$STOP" == "1" ]]; then
|
||||
systemctl stop swarmui 2>/dev/null || true
|
||||
for _ in $(seq 1 40); do
|
||||
if ! pgrep -f 'SwarmUI.dll' >/dev/null 2>&1 \
|
||||
&& ! pgrep -f 'launch-linux.sh' >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
|
||||
unmount_clean() {
|
||||
local dst="$1"
|
||||
local i
|
||||
for i in $(seq 1 20); do
|
||||
if ! findmnt "$dst" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
umount "$dst" 2>/dev/null || true
|
||||
sleep 0.25
|
||||
done
|
||||
if findmnt "$dst" >/dev/null 2>&1; then
|
||||
log "WARN still mounted $dst — not using umount -l (would drop Models later)"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
bind_one() {
|
||||
local src="$1" dst="$2" name="$3"
|
||||
mkdir -p "$src" "$dst"
|
||||
local cur
|
||||
cur="$(findmnt -n -o SOURCE --target "$dst" 2>/dev/null || true)"
|
||||
if [[ "$cur" == "$src" ]]; then
|
||||
log "ok $name bind $src"
|
||||
return 0
|
||||
fi
|
||||
log "remount $name (${cur:-none} → $src)"
|
||||
if ! unmount_clean "$dst"; then
|
||||
log "ERROR $name: $dst busy, skip bind (SwarmUI still holding files?)"
|
||||
return 1
|
||||
fi
|
||||
if ! mount --bind "$src" "$dst"; then
|
||||
log "ERROR $name: mount --bind failed"
|
||||
return 1
|
||||
fi
|
||||
cur="$(findmnt -n -o SOURCE --target "$dst" 2>/dev/null || true)"
|
||||
if [[ "$cur" != "$src" ]]; then
|
||||
log "ERROR $name: SOURCE='$cur' want='$src'"
|
||||
return 1
|
||||
fi
|
||||
log "ok $name remounted"
|
||||
return 0
|
||||
}
|
||||
|
||||
fail=0
|
||||
bind_one "${DATA}/Models" "${SWARM}/Models" Models || fail=1
|
||||
bind_one "${DATA}/Output" "${SWARM}/Output" Output || fail=1
|
||||
bind_one "${DATA}/Data" "${SWARM}/Data" Data || fail=1
|
||||
bind_one "${DATA}/dlbackend" "${SWARM}/dlbackend" dlbackend || fail=1
|
||||
|
||||
data_n="$(count_weights "${DATA}/Models")"
|
||||
opt_n="$(count_weights "${SWARM}/Models")"
|
||||
log "weights data=${data_n} swarm=${opt_n}"
|
||||
if [[ "${data_n}" -gt 0 && "${opt_n}" -eq 0 ]]; then
|
||||
log "ERROR Models bind empty but data volume has ${data_n} weights"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -540,17 +540,37 @@ def ensure_dlbackend_bind(*, stop_for_remount: bool = True) -> bool:
|
||||
print(f"{name} bind missing — mount --bind {src} → {dst}", flush=True)
|
||||
subprocess.run(["sudo", "-n", "mkdir", "-p", src, dst], check=False, timeout=15)
|
||||
if mounted.returncode == 0:
|
||||
# Lazy umount if busy (open files from old SwarmUI process).
|
||||
subprocess.run(
|
||||
["sudo", "-n", "umount", "-l", dst],
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
subprocess.run(
|
||||
umounted = False
|
||||
for _ in range(20):
|
||||
still = subprocess.run(
|
||||
["findmnt", "-n", "--target", dst],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if still.returncode != 0:
|
||||
umounted = True
|
||||
break
|
||||
subprocess.run(
|
||||
["sudo", "-n", "umount", dst],
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
time.sleep(0.25)
|
||||
if not umounted:
|
||||
print(
|
||||
f"WARN {name}: {dst} busy — skip remount "
|
||||
"(no umount -l: Models tab would empty later)",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
rc = subprocess.run(
|
||||
["sudo", "-n", "mount", "--bind", src, dst],
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
print(f"WARN {name} bind failed rc={rc.returncode}", flush=True)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
print(f"WARN {name} bind: {exc}", flush=True)
|
||||
return stopped
|
||||
|
||||
@@ -97,6 +97,23 @@ def swarm_api_bits() -> str:
|
||||
"current_model": val.get("current_model"),
|
||||
}
|
||||
chunks.append("ListBackends=" + json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
try:
|
||||
listed = _post(
|
||||
"/API/ListModels",
|
||||
{
|
||||
"session_id": sid,
|
||||
"path": "",
|
||||
"depth": 3,
|
||||
"subtype": "Stable-Diffusion",
|
||||
},
|
||||
)
|
||||
files = listed.get("files") if isinstance(listed, dict) else None
|
||||
folders = listed.get("folders") if isinstance(listed, dict) else None
|
||||
nfiles = len(files) if isinstance(files, list) else "?"
|
||||
nfolders = len(folders) if isinstance(folders, list) else "?"
|
||||
chunks.append(f"ListModels Stable-Diffusion files={nfiles} folders={nfolders}")
|
||||
except Exception as exc:
|
||||
chunks.append(f"ListModels fail: {exc}")
|
||||
# Full settings for first backend (StartScript path matters)
|
||||
for key, val in (backends or {}).items():
|
||||
if isinstance(val, dict) and val.get("settings"):
|
||||
@@ -125,6 +142,24 @@ def paths_bits() -> str:
|
||||
f"Backends.fds exists={BACKENDS_FDS.is_file()}",
|
||||
]
|
||||
rows.append("findmnt /opt/swarmui/dlbackend:\n" + _run(["findmnt", "/opt/swarmui/dlbackend"]))
|
||||
rows.append("findmnt /opt/swarmui/Models:\n" + _run(["findmnt", "/opt/swarmui/Models"]))
|
||||
data_models = DATA / "Models"
|
||||
opt_models = Path("/opt/swarmui/Models")
|
||||
def _n_weights(root: Path) -> int:
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
n = 0
|
||||
try:
|
||||
for p in root.rglob("*"):
|
||||
if p.suffix.lower() in {".safetensors", ".ckpt", ".sft"}:
|
||||
n += 1
|
||||
except OSError:
|
||||
return -1
|
||||
return n
|
||||
|
||||
rows.append(
|
||||
f"weights data={_n_weights(data_models)} swarm={_n_weights(opt_models)}"
|
||||
)
|
||||
if SETTINGS.is_file():
|
||||
try:
|
||||
for line in SETTINGS.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
|
||||
+12
-17
@@ -22,8 +22,10 @@ from gpu_rent.cloud import (
|
||||
)
|
||||
from gpu_rent.bootstrap import run_bootstrap
|
||||
from gpu_rent.provision import (
|
||||
ensure_data_binds,
|
||||
ensure_swarm_comfy_installed,
|
||||
provision_vm,
|
||||
seed_autocomplete,
|
||||
seed_swarmui_api_keys,
|
||||
tune_swarm_perf,
|
||||
)
|
||||
@@ -169,6 +171,12 @@ def _bind_access(
|
||||
log(f"diag: {diag_exc}")
|
||||
raise
|
||||
clock.mark("comfy-install", log)
|
||||
try:
|
||||
if seed_autocomplete(cfg, ip, log):
|
||||
log("systemctl restart swarmui (AutoComplete.Source после install)")
|
||||
run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120)
|
||||
except Exception as exc:
|
||||
log(f"autocomplete after comfy: {exc}")
|
||||
try:
|
||||
wait_backend_idle(cfg, ip, log)
|
||||
except CloudError as exc:
|
||||
@@ -186,23 +194,10 @@ def _bind_access(
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
run_ssh(
|
||||
cfg,
|
||||
ip,
|
||||
"sudo -n bash -c '"
|
||||
"for p in dlbackend Data Models Output; do "
|
||||
"src=/mnt/swarm_data/$p; dst=/opt/swarmui/$p; "
|
||||
"mkdir -p \"$src\" \"$dst\"; "
|
||||
"cur=$(findmnt -n -o SOURCE --target \"$dst\" 2>/dev/null || true); "
|
||||
"if [[ \"$cur\" != \"$src\" ]]; then "
|
||||
"umount -l \"$dst\" 2>/dev/null || umount \"$dst\" 2>/dev/null || true; "
|
||||
"mount --bind \"$src\" \"$dst\" || true; "
|
||||
"echo remounted $dst; "
|
||||
"fi; "
|
||||
"done'",
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
ensure_data_binds(cfg, ip, log, stop_swarm=False)
|
||||
except Exception as exc:
|
||||
log(f"binds remount: {exc}")
|
||||
log("systemctl restart swarmui (perf ExtraArgs)")
|
||||
run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120)
|
||||
try:
|
||||
|
||||
+76
-5
@@ -1,7 +1,12 @@
|
||||
"""SSH local forward with Nova watchdog. Ctrl+C closes tunnel only."""
|
||||
"""SSH local forward with Nova watchdog.
|
||||
|
||||
Ctrl+C closes the tunnel and leaves the GPU. Ctrl+D (EOF) runs ``stop``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
@@ -138,6 +143,57 @@ def _stop_forwarder(server) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def poll_ctrl_d(timeout: float = 1.0) -> bool:
|
||||
"""True if the user sent Ctrl+D / EOF. Ctrl+C stays KeyboardInterrupt.
|
||||
|
||||
Windows console delivers Ctrl+D as ``\\x04`` (and Ctrl+Z as ``\\x1a``).
|
||||
Those keys are ignored unless we read them — the old sleep-loop never did.
|
||||
"""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
if timeout > 0:
|
||||
time.sleep(timeout)
|
||||
return False
|
||||
except Exception:
|
||||
if timeout > 0:
|
||||
time.sleep(timeout)
|
||||
return False
|
||||
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError:
|
||||
if timeout > 0:
|
||||
time.sleep(timeout)
|
||||
return False
|
||||
deadline = time.time() + max(timeout, 0.0)
|
||||
while True:
|
||||
if msvcrt.kbhit():
|
||||
ch = msvcrt.getch()
|
||||
if ch in (b"\x00", b"\xe0") and msvcrt.kbhit():
|
||||
msvcrt.getch()
|
||||
continue
|
||||
if ch in (b"\x04", b"\x1a"):
|
||||
return True
|
||||
if ch == b"\x03":
|
||||
raise KeyboardInterrupt
|
||||
continue
|
||||
if time.time() >= deadline:
|
||||
return False
|
||||
time.sleep(0.05)
|
||||
|
||||
import select
|
||||
|
||||
r, _, _ = select.select([sys.stdin], [], [], max(timeout, 0.0))
|
||||
if not r:
|
||||
return False
|
||||
try:
|
||||
data = os.read(sys.stdin.fileno(), 64)
|
||||
except OSError:
|
||||
return False
|
||||
return (not data) or (b"\x04" in data)
|
||||
|
||||
|
||||
def _recover_unshelve(cfg: Config, log: Log) -> str:
|
||||
"""Unshelve EXPIRED VM, rebind FIP, wait SSH. Returns new host."""
|
||||
conn = connect(cfg)
|
||||
@@ -174,6 +230,8 @@ def run_tunnel(
|
||||
log: Log = print,
|
||||
wait: Callable[[], None] | None = None,
|
||||
poll_seconds: float = 30.0,
|
||||
stop_gpu: Callable[[], None] | None = None,
|
||||
session_end_poll: Callable[[float], bool] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
_ssh_tunnel_forwarder()
|
||||
@@ -184,7 +242,7 @@ def run_tunnel(
|
||||
current_host = host
|
||||
for loc, rem in forwards:
|
||||
log(f"туннель 127.0.0.1:{loc} -> {current_host}:{rem}")
|
||||
log("Ctrl+C закрывает туннель, GPU оставляет. Стоп GPU: gpu-rent stop")
|
||||
log("Ctrl+C — туннель off, GPU жив. Ctrl+D — stop GPU (диски остаются).")
|
||||
log("watchdog: EXPIRED → unshelve + reconnect")
|
||||
|
||||
server = _start_forwarder(cfg, current_host, forwards)
|
||||
@@ -233,7 +291,7 @@ def run_tunnel(
|
||||
start_heartbeat_thread()
|
||||
log(
|
||||
"local-watchdog: heartbeat активен — аварийное закрытие "
|
||||
"(не Ctrl+C) → stop после grace"
|
||||
"(не Ctrl+C / не Ctrl+D) → stop после grace"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -241,9 +299,22 @@ def run_tunnel(
|
||||
wait()
|
||||
return
|
||||
|
||||
end_poll = session_end_poll or poll_ctrl_d
|
||||
next_poll = time.time() + poll_seconds
|
||||
while True:
|
||||
time.sleep(1)
|
||||
if end_poll(1.0):
|
||||
log("Ctrl+D — гашу GPU (диски остаются)")
|
||||
_stop_forwarder(server)
|
||||
server = None
|
||||
stop_heartbeat_thread()
|
||||
if stop_gpu is not None:
|
||||
stop_gpu()
|
||||
else:
|
||||
from gpu_rent.session import cmd_stop
|
||||
|
||||
cmd_stop(cfg, log=log)
|
||||
log("туннель закрыт. GPU остановлен.")
|
||||
return
|
||||
if not server.is_active:
|
||||
next_poll = 0
|
||||
if time.time() < next_poll:
|
||||
@@ -280,7 +351,7 @@ def run_tunnel(
|
||||
return
|
||||
except KeyboardInterrupt:
|
||||
detach_lease_keep_gpu()
|
||||
log("туннель закрыт. GPU жив.")
|
||||
log("Ctrl+C — туннель закрыт. GPU жив.")
|
||||
finally:
|
||||
stop_heartbeat_thread()
|
||||
_stop_forwarder(server)
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ def cost_and_risk_lines(cfg: Config, *, spot: bool, flavor_name: str) -> list[st
|
||||
f"{cfg.idle_minutes} мин пустой очереди → delete compute. Отложить: gpu-rent hold",
|
||||
"preemptible: хостер может усыпить (~24 ч окно) → EXPIRED; tunnel сам unshelve, "
|
||||
"или gpu-rent up",
|
||||
"Ctrl+C на tunnel GPU не гасит — только gpu-rent stop или idle-killer. "
|
||||
"Ctrl+C на tunnel — туннель off, GPU жив. Ctrl+D — stop GPU (диски остаются). "
|
||||
"Опционально: gpu-rent watchdog install — аварийное закрытие окна/ребут "
|
||||
"после grace тоже stop (Ctrl+C по-прежнему detach)",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user