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(