diff --git a/README.md b/README.md index 0690ec3..7060176 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,15 @@ Unix: `./gpu-rent.sh …` (один раз `chmod +x gpu-rent.sh`). ```powershell .\gpu-rent.ps1 up --yes # GPU + SwarmUI + туннель :17801 # работаешь в браузере… -# Ctrl+C = туннель off, GPU жив -# Ctrl+D = stop GPU (диски остаются) -.\gpu-rent.ps1 tunnel --open # снова UI, если закрыл Ctrl+C +# Ctrl+C / Ctrl+D = stop GPU (диски остаются) +.\gpu-rent.ps1 tunnel --open # снова UI, если GPU ещё жив .\gpu-rent.ps1 hold # отложить idle-killer .\gpu-rent.ps1 stop # погасить GPU, диски оставить ``` | Важно | | | --- | --- | -| `Ctrl+C` на туннеле | **не** гасит GPU | -| `Ctrl+D` на туннеле | **stop** GPU, диски остаются | +| `Ctrl+C` / `Ctrl+D` на туннеле | **stop** GPU, диски остаются | | Простой ~30 мин | idle-killer сам удалит compute | | Preemptible | ~24 ч → `EXPIRED`; `tunnel` / `up` поднимут снова | | Двойной клик лаунчера | задай `GPU_RENT_DEFAULT_ARGS=up --yes` в `gpu-rent.vars` | diff --git a/docs/architecture.md b/docs/architecture.md index 513a2a4..9f34dd4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,7 +146,7 @@ Killer молчит: 3. `ERROR` / нет GPU → выход, не бесконечный recreate. 4. Туннель мёртв при ACTIVE → reconnect. -`Ctrl+C` здесь закрывает туннель, **не** вызывает `stop`. **Ctrl+D** — `stop` (диски остаются). +`Ctrl+C` и `Ctrl+D` вызывают `stop` (диски остаются). ## Teardown (`gpu-rent stop`) diff --git a/docs/cli.md b/docs/cli.md index 905dac5..3e6bb0f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,8 +23,7 @@ gpu-rent stop ```text gpu-rent up --yes -# Ctrl+C → туннель off, GPU жив -# Ctrl+D → stop GPU +# Ctrl+C / Ctrl+D → stop GPU (диски остаются) gpu-rent tunnel --open gpu-rent hold gpu-rent stop @@ -66,7 +65,7 @@ gpu-rent up --yes --ollama | `gpu-rent up --no-tunnel` | Только облако | | `gpu-rent up --no-spot` | Не preemptible | | `gpu-rent up --flavor ID --yes` | Явный flavor; без `--flavor` + `--yes` — первый доступный из `FLAVOR_PREFERENCE` | -| `gpu-rent tunnel` / `tunnel --open` | Повторный проброс; `--open` сразу браузер. Ctrl+C GPU **не** гасит; **Ctrl+D** вызывает `stop` | +| `gpu-rent tunnel` / `tunnel --open` | Повторный проброс; `--open` сразу браузер. **Ctrl+C / Ctrl+D** вызывают `stop` | | `gpu-rent open` / `open --llm` | Браузер на SwarmUI / LLM-порт (туннель уже должен слушать) | | `gpu-rent status` | State, Nova, диск, killer/hold, LLM, local-watchdog | | `gpu-rent hold` / `--minutes N` / `--until ISO` / `--clear` | Пауза idle-killer (нужны живая VM + SSH) | @@ -125,7 +124,7 @@ Exit 0 → можно `up`. Exit 1 → причина в таблице / кра 1. `gpu-rent watchdog install` (раз на машине, из корня репо) 2. Пока крутится `up`/`tunnel`, пишется heartbeat `.gpu-rent/local-lease.json` -3. **Ctrl+C** → detach, GPU **не** трогаем. **Ctrl+D** → `stop` (как `gpu-rent stop`) +3. **Ctrl+C / Ctrl+D** → `stop` (как `gpu-rent stop`) 4. Процесс умер / ребут → через `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop` 5. `gpu-rent stop` чистит lease сам diff --git a/docs/decisions.md b/docs/decisions.md index 680410f..45f54db 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,7 +5,7 @@ | Тема | Решение | | --- | --- | | Аудитория | Личный инструмент, репозиторий можно показать другим: MIT, без аккаунтных id и без имён локальных чекпоинтов | -| Сессия | GPU живёт до `stop` / **Ctrl+D на туннеле** **или** простоя (idle-killer **на VM**). **Ctrl+C** туннель закрывает, compute оставляет. Ноут можно закрыть — compute не обязан умереть. Опционально: `gpu-rent watchdog install` — локальный тик; если туннель умер без Ctrl+C/`Ctrl+D`/`stop`, после `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop` | +| Сессия | GPU живёт до `stop` / **Ctrl+C или Ctrl+D на туннеле** **или** простоя (idle-killer **на VM**). Выход из туннеля гасит compute (диски остаются). Опционально: `gpu-rent watchdog install` — локальный тик; если процесс умер без stop, после `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop` | | Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем | | Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | | Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`). `ru-6` — мультизональный: ходим на `https://ru-6.cloud.api.selcloud.ru/compute/` тем же токеном (SDK-каталог часто знает только RC-пул). Собираем типы GPU из extra_specs и совпадения с `FLAVOR_PREFERENCE`. Автоматом `.env` не пишем — печатаем рекомендацию `OS_REGION_NAME` / `GPU_RENT_AZ` | @@ -19,7 +19,7 @@ | Floating IP | `KEEP_FLOATING_IP=false`: выделить на `up`, удалить на `stop` | | Idle-killer | 30 минут пустой очереди генерации. Открытый браузер не продлевает жизнь | | Льгота после boot | Killer молчит во время clone/seed/push, hold, качалки в UI SwarmUI, пока backend не Idle, и 45 мин после ACTIVE/unshelve | -| `up` / `tunnel` | `up` по умолчанию после ready открывает туннель `:17801`, печатает URL и ждёт. `--no-tunnel` — только облако. Ctrl+C на туннеле GPU не гасит. **Ctrl+D — `stop`** (диски остаются). Команда `tunnel` остаётся для повторного входа | +| `up` / `tunnel` | `up` по умолчанию после ready открывает туннель `:17801`, печатает URL и ждёт. `--no-tunnel` — только облако. **Ctrl+C и Ctrl+D на туннеле — `stop`** (диски остаются). Команда `tunnel` остаётся для повторного входа | | Interactive `up` | Без `--yes`: нумерованные меню (LLM, пресет, flavor #, data GB, preemptible) → confirm. Изменения можно сохранить в `gpu-rent.vars` | | Git update | На каждом `up` по умолчанию: `git pull` SwarmUI + репы из `extensions.yaml` + уже установленные на data (`Extensions`/`DLNodes`). `--no-update` или `UPDATE_GIT=false` — не тянуть | | LLM (opt-in) | `none` по умолчанию. Флаги / `LLM_RUNTIME` / меню. Манифест: `ollama-models.yaml`. Порт 17811. Prompt-help, не замена SwarmUI | @@ -49,6 +49,6 @@ 1. **Туннель ≠ жизнь GPU.** `stop` и idle-killer не зависят от того, открыт ли SSH с ноутбука. 2. **Локальный SwarmUI на 7801 не трогаем.** Туннель по умолчанию на **17801** (на VM по-прежнему 7801 на loopback). -3. **`Ctrl+C` на туннеле не удаляет VM.** Иначе «закрыл ноут» невозможно. **`Ctrl+D` вызывает `stop`** (диски на месте). При установленном **local-watchdog** Ctrl+C по-прежнему detach; убийство процесса/ребут без detach → stop после grace. +3. **`Ctrl+C` и `Ctrl+D` на туннеле вызывают `stop`** (диски на месте). Убийство процесса/ребут без stop → idle-killer на VM; при **local-watchdog** — ещё и локальный stop после grace. 4. Idle-killer на VM **не может** быть `shutdown -h`: у Selectel остановленная изнутри VM часто продолжает тарифицировать ресурсы. Нужен вызов OpenStack: удалить **этот** compute, диски оставить. 5. Для этого на VM — OpenStack **application credential** с правом DELETE/GET **только этого** compute (не `/servers/*`, не unrestricted fallback). На `stop`/`destroy` cred отзывается. Компрометация SwarmUI в худшем случае сносит текущую GPU-сессию, а не произвольные машины проекта. diff --git a/docs/setup.md b/docs/setup.md index 6346a80..a5e24a3 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -235,7 +235,7 @@ copy models.example.yaml models.yaml 2. Create/unshelve GPU + диски (после confirm). 3. Bootstrap SwarmUI, extensions, autocomplete, Civitai-seed, push локальных папок; optional LLM. 4. Туннель на `localhost:17801` (или LLM-порт), **проверка** что сервисы отвечают, access-card. -5. Процесс ждёт: **Ctrl+C** закрывает только туннель (GPU остаётся); **Ctrl+D** — `stop` GPU (диски остаются). +5. Процесс ждёт: **Ctrl+C** и **Ctrl+D** — `stop` GPU (диски остаются). Полезные флаги: diff --git a/docs/spike-notes.md b/docs/spike-notes.md index 861a087..bc48609 100644 --- a/docs/spike-notes.md +++ b/docs/spike-notes.md @@ -63,7 +63,7 @@ ## 3. Туннель и API -Если закрыл туннель (**Ctrl+C**, GPU жив): +Если GPU ещё жив, а туннель закрыт (`--no-tunnel` или оборвался SSH): ```powershell .\gpu-rent.ps1 tunnel --open diff --git a/src/gpu_rent/access_card.py b/src/gpu_rent/access_card.py index 0cce2a7..2642ea2 100644 --- a/src/gpu_rent/access_card.py +++ b/src/gpu_rent/access_card.py @@ -125,8 +125,7 @@ def render_access_panel( 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, диски остаются") + cmds.add_row("Ctrl+C / Ctrl+D", "stop GPU, диски остаются") if not tunneled: cmds.add_row("туннель", "gpu-rent tunnel --open") @@ -220,7 +219,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("Ctrl+C / Ctrl+D — stop GPU (диски остаются)") log("") return from gpu_rent.term import console as default_console diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index bad6aa2..58cd8e2 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -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+D гасит)" + "(Ctrl+C / Ctrl+D на туннеле гасят GPU, диски остаются)" ) 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 — туннель off; Ctrl+D — stop GPU.""" + """SSH localhost:17801 -> VM :7801. Ctrl+C / 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 / Ctrl+D / stop — " + "Локальный сервис: если процесс туннеля умер без stop (kill/reboot) — " "через grace удалить compute. Не путать с idle-killer на VM." ), no_args_is_help=True, diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py index 48733d9..aa00990 100644 --- a/src/gpu_rent/doctor.py +++ b/src/gpu_rent/doctor.py @@ -423,7 +423,7 @@ def dry_run_plan(checks: list[Check]) -> list[str]: "₽: в API нет — смотри панель; диск 24/7 даже после stop", f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801", "gpu-rent up --yes создаст GPU + SwarmUI и откроет туннель :17801 " - "(Ctrl+C не гасит GPU, Ctrl+D гасит)", + "(Ctrl+C / Ctrl+D гасят GPU)", "только облако без туннеля: gpu-rent up --yes --no-tunnel", ] flavor = next((c.detail for c in checks if c.name == "flavor" and c.ok), None) diff --git a/src/gpu_rent/local_watchdog.py b/src/gpu_rent/local_watchdog.py index 3fac64b..fd89f5a 100644 --- a/src/gpu_rent/local_watchdog.py +++ b/src/gpu_rent/local_watchdog.py @@ -2,7 +2,7 @@ VM idle-killer remains the default safety net. This module is opt-in via `gpu-rent watchdog install`: while a tunnel is armed, a scheduled tick stops -compute if the laptop/process died without Ctrl+C detach or `gpu-rent stop`. +compute if the laptop/process died without Ctrl+C/Ctrl+D ``stop`` or `gpu-rent stop`. """ from __future__ import annotations @@ -189,7 +189,7 @@ def touch_heartbeat() -> None: def detach_lease_keep_gpu() -> None: - """Ctrl+C on tunnel: leave GPU running; local tick must not stop.""" + """Mark the lease detached so the local tick will not stop compute.""" lease = load_lease() if lease is None: lease = LocalLease() @@ -271,7 +271,7 @@ def install_watchdog( log( f"local-watchdog установлен ({platform}): тик каждые {interval} мин. " f"Grace {grace_seconds() // 60} мин после смерти процесса туннеля → stop. " - f"Ctrl+C на туннеле GPU не гасит; Ctrl+D — stop. " + f"Ctrl+C / Ctrl+D на туннеле — stop. " "При SELECTEL_API_TOKEN — toast каждые BALANCE_NOTIFY_STEP_RUB ₽ (дефолт 200)." ) return marker diff --git a/src/gpu_rent/setup_wizard.py b/src/gpu_rent/setup_wizard.py index 6cf68c0..10b76e9 100644 --- a/src/gpu_rent/setup_wizard.py +++ b/src/gpu_rent/setup_wizard.py @@ -99,7 +99,7 @@ def run_setup( do_wd = install_watchdog if do_wd is None and confirm: - do_wd = confirm("Установить local-watchdog (аварийный stop без Ctrl+C)?") + do_wd = confirm("Установить local-watchdog (аварийный stop, если процесс умер)?") if do_wd: from gpu_rent.local_watchdog import install_watchdog as _install diff --git a/src/gpu_rent/tunnel.py b/src/gpu_rent/tunnel.py index 7761cc2..c6bff95 100644 --- a/src/gpu_rent/tunnel.py +++ b/src/gpu_rent/tunnel.py @@ -1,6 +1,6 @@ """SSH local forward with Nova watchdog. -Ctrl+C closes the tunnel and leaves the GPU. Ctrl+D (EOF) runs ``stop``. +Ctrl+C and Ctrl+D (EOF) both run ``stop`` (disks kept). """ from __future__ import annotations @@ -242,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 — туннель off, GPU жив. Ctrl+D — stop GPU (диски остаются).") + log("Ctrl+C / Ctrl+D — stop GPU (диски остаются).") log("watchdog: EXPIRED → unshelve + reconnect") server = _start_forwarder(cfg, current_host, forwards) @@ -255,46 +255,61 @@ def run_tunnel( else: open_url = f"http://127.0.0.1:{cfg.swarmui_local_port}" - from gpu_rent.ready import verify_stack_local - - try: - local_checks = verify_stack_local(cfg, log, timeout=90.0) - state = load_state() - state.notes = dict(state.notes or {}) - state.notes["stack_local"] = [ - {"name": c.name, "ok": c.ok, "detail": c.detail} for c in local_checks - ] - save_state(state) - except CloudError as exc: - log(f"проверка туннеля: {exc}") - raise - - from gpu_rent.access_card import print_access_card - - print_access_card(cfg, tunneled=True, host=current_host) - - if open_browser: - webbrowser.open(open_url) - - state = load_state() - state.phase = "ready_tunneled" - save_state(state) - from gpu_rent.local_watchdog import ( - detach_lease_keep_gpu, + clear_lease, start_heartbeat_thread, stop_heartbeat_thread, watchdog_installed, ) - if watchdog_installed(): - start_heartbeat_thread() - log( - "local-watchdog: heartbeat активен — аварийное закрытие " - "(не Ctrl+C / не Ctrl+D) → stop после grace" - ) + def _halt_gpu(reason: str) -> None: + nonlocal server + log(f"{reason} — гашу GPU (диски остаются)") + _stop_forwarder(server) + server = None + stop_heartbeat_thread() + clear_lease() + if stop_gpu is not None: + stop_gpu() + else: + from gpu_rent.session import cmd_stop + + cmd_stop(cfg, log=log) + log("туннель закрыт. GPU остановлен.") try: + if watchdog_installed(): + start_heartbeat_thread() + log( + "local-watchdog: heartbeat активен — kill/reboot без stop " + "→ stop после grace" + ) + + from gpu_rent.ready import verify_stack_local + + try: + local_checks = verify_stack_local(cfg, log, timeout=90.0) + state = load_state() + state.notes = dict(state.notes or {}) + state.notes["stack_local"] = [ + {"name": c.name, "ok": c.ok, "detail": c.detail} for c in local_checks + ] + save_state(state) + except CloudError as exc: + log(f"проверка туннеля: {exc}") + raise + + from gpu_rent.access_card import print_access_card + + print_access_card(cfg, tunneled=True, host=current_host) + + if open_browser: + webbrowser.open(open_url) + + state = load_state() + state.phase = "ready_tunneled" + save_state(state) + if wait is not None: wait() return @@ -303,17 +318,7 @@ def run_tunnel( next_poll = time.time() + poll_seconds while True: 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 остановлен.") + _halt_gpu("Ctrl+D") return if not server.is_active: next_poll = 0 @@ -350,8 +355,7 @@ def run_tunnel( log(f"unshelve/reconnect fail: {exc}") return except KeyboardInterrupt: - detach_lease_keep_gpu() - log("Ctrl+C — туннель закрыт. GPU жив.") + _halt_gpu("Ctrl+C") finally: stop_heartbeat_thread() _stop_forwarder(server) diff --git a/src/gpu_rent/ux.py b/src/gpu_rent/ux.py index bec8a7d..29fce95 100644 --- a/src/gpu_rent/ux.py +++ b/src/gpu_rent/ux.py @@ -49,9 +49,9 @@ 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 — туннель off, GPU жив. Ctrl+D — stop GPU (диски остаются). " - "Опционально: gpu-rent watchdog install — аварийное закрытие окна/ребут " - "после grace тоже stop (Ctrl+C по-прежнему detach)", + "Ctrl+C / Ctrl+D на tunnel — stop GPU (диски остаются). " + "Опционально: gpu-rent watchdog install — kill/reboot окна " + "после grace тоже stop", ] diff --git a/tests/test_tunnel_watch.py b/tests/test_tunnel_watch.py index 0613d21..aa121cd 100644 --- a/tests/test_tunnel_watch.py +++ b/tests/test_tunnel_watch.py @@ -145,6 +145,7 @@ def _stub_tunnel(monkeypatch) -> None: monkeypatch.setattr("gpu_rent.tunnel.save_state", lambda s: None) monkeypatch.setattr("gpu_rent.access_card.print_access_card", lambda *a, **k: None) monkeypatch.setattr("gpu_rent.local_watchdog.watchdog_installed", lambda: False) + monkeypatch.setattr("gpu_rent.local_watchdog.clear_lease", lambda: None) def test_run_tunnel_ctrl_d_stops_gpu(monkeypatch): @@ -163,7 +164,7 @@ def test_run_tunnel_ctrl_d_stops_gpu(monkeypatch): assert any("GPU остановлен" in x for x in logs) -def test_run_tunnel_ctrl_c_keeps_gpu(monkeypatch): +def test_run_tunnel_ctrl_c_stops_gpu(monkeypatch): _stub_tunnel(monkeypatch) logs: list[str] = [] stopped: list[bool] = [] @@ -178,5 +179,6 @@ def test_run_tunnel_ctrl_c_keeps_gpu(monkeypatch): wait=boom, stop_gpu=lambda: stopped.append(True), ) - assert stopped == [] - assert any("GPU жив" in x for x in logs) + assert stopped == [True] + assert any("GPU остановлен" in x for x in logs) + assert not any("GPU жив" in x for x in logs)