Fix "Attempt to reinitialise Reticulum" on setup/retry

RNS.Reticulum.__init__ sets its internal singleton before it can fail partway
through init, which left our module-level _RNS_INSTANCE cache out of sync (None)
while RNS believed it was already running. Every setup retry then hit RNS's
reinit guard and raised "Attempt to reinitialise Reticulum, when it was already
running".

Use RNS.Reticulum.get_instance() as the source of truth and adopt an existing
instance instead of re-creating it (with an OSError fallback that also adopts).
Also cache and reuse the LXMF router, delivery destination, identity and
announce handler across reloads so a reload never spawns a duplicate router or
stacks announce handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-07-22 18:30:43 +03:00
parent b40c527515
commit f75235f9df
3 changed files with 77 additions and 27 deletions

View File

@@ -207,9 +207,11 @@ action:
- Reticulum uses a **single process-wide instance**. Only one Reticulum config - Reticulum uses a **single process-wide instance**. Only one Reticulum config
entry is allowed, and because RNS/LXMF cannot be cleanly torn down inside a entry is allowed, and because RNS/LXMF cannot be cleanly torn down inside a
running process, changing the host/port or fully restarting the stack requires running process, the running stack is reused across reloads. Changing the
restarting Home Assistant. Option changes that don't touch the stack apply on host/port (or recovering from a failed first start) therefore requires a
reload. **full Home Assistant restart**, not just an integration reload — a reload
reuses the already-running stack. Option changes that don't touch the stack
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.
- Assist replies use the standard conversation pipeline, so whichever agent you - Assist replies use the standard conversation pipeline, so whichever agent you

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.0" "version": "1.0.1"
} }

View File

@@ -37,10 +37,16 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# A process may only ever hold a single RNS.Reticulum instance. Home Assistant # A process may only ever hold a single RNS.Reticulum instance, and RNS/LXMF
# is a single process, so we guard against re-initialisation across config-entry # cannot be cleanly torn down inside a running process. Home Assistant is a
# reloads by caching the running instance module-side. # single process, so we cache the running stack objects module-side and reuse
# them across config-entry reloads instead of rebuilding (which would raise
# "Attempt to reinitialise Reticulum" and/or leak duplicate LXMF routers).
_RNS_INSTANCE: Any = None _RNS_INSTANCE: Any = None
_LXM_ROUTER: Any = None
_LOCAL_DESTINATION: Any = None
_IDENTITY: Any = None
_ANNOUNCE_HANDLER: Any = 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.
@@ -140,7 +146,8 @@ class ReticulumManager:
def _start(self) -> None: def _start(self) -> None:
"""Blocking init. Runs in the executor thread.""" """Blocking init. Runs in the executor thread."""
global _RNS_INSTANCE # noqa: PLW0603 global _RNS_INSTANCE, _LXM_ROUTER, _LOCAL_DESTINATION # noqa: PLW0603
global _IDENTITY, _ANNOUNCE_HANDLER # noqa: PLW0603
import LXMF # noqa: PLC0415 import LXMF # noqa: PLC0415
import RNS # noqa: PLC0415 import RNS # noqa: PLC0415
@@ -148,33 +155,74 @@ class ReticulumManager:
os.makedirs(os.path.join(self.storage_dir, ATTACHMENTS_SUBDIR), exist_ok=True) os.makedirs(os.path.join(self.storage_dir, ATTACHMENTS_SUBDIR), exist_ok=True)
self._write_config_file() self._write_config_file()
if _RNS_INSTANCE is None: # RNS is a hard process-wide singleton: calling RNS.Reticulum() twice
_LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir) # raises "Attempt to reinitialise Reticulum, when it was already
_RNS_INSTANCE = RNS.Reticulum( # running". Our module-level cache can get out of sync with RNS's own
configdir=self.storage_dir, loglevel=self.loglevel # internal singleton (e.g. the integration module is re-imported after
) # an update, or a partial-setup retry), so treat RNS itself as the
# source of truth via get_instance().
existing = None
try:
existing = RNS.Reticulum.get_instance()
except Exception: # noqa: BLE001 - older RNS may lack get_instance
existing = _RNS_INSTANCE
if existing is not None:
_LOGGER.debug("Reusing already-running Reticulum instance")
_RNS_INSTANCE = existing
else: else:
_LOGGER.debug("Reusing existing Reticulum instance") _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
self._rns = _RNS_INSTANCE self._rns = _RNS_INSTANCE
# Stable identity so our LXMF address survives restarts. # Stable identity so our LXMF address survives restarts.
identity_path = os.path.join(self.storage_dir, IDENTITY_FILENAME) if _IDENTITY is None:
if os.path.isfile(identity_path): identity_path = os.path.join(self.storage_dir, IDENTITY_FILENAME)
self._identity = RNS.Identity.from_file(identity_path) identity = None
if self._identity is None: if os.path.isfile(identity_path):
self._identity = RNS.Identity() identity = RNS.Identity.from_file(identity_path)
self._identity.to_file(identity_path) if identity is None:
identity = RNS.Identity()
identity.to_file(identity_path)
_IDENTITY = identity
self._identity = _IDENTITY
self._router = LXMF.LXMRouter( # Reuse the LXMF router + delivery destination across reloads; only the
storagepath=os.path.join(self.storage_dir, "lxmf"), # delivery callback (which is bound to this manager) is (re)registered.
) if _LXM_ROUTER is None:
self._local_destination = self._router.register_delivery_identity( _LXM_ROUTER = LXMF.LXMRouter(
self._identity, display_name=self.display_name storagepath=os.path.join(self.storage_dir, "lxmf"),
) )
_LOCAL_DESTINATION = _LXM_ROUTER.register_delivery_identity(
self._identity, display_name=self.display_name
)
self._router = _LXM_ROUTER
self._local_destination = _LOCAL_DESTINATION
self._router.register_delivery_callback(self._delivery_callback) self._router.register_delivery_callback(self._delivery_callback)
# Discover peers via their LXMF delivery announces. # Discover peers via their LXMF delivery announces. Deregister any
# previous handler (bound to a stale manager) before installing ours.
if _ANNOUNCE_HANDLER is not None:
try:
RNS.Transport.deregister_announce_handler(_ANNOUNCE_HANDLER)
except Exception: # noqa: BLE001 - best effort / older RNS
_LOGGER.debug("Could not deregister old announce handler", exc_info=True)
self._announce_handler = _AnnounceHandler(self) self._announce_handler = _AnnounceHandler(self)
_ANNOUNCE_HANDLER = self._announce_handler
RNS.Transport.register_announce_handler(self._announce_handler) RNS.Transport.register_announce_handler(self._announce_handler)
self.state.lxmf_address = RNS.hexrep( self.state.lxmf_address = RNS.hexrep(