From 752b9be0880ad9a544fe363bba40f027b305fd1a Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 13 Aug 2026 17:51:02 -0500 Subject: [PATCH] refactor: update path response and link establishment timeout handling across various components to improve reliability and performance --- .../skills/reticulum-design-gates/SKILL.md | 1 + .agents/skills/reticulum-stack/SKILL.md | 5 ++ meshchatx.rsm | Bin 192940 -> 192940 bytes meshchatx/src/backend/bug_report_manager.py | 15 ++++- .../src/backend/http/routes/path_probe.py | 24 +++++-- meshchatx/src/backend/http/routes/rn_tools.py | 12 +++- meshchatx/src/backend/map_data_manager.py | 9 ++- meshchatx/src/backend/map_geo_sanitizer.py | 23 ++++++- meshchatx/src/backend/map_overlay_manager.py | 10 ++- meshchatx/src/backend/nomadnet_downloader.py | 18 ++--- meshchatx/src/backend/path_utils.py | 63 ++++++++++++++++-- .../src/backend/remote_management_client.py | 17 +++-- .../src/backend/reticulum_pathfinding.py | 17 ++++- meshchatx/src/backend/rncp_handler.py | 34 +++++++--- meshchatx/src/backend/rnprobe_handler.py | 4 +- meshchatx/src/backend/rns_link_manager.py | 18 ++--- meshchatx/src/backend/rrc/manager.py | 3 +- meshchatx/src/backend/telephone_manager.py | 11 ++- meshchatx/src/frontend/components/App.vue | 7 +- .../src/frontend/components/SidebarLink.vue | 4 +- .../layout/AppSidebarAccountFooter.vue | 2 +- .../layout/AppSidebarClassicFooter.vue | 11 +-- .../layout/AppSidebarClassicNav.vue | 3 +- .../components/layout/AppSidebarNav.vue | 8 ++- .../components/messages/MessagesSidebar.vue | 6 +- .../nomadnetwork/NomadNetworkSidebar.vue | 2 +- .../frontend/js/mapExchange/kmlSanitize.js | 34 +++++++--- .../src/frontend/js/mapExchange/kmzCodec.js | 3 +- tests/backend/test_map_geo_sanitizer.py | 60 +++++++++++++++-- tests/backend/test_path_utils.py | 47 +++++++++++++ tests/backend/test_reticulum_pathfinding.py | 12 +++- tests/backend/test_telephone_initiation.py | 37 ++++++++++ tests/frontend/AppSidebarNav.test.js | 34 ++++++++++ tests/frontend/SidebarLink.test.js | 11 +++ .../__snapshots__/uiSnapshots.test.js.snap | 2 +- tests/frontend/mapExchange.sanitize.test.js | 51 +++++++++++++- vendor/rns_filesync/rns_filesync/peers.py | 33 ++++++++- vendor/rns_filesync/rns_filesync/service.py | 14 ++-- 38 files changed, 562 insertions(+), 103 deletions(-) diff --git a/.agents/skills/reticulum-design-gates/SKILL.md b/.agents/skills/reticulum-design-gates/SKILL.md index d3538e0d..3d64bef2 100644 --- a/.agents/skills/reticulum-design-gates/SKILL.md +++ b/.agents/skills/reticulum-design-gates/SKILL.md @@ -55,6 +55,7 @@ Allowed: - Prefer event/handler and store-and-forward over blocking request/response UIs. - Missing path: request path, allow propagate, surface recoverable error. Do not spin forever. +- Path and first-hop link waits use `path_utils.path_response_window` and `link.establishment_timeout`. Do not pin 15s (or any flat timer) for Nomad pages, RNCP, FileSync, LXST, or map fetches. - Keep list APIs and announces slim. Do not ship multi-MB blobs in conversation lists. - Large files use RNCP / attachments / explicit transfer tools, not chat text fields. diff --git a/.agents/skills/reticulum-stack/SKILL.md b/.agents/skills/reticulum-stack/SKILL.md index 05f2c7ed..12969882 100644 --- a/.agents/skills/reticulum-stack/SKILL.md +++ b/.agents/skills/reticulum-stack/SKILL.md @@ -59,6 +59,11 @@ Announce ingest caps and store toggles live in `announce_manager.py` (`announce_ Missing path: request a path, allow LXMF propagate where that is the protocol, show a recoverable outbound state. Do not spin the UI until an ACK arrives on a LoRa-class link. +Do not pin a 15 second (or any fixed) timer for cold path requests or first-hop link setup. Reticulum already knows the interface bitrate. + +- Path wait: `meshchatx/src/backend/path_utils.py` `path_response_window`. Uses `RNS.Reticulum.get_instance().get_first_hop_timeout()` (not `RNS.Transport.first_hop_timeout()`, which is wrong on a shared rnsd client) plus an airtime floor from the slowest online interface bitrate, clamped to `RNS.Reticulum.MINIMUM_BITRATE` (5 bps). +- Link wait: `link.establishment_timeout` plus `LINK_ESTABLISHMENT_MARGIN_S` via `link_establishment_window`. Pass `None` so callers do not override RNS. + Links are live sessions on top of paths. LXST calls and RRC hubs use links. LXMF mail is store-and-forward and must survive a missing path. ## Key files diff --git a/meshchatx.rsm b/meshchatx.rsm index a5657a246fa8a2eb3465b0db8e3b5ff4e5a03832..decee43908b6ec6fde299941171572837f0fa1ee 100644 GIT binary patch delta 2733 zcmXX|Td15@6?P_K64NA2s*-aOYg;201>N_(HXhGRqy|B%4L;RU)3x_nTd)XGBS=B; zR-v`krav+d+J}NoDkNSy>cJYdK|-xQH4kcx7h*{IP^F4!u~6#ww|zLAVdnqW-s`)q zb@I}-lb5#5AANZLUFUAQ^WNug|HmztKK0~+jq9&ii*BoA0@9_15nk{>|fC_4*6{JNuve zUR?X^r7s-(pn--e|R%%ePl;O;YeS-Ld$O-|Hx#PIP^%3 zYYSB~h^HXNI=qEmEKxVfEdS#8r!*y>+^6`LRdarnx5T zigAU(m^zNp3o`(wk*aQ;Yf{xFUvqc1*j}<~uhi6l1ZrhDIe8~=Pu{QxF6w%#5_2rl zaMy-*OEk?{yo5&zz6c=_N;Lx43bNEe3g25@o;~&P%`?Y^vlVufMnwgdN*R;|@wTdg z0&~Na3l5B>c*nt64y+0mUDe^t_g9u*`SGU7449Xe$=1XU$Q!^9v-H5FDvsptp(zO0 zp}FXxN(k4PU|h@iwGB@{v@-nlCwI>X-pL(;s^pD<0diMhK`=<7;5Ca1_hw?QE+@88 z17#={jauA&tg}45@vF%^<^(7~C}i0M-4{u?cCb4|k ze%SS+mEjLR{n9K1?MyytNo}On;(}UiQP9{;2&2>t3Q?!Zim$VWq>4hjA&46e-LtZM z?D0=*ngIuH99y?&IR`QfXi-*5Qx>F*?I;BuRd7I%Msuk`V`LH~X)j}a^cQyXymR$l z14QS9LL)Xg1@_gZpskXlnnzS6qxTF^z1z>m!4%TBo&Sqp=6E zR&%h(4a7Xy6lBf#I|na_SsM6nuUzc6S9Z?W!EwT|=0w~wN+N(LIG;&;a1chSwMOT; z!Qv@1DT3RTE*TLE25_08KT6nCu`oYnbq{my$E` zvBojwlUiREht^-=axvR7S=so@mdW9@b>L7+>@`C@4LRSMt-g#wte_z~m4xA^-ba&c z5Ee{TYF-sIkS-orn_RoN`ru^8;>7zW+h@&o5@1#W##F%E6(|n`C*yoWmM5H(BZ3x* zB^qHQhGG~BffPa+zWDFe#fLvIdEMd%H%+!~>^nGl$A$MIa*=>YurbkNuw1(B#(Lpk1Ip6^d>Kq{flVXifC6a$}#!TO|anwv7dOaHTrY390ASOh+@hp-px(lfyB%($a zq(U8LbefQ5^~PaBVhUyb=D$#>zh}DRE$a$KeGUc*pN$A*Q$icl4?BYqV4Pa*IRRCR zJW80V5D=!CKnt}jj@&xEZsXiXr~CHIS+NR?^N)Ir8EFHQk?lqpOAE9SU9JN1@ChA) zT*BUB5KI_WRy=LI>oe1nb{+s5Oc)Z3F$NW$?szp&eAGU%X(bY=Q{3h@Q)gg1&FOC|MHSxHfE=$ z`}fX$MGiz09b-GGMUhy&VtES{ElCP+5_UbCMp_#uL8OW44&5oN9@}EqQ`0wZJn`%4 H-*5asQ@uTo delta 2723 zcmX|@U5K4m6^1z{#YvhplT4)aOsuV%^hZj&|JK@j*L1!UtWt8(rg|X?D*I>cLQ8C_ zx$r^@S}O(>TR-I}5=A6YA+e38c2L{W2t!&0F`-(5*gDoG2tp``7nKm-uYwmdFh6H~ zYrX4v-}N1TZS(QhHcxLka^)3YJpKM3Tw1>CZ|~iA?T1gl@}r9{ePe0IjW_@G^~D4C z@8kPq?Usix@BG=-4_-L=)F0O#{MXBG|JtX%deeWt^3pR~&b_he@#!_!Z@c{;ZDrpB zyKY;&c(2hCTOXYk3Lb@$q#E&ARaInDC@LzxN%ou! zYPFVFIhS3kStVU%s8L+;G!&X2UOf8o&KDn)HzrHal1mJz;@O5Aiyhi)uDV)$EY5o+ zCQ>ClAW2rDRI&ZuhbQwdAGrALF14D)TOW*gEkS~zY@*cch_fXLS;ZPz4|4@47&A1^ zFg9%jl^LyI=6 z+OMBn?)QIv%lyPQZ`^T56|KAz3|?c*BuX0&fps+1xS~}Jm2%+PT2QEHE5s0PRbeA> z_P8GH=pQ?_rB}zcL8z)+GL#YrQ@BzM8QHI_3V< z6O;LOj(ud*MKRc_X;@Kkcx2MbDDOjMB~3AzMoP1jM36K{%Ho*ypp`H9O@HRdq(ArL zEwf+0XS{j-iJz1W)0{0A(q8FeZG@Vx`3jY*Qk0t)A#Mr9IV9lG9N}+lCB;Hnw`k_0 z;}49cfkMK<#9#ulz{oe7h&UB;gaJWQ@y>*#lyxy#pO}PQbDRiZ#Quj*PUdST?ps*F zBJh7w3Z7iNXP}g#ZoxOw5{bp?K&*AhSSny>+>C*&MKjRr_a9!Kf9V$+Mjfzq%EyQY zr)VtJW{cLC&1-5zrUa1aKuEU1Sg2@i@GPWKamqCyHR)e{`q8O?wx-GEQX1uGg2o~t zv?Y%=A%Jobrhpzzo7yZaqG6SmJY1t4_uHSE%%5HT&8TavLkifi1g~6R&MFKbFlCrV3!eXSh+402@xc6(_RJsO1S9acqn405HBu*m%q3uEggV;)}-E`pd3!;c0CWYmFOfC9w-f34r0Uz~zxgb=x64CH{q zFjj8HC(BqCr<9e3lMsf86pJtDjYATX^O#<#{tJJ8m4QetxID4+`+t8!f7`z|%|HF-#j84{@RuPFEC7Zx z9FV7CfwZpBUXT~a5&Qy4ZM1+C2l&Kd_Yv7TTi^RXC}!Qp(PZ_NjiXQQ={#mJh;|$& zoG3#sF(gbZL2Z&t0Rp&7Qn-v}tzq1Ea-G z4M>EL$uZ0rQ4P}$L^xl?hMZ)hlSG%Jot?>}Yge~@aKwM^D)ffU zlnMy?3Vao~uMGi|hda$wAqC9DLASx*aWD!y927Rq$hY4ZU%mRhca0y}Iu(t$gm5Jx zdhiLd7K192JgzWqduUR`z+_DcK!eOC7e=lnD49x`ol>ZENR5wN*)`nTRaCv{AJ@~W@kWp_6nxT None: el.attrib[key] = value +_HTML_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"\s+") + + +def _flatten_html_text(text: str) -> str: + return _WS_RE.sub(" ", _HTML_TAG_RE.sub(" ", text)).strip() + + def _strip_description_html(el: ET.Element) -> bool: tag = _strip_ns(el.tag).lower() if tag != "description": @@ -129,8 +138,13 @@ def _strip_description_html(el: ET.Element) -> bool: if child.tail and child.tail.strip(): texts.append(child.tail.strip()) el.remove(child) - el.text = " ".join(texts) if texts else None - return had_children + combined = " ".join(texts) + changed = had_children + if "<" in combined and ">" in combined: + combined = _flatten_html_text(combined) + changed = True + el.text = combined or None + return changed def _walk_strip_kml( @@ -277,7 +291,10 @@ def sanitize_kmz_bytes(data: bytes) -> SanitizeResult: raise GeoValidationError("path_traversal") ext = _zip_entry_ext(name) if ext not in ALLOWED_KMZ_EXTS: - raise GeoValidationError("unsafe_kmz_entry") + # ArcGIS KMZ exports include unused .xsl balloon stylesheets. + # Drop sidecars instead of rejecting the archive. + stripped.append("skipped_kmz_entry") + continue payload = zf.read(info.filename) kept[name] = payload lower = name.lower() diff --git a/meshchatx/src/backend/map_overlay_manager.py b/meshchatx/src/backend/map_overlay_manager.py index d0933c49..840d358d 100644 --- a/meshchatx/src/backend/map_overlay_manager.py +++ b/meshchatx/src/backend/map_overlay_manager.py @@ -38,6 +38,7 @@ from meshchatx.src.backend.map_overlay_sources import ( parse_create_payload, ) from meshchatx.src.backend.nomadnet_downloader import NomadnetFileDownloader +from meshchatx.src.backend.path_utils import path_response_window from meshchatx.src.backend.rngit_sparse_fetcher import ( RngitFetchError, RngitSparseFetcher, @@ -623,6 +624,14 @@ class MapOverlayManager: generation: int, ) -> None: path_timeout = self._cfg_int("map_overlay_path_timeout_seconds") + try: + dest = bytes.fromhex(spec.destination_hash) + path_timeout = max( + float(path_timeout), + path_response_window(dest, self.reticulum), + ) + except Exception: + path_timeout = float(path_timeout) transfer_timeout = self._cfg_int("map_overlay_transfer_timeout_seconds") job_timeout = self._cfg_int("map_overlay_job_timeout_seconds") @@ -662,7 +671,6 @@ class MapOverlayManager: await asyncio.wait_for( downloader.download( path_lookup_timeout=path_timeout, - link_establishment_timeout=path_timeout, ), timeout=job_timeout, ) diff --git a/meshchatx/src/backend/nomadnet_downloader.py b/meshchatx/src/backend/nomadnet_downloader.py index 5ded9a2e..2c7e076b 100644 --- a/meshchatx/src/backend/nomadnet_downloader.py +++ b/meshchatx/src/backend/nomadnet_downloader.py @@ -10,7 +10,10 @@ from collections.abc import Callable import RNS from meshchatx.src.backend import reticulum_pathfinding -from meshchatx.src.backend.path_utils import path_response_window +from meshchatx.src.backend.path_utils import ( + link_establishment_window, + path_response_window, +) from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike # Global cache for Nomad Network links (reuse instead of reconnecting per request). @@ -293,14 +296,11 @@ class NomadnetDownloader: self.link = link if link_establishment_timeout is None: - rns_timeout = getattr(link, "establishment_timeout", None) - if isinstance(rns_timeout, (int, float)) and rns_timeout > 0: - link_establishment_timeout = rns_timeout + 5 - else: - link_establishment_timeout = path_response_window( - self.destination_hash, - self._reticulum, - ) + link_establishment_timeout = link_establishment_window( + link, + self.destination_hash, + self._reticulum, + ) timeout_after_seconds = time.time() + link_establishment_timeout while ( diff --git a/meshchatx/src/backend/path_utils.py b/meshchatx/src/backend/path_utils.py index 449c2714..55dcd962 100644 --- a/meshchatx/src/backend/path_utils.py +++ b/meshchatx/src/backend/path_utils.py @@ -2,8 +2,24 @@ import RNS -MIN_WINDOW_BITRATE = 50 +# One path-request packet is about 234 bytes plus IFAC. 240 covers that. PATH_EXCHANGE_BYTES = 240 +LINK_ESTABLISHMENT_MARGIN_S = 5.0 +_FALLBACK_PATH_TIMEOUT_S = 15.0 + + +def min_window_bitrate() -> float: + try: + value = float(RNS.Reticulum.MINIMUM_BITRATE) + if value > 0: + return value + except Exception: + pass + return 5.0 + + +# Kept as a name for tests and callers. Equals RNS.Reticulum.MINIMUM_BITRATE (5). +MIN_WINDOW_BITRATE = min_window_bitrate() def slowest_online_bitrate(reticulum=None): @@ -23,14 +39,49 @@ def slowest_online_bitrate(reticulum=None): return None +def _rns_path_request_timeout() -> float: + try: + return float(RNS.Transport.PATH_REQUEST_TIMEOUT) + except Exception: + return _FALLBACK_PATH_TIMEOUT_S + + def path_response_window(destination_hash, reticulum=None) -> float: - if reticulum is None: - reticulum = RNS.Reticulum.get_instance() - window = float(reticulum.get_first_hop_timeout(destination_hash)) + """Seconds to wait for a cold path response on the slowest online interface. + + Uses Reticulum.get_first_hop_timeout so a shared rnsd client sees the + instance interface timeouts, not the local socket timeout. + """ + window = 0.0 + try: + if reticulum is None: + reticulum = RNS.Reticulum.get_instance() + window = float(reticulum.get_first_hop_timeout(destination_hash)) + except Exception: + window = 0.0 bitrate = slowest_online_bitrate(reticulum) if bitrate: + floor_bps = min_window_bitrate() window = max( window, - 2 * (PATH_EXCHANGE_BYTES * 8 / max(bitrate, MIN_WINDOW_BITRATE)) + 10, + 2 * (PATH_EXCHANGE_BYTES * 8 / max(float(bitrate), floor_bps)) + 10, ) - return max(window, float(RNS.Transport.PATH_REQUEST_TIMEOUT)) + return max(window, _rns_path_request_timeout()) + + +def link_establishment_window( + link, + destination_hash=None, + reticulum=None, +) -> float: + """Seconds to wait for a new RNS Link, from link.establishment_timeout. + + Adds LINK_ESTABLISHMENT_MARGIN_S. Falls back to path_response_window + when RNS did not report an establishment timeout. + """ + rns_timeout = getattr(link, "establishment_timeout", None) + if isinstance(rns_timeout, (int, float)) and rns_timeout > 0: + return float(rns_timeout) + LINK_ESTABLISHMENT_MARGIN_S + if destination_hash is not None: + return path_response_window(destination_hash, reticulum) + return _rns_path_request_timeout() diff --git a/meshchatx/src/backend/remote_management_client.py b/meshchatx/src/backend/remote_management_client.py index 7e6aebe3..c52cfa17 100644 --- a/meshchatx/src/backend/remote_management_client.py +++ b/meshchatx/src/backend/remote_management_client.py @@ -16,6 +16,10 @@ from typing import Any import RNS from meshchatx.src.backend.management_identities import resolve_identity_path +from meshchatx.src.backend.path_utils import ( + link_establishment_window, + path_response_window, +) def _truncated_hash_len() -> int: @@ -140,9 +144,16 @@ class _RemoteRequest: self._link = RNS.Link(destination) self._link.set_link_established_callback(on_established) self._link.set_link_closed_callback(on_closed) + request_wait = max( + float(self.timeout), + link_establishment_window( + self._link, + self.destination_hash, + ), + ) try: - if not self._event.wait(timeout=max(1.0, float(self.timeout))): + if not self._event.wait(timeout=max(1.0, request_wait)): raise TimeoutError("Remote management request timed out") if self._error is not None: raise self._error @@ -173,9 +184,7 @@ def remote_request( if identity is None: raise ValueError(f"Could not load management identity from {resolved}") wait = ( - float(timeout) - if timeout not in (None, "") - else float(RNS.Transport.PATH_REQUEST_TIMEOUT) + float(timeout) if timeout not in (None, "") else path_response_window(dest_hash) ) return _RemoteRequest(dest_hash, identity, path, data, wait).run() diff --git a/meshchatx/src/backend/reticulum_pathfinding.py b/meshchatx/src/backend/reticulum_pathfinding.py index c033f4a6..96faad86 100644 --- a/meshchatx/src/backend/reticulum_pathfinding.py +++ b/meshchatx/src/backend/reticulum_pathfinding.py @@ -8,6 +8,8 @@ from typing import Any, Optional, Protocol import RNS +from meshchatx.src.backend.path_utils import path_response_window + @dataclass(frozen=True) class OutboundPathOutcome: @@ -233,7 +235,12 @@ def nudge_path_request(destination_hash: bytes) -> None: RNS.Transport.request_path(destination_hash) -def lxmf_path_wait_cap_seconds() -> float: +def lxmf_path_wait_cap_seconds( + destination_hash: bytes | None = None, + reticulum: Optional["ReticulumLike"] = None, +) -> float: + if destination_hash is not None: + return path_response_window(destination_hash, reticulum) try: base = float(RNS.Transport.PATH_REQUEST_TIMEOUT) except Exception: @@ -245,8 +252,12 @@ async def await_transport_path_for_outbound_lxmf( reticulum: Optional["ReticulumLike"], destination_hash_bytes: bytes, ) -> OutboundPathOutcome: - long_w = lxmf_path_wait_cap_seconds() - short_w = max(15.0, long_w * 0.5) + long_w = lxmf_path_wait_cap_seconds(destination_hash_bytes, reticulum) + try: + short_floor = float(RNS.Transport.PATH_REQUEST_TIMEOUT) + except Exception: + short_floor = 15.0 + short_w = max(long_w * 0.5, short_floor) measure = prepare_fresh_path_request(reticulum, destination_hash_bytes) deadline = time.time() + long_w diff --git a/meshchatx/src/backend/rncp_handler.py b/meshchatx/src/backend/rncp_handler.py index b4ec7d23..68fd5523 100644 --- a/meshchatx/src/backend/rncp_handler.py +++ b/meshchatx/src/backend/rncp_handler.py @@ -9,7 +9,7 @@ from collections.abc import Callable import RNS -from .path_utils import path_response_window +from .path_utils import link_establishment_window, path_response_window class RNCPHandler: @@ -38,6 +38,16 @@ class RNCPHandler: except Exception: pass + def _path_wait_seconds( + self, + destination_hash: bytes, + timeout: float | None, + ) -> float: + window = path_response_window(destination_hash, self.reticulum) + if timeout is None: + return window + return max(float(timeout), window) + def _default_fetch_save_dir(self) -> str: path = os.path.join(self.storage_dir, "rncp", "downloads") os.makedirs(path, exist_ok=True) @@ -398,9 +408,8 @@ class RNCPHandler: if not RNS.Transport.has_path(destination_hash): RNS.Transport.request_path(destination_hash) - if timeout is None: - timeout = path_response_window(destination_hash, self.reticulum) - timeout_after = time.time() + timeout + path_wait = self._path_wait_seconds(destination_hash, timeout) + timeout_after = time.time() + path_wait while ( not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after ): @@ -420,7 +429,11 @@ class RNCPHandler: ) link = RNS.Link(receiver_destination) - timeout_after = time.time() + timeout + timeout_after = time.time() + link_establishment_window( + link, + destination_hash, + self.reticulum, + ) while link.status != RNS.Link.ACTIVE and time.time() < timeout_after: await asyncio.sleep(0.1) @@ -501,9 +514,8 @@ class RNCPHandler: if not RNS.Transport.has_path(destination_hash): RNS.Transport.request_path(destination_hash) - if timeout is None: - timeout = path_response_window(destination_hash, self.reticulum) - timeout_after = time.time() + timeout + path_wait = self._path_wait_seconds(destination_hash, timeout) + timeout_after = time.time() + path_wait while ( not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after ): @@ -523,7 +535,11 @@ class RNCPHandler: ) link = RNS.Link(listener_destination) - timeout_after = time.time() + timeout + timeout_after = time.time() + link_establishment_window( + link, + destination_hash, + self.reticulum, + ) while link.status != RNS.Link.ACTIVE and time.time() < timeout_after: await asyncio.sleep(0.1) diff --git a/meshchatx/src/backend/rnprobe_handler.py b/meshchatx/src/backend/rnprobe_handler.py index 0b219b02..de324e8b 100644 --- a/meshchatx/src/backend/rnprobe_handler.py +++ b/meshchatx/src/backend/rnprobe_handler.py @@ -74,8 +74,8 @@ class RNProbeHandler: timeout_after = time.time() + ( timeout - or self.DEFAULT_TIMEOUT - + self.reticulum.get_first_hop_timeout(destination_hash) + if timeout is not None + else path_response_window(destination_hash, self.reticulum) ) while ( not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after diff --git a/meshchatx/src/backend/rns_link_manager.py b/meshchatx/src/backend/rns_link_manager.py index 528d4036..c8f80754 100644 --- a/meshchatx/src/backend/rns_link_manager.py +++ b/meshchatx/src/backend/rns_link_manager.py @@ -10,7 +10,10 @@ from typing import Optional import RNS from meshchatx.src.backend import reticulum_pathfinding -from meshchatx.src.backend.path_utils import path_response_window +from meshchatx.src.backend.path_utils import ( + link_establishment_window, + path_response_window, +) from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike # Cache of established RNS Links keyed by (aspect_str, destination_hash_bytes). @@ -38,10 +41,8 @@ LINK_IDLE_TTL_S = 30 * 60 # Wait granularity while polling for path / link (seconds). _POLL_INTERVAL_S = 0.02 -# Slow path and link margins +# Slow path UI phase margin before finding_path_slow PATH_MARGIN_S = 5.0 -LINK_MARGIN_S = 5.0 -_FALLBACK_LINK_TIMEOUT_S = 15.0 def cached_link_count() -> int: @@ -368,13 +369,14 @@ class RnsLinkManager: ) # Get the link establishment_timeout from RNS if not explicitly pinned - rns_timeout = getattr(link, "establishment_timeout", None) if link_establishment_timeout is not None: deadline = time.time() + link_establishment_timeout - elif isinstance(rns_timeout, (int, float)) and rns_timeout > 0: - deadline = time.time() + rns_timeout + LINK_MARGIN_S else: - deadline = time.time() + _FALLBACK_LINK_TIMEOUT_S + deadline = time.time() + link_establishment_window( + link, + destination_hash, + self._get_reticulum(), + ) try: while ( link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED) diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py index bcbb7627..f8dd8a91 100644 --- a/meshchatx/src/backend/rrc/manager.py +++ b/meshchatx/src/backend/rrc/manager.py @@ -315,6 +315,7 @@ class RRCHub: if gate is not None: gate.acquire() try: + path_wait = timeout_s if not RNS.Transport.has_path(self.hub_hash): RNS.Transport.request_path(self.hub_hash) try: @@ -328,7 +329,7 @@ class RRCHub: time.sleep(0.1) hub_identity = None - deadline = time.monotonic() + timeout_s + deadline = time.monotonic() + path_wait while time.monotonic() < deadline: hub_identity = RNS.Identity.recall(self.hub_hash) if hub_identity is not None: diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py index 3277fe6d..9e7a81d3 100644 --- a/meshchatx/src/backend/telephone_manager.py +++ b/meshchatx/src/backend/telephone_manager.py @@ -15,6 +15,7 @@ from meshchatx.src.backend.meshchat_utils import ( hex_identifier_to_bytes, normalize_hex_identifier, ) +from meshchatx.src.backend.path_utils import path_response_window class Tee: @@ -638,9 +639,17 @@ class TelephoneManager: if not RNS.Transport.has_path(call_destination_hash): self._update_initiation_status("Requesting path...") + path_wait = float(timeout_seconds) + try: + path_wait = max( + path_wait, + path_response_window(call_destination_hash), + ) + except Exception: + pass has_path = await self._await_path( call_destination_hash, - timeout_seconds=min(timeout_seconds, 10), + timeout_seconds=path_wait, ) if self._is_initiation_cancelled(): return None diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 4d4a282e..740a2d48 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -226,8 +226,11 @@ >
"`; -exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"MMessages"`; +exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"MMessages"`; exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"
"`; diff --git a/tests/frontend/mapExchange.sanitize.test.js b/tests/frontend/mapExchange.sanitize.test.js index 0afab05e..0a58522a 100644 --- a/tests/frontend/mapExchange.sanitize.test.js +++ b/tests/frontend/mapExchange.sanitize.test.js @@ -116,15 +116,60 @@ describe("kmlSanitize oracle", () => { expect(out.stripped).toContain("remote_href"); }); - it("rejects kmz with svg entry", async () => { + it("flattens CDATA HTML descriptions without leaving a CDATA closer", () => { + const kml = ` + +Leaking Tanks +Hostile
]]>
+-117.99,33.78,0 +
`; + const out = sanitizeKmlText(kml); + expect(out.stripped).toContain("html_description"); + expect(out.text).not.toMatch(/\]\]>/); + expect(out.text.toLowerCase()).not.toContain(" { const kml = ` -1,2,0 +Keep1,2,0 `; const zip = new JSZip(); zip.file("doc.kml", kml); zip.file("icon.svg", ""); const buf = await zip.generateAsync({ type: "arraybuffer" }); - await expect(readKmzToFeatures(buf, "EPSG:3857")).rejects.toMatchObject({ code: "unsafe_kmz_entry" }); + const features = await readKmzToFeatures(buf, "EPSG:3857"); + expect(features.length).toBeGreaterThanOrEqual(1); + }); + + it("imports ArcGIS-style kmz with unused xsl sidecar", async () => { + const kml = ` + + +ArcGIS Point +#s +1,2,0 +`; + const zip = new JSZip(); + zip.file("doc.kml", kml); + zip.file( + "F2E8A9CB2E0A446C9BCA87742DD683E5.xsl", + "" + ); + const png = Uint8Array.from( + atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="), + (c) => c.charCodeAt(0) + ); + zip.file("Layer0_Symbol.png", png); + const buf = await zip.generateAsync({ type: "arraybuffer" }); + const features = await readKmzToFeatures(buf, "EPSG:3857"); + expect(features.length).toBeGreaterThanOrEqual(1); + expect(String(features[0].get("name") || "")).toContain("ArcGIS"); }); }); diff --git a/vendor/rns_filesync/rns_filesync/peers.py b/vendor/rns_filesync/rns_filesync/peers.py index 7b76cfad..2587b942 100644 --- a/vendor/rns_filesync/rns_filesync/peers.py +++ b/vendor/rns_filesync/rns_filesync/peers.py @@ -75,16 +75,37 @@ def is_local_destination(destination_hash: bytes) -> bool: return False +def adaptive_path_timeout( + destination_hash: bytes, + timeout: float | None = None, +) -> float: + if timeout is not None: + return float(timeout) + try: + from meshchatx.src.backend.path_utils import path_response_window + + return path_response_window(destination_hash) + except Exception: + pass + try: + reticulum = RNS.Reticulum.get_instance() + window = float(reticulum.get_first_hop_timeout(destination_hash)) + return max(window, float(RNS.Transport.PATH_REQUEST_TIMEOUT)) + except Exception: + return PATH_TIMEOUT_DEFAULT + + def wait_for_path( destination_hash: bytes, - timeout: float = PATH_TIMEOUT_DEFAULT, + timeout: float | None = None, ) -> bool: + wait_s = adaptive_path_timeout(destination_hash, timeout) if RNS.Transport.has_path(destination_hash) or is_local_destination( destination_hash, ): return True RNS.Transport.request_path(destination_hash) - deadline = time.time() + timeout + deadline = time.time() + wait_s while time.time() < deadline: if RNS.Transport.has_path(destination_hash) or is_local_destination( destination_hash, @@ -111,13 +132,19 @@ def establish_link( *, established_callback: Callable | None = None, closed_callback: Callable | None = None, - timeout: float = LINK_TIMEOUT_DEFAULT, + timeout: float | None = None, ): link = RNS.Link( destination, established_callback=established_callback, closed_callback=closed_callback, ) + if timeout is None: + rns_timeout = getattr(link, "establishment_timeout", None) + if isinstance(rns_timeout, (int, float)) and rns_timeout > 0: + timeout = float(rns_timeout) + 5.0 + else: + timeout = LINK_TIMEOUT_DEFAULT deadline = time.time() + timeout while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED): if time.time() > deadline: diff --git a/vendor/rns_filesync/rns_filesync/service.py b/vendor/rns_filesync/rns_filesync/service.py index 7f34b535..989610d9 100644 --- a/vendor/rns_filesync/rns_filesync/service.py +++ b/vendor/rns_filesync/rns_filesync/service.py @@ -18,7 +18,6 @@ from rns_filesync.constants import ( APP_NAME, ASPECT, BLOCK_SIZE, - LINK_TIMEOUT_DEFAULT, PATH_TIMEOUT_DEFAULT, RECONNECT_BASE_INTERVAL, RECONNECT_MAX_INTERVAL, @@ -33,6 +32,7 @@ from rns_filesync.inventory import ( ) from rns_filesync.paths import PathJailError, normalize_relpath, resolve_under_root from rns_filesync.peers import ( + adaptive_path_timeout, create_outbound_destination, establish_link, hex_hash, @@ -276,7 +276,7 @@ class FileSyncService: def connect_peer( self, identity_hash: str | bytes, - timeout: float = PATH_TIMEOUT_DEFAULT, + timeout: float | None = None, ) -> dict[str, Any]: """Connect using identity hash (destination hash accepted as fallback).""" peer_hash = parse_hash(identity_hash) @@ -291,8 +291,7 @@ class FileSyncService: identity, how = resolve_peer_identity(peer_hash) if identity is None: - # Path request may populate known destinations for destination hashes. - wait_for_path(peer_hash, timeout=min(timeout, 5.0)) + wait_for_path(peer_hash, timeout=timeout) identity, how = resolve_peer_identity(peer_hash) if identity is None: msg = f"could not recall identity for {peer_hex}" @@ -309,7 +308,6 @@ class FileSyncService: destination, established_callback=self._on_link_established, closed_callback=self._on_link_closed, - timeout=LINK_TIMEOUT_DEFAULT, ) if link is None: msg = f"link failed for {peer_hex}" @@ -1053,7 +1051,11 @@ class FileSyncService: def job(target=peer_id): try: - result = self.connect_peer(target, timeout=PATH_TIMEOUT_DEFAULT) + try: + wait_s = adaptive_path_timeout(parse_hash(target)) + except Exception: + wait_s = PATH_TIMEOUT_DEFAULT + result = self.connect_peer(target, timeout=wait_s) with self._lock: if result.get("ok"): self._reconnect_backoff.pop(target, None)