Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class PartialManager(
val status: Status,
val document: MicronDocument?,
val refreshInterval: Int?,
val fieldNames: List<String> = emptyList(),
) {
enum class Status { LOADING, LOADED, ERROR }
}
Expand Down Expand Up @@ -138,6 +139,7 @@ class PartialManager(
status = PartialState.Status.LOADING,
document = null,
refreshInterval = partial.refreshInterval,
fieldNames = partial.fieldNames,
)
)
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -191,21 +193,22 @@ 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<String> = emptyList(),
) {
var consecutiveErrors = 0
try {
while (true) {
fetchSemaphore.withPermit {
val (nodeHash, path) = resolveNomadNetUrl(url, currentNodeHash())

val formDataJson = buildFormDataJson()
val formDataJson = buildFormDataJson(allowedFields)

val result =
protocol.requestNomadnetPage(
Comment thread
sentry[bot] marked this conversation as resolved.
Expand All @@ -228,6 +231,7 @@ class PartialManager(
status = PartialState.Status.LOADED,
document = doc,
refreshInterval = refreshInterval,
fieldNames = allowedFields,
)
)
}
Expand All @@ -244,6 +248,7 @@ class PartialManager(
status = PartialState.Status.ERROR,
document = null,
refreshInterval = refreshInterval,
fieldNames = allowedFields,
)
)
}
Expand Down Expand Up @@ -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>): 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MicronElement.Link>()
.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<MicronElement.Text>()
assertTrue(texts.any { it.content.contains("Reticulumified!") })
// No stray backtick in any text element
assertTrue(texts.none { it.content.contains("`") })
}

Expand Down
128 changes: 85 additions & 43 deletions python/rns_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down