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
+372
-58
No files matched your search
+39
-1
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user