From 8933a131cd7eb35388b4acdab0193cdbc7830219 Mon Sep 17 00:00:00 2001 From: Corvo <60719165+brothercorvo@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:56:35 -0300 Subject: [PATCH] Wait for announced LXMF identity before creating links --- reticulum_openapi/client.py | 53 +++++++++++++++++++++++++++++++-- tests/test_client_extra.py | 58 +++++++++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/reticulum_openapi/client.py b/reticulum_openapi/client.py index b94a59d..8a8234e 100644 --- a/reticulum_openapi/client.py +++ b/reticulum_openapi/client.py @@ -166,6 +166,55 @@ def _build_link_destination(self, dest_identity: RNS.Identity) -> RNS.Destinatio "link", ) + async def _resolve_destination_identity( + self, dest_hex: str, dest_hash: bytes, timeout: float + ) -> RNS.Identity: + """Return the announced identity for ``dest_hash`` waiting if needed. + + Args: + dest_hex (str): Hexadecimal representation of the destination hash. + dest_hash (bytes): Binary destination hash. + timeout (float): Maximum seconds to wait for an announce. + + Returns: + RNS.Identity: The recalled destination identity. + + Raises: + TimeoutError: If the identity is not announced before ``timeout``. + """ + + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + return identity + + deadline = self._loop.time() + timeout + request_interval = min(1.0, max(0.5, timeout)) + next_request = 0.0 + + while True: + remaining = deadline - self._loop.time() + if remaining <= 0: + raise TimeoutError( + "Destination identity " + f"{dest_hex} was not announced within {timeout} seconds" + ) + + if self._loop.time() >= next_request: + try: + RNS.Transport.request_path(dest_hash) + except Exception: # pragma: no cover - defensive logging path + logger.debug( + "Failed to request path for destination %s", + dest_hex, + exc_info=True, + ) + next_request = self._loop.time() + request_interval + + await asyncio.sleep(min(0.2, remaining)) + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + return identity + async def _ensure_link( self, dest_hex: str, dest_hash: bytes, timeout: float ) -> RNS.Link: @@ -176,8 +225,8 @@ async def _ensure_link( link = self._links.get(dest_hash) event = self._link_events.get(dest_hash) if link is None or event is None: - dest_identity = RNS.Identity.recall(dest_hash) or RNS.Identity.recall( - dest_hash, create=True + dest_identity = await self._resolve_destination_identity( + dest_hex, dest_hash, timeout ) destination = self._build_link_destination(dest_identity) event = asyncio.Event() diff --git a/tests/test_client_extra.py b/tests/test_client_extra.py index 496f370..03635e6 100644 --- a/tests/test_client_extra.py +++ b/tests/test_client_extra.py @@ -150,9 +150,7 @@ async def test_send_command_bytes_payload(monkeypatch): cli.auth_token = None cli.timeout = 0.2 - monkeypatch.setattr( - client_module.RNS.Identity, "recall", lambda h, create=False: object() - ) + cli._resolve_destination_identity = AsyncMock(return_value=object()) class FakeDestination: OUT = object() @@ -202,9 +200,7 @@ async def test_send_command_dict_payload(monkeypatch): cli.auth_token = "secret" cli.timeout = 0.2 - monkeypatch.setattr( - client_module.RNS.Identity, "recall", lambda h, create=False: object() - ) + cli._resolve_destination_identity = AsyncMock(return_value={"id": 1}) class FakeDestination: OUT = object() @@ -404,6 +400,56 @@ async def test_wait_for_server_announce_timeout(monkeypatch): await client.wait_for_server_announce(timeout=0.05) +@pytest.mark.asyncio +async def test_resolve_destination_identity_requests_path(monkeypatch): + loop = asyncio.get_running_loop() + client = client_module.LXMFClient.__new__(client_module.LXMFClient) + client._loop = loop + identity = object() + recall_results = [None, identity] + + def fake_recall(_hash): + if recall_results: + return recall_results.pop(0) + return identity + + path_requests = [] + + monkeypatch.setattr(client_module.RNS.Identity, "recall", fake_recall) + monkeypatch.setattr( + client_module.RNS.Transport, + "request_path", + lambda dest: path_requests.append(dest), + ) + + result = await client._resolve_destination_identity( + "abcd", bytes.fromhex("abcd"), 0.3 + ) + + assert result is identity + assert path_requests + + +@pytest.mark.asyncio +async def test_resolve_destination_identity_timeout(monkeypatch): + loop = asyncio.get_running_loop() + client = client_module.LXMFClient.__new__(client_module.LXMFClient) + client._loop = loop + + monkeypatch.setattr(client_module.RNS.Identity, "recall", lambda _hash: None) + path_requests = [] + monkeypatch.setattr( + client_module.RNS.Transport, + "request_path", + lambda dest: path_requests.append(dest), + ) + + with pytest.raises(TimeoutError): + await client._resolve_destination_identity("abcd", bytes.fromhex("abcd"), 0.15) + + assert path_requests + + @pytest.mark.asyncio async def test_discover_server_identity_returns_hex(monkeypatch): register_calls = _patch_dependencies(monkeypatch)