diff --git a/main.py b/main.py index 7442007..184757a 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,8 @@ from mudfish_decky.client import MudfishClient # noqa: E402 from mudfish_decky.controller import Controller # noqa: E402 from mudfish_decky.store import Store # noqa: E402 +RESUME_POLL_SECONDS = 2 + class Plugin: controller = None @@ -44,9 +46,18 @@ class Plugin: log.setLevel(logging.INFO) for handler in decky.logger.handlers: log.addHandler(handler) + controller = self._get_controller() # Bring up the idle launcher and sign in so nodes are listed. Never starts the tunnel. - await asyncio.to_thread(self._get_controller().prepare) + await asyncio.to_thread(controller.prepare) decky.logger.info("Mudfish VPN plugin loaded") + # Watch for sleep/resume even while the panel is closed; a resumed tunnel is turned off. + while True: + try: + await asyncio.to_thread(controller.check_resume, system.sleep_offset()) + except Exception as e: + decky.logger.warning("resume check failed: %s", e) + await asyncio.sleep(RESUME_POLL_SECONDS) async def _unload(self): + await asyncio.to_thread(self._get_controller().shutdown) decky.logger.info("Mudfish VPN plugin unloaded") diff --git a/py_modules/mudfish_decky/client.py b/py_modules/mudfish_decky/client.py index 57836b8..88ffff6 100644 --- a/py_modules/mudfish_decky/client.py +++ b/py_modules/mudfish_decky/client.py @@ -27,6 +27,10 @@ class SigninError(MudfishError): pass +class AuthError(MudfishError): + """The cloud API rejected our jwt (expired session); a forced re-signin may fix it.""" + + # Decky's bundled Python ships without a CA store, so point it at SteamOS's system bundle. SYSTEM_CA_BUNDLES = ["/etc/ssl/certs/ca-certificates.crt", "/etc/ca-certificates/extracted/tls-ca-bundle.pem"] @@ -84,18 +88,27 @@ class MudfishClient: def status(self) -> dict: return self._json("GET", self.base_url + "/do/mudrun/status") - def ensure_signed_in(self) -> dict: - """Replay the launcher's remember-me autologin when the daemon has no session.""" + def ensure_signed_in(self, force: bool = False) -> dict: + """Replay the launcher's remember-me autologin when the daemon has no session (or force=True).""" st = self.status() - if st.get("isSignedIn"): + if st.get("isSignedIn") and not force: return st - if not st.get("autologin") and not st.get("token_v3"): + if not st.get("token_v3") or not st.get("username"): raise SigninError("no saved login") body, ctype = _multipart({ "autologin": "ON", "username": st["username"], "password": st["token_v3"], - "item_conf_mode": st["item_conf_mode"], "version": st["version"], + "item_conf_mode": st.get("item_conf_mode", ""), "version": st.get("version", ""), }) - signin = self._json("POST", f"https://{st['api_server']}/signin_ng_v3", body, {"Content-Type": ctype}) + url = f"https://{st['api_server']}/signin_ng_v3" + code, data = self.transport("POST", url, body, {"Content-Type": ctype}) + if code in (401, 403): + raise SigninError(f"signin HTTP {code}") + if not 200 <= code < 300: + raise MudfishError(f"HTTP {code} from {url}") + try: + signin = json.loads(data) + except ValueError: + raise MudfishError(f"invalid response from {url}") if signin.get("status") != 200: raise SigninError(f"signin status {signin.get('status')}") auth = self._post_json(self.base_url + "/do/auth", { @@ -120,12 +133,24 @@ class MudfishClient: def rtts(self) -> dict: return rtts_from_response(self._json("GET", self.base_url + "/do/rtt/staticnodes")) - def _graphql_user(self, jwt, api, fields) -> dict: - resp = self._post_json(f"https://{api}/graphql", {"query": "{ user { %s } }" % fields}, {"Authorization": jwt}) + def _cloud(self, jwt, url, payload) -> dict: + code, data = self.transport("POST", url, json.dumps(payload).encode(), + {"Content-Type": "application/json", "Authorization": jwt}) + if code in (401, 403): + raise AuthError(f"HTTP {code} from {url}") + if not 200 <= code < 300: + raise MudfishError(f"HTTP {code} from {url}") try: - return resp["data"]["user"] - except (KeyError, TypeError): - raise MudfishError("graphql returned no user") + return json.loads(data) + except ValueError: + raise MudfishError(f"invalid response from {url}") + + def _graphql_user(self, jwt, api, fields) -> dict: + resp = self._cloud(jwt, f"https://{api}/graphql", {"query": "{ user { %s } }" % fields}) + user = (resp.get("data") or {}).get("user") if isinstance(resp, dict) else None + if not user: + raise AuthError("graphql returned no user") + return user def static_nodes(self, jwt, api) -> list: return self._graphql_user(jwt, api, "staticNodes { sid location }")["staticNodes"] @@ -134,7 +159,7 @@ class MudfishClient: return self._graphql_user(jwt, api, "progConf { %s }" % " ".join(PROG_CONF_FIELDS))["progConf"] def save_prog_conf(self, jwt, api, conf: dict) -> dict: - resp = self._post_json(f"https://{api}/prog", conf, {"Authorization": jwt}) + resp = self._cloud(jwt, f"https://{api}/prog", conf) if resp.get("status") != 200: raise MudfishError(resp.get("msg") or f"prog status {resp.get('status')}") return resp diff --git a/py_modules/mudfish_decky/controller.py b/py_modules/mudfish_decky/controller.py index ac29e1a..ce4be96 100644 --- a/py_modules/mudfish_decky/controller.py +++ b/py_modules/mudfish_decky/controller.py @@ -1,7 +1,8 @@ """Connect/disconnect orchestration and the state contract the panel renders. The core tunnel is only ever started from connect() (or re-applied by set_node() while already on); -state() and plugin load must never start it, because Full VPN traffic is billed per GB. +state(), prepare(), resume handling and shutdown must never start it, because Full VPN traffic is +billed per GB. Every path that may leave a tunnel behind goes through _teardown(). """ import dataclasses import logging @@ -9,7 +10,7 @@ import threading import time from typing import Optional -from .client import SigninError +from .client import AuthError, SigninError from .nodes import Node, japan_nodes, pick_auto from .routes import host_routes, leftover_routes @@ -17,10 +18,15 @@ MSG_RELOGIN = "데스크탑 모드에서 미꾸라지(127.0.0.1:8282)에 '로그 MSG_RTT = "노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요" MSG_TIMEOUT = "30초 안에 연결되지 않아 연결을 취소했어요" MSG_MISSING = "데스크탑 모드에서 미꾸라지를 설치하세요 (docs.mudfish.net Steam Deck 가이드)" +MSG_STILL_UP = "터널이 아직 남아 있어요. 끄기를 다시 눌러 주세요" +MSG_NO_RESPONSE = "미꾸라지가 응답하지 않아요. 터널이 남아 있을 수 있으니 끄기를 눌러 주세요" +MSG_SLEEP = "절전에서 깨어나 VPN을 껐어요. 필요하면 다시 켜 주세요" CONNECT_TIMEOUT = 30 DISCONNECT_TIMEOUT = 10 PUBLIC_IP_TTL = 30 +RTT_TTL = 600 # cached pings older than this are ignored for the auto pick +RESUME_JUMP = 3.0 # seconds of suspended time that count as a sleep/resume log = logging.getLogger("mudfish_decky") @@ -34,8 +40,10 @@ class Controller: self.sleep = sleep self._lock = threading.Lock() self._error: Optional[str] = None + self._notice: Optional[str] = None self._static: Optional[list] = None self._ip_cache = (0.0, None) + self._sleep_offset: Optional[float] = None # ---- public API ------------------------------------------------------------------------- @@ -62,10 +70,11 @@ class Controller: def connect(self) -> dict: with self._lock: - self._error = None + self._error = self._notice = None installed = self.system.find_mudfish() if not installed: return self._state() + started = False try: st = self._signed_in(installed) if self._is_on(st): @@ -74,44 +83,35 @@ class Controller: node = self._target_node() if node is None: return self._fail(MSG_RTT) - self._apply_sid(st, node.sid) + self._with_auth_retry(st, lambda s: self._apply_sid(s, node.sid)) self.store.update(routes_before=sorted(host_routes(self.system.route_text())), active_sid=node.sid) + started = True self.client.start() if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT): - self.client.stop() - return self._fail(MSG_TIMEOUT) + return self._fail(MSG_TIMEOUT if self._teardown() else MSG_STILL_UP) self.store.update(connected_since=self.clock()) except SigninError: - return self._fail(MSG_RELOGIN) + return self._abort(started, MSG_RELOGIN) except Exception as e: log.exception("connect failed") - return self._fail(f"연결 실패: {e}") + return self._abort(started, f"연결 실패: {e}") return self._state() def disconnect(self) -> dict: with self._lock: - self._error = None - try: - self.client.stop() - self._wait(lambda: not self.system.tap_up(), DISCONNECT_TIMEOUT) - except Exception as e: - log.exception("disconnect failed") - return self._fail(f"해제 실패: {e}") - before = self.store.load().get("routes_before") - lines = leftover_routes(set(before) if before is not None else None, self.system.route_text()) - if lines: - log.info("removing %d leftover node routes", self.system.delete_routes(lines)) - self.store.update(routes_before=None, connected_since=None, active_sid=None) + self._error = self._notice = None + if not self._teardown(): + return self._fail(MSG_STILL_UP) return self._state() def set_node(self, choice: str) -> dict: with self._lock: - self._error = None - if choice == "auto": - self.store.update(node_mode="auto", manual_sid=None) - else: - self.store.update(node_mode="manual", manual_sid=int(choice)) + self._error = self._notice = None try: + if choice == "auto": + self.store.update(node_mode="auto", manual_sid=None) + else: + self.store.update(node_mode="manual", manual_sid=int(choice)) st = self.client.status() if self._is_on(st): self._reapply(st) @@ -122,12 +122,39 @@ class Controller: return self._fail(f"노드 변경 실패: {e}") return self._state() + def check_resume(self, offset: float) -> bool: + """Feed boottime-minus-monotonic; a jump means the Deck slept. Then any tunnel is torn down, + because NetworkManager drops Mudfish's relay route on wake and the tunnel black-holes traffic.""" + with self._lock: + prev, self._sleep_offset = self._sleep_offset, offset + if prev is None or offset - prev <= RESUME_JUMP: + return False + if self._tunnel_present(): + log.info("resume detected; turning VPN off") + self._error = None + self._notice = MSG_SLEEP if self._teardown() else None + if self._notice is None: + self._error = MSG_STILL_UP + return True + + def shutdown(self) -> None: + """Plugin unload (Deck shutdown/restart, Decky restart): never leave a tunnel behind.""" + with self._lock: + if self._tunnel_present(): + log.info("unload; turning VPN off") + self._teardown() + # ---- internals ---------------------------------------------------------------------------- def _fail(self, message: str) -> dict: self._error = message return self._state() + def _abort(self, started: bool, message: str) -> dict: + if started and not self._teardown(): + message = MSG_STILL_UP + return self._fail(message) + def _wait(self, cond, seconds) -> bool: for _ in range(seconds): if cond(): @@ -135,9 +162,37 @@ class Controller: self.sleep(1) return cond() + def _teardown(self) -> bool: + """Stop the core, confirm tap0 is gone, remove the node routes Mudfish left, clear session state. + Returns False (keeping the route snapshot for a retry) when the tunnel is still up.""" + try: + self.client.stop() + except Exception as e: + log.warning("stop failed: %s", e) + if not self._wait(lambda: not self.system.tap_up(), DISCONNECT_TIMEOUT): + return False + before = self.store.load().get("routes_before") + try: + lines = leftover_routes(set(before) if before is not None else None, self.system.route_text()) + if lines: + log.info("removing %d leftover node routes", self.system.delete_routes(lines)) + except Exception as e: + log.warning("route cleanup failed: %s", e) + self.store.update(routes_before=None, connected_since=None, active_sid=None) + self._ip_cache = (0.0, None) + return True + def _is_on(self, st: dict) -> bool: return bool(st.get("core_running") and st.get("client_ready") and self.system.tap_up()) + def _tunnel_present(self) -> bool: + if self.system.tap_up(): + return True + try: + return bool(self.client.status().get("core_running")) + except Exception: + return False + def _signed_in(self, installed) -> dict: self.system.ensure_daemon(installed[0], self.daemon_up) return self.client.ensure_signed_in() @@ -149,18 +204,26 @@ class Controller: except Exception: return False - def _refresh_rtts(self) -> dict: + def _with_auth_retry(self, st: dict, fn): + """Run a cloud call; if the daemon's jwt was rejected, force a fresh signin once and retry.""" + try: + return fn(st) + except AuthError: + return fn(self.client.ensure_signed_in(force=True)) + + def _refresh_rtts(self) -> None: try: rtts = self.client.rtts() except Exception: rtts = {} if rtts: - self.store.update(rtts={str(k): v for k, v in rtts.items()}) - return rtts - return {int(k): v for k, v in (self.store.load().get("rtts") or {}).items()} + self.store.update(rtts={str(k): v for k, v in rtts.items()}, rtts_at=self.clock()) def _cached_rtts(self) -> dict: - return {int(k): v for k, v in (self.store.load().get("rtts") or {}).items()} + data = self.store.load() + if not data.get("rtts") or self.clock() - (data.get("rtts_at") or 0) > RTT_TTL: + return {} + return {int(k): v for k, v in data["rtts"].items()} def _static_nodes(self, st: Optional[dict] = None) -> list: if self._static is None: @@ -168,7 +231,7 @@ class Controller: if saved: self._static = saved elif st and st.get("isSignedIn") and st.get("jwt"): - self._static = self.client.static_nodes(st["jwt"], st["api_server"]) + self._static = self._with_auth_retry(st, lambda s: self.client.static_nodes(s["jwt"], s["api_server"])) self.store.update(static_nodes=self._static) return self._static or [] @@ -204,7 +267,7 @@ class Controller: if node is None: return st = self.client.ensure_signed_in() - result = self._apply_sid(st, node.sid) + result = self._with_auth_retry(st, lambda s: self._apply_sid(s, node.sid)) self.store.update(active_sid=node.sid) if not result["changed"]: return @@ -213,8 +276,7 @@ class Controller: self._wait(lambda: not self.system.tap_up(), DISCONNECT_TIMEOUT) self.client.start() if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT): - self.client.stop() - raise RuntimeError(MSG_TIMEOUT) + raise RuntimeError(MSG_TIMEOUT if self._teardown() else MSG_STILL_UP) else: self.client.fullvpn_refresh(node.sid, result["conf"].get("mudfishFullvpnExceptions") or "") self._ip_cache = (0.0, None) @@ -229,7 +291,7 @@ class Controller: def _state(self) -> dict: data = self.store.load() out = {"phase": "off", "message": None, "node_mode": data.get("node_mode") or "auto", "node": None, - "ip": None, "country": None, "connected_since": None} + "ip": None, "country": None, "connected_since": None, "tunnel": False} if not self.system.find_mudfish(): out.update(phase="missing", message=MSG_MISSING) return out @@ -237,9 +299,11 @@ class Controller: st = self.client.status() except Exception: st = None + tap = self.system.tap_up() + on = bool(st) and self._is_on(st) + out["tunnel"] = tap or bool(st and st.get("core_running")) if st: self._refresh_rtts() - on = bool(st) and self._is_on(st) if on: since = data.get("connected_since") if since is None: @@ -247,14 +311,20 @@ class Controller: self.store.update(connected_since=since) ip = self._public_ip() or {} out.update(phase="on", connected_since=since, ip=ip.get("ip"), country=ip.get("country")) + elif st is None and tap: + out.update(phase="error", message=MSG_NO_RESPONSE, connected_since=data.get("connected_since")) elif st and st.get("core_running"): out["phase"] = "connecting" - else: + elif not out["tunnel"]: if data.get("connected_since") is not None: self.store.update(connected_since=None) self._ip_cache = (0.0, None) + if out["tunnel"] and not on and data.get("connected_since") is not None: + out["connected_since"] = data["connected_since"] if self._error and not on: out.update(phase="error", message=self._error) + elif self._notice and out["message"] is None: + out["message"] = self._notice nodes = self._jp_nodes(st) active = self.store.load().get("active_sid") diff --git a/py_modules/mudfish_decky/system.py b/py_modules/mudfish_decky/system.py index 3c240a8..18b021e 100644 --- a/py_modules/mudfish_decky/system.py +++ b/py_modules/mudfish_decky/system.py @@ -57,6 +57,12 @@ def ensure_daemon(bin_path: str, is_up: Callable[[], bool], run=subprocess.run, return +def sleep_offset(clock=time.clock_gettime, boot=getattr(time, "CLOCK_BOOTTIME", None), + mono=time.CLOCK_MONOTONIC) -> float: + """Total time spent suspended since boot: BOOTTIME keeps counting during suspend, MONOTONIC doesn't.""" + return clock(boot) - clock(mono) + + def tap_up() -> bool: return os.path.exists("/sys/class/net/tap0") diff --git a/src/Panel.tsx b/src/Panel.tsx index 93625af..422f680 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -65,12 +65,17 @@ export function Panel() { const [advanced, setAdvanced] = useState(false); const [nodes, setNodes] = useState([]); const polling = useRef(false); + const acting = useRef(false); const refresh = async () => { if (polling.current) return; polling.current = true; try { - setState(await getState()); + const next = await getState(); + // A poll that started before a toggle press would overwrite the action's result. + if (!acting.current) setState(next); + } catch (e) { + console.error("mudfish: getState failed", e); } finally { polling.current = false; } @@ -84,18 +89,28 @@ export function Panel() { useEffect(() => { if (!advanced) return; - const load = async () => setNodes(await listNodes()); + const load = async () => { + try { + setNodes(await listNodes()); + } catch (e) { + console.error("mudfish: listNodes failed", e); + } + }; load(); const id = setInterval(load, NODES_POLL_MS); return () => clearInterval(id); }, [advanced]); const run = async (action: () => Promise, pending?: Phase) => { + acting.current = true; setBusy(true); if (pending && state) setState({ ...state, phase: pending, message: null }); try { setState(await action()); + } catch (e) { + if (state) setState({ ...state, phase: "error", message: `요청 실패: ${e}` }); } finally { + acting.current = false; setBusy(false); } }; @@ -110,7 +125,8 @@ export function Panel() { ); } - const active = state.phase === "on" || state.phase === "connecting"; + // Checked whenever any tunnel exists (even a stuck one), so the toggle can always turn it off. + const active = state.tunnel || state.phase === "on" || state.phase === "connecting"; const options = [ { data: "auto", label: "자동 (핑 최저)" }, ...nodes.map((n) => ({ data: String(n.sid), label: `${n.label} · ${rttText(n.rtt)}` })), @@ -128,7 +144,7 @@ export function Panel() { run(on ? connect : disconnect, on ? "connecting" : undefined)} /> diff --git a/src/backend.ts b/src/backend.ts index 956889f..8b5dbba 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -16,6 +16,7 @@ export interface VpnState { ip: string | null; country: string | null; connected_since: number | null; + tunnel: boolean; } export const getState = callable<[], VpnState>("get_state"); diff --git a/tests/test_client.py b/tests/test_client.py index 2a74abf..8590ce3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,7 @@ import json import unittest -from mudfish_decky.client import MudfishClient, MudfishError, SigninError +from mudfish_decky.client import AuthError, MudfishClient, MudfishError, SigninError BASE = "http://127.0.0.1:8282" SIGNED_OUT = {"status": 200, "isSignedIn": False, "username": "u", "token_v3": "123", @@ -74,6 +74,44 @@ class ClientTest(unittest.TestCase): with self.assertRaises(SigninError): MudfishClient(BASE, t).ensure_signed_in() + def test_ensure_signed_in_force_skips_shortcut(self): + t = FakeTransport({ + ("GET", BASE + "/do/mudrun/status"): [dict(SIGNED_OUT, isSignedIn=True)], + ("POST", "https://api.mudfish.net/signin_ng_v3"): [SIGNIN_OK], + ("POST", BASE + "/do/auth"): [{"status": 200, "msg": "OK"}], + }) + MudfishClient(BASE, t).ensure_signed_in(force=True) + self.assertEqual(len(t.calls_to("POST", "https://api.mudfish.net/signin_ng_v3")), 1) + + def test_ensure_signed_in_http_401_is_signin_error(self): + t = FakeTransport({("GET", BASE + "/do/mudrun/status"): [SIGNED_OUT]}) + real = t.__call__ + + def transport(method, url, body, headers): + if "signin_ng_v3" in url: + return 401, b"{}" + return real(method, url, body, headers) + + with self.assertRaises(SigninError): + MudfishClient(BASE, transport).ensure_signed_in() + + def test_ensure_signed_in_without_token_is_signin_error(self): + t = FakeTransport({("GET", BASE + "/do/mudrun/status"): [dict(SIGNED_OUT, autologin=True, token_v3="")]}) + with self.assertRaises(SigninError): + MudfishClient(BASE, t).ensure_signed_in() + + def test_graphql_without_user_is_auth_error(self): + t = FakeTransport({("POST", "https://api.mudfish.net/graphql"): [{"errors": ["unauthorized"]}]}) + with self.assertRaises(AuthError): + MudfishClient(BASE, t).prog_conf("JWT", "api.mudfish.net") + + def test_prog_http_401_is_auth_error(self): + def transport(method, url, body, headers): + return 401, b"{}" + + with self.assertRaises(AuthError): + MudfishClient(BASE, transport).save_prog_conf("JWT", "api.mudfish.net", {}) + def test_start_accepts_201(self): t = FakeTransport({("POST", BASE + "/do/mudfish/start"): [{"status": 201}]}) MudfishClient(BASE, t).start() diff --git a/tests/test_controller.py b/tests/test_controller.py index d30c8f4..8dea160 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -2,8 +2,9 @@ import os import tempfile import unittest -from mudfish_decky.client import SigninError -from mudfish_decky.controller import MSG_MISSING, MSG_RELOGIN, MSG_RTT, MSG_TIMEOUT, Controller +from mudfish_decky.client import AuthError, SigninError +from mudfish_decky.controller import (MSG_MISSING, MSG_NO_RESPONSE, MSG_RELOGIN, MSG_RTT, MSG_SLEEP, MSG_STILL_UP, + MSG_TIMEOUT, Controller) from mudfish_decky.store import Store from tests.test_nodes import STATIC @@ -58,7 +59,7 @@ class FakeClient: def status(self): return dict(self.st) - def ensure_signed_in(self): + def ensure_signed_in(self, force=False): if self.signin_error: raise self.signin_error return dict(self.st) @@ -238,6 +239,146 @@ class ControllerTest(unittest.TestCase): self.assertEqual(st["node_mode"], "auto") self.assertEqual(st["node"]["sid"], 11) + # ---- final review fixes ------------------------------------------------------------------ + + def test_connect_status_error_after_start_tears_down(self): + real_start = self.client.start + + def start(): + real_start() + self.client.status = lambda: (_ for _ in ()).throw(OSError("launcher busy")) + + self.client.start = start + st = self.make().connect() + self.assertEqual(self.client.stop_calls, 1) + self.assertEqual(st["phase"], "error") + self.assertEqual(len(self.system.deleted), 2) + + def test_connect_timeout_cleans_routes_and_store(self): + self.client.ready_on_start = False + self.client.start = self._start_adding_routes_not_ready() + self.make().connect() + self.assertEqual(len(self.system.deleted), 2) + data = Store(self.path).load() + self.assertIsNone(data.get("routes_before")) + self.assertIsNone(data.get("active_sid")) + + def _start_adding_routes_not_ready(self): + def start(): + self.client.start_calls += 1 + self.client.st["core_running"] = True + self.system.routes = BEFORE + NEW_ROUTES + return start + + def test_disconnect_keeps_snapshot_when_tap_stays_up(self): + c = self.make() + c.connect() + real_stop = self.client.stop + + def stop(): + real_stop() + self.system.tap = True # tunnel did not go away + + self.client.stop = stop + st = c.disconnect() + self.assertEqual(st["phase"], "error") + self.assertEqual(st["message"], MSG_STILL_UP) + self.assertTrue(st["tunnel"]) + data = Store(self.path).load() + self.assertIsNotNone(data.get("routes_before")) + self.assertEqual(data.get("connected_since"), 1000.0) + self.assertEqual(self.system.deleted, []) + + def test_state_reports_tunnel_when_core_stuck(self): + self.client.st.update(core_running=True) + st = self.make().state() + self.assertEqual(st["phase"], "connecting") + self.assertTrue(st["tunnel"]) + + def test_state_tunnel_false_when_off(self): + self.assertFalse(self.make().state()["tunnel"]) + + def test_state_status_failure_with_tap_up_is_error_not_off(self): + Store(self.path).save({"connected_since": 100.0}) + self.system.tap = True + self.client.status = lambda: (_ for _ in ()).throw(OSError("no answer")) + st = self.make().state() + self.assertEqual(st["phase"], "error") + self.assertEqual(st["message"], MSG_NO_RESPONSE) + self.assertTrue(st["tunnel"]) + self.assertEqual(Store(self.path).load()["connected_since"], 100.0) + + def test_connect_resigns_once_when_cloud_rejects_jwt(self): + calls = [] + real_prog_conf = self.client.prog_conf + + def prog_conf(jwt, api): + calls.append(jwt) + if len(calls) == 1: + raise AuthError("graphql returned no user") + return real_prog_conf(jwt, api) + + forced = [] + self.client.prog_conf = prog_conf + self.client.ensure_signed_in = lambda force=False: forced.append(force) or dict(self.client.st) + st = self.make().connect() + self.assertEqual(st["phase"], "on") + self.assertIn(True, forced) + self.assertEqual(len(calls), 2) + + def test_connect_stale_rtt_cache_is_ignored(self): + Store(self.path).save({"rtts": {"11": 31.0}, "rtts_at": 1000.0 - 601}) + self.client.rtt_map = {} + st = self.make().connect() + self.assertEqual(st["message"], MSG_RTT) + self.assertEqual(self.client.start_calls, 0) + + # ---- sleep / shutdown -------------------------------------------------------------------- + + def test_resume_jump_turns_tunnel_off_with_notice(self): + c = self.make() + c.connect() + self.assertFalse(c.check_resume(5.0)) # first sample only sets the baseline + self.assertFalse(c.check_resume(5.5)) # small drift: not a resume + self.assertTrue(c.check_resume(65.5)) # suspended ~60s + st = c.state() + self.assertEqual(self.client.stop_calls, 1) + self.assertEqual(st["phase"], "off") + self.assertEqual(st["message"], MSG_SLEEP) + self.assertEqual(len(self.system.deleted), 2) + + def test_resume_while_off_does_nothing(self): + c = self.make() + c.check_resume(5.0) + c.check_resume(100.0) + self.assertEqual(self.client.stop_calls, 0) + self.assertIsNone(c.state()["message"]) + + def test_resume_never_starts_core(self): + c = self.make() + c.check_resume(5.0) + c.check_resume(100.0) + self.assertEqual(self.client.start_calls, 0) + + def test_notice_cleared_by_next_connect(self): + c = self.make() + c.connect() + c.check_resume(5.0) + c.check_resume(100.0) + self.assertIsNone(c.connect()["message"]) + + def test_shutdown_turns_tunnel_off(self): + c = self.make() + c.connect() + c.shutdown() + self.assertEqual(self.client.stop_calls, 1) + self.assertEqual(len(self.system.deleted), 2) + self.assertIsNone(Store(self.path).load().get("connected_since")) + + def test_shutdown_while_off_does_not_call_stop(self): + self.make().shutdown() + self.assertEqual(self.client.stop_calls, 0) + def test_prepare_signs_in_and_caches_nodes_without_starting(self): self.client.st["isSignedIn"] = False signed = [] diff --git a/tests/test_system.py b/tests/test_system.py index b29a0d1..e395e25 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -35,6 +35,12 @@ class SystemTest(unittest.TestCase): with tempfile.TemporaryDirectory() as var: self.assertEqual(api_base(var), "http://127.0.0.1:8282") + def test_sleep_offset_is_boottime_minus_monotonic(self): + from mudfish_decky.system import sleep_offset + clocks = {"boot": 500.0, "mono": 420.0} + self.assertEqual(sleep_offset(lambda c: clocks["boot"] if c == "boot" else clocks["mono"], + boot="boot", mono="mono"), 80.0) + def test_ensure_daemon_noop_when_up(self): calls = [] ensure_daemon("/bin/x", lambda: True, run=lambda cmd, **kw: calls.append(cmd))