From f75235f9df36105c9e3e06c9edf17ce8f8755ce1 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 22 Jul 2026 18:30:43 +0300 Subject: [PATCH] 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 --- README.md | 8 +- custom_components/reticulum/manifest.json | 2 +- .../reticulum/reticulum_client.py | 94 ++++++++++++++----- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5adcfb7..08fca04 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,11 @@ action: - 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 - running process, changing the host/port or fully restarting the stack requires - restarting Home Assistant. Option changes that don't touch the stack apply on - reload. + running process, the running stack is reused across reloads. Changing the + host/port (or recovering from a failed first start) therefore requires a + **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 same address. - Assist replies use the standard conversation pipeline, so whichever agent you diff --git a/custom_components/reticulum/manifest.json b/custom_components/reticulum/manifest.json index 628c632..56bf04a 100644 --- a/custom_components/reticulum/manifest.json +++ b/custom_components/reticulum/manifest.json @@ -12,5 +12,5 @@ "loggers": ["RNS", "LXMF"], "requirements": ["rns>=0.9.0", "lxmf>=0.6.0"], "single_config_entry": true, - "version": "1.0.0" + "version": "1.0.1" } diff --git a/custom_components/reticulum/reticulum_client.py b/custom_components/reticulum/reticulum_client.py index f24de5f..ed7f635 100644 --- a/custom_components/reticulum/reticulum_client.py +++ b/custom_components/reticulum/reticulum_client.py @@ -37,10 +37,16 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -# A process may only ever hold a single RNS.Reticulum instance. Home Assistant -# is a single process, so we guard against re-initialisation across config-entry -# reloads by caching the running instance module-side. +# A process may only ever hold a single RNS.Reticulum instance, and RNS/LXMF +# cannot be cleanly torn down inside a running process. Home Assistant is a +# 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 +_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 # an outbound message. @@ -140,7 +146,8 @@ class ReticulumManager: def _start(self) -> None: """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 RNS # noqa: PLC0415 @@ -148,33 +155,74 @@ class ReticulumManager: os.makedirs(os.path.join(self.storage_dir, ATTACHMENTS_SUBDIR), exist_ok=True) self._write_config_file() - if _RNS_INSTANCE is None: - _LOGGER.debug("Initialising Reticulum instance at %s", self.storage_dir) - _RNS_INSTANCE = RNS.Reticulum( - configdir=self.storage_dir, loglevel=self.loglevel - ) + # RNS is a hard process-wide singleton: calling RNS.Reticulum() twice + # raises "Attempt to reinitialise Reticulum, when it was already + # running". Our module-level cache can get out of sync with RNS's own + # 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: - _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 # Stable identity so our LXMF address survives restarts. - identity_path = os.path.join(self.storage_dir, IDENTITY_FILENAME) - if os.path.isfile(identity_path): - self._identity = RNS.Identity.from_file(identity_path) - if self._identity is None: - self._identity = RNS.Identity() - self._identity.to_file(identity_path) + if _IDENTITY is None: + identity_path = os.path.join(self.storage_dir, IDENTITY_FILENAME) + identity = None + if os.path.isfile(identity_path): + identity = RNS.Identity.from_file(identity_path) + if identity is None: + identity = RNS.Identity() + identity.to_file(identity_path) + _IDENTITY = identity + self._identity = _IDENTITY - self._router = LXMF.LXMRouter( - storagepath=os.path.join(self.storage_dir, "lxmf"), - ) - self._local_destination = self._router.register_delivery_identity( - self._identity, display_name=self.display_name - ) + # Reuse the LXMF router + delivery destination across reloads; only the + # delivery callback (which is bound to this manager) is (re)registered. + if _LXM_ROUTER is None: + _LXM_ROUTER = LXMF.LXMRouter( + 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) - # 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) + _ANNOUNCE_HANDLER = self._announce_handler RNS.Transport.register_announce_handler(self._announce_handler) self.state.lxmf_address = RNS.hexrep(