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

@@ -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"
}

View File

@@ -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: