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:
Leonid Pershin
2026-08-21 13:25:55 +03:00
parent 1d18ece17b
commit f437cd0373
26 changed files with 712 additions and 142 deletions
+4
View File
@@ -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
View File
@@ -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,
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+6 -1
View File
@@ -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
+93
View File
@@ -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
+27 -7
View File
@@ -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
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)",
]