fix: turn VPN off on sleep/resume and unload; harden teardown

- Detect resume (boottime-monotonic jump) and tear down the tunnel: on wake
  NetworkManager drops Mudfish's relay route and all traffic black-holes.
- Turn the tunnel off on plugin unload (Deck shutdown/restart).
- One verified teardown path for disconnect, connect failure/timeout and
  node-change failure; keep the route snapshot if tap0 stays up.
- Toggle can always turn off a stuck tunnel; status failure with tap0 up
  is reported as an error, not off.
- Re-sign in once when the cloud rejects an expired jwt.
- Expire cached pings after 10 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-24 21:21:21 +09:00
1 parent f957489866
commit 8c595389a4
9 files changed
+372 -58

No files matched your search

+12 -1
View File
@@ -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")
+37 -12
View File
@@ -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
+107 -37
View File
@@ -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")
+6
View File
@@ -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")
+20 -4
View File
@@ -65,12 +65,17 @@ export function Panel() {
const [advanced, setAdvanced] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
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<VpnState>, 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() {
<ToggleField
label="미꾸라지 Full VPN"
checked={active}
disabled={busy || state.phase === "connecting" || state.phase === "missing"}
disabled={busy || state.phase === "missing"}
onChange={(on) => run(on ? connect : disconnect, on ? "connecting" : undefined)}
/>
</PanelSectionRow>
+1
View File
@@ -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");
+39 -1
View File
@@ -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()
+144 -3
View File
@@ -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 = []
+6
View File
@@ -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))