diff --git a/main.py b/main.py index 77ac72a..2a2274a 100644 --- a/main.py +++ b/main.py @@ -73,13 +73,16 @@ class Plugin: controller = self._get_controller() # Bring up the idle launcher and sign in so nodes are listed. Never starts the tunnel. await asyncio.to_thread(controller.prepare) + # Clean up Mudfish if the plugin folder disappears (uninstall), even if Decky kills us first. + plugin_dir = os.path.dirname(os.path.abspath(__file__)) + await asyncio.to_thread(system.ensure_cleanup_watcher, plugin_dir, decky.DECKY_PLUGIN_SETTINGS_DIR) decky.logger.info("Mudfish VPN plugin loaded") # Watch for sleep/resume even while the panel is closed; a resumed tunnel is turned off. # Also look for a newer Mudfish release now and then (the controller limits it to once a day). ticks = 0 while True: try: - await asyncio.to_thread(controller.check_resume, system.sleep_offset()) + controller.check_resume(system.sleep_offset()) if ticks % LATEST_POLL_TICKS == 0: await asyncio.to_thread(controller.refresh_latest) except Exception as e: @@ -87,11 +90,14 @@ class Plugin: ticks += 1 await asyncio.sleep(RESUME_POLL_SECONDS) + # _unload/_uninstall run synchronously on purpose: Decky closes the plugin socket right after + # asking it to stop, and its listener then spins on EOF and starves the event loop, so an + # awaited worker thread would never resume and the plugin is SIGKILLed after 5 seconds. async def _unload(self): - await asyncio.to_thread(self._get_controller().shutdown) + self._get_controller().shutdown() decky.logger.info("Mudfish VPN plugin unloaded") async def _uninstall(self): # Removing the plugin removes Mudfish too (user decision): VPN off, daemon stopped, /opt/mudfish gone. - await asyncio.to_thread(self._get_controller().uninstall) + self._get_controller().uninstall() decky.logger.info("Mudfish removed with the plugin") diff --git a/py_modules/mudfish_decky/controller.py b/py_modules/mudfish_decky/controller.py index 36a66f8..b9bce3f 100644 --- a/py_modules/mudfish_decky/controller.py +++ b/py_modules/mudfish_decky/controller.py @@ -170,7 +170,7 @@ class Controller: self._teardown() except Exception as e: log.warning("uninstall: teardown failed: %s", e) - for step in (self.system.stop_daemon, self.system.remove_nm_connection, + for step in (lambda: self.system.stop_daemon(wait=False), self.system.remove_nm_connection, lambda: self.installer.remove_all(self.installer.MUDFISH_ROOT)): try: step() diff --git a/py_modules/mudfish_decky/system.py b/py_modules/mudfish_decky/system.py index b955c0f..1e0d982 100644 --- a/py_modules/mudfish_decky/system.py +++ b/py_modules/mudfish_decky/system.py @@ -42,8 +42,9 @@ def version_of(bin_path: str) -> str: return os.path.basename(os.path.dirname(os.path.dirname(bin_path))) -def stop_daemon(run=subprocess.run) -> None: - run(["systemctl", "stop", DAEMON_UNIT], capture_output=True, timeout=20) +def stop_daemon(run=subprocess.run, wait: bool = True) -> None: + cmd = ["systemctl", "stop", DAEMON_UNIT] + ([] if wait else ["--no-block"]) + run(cmd, capture_output=True, timeout=20) def remove_nm_connection(run=subprocess.run) -> None: @@ -51,6 +52,33 @@ def remove_nm_connection(run=subprocess.run) -> None: run(["nmcli", "connection", "delete", "mudfish-tap"], capture_output=True, timeout=10) +CLEANUP_UNIT = "mudfish-decky-cleanup" +CLEANUP_DELAY = 20 + + +def ensure_cleanup_watcher(plugin_dir: str, settings_dir: str, run=subprocess.run) -> None: + """Remove Mudfish when the plugin is uninstalled, independently of the plugin process. + + Decky 3.3 often SIGKILLs plugins before _uninstall runs, so a transient systemd path unit watches + the plugins directory; when this plugin's folder has stayed gone for CLEANUP_DELAY seconds (not a + reinstall/update), it stops the daemon and deletes /opt/mudfish and the plugin settings. + """ + if run(["systemctl", "is-active", "--quiet", f"{CLEANUP_UNIT}.path"], + capture_output=True, timeout=10).returncode == 0: + return + script = "; ".join([ + f"sleep {CLEANUP_DELAY}", + f'[ -e "{plugin_dir}/plugin.json" ] && exit 0', + f"systemctl stop {DAEMON_UNIT}", + "nmcli connection delete mudfish-tap", + f"rm -rf {MUDFISH_ROOT}", + f'rm -rf "{settings_dir}"', + f"systemctl stop {CLEANUP_UNIT}.path", + ]) + run(["systemd-run", f"--unit={CLEANUP_UNIT}", f"--path-property=PathChanged={os.path.dirname(plugin_dir)}", + "/bin/sh", "-c", script], capture_output=True, timeout=10) + + def api_base(var_dir: str) -> str: try: with open(os.path.join(var_dir, ".ipport")) as f: diff --git a/tests/test_controller.py b/tests/test_controller.py index 7ebce85..6438834 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -43,7 +43,7 @@ class FakeSystem: self.daemon_calls += 1 self.daemon_bins.append(bin_path) - def stop_daemon(self): + def stop_daemon(self, wait=True): self.daemon_stops += 1 def remove_nm_connection(self): diff --git a/tests/test_system.py b/tests/test_system.py index f3a9d02..4fe0498 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -48,6 +48,40 @@ class SystemTest(unittest.TestCase): self.assertEqual(calls, [["systemctl", "stop", "mudfish-decky"], ["nmcli", "connection", "delete", "mudfish-tap"]]) + def test_cleanup_watcher_created_when_missing(self): + from mudfish_decky.system import CLEANUP_UNIT, ensure_cleanup_watcher + calls = [] + + def run(cmd, **kw): + calls.append(cmd) + return types.SimpleNamespace(returncode=3 if cmd[:2] == ["systemctl", "is-active"] else 0) + + ensure_cleanup_watcher("/home/deck/homebrew/plugins/mudfish-decky", + "/home/deck/homebrew/settings/mudfish-decky", run=run) + self.assertEqual(calls[0], ["systemctl", "is-active", "--quiet", f"{CLEANUP_UNIT}.path"]) + cmd = calls[1] + self.assertEqual(cmd[:3], ["systemd-run", f"--unit={CLEANUP_UNIT}", + "--path-property=PathChanged=/home/deck/homebrew/plugins"]) + script = cmd[-1] + self.assertIn("sleep 20", script) + self.assertIn('[ -e "/home/deck/homebrew/plugins/mudfish-decky/plugin.json" ] && exit 0', script) + self.assertIn("rm -rf /opt/mudfish", script) + self.assertIn('rm -rf "/home/deck/homebrew/settings/mudfish-decky"', script) + self.assertIn(f"systemctl stop {CLEANUP_UNIT}.path", script) + # the plugin-dir check must come before anything destructive + self.assertLess(script.index("plugin.json"), script.index("rm -rf")) + + def test_cleanup_watcher_not_recreated_when_active(self): + from mudfish_decky.system import ensure_cleanup_watcher + calls = [] + + def run(cmd, **kw): + calls.append(cmd) + return types.SimpleNamespace(returncode=0) + + ensure_cleanup_watcher("/p/mudfish-decky", "/s/mudfish-decky", run=run) + self.assertEqual(len(calls), 1) + def test_lan_ip_from_route_get(self): from mudfish_decky.system import lan_ip out = "1.1.1.1 via 192.168.1.1 dev wlan0 src 192.168.1.50 uid 0 \n cache \n"