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:
1 parent
f957489866
commit
8c595389a4
9 files changed
+369
-55
No files matched your search
@@ -12,6 +12,8 @@ from mudfish_decky.client import MudfishClient # noqa: E402
|
|||||||
from mudfish_decky.controller import Controller # noqa: E402
|
from mudfish_decky.controller import Controller # noqa: E402
|
||||||
from mudfish_decky.store import Store # noqa: E402
|
from mudfish_decky.store import Store # noqa: E402
|
||||||
|
|
||||||
|
RESUME_POLL_SECONDS = 2
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
class Plugin:
|
||||||
controller = None
|
controller = None
|
||||||
@@ -44,9 +46,18 @@ class Plugin:
|
|||||||
log.setLevel(logging.INFO)
|
log.setLevel(logging.INFO)
|
||||||
for handler in decky.logger.handlers:
|
for handler in decky.logger.handlers:
|
||||||
log.addHandler(handler)
|
log.addHandler(handler)
|
||||||
|
controller = self._get_controller()
|
||||||
# Bring up the idle launcher and sign in so nodes are listed. Never starts the tunnel.
|
# 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")
|
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):
|
async def _unload(self):
|
||||||
|
await asyncio.to_thread(self._get_controller().shutdown)
|
||||||
decky.logger.info("Mudfish VPN plugin unloaded")
|
decky.logger.info("Mudfish VPN plugin unloaded")
|
||||||
@@ -27,6 +27,10 @@ class SigninError(MudfishError):
|
|||||||
pass
|
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.
|
# 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"]
|
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:
|
def status(self) -> dict:
|
||||||
return self._json("GET", self.base_url + "/do/mudrun/status")
|
return self._json("GET", self.base_url + "/do/mudrun/status")
|
||||||
|
|
||||||
def ensure_signed_in(self) -> dict:
|
def ensure_signed_in(self, force: bool = False) -> dict:
|
||||||
"""Replay the launcher's remember-me autologin when the daemon has no session."""
|
"""Replay the launcher's remember-me autologin when the daemon has no session (or force=True)."""
|
||||||
st = self.status()
|
st = self.status()
|
||||||
if st.get("isSignedIn"):
|
if st.get("isSignedIn") and not force:
|
||||||
return st
|
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")
|
raise SigninError("no saved login")
|
||||||
body, ctype = _multipart({
|
body, ctype = _multipart({
|
||||||
"autologin": "ON", "username": st["username"], "password": st["token_v3"],
|
"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:
|
if signin.get("status") != 200:
|
||||||
raise SigninError(f"signin status {signin.get('status')}")
|
raise SigninError(f"signin status {signin.get('status')}")
|
||||||
auth = self._post_json(self.base_url + "/do/auth", {
|
auth = self._post_json(self.base_url + "/do/auth", {
|
||||||
@@ -120,12 +133,24 @@ class MudfishClient:
|
|||||||
def rtts(self) -> dict:
|
def rtts(self) -> dict:
|
||||||
return rtts_from_response(self._json("GET", self.base_url + "/do/rtt/staticnodes"))
|
return rtts_from_response(self._json("GET", self.base_url + "/do/rtt/staticnodes"))
|
||||||
|
|
||||||
def _graphql_user(self, jwt, api, fields) -> dict:
|
def _cloud(self, jwt, url, payload) -> dict:
|
||||||
resp = self._post_json(f"https://{api}/graphql", {"query": "{ user { %s } }" % fields}, {"Authorization": jwt})
|
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:
|
try:
|
||||||
return resp["data"]["user"]
|
return json.loads(data)
|
||||||
except (KeyError, TypeError):
|
except ValueError:
|
||||||
raise MudfishError("graphql returned no user")
|
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:
|
def static_nodes(self, jwt, api) -> list:
|
||||||
return self._graphql_user(jwt, api, "staticNodes { sid location }")["staticNodes"]
|
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"]
|
return self._graphql_user(jwt, api, "progConf { %s }" % " ".join(PROG_CONF_FIELDS))["progConf"]
|
||||||
|
|
||||||
def save_prog_conf(self, jwt, api, conf: dict) -> dict:
|
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:
|
if resp.get("status") != 200:
|
||||||
raise MudfishError(resp.get("msg") or f"prog status {resp.get('status')}")
|
raise MudfishError(resp.get("msg") or f"prog status {resp.get('status')}")
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Connect/disconnect orchestration and the state contract the panel renders.
|
"""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);
|
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 dataclasses
|
||||||
import logging
|
import logging
|
||||||
@@ -9,7 +10,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .client import SigninError
|
from .client import AuthError, SigninError
|
||||||
from .nodes import Node, japan_nodes, pick_auto
|
from .nodes import Node, japan_nodes, pick_auto
|
||||||
from .routes import host_routes, leftover_routes
|
from .routes import host_routes, leftover_routes
|
||||||
|
|
||||||
@@ -17,10 +18,15 @@ MSG_RELOGIN = "데스크탑 모드에서 미꾸라지(127.0.0.1:8282)에 '로그
|
|||||||
MSG_RTT = "노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요"
|
MSG_RTT = "노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요"
|
||||||
MSG_TIMEOUT = "30초 안에 연결되지 않아 연결을 취소했어요"
|
MSG_TIMEOUT = "30초 안에 연결되지 않아 연결을 취소했어요"
|
||||||
MSG_MISSING = "데스크탑 모드에서 미꾸라지를 설치하세요 (docs.mudfish.net Steam Deck 가이드)"
|
MSG_MISSING = "데스크탑 모드에서 미꾸라지를 설치하세요 (docs.mudfish.net Steam Deck 가이드)"
|
||||||
|
MSG_STILL_UP = "터널이 아직 남아 있어요. 끄기를 다시 눌러 주세요"
|
||||||
|
MSG_NO_RESPONSE = "미꾸라지가 응답하지 않아요. 터널이 남아 있을 수 있으니 끄기를 눌러 주세요"
|
||||||
|
MSG_SLEEP = "절전에서 깨어나 VPN을 껐어요. 필요하면 다시 켜 주세요"
|
||||||
|
|
||||||
CONNECT_TIMEOUT = 30
|
CONNECT_TIMEOUT = 30
|
||||||
DISCONNECT_TIMEOUT = 10
|
DISCONNECT_TIMEOUT = 10
|
||||||
PUBLIC_IP_TTL = 30
|
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")
|
log = logging.getLogger("mudfish_decky")
|
||||||
|
|
||||||
@@ -34,8 +40,10 @@ class Controller:
|
|||||||
self.sleep = sleep
|
self.sleep = sleep
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._error: Optional[str] = None
|
self._error: Optional[str] = None
|
||||||
|
self._notice: Optional[str] = None
|
||||||
self._static: Optional[list] = None
|
self._static: Optional[list] = None
|
||||||
self._ip_cache = (0.0, None)
|
self._ip_cache = (0.0, None)
|
||||||
|
self._sleep_offset: Optional[float] = None
|
||||||
|
|
||||||
# ---- public API -------------------------------------------------------------------------
|
# ---- public API -------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -62,10 +70,11 @@ class Controller:
|
|||||||
|
|
||||||
def connect(self) -> dict:
|
def connect(self) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._error = None
|
self._error = self._notice = None
|
||||||
installed = self.system.find_mudfish()
|
installed = self.system.find_mudfish()
|
||||||
if not installed:
|
if not installed:
|
||||||
return self._state()
|
return self._state()
|
||||||
|
started = False
|
||||||
try:
|
try:
|
||||||
st = self._signed_in(installed)
|
st = self._signed_in(installed)
|
||||||
if self._is_on(st):
|
if self._is_on(st):
|
||||||
@@ -74,44 +83,35 @@ class Controller:
|
|||||||
node = self._target_node()
|
node = self._target_node()
|
||||||
if node is None:
|
if node is None:
|
||||||
return self._fail(MSG_RTT)
|
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)
|
self.store.update(routes_before=sorted(host_routes(self.system.route_text())), active_sid=node.sid)
|
||||||
|
started = True
|
||||||
self.client.start()
|
self.client.start()
|
||||||
if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT):
|
if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT):
|
||||||
self.client.stop()
|
return self._fail(MSG_TIMEOUT if self._teardown() else MSG_STILL_UP)
|
||||||
return self._fail(MSG_TIMEOUT)
|
|
||||||
self.store.update(connected_since=self.clock())
|
self.store.update(connected_since=self.clock())
|
||||||
except SigninError:
|
except SigninError:
|
||||||
return self._fail(MSG_RELOGIN)
|
return self._abort(started, MSG_RELOGIN)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("connect failed")
|
log.exception("connect failed")
|
||||||
return self._fail(f"연결 실패: {e}")
|
return self._abort(started, f"연결 실패: {e}")
|
||||||
return self._state()
|
return self._state()
|
||||||
|
|
||||||
def disconnect(self) -> dict:
|
def disconnect(self) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._error = None
|
self._error = self._notice = None
|
||||||
try:
|
if not self._teardown():
|
||||||
self.client.stop()
|
return self._fail(MSG_STILL_UP)
|
||||||
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)
|
|
||||||
return self._state()
|
return self._state()
|
||||||
|
|
||||||
def set_node(self, choice: str) -> dict:
|
def set_node(self, choice: str) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._error = None
|
self._error = self._notice = None
|
||||||
|
try:
|
||||||
if choice == "auto":
|
if choice == "auto":
|
||||||
self.store.update(node_mode="auto", manual_sid=None)
|
self.store.update(node_mode="auto", manual_sid=None)
|
||||||
else:
|
else:
|
||||||
self.store.update(node_mode="manual", manual_sid=int(choice))
|
self.store.update(node_mode="manual", manual_sid=int(choice))
|
||||||
try:
|
|
||||||
st = self.client.status()
|
st = self.client.status()
|
||||||
if self._is_on(st):
|
if self._is_on(st):
|
||||||
self._reapply(st)
|
self._reapply(st)
|
||||||
@@ -122,12 +122,39 @@ class Controller:
|
|||||||
return self._fail(f"노드 변경 실패: {e}")
|
return self._fail(f"노드 변경 실패: {e}")
|
||||||
return self._state()
|
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 ----------------------------------------------------------------------------
|
# ---- internals ----------------------------------------------------------------------------
|
||||||
|
|
||||||
def _fail(self, message: str) -> dict:
|
def _fail(self, message: str) -> dict:
|
||||||
self._error = message
|
self._error = message
|
||||||
return self._state()
|
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:
|
def _wait(self, cond, seconds) -> bool:
|
||||||
for _ in range(seconds):
|
for _ in range(seconds):
|
||||||
if cond():
|
if cond():
|
||||||
@@ -135,9 +162,37 @@ class Controller:
|
|||||||
self.sleep(1)
|
self.sleep(1)
|
||||||
return cond()
|
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:
|
def _is_on(self, st: dict) -> bool:
|
||||||
return bool(st.get("core_running") and st.get("client_ready") and self.system.tap_up())
|
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:
|
def _signed_in(self, installed) -> dict:
|
||||||
self.system.ensure_daemon(installed[0], self.daemon_up)
|
self.system.ensure_daemon(installed[0], self.daemon_up)
|
||||||
return self.client.ensure_signed_in()
|
return self.client.ensure_signed_in()
|
||||||
@@ -149,18 +204,26 @@ class Controller:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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:
|
try:
|
||||||
rtts = self.client.rtts()
|
rtts = self.client.rtts()
|
||||||
except Exception:
|
except Exception:
|
||||||
rtts = {}
|
rtts = {}
|
||||||
if rtts:
|
if rtts:
|
||||||
self.store.update(rtts={str(k): v for k, v in rtts.items()})
|
self.store.update(rtts={str(k): v for k, v in rtts.items()}, rtts_at=self.clock())
|
||||||
return rtts
|
|
||||||
return {int(k): v for k, v in (self.store.load().get("rtts") or {}).items()}
|
|
||||||
|
|
||||||
def _cached_rtts(self) -> dict:
|
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:
|
def _static_nodes(self, st: Optional[dict] = None) -> list:
|
||||||
if self._static is None:
|
if self._static is None:
|
||||||
@@ -168,7 +231,7 @@ class Controller:
|
|||||||
if saved:
|
if saved:
|
||||||
self._static = saved
|
self._static = saved
|
||||||
elif st and st.get("isSignedIn") and st.get("jwt"):
|
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)
|
self.store.update(static_nodes=self._static)
|
||||||
return self._static or []
|
return self._static or []
|
||||||
|
|
||||||
@@ -204,7 +267,7 @@ class Controller:
|
|||||||
if node is None:
|
if node is None:
|
||||||
return
|
return
|
||||||
st = self.client.ensure_signed_in()
|
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)
|
self.store.update(active_sid=node.sid)
|
||||||
if not result["changed"]:
|
if not result["changed"]:
|
||||||
return
|
return
|
||||||
@@ -213,8 +276,7 @@ class Controller:
|
|||||||
self._wait(lambda: not self.system.tap_up(), DISCONNECT_TIMEOUT)
|
self._wait(lambda: not self.system.tap_up(), DISCONNECT_TIMEOUT)
|
||||||
self.client.start()
|
self.client.start()
|
||||||
if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT):
|
if not self._wait(lambda: self._is_on(self.client.status()), CONNECT_TIMEOUT):
|
||||||
self.client.stop()
|
raise RuntimeError(MSG_TIMEOUT if self._teardown() else MSG_STILL_UP)
|
||||||
raise RuntimeError(MSG_TIMEOUT)
|
|
||||||
else:
|
else:
|
||||||
self.client.fullvpn_refresh(node.sid, result["conf"].get("mudfishFullvpnExceptions") or "")
|
self.client.fullvpn_refresh(node.sid, result["conf"].get("mudfishFullvpnExceptions") or "")
|
||||||
self._ip_cache = (0.0, None)
|
self._ip_cache = (0.0, None)
|
||||||
@@ -229,7 +291,7 @@ class Controller:
|
|||||||
def _state(self) -> dict:
|
def _state(self) -> dict:
|
||||||
data = self.store.load()
|
data = self.store.load()
|
||||||
out = {"phase": "off", "message": None, "node_mode": data.get("node_mode") or "auto", "node": None,
|
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():
|
if not self.system.find_mudfish():
|
||||||
out.update(phase="missing", message=MSG_MISSING)
|
out.update(phase="missing", message=MSG_MISSING)
|
||||||
return out
|
return out
|
||||||
@@ -237,9 +299,11 @@ class Controller:
|
|||||||
st = self.client.status()
|
st = self.client.status()
|
||||||
except Exception:
|
except Exception:
|
||||||
st = None
|
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:
|
if st:
|
||||||
self._refresh_rtts()
|
self._refresh_rtts()
|
||||||
on = bool(st) and self._is_on(st)
|
|
||||||
if on:
|
if on:
|
||||||
since = data.get("connected_since")
|
since = data.get("connected_since")
|
||||||
if since is None:
|
if since is None:
|
||||||
@@ -247,14 +311,20 @@ class Controller:
|
|||||||
self.store.update(connected_since=since)
|
self.store.update(connected_since=since)
|
||||||
ip = self._public_ip() or {}
|
ip = self._public_ip() or {}
|
||||||
out.update(phase="on", connected_since=since, ip=ip.get("ip"), country=ip.get("country"))
|
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"):
|
elif st and st.get("core_running"):
|
||||||
out["phase"] = "connecting"
|
out["phase"] = "connecting"
|
||||||
else:
|
elif not out["tunnel"]:
|
||||||
if data.get("connected_since") is not None:
|
if data.get("connected_since") is not None:
|
||||||
self.store.update(connected_since=None)
|
self.store.update(connected_since=None)
|
||||||
self._ip_cache = (0.0, 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:
|
if self._error and not on:
|
||||||
out.update(phase="error", message=self._error)
|
out.update(phase="error", message=self._error)
|
||||||
|
elif self._notice and out["message"] is None:
|
||||||
|
out["message"] = self._notice
|
||||||
|
|
||||||
nodes = self._jp_nodes(st)
|
nodes = self._jp_nodes(st)
|
||||||
active = self.store.load().get("active_sid")
|
active = self.store.load().get("active_sid")
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ def ensure_daemon(bin_path: str, is_up: Callable[[], bool], run=subprocess.run,
|
|||||||
return
|
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:
|
def tap_up() -> bool:
|
||||||
return os.path.exists("/sys/class/net/tap0")
|
return os.path.exists("/sys/class/net/tap0")
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -65,12 +65,17 @@ export function Panel() {
|
|||||||
const [advanced, setAdvanced] = useState(false);
|
const [advanced, setAdvanced] = useState(false);
|
||||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||||
const polling = useRef(false);
|
const polling = useRef(false);
|
||||||
|
const acting = useRef(false);
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (polling.current) return;
|
if (polling.current) return;
|
||||||
polling.current = true;
|
polling.current = true;
|
||||||
try {
|
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 {
|
} finally {
|
||||||
polling.current = false;
|
polling.current = false;
|
||||||
}
|
}
|
||||||
@@ -84,18 +89,28 @@ export function Panel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!advanced) return;
|
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();
|
load();
|
||||||
const id = setInterval(load, NODES_POLL_MS);
|
const id = setInterval(load, NODES_POLL_MS);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [advanced]);
|
}, [advanced]);
|
||||||
|
|
||||||
const run = async (action: () => Promise<VpnState>, pending?: Phase) => {
|
const run = async (action: () => Promise<VpnState>, pending?: Phase) => {
|
||||||
|
acting.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
if (pending && state) setState({ ...state, phase: pending, message: null });
|
if (pending && state) setState({ ...state, phase: pending, message: null });
|
||||||
try {
|
try {
|
||||||
setState(await action());
|
setState(await action());
|
||||||
|
} catch (e) {
|
||||||
|
if (state) setState({ ...state, phase: "error", message: `요청 실패: ${e}` });
|
||||||
} finally {
|
} finally {
|
||||||
|
acting.current = false;
|
||||||
setBusy(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 = [
|
const options = [
|
||||||
{ data: "auto", label: "자동 (핑 최저)" },
|
{ data: "auto", label: "자동 (핑 최저)" },
|
||||||
...nodes.map((n) => ({ data: String(n.sid), label: `${n.label} · ${rttText(n.rtt)}` })),
|
...nodes.map((n) => ({ data: String(n.sid), label: `${n.label} · ${rttText(n.rtt)}` })),
|
||||||
@@ -128,7 +144,7 @@ export function Panel() {
|
|||||||
<ToggleField
|
<ToggleField
|
||||||
label="미꾸라지 Full VPN"
|
label="미꾸라지 Full VPN"
|
||||||
checked={active}
|
checked={active}
|
||||||
disabled={busy || state.phase === "connecting" || state.phase === "missing"}
|
disabled={busy || state.phase === "missing"}
|
||||||
onChange={(on) => run(on ? connect : disconnect, on ? "connecting" : undefined)}
|
onChange={(on) => run(on ? connect : disconnect, on ? "connecting" : undefined)}
|
||||||
/>
|
/>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface VpnState {
|
|||||||
ip: string | null;
|
ip: string | null;
|
||||||
country: string | null;
|
country: string | null;
|
||||||
connected_since: number | null;
|
connected_since: number | null;
|
||||||
|
tunnel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getState = callable<[], VpnState>("get_state");
|
export const getState = callable<[], VpnState>("get_state");
|
||||||
|
|||||||
+39
-1
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import unittest
|
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"
|
BASE = "http://127.0.0.1:8282"
|
||||||
SIGNED_OUT = {"status": 200, "isSignedIn": False, "username": "u", "token_v3": "123",
|
SIGNED_OUT = {"status": 200, "isSignedIn": False, "username": "u", "token_v3": "123",
|
||||||
@@ -74,6 +74,44 @@ class ClientTest(unittest.TestCase):
|
|||||||
with self.assertRaises(SigninError):
|
with self.assertRaises(SigninError):
|
||||||
MudfishClient(BASE, t).ensure_signed_in()
|
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):
|
def test_start_accepts_201(self):
|
||||||
t = FakeTransport({("POST", BASE + "/do/mudfish/start"): [{"status": 201}]})
|
t = FakeTransport({("POST", BASE + "/do/mudfish/start"): [{"status": 201}]})
|
||||||
MudfishClient(BASE, t).start()
|
MudfishClient(BASE, t).start()
|
||||||
|
|||||||
+144
-3
@@ -2,8 +2,9 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mudfish_decky.client import SigninError
|
from mudfish_decky.client import AuthError, SigninError
|
||||||
from mudfish_decky.controller import MSG_MISSING, MSG_RELOGIN, MSG_RTT, MSG_TIMEOUT, Controller
|
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 mudfish_decky.store import Store
|
||||||
from tests.test_nodes import STATIC
|
from tests.test_nodes import STATIC
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ class FakeClient:
|
|||||||
def status(self):
|
def status(self):
|
||||||
return dict(self.st)
|
return dict(self.st)
|
||||||
|
|
||||||
def ensure_signed_in(self):
|
def ensure_signed_in(self, force=False):
|
||||||
if self.signin_error:
|
if self.signin_error:
|
||||||
raise self.signin_error
|
raise self.signin_error
|
||||||
return dict(self.st)
|
return dict(self.st)
|
||||||
@@ -238,6 +239,146 @@ class ControllerTest(unittest.TestCase):
|
|||||||
self.assertEqual(st["node_mode"], "auto")
|
self.assertEqual(st["node_mode"], "auto")
|
||||||
self.assertEqual(st["node"]["sid"], 11)
|
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):
|
def test_prepare_signs_in_and_caches_nodes_without_starting(self):
|
||||||
self.client.st["isSignedIn"] = False
|
self.client.st["isSignedIn"] = False
|
||||||
signed = []
|
signed = []
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ class SystemTest(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as var:
|
with tempfile.TemporaryDirectory() as var:
|
||||||
self.assertEqual(api_base(var), "http://127.0.0.1:8282")
|
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):
|
def test_ensure_daemon_noop_when_up(self):
|
||||||
calls = []
|
calls = []
|
||||||
ensure_daemon("/bin/x", lambda: True, run=lambda cmd, **kw: calls.append(cmd))
|
ensure_daemon("/bin/x", lambda: True, run=lambda cmd, **kw: calls.append(cmd))
|
||||||
|
|||||||
Reference in new issue
Block a user