From bc45f66da5edf9dc3a21199df10b75ba3e5fe3e8 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Sat, 24 Oct 2026 22:14:51 +0900 Subject: [PATCH] fix: reliable Mudfish removal on plugin uninstall Decky 3.3 often SIGKILLs plugins 5s after SIGTERM because its socket listener spins on EOF and starves the event loop (seen for several plugins). So: - _unload/_uninstall run synchronously (an awaited worker thread could never resume), and the daemon stop no longer waits. - A transient systemd path unit watches the plugins folder; if this plugin's folder stays gone for 20s it stops the daemon and deletes /opt/mudfish and the plugin settings, then removes itself. Co-Authored-By: Claude Opus 5.5 --- main.py | 12 ++++++--- py_modules/mudfish_decky/controller.py | 2 +- py_modules/mudfish_decky/system.py | 32 ++++++++++++++++++++++-- tests/test_controller.py | 2 +- tests/test_system.py | 34 ++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 7 deletions(-) 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"