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 <noreply@anthropic.com>
This commit is contained in:
claude
2026-07-22 18:53:30 +03:00
parent a80f65c442
commit f02d48777b
3 changed files with 34 additions and 21 deletions

View File

@@ -214,11 +214,12 @@ action:
apply on reload. apply on reload.
- The identity is stored in `config/reticulum/identity` — back it up to keep the - The identity is stored in `config/reticulum/identity` — back it up to keep the
same address. same address.
- RNS is a standalone-daemon-style library. To embed it cleanly the integration - RNS and LXMF are standalone-daemon-style libraries — both install
suppresses RNS's own signal handlers (so Home Assistant keeps ownership of SIGINT/SIGTERM handlers and `atexit` persistence in their constructors. To
SIGINT/SIGTERM — important under Kubernetes) and unregisters its blocking embed them cleanly the integration suppresses those signal handlers during
`atexit` persistence, persisting RNS/LXMF state itself, off the event loop, init (so Home Assistant keeps ownership of SIGINT/SIGTERM — important under
on the `homeassistant_stop` event. RNS still spawns a couple of non-daemon 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 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 a short delay before the process exits (within the pod's termination grace
period). period).

View File

@@ -12,5 +12,5 @@
"loggers": ["RNS", "LXMF"], "loggers": ["RNS", "LXMF"],
"requirements": ["rns>=0.9.0", "lxmf>=0.6.0"], "requirements": ["rns>=0.9.0", "lxmf>=0.6.0"],
"single_config_entry": true, "single_config_entry": true,
"version": "1.0.2" "version": "1.0.3"
} }

View File

@@ -51,6 +51,11 @@ _LOCAL_DESTINATION: Any = None
_IDENTITY: Any = None _IDENTITY: Any = None
_ANNOUNCE_HANDLER: 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 # How long (seconds) to wait for a path/identity to resolve before giving up on
# an outbound message. # an outbound message.
PATH_RESOLVE_TIMEOUT = 15.0 PATH_RESOLVE_TIMEOUT = 15.0
@@ -148,7 +153,27 @@ class ReticulumManager:
self._push_state() self._push_state()
def _start(self) -> None: 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 _RNS_INSTANCE, _LXM_ROUTER, _LOCAL_DESTINATION # noqa: PLW0603
global _IDENTITY, _ANNOUNCE_HANDLER # noqa: PLW0603 global _IDENTITY, _ANNOUNCE_HANDLER # noqa: PLW0603
import LXMF # noqa: PLC0415 import LXMF # noqa: PLC0415
@@ -247,18 +272,7 @@ class ReticulumManager:
handle.write(contents) handle.write(contents)
def _create_rns_instance(self, rns: Any) -> Any: def _create_rns_instance(self, rns: Any) -> Any:
"""Create the RNS instance from an executor thread. """Create the RNS instance (called with signal handlers suppressed)."""
``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]
try: try:
return rns.Reticulum( return rns.Reticulum(
configdir=self.storage_dir, loglevel=self.loglevel configdir=self.storage_dir, loglevel=self.loglevel
@@ -274,8 +288,6 @@ class ReticulumManager:
raise raise
_LOGGER.debug("Adopted existing Reticulum instance after %s", err) _LOGGER.debug("Adopted existing Reticulum instance after %s", err)
return adopted return adopted
finally:
signal.signal = saved_signal # type: ignore[assignment]
@staticmethod @staticmethod
def _tame_exit_handler(obj: Any) -> None: def _tame_exit_handler(obj: Any) -> None: