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:
claude
2026-07-22 18:46:06 +03:00
parent f75235f9df
commit a80f65c442
4 changed files with 95 additions and 17 deletions

View File

@@ -214,6 +214,14 @@ 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
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
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).
- Assist replies use the standard conversation pipeline, so whichever agent you - Assist replies use the standard conversation pipeline, so whichever agent you
select (built-in intents, a local LLM, a cloud LLM, …) is what answers. select (built-in intents, a local LLM, a cloud LLM, …) is what answers.

View File

@@ -9,7 +9,7 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import ( from homeassistant.core import (
HomeAssistant, HomeAssistant,
ServiceCall, ServiceCall,
@@ -121,6 +121,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
) )
# Persist RNS/LXMF state off-loop when Home Assistant stops (we unregister
# RNS's own atexit handlers, which would otherwise do this with blocking I/O
# on the event loop and detach HA's stdout).
async def _on_ha_stop(_event) -> None:
await manager.async_persist()
runtime.unsubs.append(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_ha_stop)
)
entry.async_on_unload(entry.add_update_listener(_async_options_updated)) entry.async_on_unload(entry.add_update_listener(_async_options_updated))
_async_register_services(hass) _async_register_services(hass)

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.1" "version": "1.0.2"
} }

View File

@@ -13,8 +13,11 @@ run a standalone (non-shared) RNS instance inside the Home Assistant process.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import atexit
import logging import logging
import os import os
import signal
import threading
import time import time
from collections import deque from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -172,22 +175,9 @@ class ReticulumManager:
_RNS_INSTANCE = existing _RNS_INSTANCE = existing
else: else:
_LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir) _LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir)
try: _RNS_INSTANCE = self._create_rns_instance(RNS)
_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
self._rns = _RNS_INSTANCE self._rns = _RNS_INSTANCE
self._tame_exit_handler(RNS.Reticulum)
# Stable identity so our LXMF address survives restarts. # Stable identity so our LXMF address survives restarts.
if _IDENTITY is None: if _IDENTITY is None:
@@ -213,6 +203,7 @@ class ReticulumManager:
self._router = _LXM_ROUTER self._router = _LXM_ROUTER
self._local_destination = _LOCAL_DESTINATION self._local_destination = _LOCAL_DESTINATION
self._router.register_delivery_callback(self._delivery_callback) self._router.register_delivery_callback(self._delivery_callback)
self._tame_exit_handler(self._router)
# Discover peers via their LXMF delivery announces. Deregister any # Discover peers via their LXMF delivery announces. Deregister any
# previous handler (bound to a stale manager) before installing ours. # 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: with open(config_path, "w", encoding="utf-8") as handle:
handle.write(contents) 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: async def async_stop(self) -> None:
"""Detach our callbacks. The RNS instance itself lives for the process. """Detach our callbacks. The RNS instance itself lives for the process.