diff --git a/app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt b/app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt index f0548fe6d..3172aa7a2 100644 --- a/app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt +++ b/app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt @@ -37,6 +37,7 @@ class PartialManager( val status: Status, val document: MicronDocument?, val refreshInterval: Int?, + val fieldNames: List = emptyList(), ) { enum class Status { LOADING, LOADED, ERROR } } @@ -138,6 +139,7 @@ class PartialManager( status = PartialState.Status.LOADING, document = null, refreshInterval = partial.refreshInterval, + fieldNames = partial.fieldNames, ) ) } @@ -174,7 +176,7 @@ class PartialManager( jobs[key] = scope.launch(Dispatchers.IO) { - fetchAndUpdate(key, existing.url, existing.refreshInterval) + fetchAndUpdate(key, existing.url, existing.refreshInterval, existing.fieldNames) } } @@ -191,13 +193,14 @@ class PartialManager( key: String, partial: MicronElement.Partial, ) { - fetchAndUpdate(key, partial.url, partial.refreshInterval) + fetchAndUpdate(key, partial.url, partial.refreshInterval, partial.fieldNames) } private suspend fun fetchAndUpdate( key: String, url: String, refreshInterval: Int?, + allowedFields: List = emptyList(), ) { var consecutiveErrors = 0 try { @@ -205,7 +208,7 @@ class PartialManager( fetchSemaphore.withPermit { val (nodeHash, path) = resolveNomadNetUrl(url, currentNodeHash()) - val formDataJson = buildFormDataJson() + val formDataJson = buildFormDataJson(allowedFields) val result = protocol.requestNomadnetPage( @@ -228,6 +231,7 @@ class PartialManager( status = PartialState.Status.LOADED, document = doc, refreshInterval = refreshInterval, + fieldNames = allowedFields, ) ) } @@ -244,6 +248,7 @@ class PartialManager( status = PartialState.Status.ERROR, document = null, refreshInterval = refreshInterval, + fieldNames = allowedFields, ) ) } @@ -273,17 +278,27 @@ class PartialManager( status = PartialState.Status.ERROR, document = null, refreshInterval = refreshInterval, + fieldNames = allowedFields, ) ) } } } - private fun buildFormDataJson(): String? { + private fun buildFormDataJson(allowedFields: List): String? { val fields = formFields() if (fields.isEmpty()) return null + // Match NomadNet TUI: only forward fields declared by the partial. + // "*" means all fields; empty list means no fields. + val filtered = + when { + "*" in allowedFields -> fields + allowedFields.isEmpty() -> emptyMap() + else -> fields.filterKeys { it in allowedFields } + } + if (filtered.isEmpty()) return null val json = JSONObject() - for ((k, v) in fields) { + for ((k, v) in filtered) { json.put(k, v) } return json.toString() diff --git a/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt b/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt index 10f847dc5..82d8dc87e 100644 --- a/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt +++ b/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt @@ -726,18 +726,20 @@ class MicronParserTest { @Test fun `backtick before link with formatting`() { - // Real-world pattern: `c`F0ad`_`[label`dest]`_`f - val doc = MicronParser.parse("`c`F0ad`_`[\"Hoard's Heart\"`/page/hoardsheart.mu]`_`f") + // Real-world line from a NomadNet node index page + val doc = MicronParser.parse("`c`F0ad`_`[\"Hoard's Heart\" by CupsofJade`:/page/hoardsheart.mu]`_`f - Reticulumified!") assertEquals(MicronAlignment.CENTER, doc.lines[0].alignment) val link = doc.lines[0] .elements .filterIsInstance() .first() - assertEquals("\"Hoard's Heart\"", link.label) - assertEquals("/page/hoardsheart.mu", link.destination) - // No stray backtick in any text element + assertEquals("\"Hoard's Heart\" by CupsofJade", link.label) + assertEquals(":/page/hoardsheart.mu", link.destination) + // Trailing text after the link val texts = doc.lines[0].elements.filterIsInstance() + assertTrue(texts.any { it.content.contains("Reticulumified!") }) + // No stray backtick in any text element assertTrue(texts.none { it.content.contains("`") }) } diff --git a/python/rns_api.py b/python/rns_api.py index 0eebd87f0..b850c12f5 100644 --- a/python/rns_api.py +++ b/python/rns_api.py @@ -226,63 +226,105 @@ def _establish_link(self, wrapper, dest_hash, dest_hash_hex, deadline): log_warning("RnsApi", "request_nomadnet_page", f"Destination hash mismatch! passed={dest_hash_hex} computed={node_dest.hash.hex()}") - # ── Phase 4: Establish link ── - log_info("RnsApi", "request_nomadnet_page", - f"Creating link to {dest_hash_hex[:16]} (hops={hops})") + # ── Phase 4: Establish link with retry ── + # RNS sends ONE link request packet with no retry. At high hop + # counts the packet (or its proof) can be lost, leaving the link + # stuck at PENDING until establishment_timeout (~6s × hops). + # Retry up to MAX_LINK_ATTEMPTS times, tearing down the stale + # link before each retry so RNS allocates a fresh request. + MAX_LINK_ATTEMPTS = 3 + # Per-attempt timeout: use the RNS establishment_timeout for the + # hop count, but cap so we leave time for the page request itself. + # RNS.Link.ESTABLISHMENT_TIMEOUT_PER_HOP is the documented Link-level + # alias for RNS.Reticulum.DEFAULT_PER_HOP_TIMEOUT (both = 6 in the + # reference; see RNS/Link.py:75 and RNS/Reticulum.py:141). Use a + # defensive getattr chain so older/fork RNS builds that don't expose + # the constant under either name fall back to the literal default of + # 6s rather than raising AttributeError and breaking all browsing. + per_hop_timeout = getattr( + RNS.Link, 'ESTABLISHMENT_TIMEOUT_PER_HOP', + getattr(RNS.Reticulum, 'DEFAULT_PER_HOP_TIMEOUT', 6)) + per_attempt_base = per_hop_timeout * max(1, hops) + 6 + last_reason = None + last_status = None + attempts_made = 0 + + for attempt in range(1, MAX_LINK_ATTEMPTS + 1): + if self._cancel_flag: + return {"success": False, "error": "Cancelled"} - link_established = threading.Event() - link_closed_reason = [None] + remaining = deadline - time.time() + if remaining < 5: + break - def on_link_established(established_link): - log_info("RnsApi", "request_nomadnet_page", - f"Link established to {dest_hash_hex[:16]} (RTT={established_link.rtt})") - link_established.set() + attempts_made += 1 + if attempt > 1: + log_info("RnsApi", "request_nomadnet_page", + f"Link attempt #{attempt} to {dest_hash_hex[:16]}") + else: + log_info("RnsApi", "request_nomadnet_page", + f"Creating link to {dest_hash_hex[:16]} (hops={hops})") - def on_link_closed(closed_link): - reason = getattr(closed_link, 'teardown_reason', None) - reason_str = {0x01: "TIMEOUT", 0x02: "INITIATOR_CLOSED", 0x03: "DESTINATION_CLOSED"}.get(reason, str(reason)) - log_warning("RnsApi", "request_nomadnet_page", - f"Link to {dest_hash_hex[:16]} closed during establishment (reason={reason_str}, status={closed_link.status})") - link_closed_reason[0] = reason - link_established.set() + link_established = threading.Event() + link_closed_reason = [None] - link = RNS.Link(node_dest, - established_callback=on_link_established, - closed_callback=on_link_closed) + # Capture loop variables via default args to avoid late-binding + # closure bug: stale callbacks from a prior iteration could + # otherwise set the new iteration's Event or reason list. + def on_link_established(established_link, _evt=link_established): + log_info("RnsApi", "request_nomadnet_page", + f"Link established to {dest_hash_hex[:16]} (RTT={established_link.rtt})") + _evt.set() + + def on_link_closed(closed_link, _evt=link_established, _reason=link_closed_reason): + reason = getattr(closed_link, 'teardown_reason', None) + reason_str = {0x01: "TIMEOUT", 0x02: "INITIATOR_CLOSED", + 0x03: "DESTINATION_CLOSED"}.get(reason, str(reason)) + log_warning("RnsApi", "request_nomadnet_page", + f"Link to {dest_hash_hex[:16]} closed during establishment " + f"(reason={reason_str}, status={closed_link.status})") + _reason[0] = reason + _evt.set() + + link = RNS.Link(node_dest, + established_callback=on_link_established, + closed_callback=on_link_closed) + + # Wait up to per_attempt_base, but never exceed remaining - 5s + # (reserve 5s for the page request on the last attempt). + attempt_wait = min(per_attempt_base, remaining - 5) + log_debug("RnsApi", "request_nomadnet_page", + f"Waiting up to {attempt_wait:.0f}s for link to {dest_hash_hex[:16]}") + link_established.wait(timeout=max(attempt_wait, 5.0)) - est_timeout = getattr(link, 'establishment_timeout', None) - log_info("RnsApi", "request_nomadnet_page", - f"Link establishment_timeout={est_timeout:.1f}s" if est_timeout else "Link establishment_timeout=unknown") + if self._cancel_flag: + try: + link.teardown() + except Exception: + pass + return {"success": False, "error": "Cancelled"} - link_wait = max(deadline - time.time() - 10, 5.0) - log_debug("RnsApi", "request_nomadnet_page", - f"Waiting up to {link_wait:.0f}s for link to {dest_hash_hex[:16]}") - link_established.wait(timeout=link_wait) + if link.status == RNS.Link.ACTIVE: + return link - if self._cancel_flag: + # Link didn't establish — record reason and tear down + last_reason = link_closed_reason[0] + last_status = str(link.status) try: link.teardown() except Exception: pass - return {"success": False, "error": "Cancelled"} - if link_closed_reason[0] is not None or link.status != RNS.Link.ACTIVE: - status_str = str(link.status) if link else "unknown" - reason = link_closed_reason[0] - log_warning("RnsApi", "request_nomadnet_page", - f"Link to {dest_hash_hex[:16]} failed (status={status_str}, teardown_reason={reason})") - try: - link.teardown() - except Exception: - pass - if reason == 0x03: + # If destination explicitly rejected, don't retry + if last_reason == 0x03: return {"success": False, "error": "Connection closed by node"} - elif reason == 0x01 or link.status == RNS.Link.PENDING: - return {"success": False, "error": f"Connection timed out ({hops} hops). Node may be offline or unreachable."} - else: - return {"success": False, "error": f"Connection failed (status={status_str})"} - return link + # All attempts exhausted (or skipped because deadline was already near-exhausted) + log_warning("RnsApi", "request_nomadnet_page", + f"Link to {dest_hash_hex[:16]} failed after {attempts_made} attempt(s) " + f"(last_status={last_status}, last_reason={last_reason})") + return {"success": False, + "error": f"Connection timed out ({hops} hops). Node may be offline or unreachable."} def _send_page_request(self, link, path, request_data, dest_hash_hex, deadline): """Send a page request over an established link.