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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user