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:
@@ -51,6 +51,10 @@ def _mock_bind(monkeypatch):
|
||||
"gpu_rent.session.ensure_swarm_comfy_installed",
|
||||
lambda cfg, host, log: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.seed_autocomplete",
|
||||
lambda cfg, host, log: False,
|
||||
)
|
||||
monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.verify_stack_on_vm",
|
||||
@@ -64,6 +68,10 @@ def _mock_bind(monkeypatch):
|
||||
"gpu_rent.session.tune_swarm_perf",
|
||||
lambda cfg, host, log: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.ensure_data_binds",
|
||||
lambda cfg, host, log, **kw: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.session.ensure_boot_snapshot",
|
||||
lambda conn, boot_volume_id, cfg, log: None,
|
||||
|
||||
+110
-1
@@ -1,4 +1,9 @@
|
||||
from gpu_rent.tunnel import decide_watch, tunnel_forwards
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from gpu_rent.tunnel import decide_watch, poll_ctrl_d, run_tunnel, tunnel_forwards
|
||||
|
||||
|
||||
def test_decide_ok_active():
|
||||
@@ -71,3 +76,107 @@ def test_resolve_llm_uses_cfg_only(monkeypatch):
|
||||
llm_runtime = "ollama"
|
||||
|
||||
assert resolve_llm_runtime(Cfg2()) == "ollama"
|
||||
|
||||
|
||||
def test_poll_ctrl_d_skips_when_not_tty(monkeypatch):
|
||||
monkeypatch.setattr("gpu_rent.tunnel.sys.stdin.isatty", lambda: False)
|
||||
t0 = time.time()
|
||||
assert poll_ctrl_d(0.02) is False
|
||||
assert time.time() - t0 < 0.5
|
||||
|
||||
|
||||
def test_poll_ctrl_d_windows_eot(monkeypatch):
|
||||
class Msvcrt:
|
||||
def kbhit(self) -> bool:
|
||||
return True
|
||||
|
||||
def getch(self) -> bytes:
|
||||
return b"\x04"
|
||||
|
||||
monkeypatch.setattr("gpu_rent.tunnel.sys.stdin.isatty", lambda: True)
|
||||
monkeypatch.setattr("gpu_rent.tunnel.os.name", "nt")
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", Msvcrt())
|
||||
assert poll_ctrl_d(0.2) is True
|
||||
|
||||
|
||||
def test_poll_ctrl_d_windows_ctrl_c(monkeypatch):
|
||||
class Msvcrt:
|
||||
def kbhit(self) -> bool:
|
||||
return True
|
||||
|
||||
def getch(self) -> bytes:
|
||||
return b"\x03"
|
||||
|
||||
monkeypatch.setattr("gpu_rent.tunnel.sys.stdin.isatty", lambda: True)
|
||||
monkeypatch.setattr("gpu_rent.tunnel.os.name", "nt")
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", Msvcrt())
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
poll_ctrl_d(0.2)
|
||||
|
||||
|
||||
class _Fwd:
|
||||
is_active = True
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _State:
|
||||
notes: dict = {}
|
||||
phase = ""
|
||||
floating_ip = "1.2.3.4"
|
||||
|
||||
|
||||
class _Cfg:
|
||||
ssh_user = "u"
|
||||
ssh_private_key_path = "k"
|
||||
swarmui_local_port = 17801
|
||||
ollama_local_port = 17811
|
||||
llm_runtime = "none"
|
||||
enable_swarmui = True
|
||||
|
||||
|
||||
def _stub_tunnel(monkeypatch) -> None:
|
||||
st = _State()
|
||||
monkeypatch.setattr("gpu_rent.tunnel._ssh_tunnel_forwarder", lambda: object)
|
||||
monkeypatch.setattr("gpu_rent.tunnel._start_forwarder", lambda *a, **k: _Fwd())
|
||||
monkeypatch.setattr("gpu_rent.ready.verify_stack_local", lambda *a, **k: [])
|
||||
monkeypatch.setattr("gpu_rent.tunnel.load_state", lambda: st)
|
||||
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)
|
||||
|
||||
|
||||
def test_run_tunnel_ctrl_d_stops_gpu(monkeypatch):
|
||||
_stub_tunnel(monkeypatch)
|
||||
logs: list[str] = []
|
||||
stopped: list[bool] = []
|
||||
run_tunnel(
|
||||
_Cfg(),
|
||||
"1.2.3.4",
|
||||
log=logs.append,
|
||||
stop_gpu=lambda: stopped.append(True),
|
||||
session_end_poll=lambda _t: True,
|
||||
poll_seconds=999,
|
||||
)
|
||||
assert stopped == [True]
|
||||
assert any("GPU остановлен" in x for x in logs)
|
||||
|
||||
|
||||
def test_run_tunnel_ctrl_c_keeps_gpu(monkeypatch):
|
||||
_stub_tunnel(monkeypatch)
|
||||
logs: list[str] = []
|
||||
stopped: list[bool] = []
|
||||
|
||||
def boom() -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
run_tunnel(
|
||||
_Cfg(),
|
||||
"1.2.3.4",
|
||||
log=logs.append,
|
||||
wait=boom,
|
||||
stop_gpu=lambda: stopped.append(True),
|
||||
)
|
||||
assert stopped == []
|
||||
assert any("GPU жив" in x for x in logs)
|
||||
|
||||
@@ -181,3 +181,11 @@ def test_verify_gpu_env_llm_only_skips_torch_requirement(monkeypatch):
|
||||
logs: list[str] = []
|
||||
out = verify_gpu_env(C(), "1.2.3.4", logs.append, timeout=5.0)
|
||||
assert all(c.ok for c in out)
|
||||
|
||||
|
||||
def test_stack_probe_requires_ollama_models():
|
||||
from gpu_rent.ready import _REMOTE_STACK_PROBE
|
||||
|
||||
assert "/api/tags" in _REMOTE_STACK_PROBE
|
||||
assert "0 models" in _REMOTE_STACK_PROBE
|
||||
assert 'payload.get("models")' in _REMOTE_STACK_PROBE
|
||||
|
||||
@@ -138,9 +138,34 @@ def test_swarm_diag_script_covers_api_and_journal():
|
||||
|
||||
text = files("gpu_rent.remote").joinpath("swarm_diag.py").read_text(encoding="utf-8")
|
||||
assert "ListBackends" in text
|
||||
assert "ListModels" in text
|
||||
assert "journalctl" in text
|
||||
assert ".gpu-rent-last-diag.txt" in text
|
||||
assert "nvidia-smi" in text
|
||||
assert "findmnt /opt/swarmui/Models" in text
|
||||
|
||||
|
||||
def test_ensure_binds_refuses_lazy_umount():
|
||||
from importlib.resources import files
|
||||
|
||||
sh = files("gpu_rent.remote").joinpath("ensure_binds.sh").read_text(encoding="utf-8")
|
||||
assert "umount -l" in sh # mentioned as forbidden
|
||||
assert 'umount -l "$dst"' not in sh
|
||||
assert "mount --bind" in sh
|
||||
assert "weights data=" in sh
|
||||
|
||||
comfy = files("gpu_rent.remote").joinpath("install_swarm_comfy.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '"umount", "-l"' not in comfy
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
session = (Path(__file__).resolve().parents[1] / "src" / "gpu_rent" / "session.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "umount -l" not in session
|
||||
assert "ensure_data_binds" in session
|
||||
|
||||
|
||||
def test_verify_gpu_env_fail_fast_empty_dlbackend(monkeypatch):
|
||||
|
||||
@@ -109,6 +109,12 @@ def test_autocomplete_merge_sets_is_installed():
|
||||
assert "GPU_RENT_COMFY_PRESENT" in _AUTOCOMPLETE_MERGE_PY
|
||||
assert "GPU_RENT_COMFY_PRESENT" in _ENSURE_INSTALLED_PY
|
||||
assert hasattr(provision, "ensure_settings_is_installed")
|
||||
assert "set_autocomplete_source" in _AUTOCOMPLETE_MERGE_PY
|
||||
assert "CHANGED inserted AutoComplete.Source" in _AUTOCOMPLETE_MERGE_PY
|
||||
assert '"settings_applied": False' in Path(provision.__file__).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "if not applied:" not in Path(provision.__file__).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_ensure_installed_clears_flag_without_comfy(tmp_path):
|
||||
@@ -178,3 +184,93 @@ def test_clear_settings_installed_flag(tmp_path, monkeypatch):
|
||||
assert "IsInstalled: false" in text
|
||||
assert "Theme: x" in text
|
||||
assert inst.clear_settings_installed_flag() is False
|
||||
|
||||
|
||||
def _run_autocomplete_merge(tmp_path, settings_text: str, fname: str = "danbooru.csv"):
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from gpu_rent.provision import _AUTOCOMPLETE_MERGE_PY
|
||||
|
||||
settings = tmp_path / "Settings.fds"
|
||||
settings.write_text(settings_text, encoding="utf-8")
|
||||
script = tmp_path / "merge.py"
|
||||
script.write_text(_AUTOCOMPLETE_MERGE_PY, encoding="utf-8")
|
||||
env = os.environ.copy()
|
||||
env["GPU_RENT_SETTINGS_FDS"] = str(settings)
|
||||
env["GPU_RENT_AUTOCOMPLETE_FILE"] = fname
|
||||
env["GPU_RENT_COMFY_PRESENT"] = "1"
|
||||
out = subprocess.check_output([sys.executable, str(script)], env=env, text=True)
|
||||
return out, settings.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_autocomplete_merge_inserts_source_when_only_escapeparens(tmp_path):
|
||||
out, text = _run_autocomplete_merge(
|
||||
tmp_path,
|
||||
"IsInstalled: true\nDefaultUser:\n AutoComplete:\n EscapeParens: true\n",
|
||||
)
|
||||
assert "CHANGED inserted AutoComplete.Source=danbooru.csv" in out
|
||||
assert "Source: danbooru.csv" in text
|
||||
assert "EscapeParens: true" in text
|
||||
|
||||
|
||||
def test_autocomplete_merge_fills_empty_fds_source(tmp_path):
|
||||
out, text = _run_autocomplete_merge(
|
||||
tmp_path,
|
||||
"DefaultUser:\n AutoComplete:\n Source: \\x\n EscapeParens: true\n",
|
||||
)
|
||||
assert "CHANGED patched AutoComplete.Source=danbooru.csv" in out
|
||||
assert "Source: danbooru.csv" in text
|
||||
|
||||
|
||||
def test_autocomplete_merge_keeps_user_source(tmp_path):
|
||||
out, text = _run_autocomplete_merge(
|
||||
tmp_path,
|
||||
"DefaultUser:\n AutoComplete:\n Source: e621.csv\n",
|
||||
)
|
||||
assert "SKIP_USER" in out
|
||||
assert "Source: e621.csv" in text
|
||||
assert "danbooru.csv" not in text
|
||||
|
||||
|
||||
def test_seed_autocomplete_merges_when_csv_already_present(monkeypatch):
|
||||
import json
|
||||
|
||||
from gpu_rent import provision
|
||||
|
||||
class Cfg:
|
||||
autocomplete_enabled = True
|
||||
autocomplete_filename = "danbooru.csv"
|
||||
autocomplete_github_repo = "org/repo"
|
||||
autocomplete_github_path = "tags/danbooru.csv"
|
||||
autocomplete_github_ref = "main"
|
||||
|
||||
monkeypatch.setattr(
|
||||
provision,
|
||||
"_github_blob",
|
||||
lambda _cfg: {"sha": "abc", "download_url": "https://example.invalid/x"},
|
||||
)
|
||||
|
||||
def fake_exists(_cfg, _host, path):
|
||||
return path.endswith("danbooru.csv") or path.endswith(".json")
|
||||
|
||||
def fake_ssh(_cfg, _host, cmd, **_kw):
|
||||
if "cat" in cmd:
|
||||
return json.dumps({"github_blob_sha": "abc", "settings_applied": True})
|
||||
return ""
|
||||
|
||||
merges: list[str] = []
|
||||
|
||||
def fake_merge(*_a, **_k):
|
||||
merges.append("called")
|
||||
return "changed"
|
||||
|
||||
monkeypatch.setattr(provision, "remote_exists", fake_exists)
|
||||
monkeypatch.setattr(provision, "run_ssh", fake_ssh)
|
||||
monkeypatch.setattr(provision, "_merge_autocomplete_into_settings", fake_merge)
|
||||
monkeypatch.setattr(provision, "put_text", lambda *_a, **_k: None)
|
||||
|
||||
logs: list[str] = []
|
||||
assert provision.seed_autocomplete(Cfg(), "1.2.3.4", logs.append) is True
|
||||
assert merges == ["called"]
|
||||
|
||||
Reference in New Issue
Block a user