From f02d48777b488a9fbcb4dd36f3a8bde96e0cb5f5 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 22 Jul 2026 18:53:30 +0300 Subject: [PATCH] Suppress signal handlers for the whole init (LXMF.LXMRouter too) v1.0.2 only neutralised signal.signal around RNS.Reticulum(), but LXMF.LXMRouter.__init__ also calls signal.signal(SIGINT/SIGTERM) (LXMRouter.py 308-309), so init still crashed with "signal only works in main thread of the main interpreter" when the router was constructed in the executor thread. Wrap the entire _start init (both constructors) with signal.signal neutralised instead of just the RNS constructor. Confirmed via upstream source that the only relevant signal registration sites are RNS/Reticulum.py:366-367 and LXMF/LXMRouter.py:308-309, both module-attribute calls, so the module-level patch intercepts both. Co-Authored-By: Claude Opus 4.8 --- README.md | 11 ++--- custom_components/reticulum/manifest.json | 2 +- .../reticulum/reticulum_client.py | 42 ++++++++++++------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a6017f2..806b822 100644 --- a/README.md +++ b/README.md @@ -214,11 +214,12 @@ action: apply on reload. - The identity is stored in `config/reticulum/identity` — back it up to keep the same address. -- RNS is a standalone-daemon-style library. To embed it cleanly the integration - suppresses RNS's own signal handlers (so Home Assistant keeps ownership of - SIGINT/SIGTERM — important under Kubernetes) and unregisters its blocking - `atexit` persistence, persisting RNS/LXMF state itself, off the event loop, - on the `homeassistant_stop` event. RNS still spawns a couple of non-daemon +- RNS and LXMF are standalone-daemon-style libraries — both install + SIGINT/SIGTERM handlers and `atexit` persistence in their constructors. To + embed them cleanly the integration suppresses those signal handlers during + init (so Home Assistant keeps ownership of SIGINT/SIGTERM — important under + Kubernetes) and unregisters their blocking `atexit` persistence, persisting + RNS/LXMF state itself, off the event loop, on the `homeassistant_stop` event. RNS still spawns a couple of non-daemon worker threads, so on shutdown you may see a "non-daemonic threads" notice and a short delay before the process exits (within the pod's termination grace period). diff --git a/custom_components/reticulum/manifest.json b/custom_components/reticulum/manifest.json index 64d62a1..f8d37dc 100644 --- a/custom_components/reticulum/manifest.json +++ b/custom_components/reticulum/manifest.json @@ -12,5 +12,5 @@ "loggers": ["RNS", "LXMF"], "requirements": ["rns>=0.9.0", "lxmf>=0.6.0"], "single_config_entry": true, - "version": "1.0.2" + "version": "1.0.3" } diff --git a/custom_components/reticulum/reticulum_client.py b/custom_components/reticulum/reticulum_client.py index 648b0d4..13f54dc 100644 --- a/custom_components/reticulum/reticulum_client.py +++ b/custom_components/reticulum/reticulum_client.py @@ -51,6 +51,11 @@ _LOCAL_DESTINATION: Any = None _IDENTITY: Any = None _ANNOUNCE_HANDLER: Any = None + +def _noop_signal(*_args: Any, **_kwargs: Any) -> None: + """Drop-in for ``signal.signal`` used while initialising off the main thread.""" + return None + # How long (seconds) to wait for a path/identity to resolve before giving up on # an outbound message. PATH_RESOLVE_TIMEOUT = 15.0 @@ -148,7 +153,27 @@ class ReticulumManager: self._push_state() def _start(self) -> None: - """Blocking init. Runs in the executor thread.""" + """Blocking init wrapper (executor thread). + + Both ``RNS.Reticulum()`` and ``LXMF.LXMRouter()`` call + ``signal.signal()`` in their constructors, which only works on the main + thread. We run init in an executor thread, so neutralise ``signal.signal`` + for the whole init. As a bonus this stops RNS/LXMF from hijacking Home + Assistant's own SIGINT/SIGTERM handling (needed for clean shutdown, + especially under Kubernetes). + """ + patched = threading.current_thread() is not threading.main_thread() + saved_signal = signal.signal + if patched: + signal.signal = _noop_signal # type: ignore[assignment] + try: + self._start_impl() + finally: + if patched: + signal.signal = saved_signal # type: ignore[assignment] + + def _start_impl(self) -> None: + """Blocking init. Runs in the executor thread (signals suppressed).""" global _RNS_INSTANCE, _LXM_ROUTER, _LOCAL_DESTINATION # noqa: PLW0603 global _IDENTITY, _ANNOUNCE_HANDLER # noqa: PLW0603 import LXMF # noqa: PLC0415 @@ -247,18 +272,7 @@ class ReticulumManager: handle.write(contents) def _create_rns_instance(self, rns: Any) -> Any: - """Create the RNS instance from an executor thread. - - ``Reticulum.__init__`` calls ``signal.signal()`` with no main-thread - guard, but signal handlers can only be installed from the main thread. - Since we (correctly) initialise off the event loop, we temporarily - neutralise ``signal.signal`` so init completes — and as a bonus this - stops RNS from hijacking Home Assistant's own SIGINT/SIGTERM handling, - which HA needs for clean shutdown (important under Kubernetes). - """ - saved_signal = signal.signal - if threading.current_thread() is not threading.main_thread(): - signal.signal = lambda *args, **kwargs: None # type: ignore[assignment] + """Create the RNS instance (called with signal handlers suppressed).""" try: return rns.Reticulum( configdir=self.storage_dir, loglevel=self.loglevel @@ -274,8 +288,6 @@ class ReticulumManager: raise _LOGGER.debug("Adopted existing Reticulum instance after %s", err) return adopted - finally: - signal.signal = saved_signal # type: ignore[assignment] @staticmethod def _tame_exit_handler(obj: Any) -> None: