Embed RNS cleanly: fix signal-thread crash, tame atexit/stdout, persist on stop
Root cause of setup failure: Reticulum.__init__ calls signal.signal() with no main-thread guard, but we (correctly) initialise off the event loop in an executor thread, where signal.signal() raises ValueError. It failed after setting the __instance singleton, which both broke setup and caused the "Attempt to reinitialise Reticulum" error on every retry. - Temporarily neutralise signal.signal during RNS init so init completes in the executor, and so RNS does not hijack HA's SIGINT/SIGTERM (needed for clean shutdown under Kubernetes). - Unregister RNS's and LXMF's atexit exit handlers, which otherwise persist state with blocking file I/O on the event-loop thread and detach HA's stdout/stderr at interpreter exit (the loop-blocking warnings seen in logs). - Persist RNS/LXMF state ourselves off-loop on the homeassistant_stop event. Documents the remaining non-daemon RNS worker threads as a known shutdown note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,11 @@ run a standalone (non-shared) RNS instance inside the Home Assistant process.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
@@ -172,22 +175,9 @@ class ReticulumManager:
|
||||
_RNS_INSTANCE = existing
|
||||
else:
|
||||
_LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir)
|
||||
try:
|
||||
_RNS_INSTANCE = RNS.Reticulum(
|
||||
configdir=self.storage_dir, loglevel=self.loglevel
|
||||
)
|
||||
except OSError as err:
|
||||
# Lost a race (or divergent state): adopt whatever is running.
|
||||
adopted = None
|
||||
try:
|
||||
adopted = RNS.Reticulum.get_instance()
|
||||
except Exception: # noqa: BLE001
|
||||
adopted = None
|
||||
if adopted is None:
|
||||
raise
|
||||
_LOGGER.debug("Adopted existing Reticulum instance after %s", err)
|
||||
_RNS_INSTANCE = adopted
|
||||
_RNS_INSTANCE = self._create_rns_instance(RNS)
|
||||
self._rns = _RNS_INSTANCE
|
||||
self._tame_exit_handler(RNS.Reticulum)
|
||||
|
||||
# Stable identity so our LXMF address survives restarts.
|
||||
if _IDENTITY is None:
|
||||
@@ -213,6 +203,7 @@ class ReticulumManager:
|
||||
self._router = _LXM_ROUTER
|
||||
self._local_destination = _LOCAL_DESTINATION
|
||||
self._router.register_delivery_callback(self._delivery_callback)
|
||||
self._tame_exit_handler(self._router)
|
||||
|
||||
# Discover peers via their LXMF delivery announces. Deregister any
|
||||
# previous handler (bound to a stale manager) before installing ours.
|
||||
@@ -255,6 +246,75 @@ class ReticulumManager:
|
||||
with open(config_path, "w", encoding="utf-8") as handle:
|
||||
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]
|
||||
try:
|
||||
return rns.Reticulum(
|
||||
configdir=self.storage_dir, loglevel=self.loglevel
|
||||
)
|
||||
except OSError as err:
|
||||
# Divergent state / race: adopt whatever is already running.
|
||||
adopted = None
|
||||
try:
|
||||
adopted = rns.Reticulum.get_instance()
|
||||
except Exception: # noqa: BLE001
|
||||
adopted = None
|
||||
if adopted is None:
|
||||
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:
|
||||
"""Unregister an RNS/LXMF ``atexit`` exit handler.
|
||||
|
||||
RNS and LXMF register ``atexit`` handlers that persist state with
|
||||
blocking file I/O on the main (event-loop) thread and detach
|
||||
stdout/stderr — both of which trip HA's loop-protection and can suppress
|
||||
HA's own shutdown logging. We persist state ourselves off-loop at HA
|
||||
stop instead (see :meth:`async_persist`).
|
||||
"""
|
||||
handler = getattr(obj, "exit_handler", None)
|
||||
if handler is None:
|
||||
return
|
||||
try:
|
||||
atexit.unregister(handler)
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
_LOGGER.debug("Could not unregister exit handler for %s", obj)
|
||||
|
||||
async def async_persist(self) -> None:
|
||||
"""Persist RNS/LXMF state off the event loop (call at HA stop)."""
|
||||
if self._rns is None:
|
||||
return
|
||||
await self.hass.async_add_executor_job(self._persist)
|
||||
|
||||
def _persist(self) -> None:
|
||||
import RNS # noqa: PLC0415
|
||||
|
||||
for label, fn in (
|
||||
("transport", getattr(RNS.Transport, "persist_data", None)),
|
||||
("identity", getattr(RNS.Identity, "persist_data", None)),
|
||||
):
|
||||
if fn is None:
|
||||
continue
|
||||
try:
|
||||
fn()
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
_LOGGER.debug("Failed to persist RNS %s data", label, exc_info=True)
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Detach our callbacks. The RNS instance itself lives for the process.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user