refactor: update path response and link establishment timeout handling across various components to improve reliability and performance

This commit is contained in:
Ivan 2026-08-13 17:51:02 -05:00
parent 062c66138d
commit 752b9be088
No known key found for this signature in database
38 changed files with 562 additions and 103 deletions

View file

@ -55,6 +55,7 @@ Allowed:
- Prefer event/handler and store-and-forward over blocking request/response UIs. - 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. - 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. - 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. - Large files use RNCP / attachments / explicit transfer tools, not chat text fields.

View file

@ -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. 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. 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 ## Key files

Binary file not shown.

View file

@ -14,6 +14,10 @@ import RNS
from meshchatx.src.backend.announce_handler import AnnounceHandler from meshchatx.src.backend.announce_handler import AnnounceHandler
from meshchatx.src.backend.log_redaction import redact_diagnostic_text from meshchatx.src.backend.log_redaction import redact_diagnostic_text
from meshchatx.src.backend.path_utils import (
link_establishment_window,
path_response_window,
)
BUG_ASPECT = "mcx-bugs-v1" BUG_ASPECT = "mcx-bugs-v1"
REPORT_PATH = "/report" REPORT_PATH = "/report"
@ -440,7 +444,10 @@ class BugReportManager:
) )
if not RNS.Transport.has_path(dest_hash): if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash) RNS.Transport.request_path(dest_hash)
deadline = time.time() + float(args.get("path_timeout") or 15) path_wait = float(args.get("path_timeout") or 0) or path_response_window(
dest_hash,
)
deadline = time.time() + path_wait
while time.time() < deadline: while time.time() < deadline:
if RNS.Transport.has_path(dest_hash): if RNS.Transport.has_path(dest_hash):
break break
@ -468,7 +475,11 @@ class BugReportManager:
response_event.set() response_event.set()
link.set_link_established_callback(on_established) link.set_link_established_callback(on_established)
if not established.wait(timeout=float(args.get("link_timeout") or 20)): link_wait = float(args.get("link_timeout") or 0) or link_establishment_window(
link,
dest_hash,
)
if not established.wait(timeout=link_wait):
try: try:
link.teardown() link.teardown()
except Exception: except Exception:

View file

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401 from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
GeoValidationError, GeoValidationError,
OutboundHttpBlockedError, OutboundHttpBlockedError,
@ -131,6 +130,8 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
zipfile, zipfile,
) )
from meshchatx.src.backend.path_utils import path_response_window
# Same ceiling as RNProbeHandler.MAX_TIMEOUT_S # Same ceiling as RNProbeHandler.MAX_TIMEOUT_S
PATH_PROBE_MIN_TIMEOUT_S = 1 PATH_PROBE_MIN_TIMEOUT_S = 1
PATH_PROBE_MAX_TIMEOUT_S = 600 PATH_PROBE_MAX_TIMEOUT_S = 600
@ -139,9 +140,11 @@ PATH_WAIT_REQUIRES_POST_MESSAGE = (
) )
def parse_path_probe_timeout(raw, *, default=15): def parse_path_probe_timeout(raw, *, default=None):
if raw is None or raw == "": if raw is None or raw == "":
raw = default raw = default
if raw is None or raw == "":
return None, None
try: try:
timeout_seconds = int(raw) timeout_seconds = int(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
@ -157,7 +160,7 @@ def parse_path_probe_timeout(raw, *, default=15):
return timeout_seconds, None return timeout_seconds, None
async def read_path_probe_timeout_raw(request, default=15): async def read_path_probe_timeout_raw(request, default=None):
query = getattr(request, "query", None) or {} query = getattr(request, "query", None) or {}
if "timeout" in query: if "timeout" in query:
return query.get("timeout") return query.get("timeout")
@ -303,10 +306,16 @@ def register_path_probe_routes(routes, app):
) )
destination_hash_hex = destination_hash_bytes.hex() destination_hash_hex = destination_hash_bytes.hex()
timeout_raw = await read_path_probe_timeout_raw(request, default=15) timeout_raw = await read_path_probe_timeout_raw(request)
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw) timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
if timeout_error: if timeout_error:
return web.json_response({"message": timeout_error}, status=400) return web.json_response({"message": timeout_error}, status=400)
if timeout_seconds is None:
reticulum = app.reticulum if hasattr(app, "reticulum") else None
timeout_seconds = path_response_window(
destination_hash_bytes,
reticulum,
)
if destination_hash_hex in local_destination_hashes(app): if destination_hash_hex in local_destination_hashes(app):
return local_path_response(destination_hash_hex) return local_path_response(destination_hash_hex)
@ -483,13 +492,18 @@ def register_path_probe_routes(routes, app):
status=400, status=400,
) )
timeout_raw = await read_path_probe_timeout_raw(request, default=15) timeout_raw = await read_path_probe_timeout_raw(request)
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw) timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
if timeout_error: if timeout_error:
return web.json_response( return web.json_response(
{"message": f"Ping failed. {timeout_error}"}, {"message": f"Ping failed. {timeout_error}"},
status=400, status=400,
) )
if timeout_seconds is None:
reticulum = app.reticulum if hasattr(app, "reticulum") else None
timeout_seconds = int(
round(path_response_window(destination_hash, reticulum)),
)
# Split the budget so path discovery cannot consume the whole timeout. # Split the budget so path discovery cannot consume the whole timeout.
path_budget_seconds = max(1, timeout_seconds // 2) path_budget_seconds = max(1, timeout_seconds // 2)

View file

@ -436,7 +436,11 @@ def register_rn_tools_routes(routes, app):
data = await request.json() data = await request.json()
destination_hash_str = data.get("destination_hash", "") destination_hash_str = data.get("destination_hash", "")
file_path = data.get("file_path", "") file_path = data.get("file_path", "")
timeout = float(data.get("timeout", RNS.Transport.PATH_REQUEST_TIMEOUT)) timeout_raw = data.get("timeout")
try:
timeout = float(timeout_raw) if timeout_raw not in (None, "") else None
except (TypeError, ValueError):
timeout = None
no_compress = bool(data.get("no_compress", False)) no_compress = bool(data.get("no_compress", False))
try: try:
@ -496,7 +500,11 @@ def register_rn_tools_routes(routes, app):
data = await request.json() data = await request.json()
destination_hash_str = data.get("destination_hash", "") destination_hash_str = data.get("destination_hash", "")
file_path = data.get("file_path", "") file_path = data.get("file_path", "")
timeout = float(data.get("timeout", RNS.Transport.PATH_REQUEST_TIMEOUT)) timeout_raw = data.get("timeout")
try:
timeout = float(timeout_raw) if timeout_raw not in (None, "") else None
except (TypeError, ValueError):
timeout = None
save_path = data.get("save_path") save_path = data.get("save_path")
allow_overwrite = bool(data.get("allow_overwrite", False)) allow_overwrite = bool(data.get("allow_overwrite", False))

View file

@ -22,6 +22,7 @@ from meshchatx.src.backend.map_geo_validator import (
validate_geo_bytes, validate_geo_bytes,
) )
from meshchatx.src.backend.map_overlay_manager import atomic_write_bytes from meshchatx.src.backend.map_overlay_manager import atomic_write_bytes
from meshchatx.src.backend.path_utils import path_response_window
from meshchatx.src.path_utils import is_path_within_dir from meshchatx.src.path_utils import is_path_within_dir
_log = logging.getLogger("meshchatx.map_data") _log = logging.getLogger("meshchatx.map_data")
@ -545,6 +546,13 @@ class MapDataManager:
if link_manager is None: if link_manager is None:
raise MapDataError("link_unavailable") raise MapDataError("link_unavailable")
path_timeout = int(self.config.map_overlay_path_timeout_seconds.get() or 30) path_timeout = int(self.config.map_overlay_path_timeout_seconds.get() or 30)
try:
path_timeout = max(
float(path_timeout),
path_response_window(destination_hash, self.reticulum),
)
except Exception:
path_timeout = float(path_timeout)
transfer_timeout = int( transfer_timeout = int(
self.config.map_overlay_transfer_timeout_seconds.get() or 120 self.config.map_overlay_transfer_timeout_seconds.get() or 120
) )
@ -553,7 +561,6 @@ class MapDataManager:
destination_hash, destination_hash,
MAP_ASPECT, MAP_ASPECT,
path_lookup_timeout=float(path_timeout), path_lookup_timeout=float(path_timeout),
link_establishment_timeout=float(path_timeout),
) )
if link is None: if link is None:
code = ( code = (

View file

@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
import zipfile import zipfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from io import BytesIO from io import BytesIO
@ -114,6 +115,14 @@ def _set_href_on_element(el: ET.Element, value: str | None) -> None:
el.attrib[key] = value 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: def _strip_description_html(el: ET.Element) -> bool:
tag = _strip_ns(el.tag).lower() tag = _strip_ns(el.tag).lower()
if tag != "description": if tag != "description":
@ -129,8 +138,13 @@ def _strip_description_html(el: ET.Element) -> bool:
if child.tail and child.tail.strip(): if child.tail and child.tail.strip():
texts.append(child.tail.strip()) texts.append(child.tail.strip())
el.remove(child) el.remove(child)
el.text = " ".join(texts) if texts else None combined = " ".join(texts)
return had_children 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( def _walk_strip_kml(
@ -277,7 +291,10 @@ def sanitize_kmz_bytes(data: bytes) -> SanitizeResult:
raise GeoValidationError("path_traversal") raise GeoValidationError("path_traversal")
ext = _zip_entry_ext(name) ext = _zip_entry_ext(name)
if ext not in ALLOWED_KMZ_EXTS: 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) payload = zf.read(info.filename)
kept[name] = payload kept[name] = payload
lower = name.lower() lower = name.lower()

View file

@ -38,6 +38,7 @@ from meshchatx.src.backend.map_overlay_sources import (
parse_create_payload, parse_create_payload,
) )
from meshchatx.src.backend.nomadnet_downloader import NomadnetFileDownloader 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 ( from meshchatx.src.backend.rngit_sparse_fetcher import (
RngitFetchError, RngitFetchError,
RngitSparseFetcher, RngitSparseFetcher,
@ -623,6 +624,14 @@ class MapOverlayManager:
generation: int, generation: int,
) -> None: ) -> None:
path_timeout = self._cfg_int("map_overlay_path_timeout_seconds") 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") transfer_timeout = self._cfg_int("map_overlay_transfer_timeout_seconds")
job_timeout = self._cfg_int("map_overlay_job_timeout_seconds") job_timeout = self._cfg_int("map_overlay_job_timeout_seconds")
@ -662,7 +671,6 @@ class MapOverlayManager:
await asyncio.wait_for( await asyncio.wait_for(
downloader.download( downloader.download(
path_lookup_timeout=path_timeout, path_lookup_timeout=path_timeout,
link_establishment_timeout=path_timeout,
), ),
timeout=job_timeout, timeout=job_timeout,
) )

View file

@ -10,7 +10,10 @@ from collections.abc import Callable
import RNS import RNS
from meshchatx.src.backend import reticulum_pathfinding 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 from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
# Global cache for Nomad Network links (reuse instead of reconnecting per request). # Global cache for Nomad Network links (reuse instead of reconnecting per request).
@ -293,14 +296,11 @@ class NomadnetDownloader:
self.link = link self.link = link
if link_establishment_timeout is None: if link_establishment_timeout is None:
rns_timeout = getattr(link, "establishment_timeout", None) link_establishment_timeout = link_establishment_window(
if isinstance(rns_timeout, (int, float)) and rns_timeout > 0: link,
link_establishment_timeout = rns_timeout + 5 self.destination_hash,
else: self._reticulum,
link_establishment_timeout = path_response_window( )
self.destination_hash,
self._reticulum,
)
timeout_after_seconds = time.time() + link_establishment_timeout timeout_after_seconds = time.time() + link_establishment_timeout
while ( while (

View file

@ -2,8 +2,24 @@
import RNS import RNS
MIN_WINDOW_BITRATE = 50 # One path-request packet is about 234 bytes plus IFAC. 240 covers that.
PATH_EXCHANGE_BYTES = 240 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): def slowest_online_bitrate(reticulum=None):
@ -23,14 +39,49 @@ def slowest_online_bitrate(reticulum=None):
return 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: def path_response_window(destination_hash, reticulum=None) -> float:
if reticulum is None: """Seconds to wait for a cold path response on the slowest online interface.
reticulum = RNS.Reticulum.get_instance()
window = float(reticulum.get_first_hop_timeout(destination_hash)) 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) bitrate = slowest_online_bitrate(reticulum)
if bitrate: if bitrate:
floor_bps = min_window_bitrate()
window = max( window = max(
window, 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()

View file

@ -16,6 +16,10 @@ from typing import Any
import RNS import RNS
from meshchatx.src.backend.management_identities import resolve_identity_path 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: def _truncated_hash_len() -> int:
@ -140,9 +144,16 @@ class _RemoteRequest:
self._link = RNS.Link(destination) self._link = RNS.Link(destination)
self._link.set_link_established_callback(on_established) self._link.set_link_established_callback(on_established)
self._link.set_link_closed_callback(on_closed) self._link.set_link_closed_callback(on_closed)
request_wait = max(
float(self.timeout),
link_establishment_window(
self._link,
self.destination_hash,
),
)
try: 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") raise TimeoutError("Remote management request timed out")
if self._error is not None: if self._error is not None:
raise self._error raise self._error
@ -173,9 +184,7 @@ def remote_request(
if identity is None: if identity is None:
raise ValueError(f"Could not load management identity from {resolved}") raise ValueError(f"Could not load management identity from {resolved}")
wait = ( wait = (
float(timeout) float(timeout) if timeout not in (None, "") else path_response_window(dest_hash)
if timeout not in (None, "")
else float(RNS.Transport.PATH_REQUEST_TIMEOUT)
) )
return _RemoteRequest(dest_hash, identity, path, data, wait).run() return _RemoteRequest(dest_hash, identity, path, data, wait).run()

View file

@ -8,6 +8,8 @@ from typing import Any, Optional, Protocol
import RNS import RNS
from meshchatx.src.backend.path_utils import path_response_window
@dataclass(frozen=True) @dataclass(frozen=True)
class OutboundPathOutcome: class OutboundPathOutcome:
@ -233,7 +235,12 @@ def nudge_path_request(destination_hash: bytes) -> None:
RNS.Transport.request_path(destination_hash) 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: try:
base = float(RNS.Transport.PATH_REQUEST_TIMEOUT) base = float(RNS.Transport.PATH_REQUEST_TIMEOUT)
except Exception: except Exception:
@ -245,8 +252,12 @@ async def await_transport_path_for_outbound_lxmf(
reticulum: Optional["ReticulumLike"], reticulum: Optional["ReticulumLike"],
destination_hash_bytes: bytes, destination_hash_bytes: bytes,
) -> OutboundPathOutcome: ) -> OutboundPathOutcome:
long_w = lxmf_path_wait_cap_seconds() long_w = lxmf_path_wait_cap_seconds(destination_hash_bytes, reticulum)
short_w = max(15.0, long_w * 0.5) 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) measure = prepare_fresh_path_request(reticulum, destination_hash_bytes)
deadline = time.time() + long_w deadline = time.time() + long_w

View file

@ -9,7 +9,7 @@ from collections.abc import Callable
import RNS import RNS
from .path_utils import path_response_window from .path_utils import link_establishment_window, path_response_window
class RNCPHandler: class RNCPHandler:
@ -38,6 +38,16 @@ class RNCPHandler:
except Exception: except Exception:
pass 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: def _default_fetch_save_dir(self) -> str:
path = os.path.join(self.storage_dir, "rncp", "downloads") path = os.path.join(self.storage_dir, "rncp", "downloads")
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
@ -398,9 +408,8 @@ class RNCPHandler:
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
if timeout is None: path_wait = self._path_wait_seconds(destination_hash, timeout)
timeout = path_response_window(destination_hash, self.reticulum) timeout_after = time.time() + path_wait
timeout_after = time.time() + timeout
while ( while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
): ):
@ -420,7 +429,11 @@ class RNCPHandler:
) )
link = RNS.Link(receiver_destination) 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: while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -501,9 +514,8 @@ class RNCPHandler:
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
if timeout is None: path_wait = self._path_wait_seconds(destination_hash, timeout)
timeout = path_response_window(destination_hash, self.reticulum) timeout_after = time.time() + path_wait
timeout_after = time.time() + timeout
while ( while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
): ):
@ -523,7 +535,11 @@ class RNCPHandler:
) )
link = RNS.Link(listener_destination) 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: while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)

View file

@ -74,8 +74,8 @@ class RNProbeHandler:
timeout_after = time.time() + ( timeout_after = time.time() + (
timeout timeout
or self.DEFAULT_TIMEOUT if timeout is not None
+ self.reticulum.get_first_hop_timeout(destination_hash) else path_response_window(destination_hash, self.reticulum)
) )
while ( while (
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after

View file

@ -10,7 +10,10 @@ from typing import Optional
import RNS import RNS
from meshchatx.src.backend import reticulum_pathfinding 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 from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
# Cache of established RNS Links keyed by (aspect_str, destination_hash_bytes). # 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). # Wait granularity while polling for path / link (seconds).
_POLL_INTERVAL_S = 0.02 _POLL_INTERVAL_S = 0.02
# Slow path and link margins # Slow path UI phase margin before finding_path_slow
PATH_MARGIN_S = 5.0 PATH_MARGIN_S = 5.0
LINK_MARGIN_S = 5.0
_FALLBACK_LINK_TIMEOUT_S = 15.0
def cached_link_count() -> int: def cached_link_count() -> int:
@ -368,13 +369,14 @@ class RnsLinkManager:
) )
# Get the link establishment_timeout from RNS if not explicitly pinned # 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: if link_establishment_timeout is not None:
deadline = time.time() + link_establishment_timeout 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: else:
deadline = time.time() + _FALLBACK_LINK_TIMEOUT_S deadline = time.time() + link_establishment_window(
link,
destination_hash,
self._get_reticulum(),
)
try: try:
while ( while (
link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED) link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED)

View file

@ -315,6 +315,7 @@ class RRCHub:
if gate is not None: if gate is not None:
gate.acquire() gate.acquire()
try: try:
path_wait = timeout_s
if not RNS.Transport.has_path(self.hub_hash): if not RNS.Transport.has_path(self.hub_hash):
RNS.Transport.request_path(self.hub_hash) RNS.Transport.request_path(self.hub_hash)
try: try:
@ -328,7 +329,7 @@ class RRCHub:
time.sleep(0.1) time.sleep(0.1)
hub_identity = None hub_identity = None
deadline = time.monotonic() + timeout_s deadline = time.monotonic() + path_wait
while time.monotonic() < deadline: while time.monotonic() < deadline:
hub_identity = RNS.Identity.recall(self.hub_hash) hub_identity = RNS.Identity.recall(self.hub_hash)
if hub_identity is not None: if hub_identity is not None:

View file

@ -15,6 +15,7 @@ from meshchatx.src.backend.meshchat_utils import (
hex_identifier_to_bytes, hex_identifier_to_bytes,
normalize_hex_identifier, normalize_hex_identifier,
) )
from meshchatx.src.backend.path_utils import path_response_window
class Tee: class Tee:
@ -638,9 +639,17 @@ class TelephoneManager:
if not RNS.Transport.has_path(call_destination_hash): if not RNS.Transport.has_path(call_destination_hash):
self._update_initiation_status("Requesting path...") 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( has_path = await self._await_path(
call_destination_hash, call_destination_hash,
timeout_seconds=min(timeout_seconds, 10), timeout_seconds=path_wait,
) )
if self._is_initiation_cancelled(): if self._is_initiation_cancelled():
return None return None

View file

@ -226,8 +226,11 @@
> >
<!-- toggle button for desktop (h-10 aligns with Messages/Nomad collapse rows) --> <!-- toggle button for desktop (h-10 aligns with Messages/Nomad collapse rows) -->
<div <div
class="h-10 shrink-0 items-center justify-end gap-1 border-b border-gray-200 dark:border-zinc-800 px-2" class="h-10 shrink-0 items-center gap-1 border-b border-gray-200 dark:border-zinc-800 px-2"
:class="isSidebarNavEditing && !isSidebarCollapsed ? 'flex' : 'hidden sm:flex'" :class="[
isSidebarNavEditing && !isSidebarCollapsed ? 'flex' : 'hidden sm:flex',
isSidebarCollapsed ? 'justify-center' : 'justify-end',
]"
> >
<button <button
v-if="isSidebarNavEditing && !isSidebarCollapsed" v-if="isSidebarNavEditing && !isSidebarCollapsed"

View file

@ -10,9 +10,9 @@
isActive isActive
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300' ? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-zinc-700', : 'hover:bg-gray-100 dark:hover:bg-zinc-700',
isCollapsed ? 'overflow-visible' : 'overflow-hidden', isCollapsed ? 'overflow-visible justify-center rounded-lg' : 'overflow-hidden rounded-r-full mr-2',
]" ]"
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500" class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 p-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
@click="handleNavigate($event, navigate)" @click="handleNavigate($event, navigate)"
> >
<span class="my-auto shrink-0"> <span class="my-auto shrink-0">

View file

@ -7,7 +7,7 @@
data-testid="sidebar-account-chip" data-testid="sidebar-account-chip"
@click="onAccountChipClick" @click="onAccountChipClick"
> >
<div class="flex items-center gap-2 p-3 pb-1"> <div class="flex items-center gap-2" :class="isCollapsed ? 'justify-center p-2' : 'p-3 pb-1'">
<RouterLink :to="{ name: 'profile.icon' }" class="shrink-0" @click.stop> <RouterLink :to="{ name: 'profile.icon' }" class="shrink-0" @click.stop>
<LxmfUserIcon <LxmfUserIcon
:icon-name="config.lxmf_user_icon_name" :icon-name="config.lxmf_user_icon_name"

View file

@ -4,10 +4,11 @@
<div v-if="config"> <div v-if="config">
<div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950"> <div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950">
<div <div
class="flex text-gray-700 p-3 cursor-pointer" class="flex text-gray-700 cursor-pointer"
:class="isCollapsed ? 'justify-center p-2' : 'p-3'"
@click="isShowingMyIdentitySection = !isShowingMyIdentitySection" @click="isShowingMyIdentitySection = !isShowingMyIdentitySection"
> >
<div class="my-auto mr-2 shrink-0"> <div :class="isCollapsed ? 'shrink-0' : 'my-auto mr-2 shrink-0'">
<RouterLink :to="{ name: 'profile.icon' }" @click.stop> <RouterLink :to="{ name: 'profile.icon' }" @click.stop>
<LxmfUserIcon <LxmfUserIcon
:icon-name="config.lxmf_user_icon_name" :icon-name="config.lxmf_user_icon_name"
@ -72,13 +73,15 @@
<div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950"> <div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950">
<div <div
class="flex text-gray-700 p-3 cursor-pointer dark:text-white" class="flex text-gray-700 cursor-pointer dark:text-white"
:class="isCollapsed ? 'justify-center p-2' : 'p-3'"
data-testid="sidebar-announce-header" data-testid="sidebar-announce-header"
@click="isShowingAnnounceSection = !isShowingAnnounceSection" @click="isShowingAnnounceSection = !isShowingAnnounceSection"
> >
<button <button
type="button" type="button"
class="my-auto mr-2 flex shrink-0 items-center justify-center rounded-md border-0 bg-transparent p-0 text-inherit cursor-pointer" class="flex shrink-0 items-center justify-center rounded-md border-0 bg-transparent p-0 text-inherit cursor-pointer"
:class="isCollapsed ? '' : 'my-auto mr-2'"
:title="$t('app.announce_now')" :title="$t('app.announce_now')"
data-testid="sidebar-announce-radio" data-testid="sidebar-announce-radio"
@click.stop="$emit('send-announce')" @click.stop="$emit('send-announce')"

View file

@ -2,12 +2,13 @@
<template> <template>
<div class="flex-1 overflow-y-auto" :class="isEditing ? 'select-none' : ''" data-testid="sidebar-classic-nav"> <div class="flex-1 overflow-y-auto" :class="isEditing ? 'select-none' : ''" data-testid="sidebar-classic-nav">
<ul class="py-3 pr-2 space-y-1"> <ul class="py-3 space-y-1" :class="isCollapsed ? 'px-0' : 'pr-2'">
<li <li
v-for="item in navItems" v-for="item in navItems"
:key="item.id" :key="item.id"
class="flex items-center" class="flex items-center"
:class="[ :class="[
isCollapsed ? 'justify-center' : '',
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '', isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
isEditing && dragOverKey === `item:${item.id}` isEditing && dragOverKey === `item:${item.id}`
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full' ? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'

View file

@ -48,7 +48,8 @@
</template> </template>
</div> </div>
<ul <ul
class="py-1 pr-2 space-y-1" class="py-1 space-y-1"
:class="isCollapsed ? 'px-0' : 'pr-2'"
@dragover.prevent="setNavDragOver(`group-end:${group.id}`, $event)" @dragover.prevent="setNavDragOver(`group-end:${group.id}`, $event)"
@drop.prevent="onGroupListDrop(group.id)" @drop.prevent="onGroupListDrop(group.id)"
> >
@ -57,6 +58,7 @@
:key="item.id" :key="item.id"
class="flex items-center" class="flex items-center"
:class="[ :class="[
isCollapsed ? 'justify-center' : '',
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '', isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
isEditing && dragOverKey === `item:${item.id}` isEditing && dragOverKey === `item:${item.id}`
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full' ? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'
@ -145,8 +147,8 @@
> >
<button <button
type="button" type="button"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:text-zinc-200 dark:hover:bg-zinc-800 transition-colors" class="flex w-full items-center gap-3 py-2.5 text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:text-zinc-200 dark:hover:bg-zinc-800 transition-colors"
:class="isCollapsed ? 'justify-center' : ''" :class="isCollapsed ? 'justify-center px-0' : 'px-4'"
data-testid="sidebar-more-toggle" data-testid="sidebar-more-toggle"
@pointerdown="onNavHoldPointerDown" @pointerdown="onNavHoldPointerDown"
@pointermove="onNavHoldPointerMove" @pointermove="onNavHoldPointerMove"

View file

@ -10,8 +10,7 @@
]" ]"
> >
<div <div
class="hidden sm:flex h-10 shrink-0 items-center border-b border-gray-200 dark:border-zinc-800 px-2" class="hidden sm:flex h-10 shrink-0 items-center justify-center border-b border-gray-200 dark:border-zinc-800 px-2"
:class="collapsedHeaderJustifyClass"
> >
<button <button
type="button" type="button"
@ -1001,9 +1000,6 @@ export default {
selectionEdgeBorderClass() { selectionEdgeBorderClass() {
return this.isRightSidebar ? "border-r-2" : "border-l-2"; return this.isRightSidebar ? "border-r-2" : "border-l-2";
}, },
collapsedHeaderJustifyClass() {
return this.isRightSidebar ? "justify-start" : "justify-end";
},
collapsedStripChevronIcon() { collapsedStripChevronIcon() {
return this.isRightSidebar ? "chevron-left" : "chevron-right"; return this.isRightSidebar ? "chevron-left" : "chevron-right";
}, },

View file

@ -7,7 +7,7 @@
class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800" class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800"
> >
<div <div
class="hidden sm:flex h-10 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2" class="hidden sm:flex h-10 shrink-0 items-center justify-center border-b border-gray-200 dark:border-zinc-800 px-2"
> >
<button <button
type="button" type="button"

View file

@ -97,18 +97,36 @@ function rewriteHrefAttrs(text, { zipLocalOk }) {
return { text: out, stripped }; return { text: out, stripped };
} }
function unwrapDescriptionInner(inner) {
const trimmed = String(inner).trim();
const cdata = trimmed.match(/^<!\[CDATA\[([\s\S]*?)\]\]>$/);
if (cdata) {
return cdata[1];
}
return String(inner);
}
function escapeXmlText(value) {
return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function looksLikeHtml(value) {
return /<[a-z][\s\S]*>/i.test(value);
}
function stripDescriptionHtml(text) { function stripDescriptionHtml(text) {
const stripped = []; const stripped = [];
const out = String(text).replace(/<description\b[^>]*>([\s\S]*?)<\/description>/gi, (full, inner) => { const out = String(text).replace(/<description\b[^>]*>([\s\S]*?)<\/description>/gi, (full, inner) => {
if (/<[a-z][\s\S]*>/i.test(inner)) { const content = unwrapDescriptionInner(inner);
stripped.push("html_description"); if (!looksLikeHtml(content) && !looksLikeHtml(inner)) {
const plain = String(inner) return full;
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return `<description>${plain}</description>`;
} }
return full; stripped.push("html_description");
const plain = content
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return `<description>${escapeXmlText(plain)}</description>`;
}); });
return { text: out, stripped }; return { text: out, stripped };
} }

View file

@ -200,7 +200,8 @@ export async function readKmzToFeatures(arrayBuffer, featureProjection) {
throw new KmlSanitizeError("path_traversal"); throw new KmlSanitizeError("path_traversal");
} }
if (!kmzEntryAllowed(name)) { if (!kmzEntryAllowed(name)) {
throw new KmlSanitizeError("unsafe_kmz_entry"); // ArcGIS KMZ exports include unused .xsl balloon stylesheets.
continue;
} }
} }
const kmlName = findKmlEntryName(zip); const kmlName = findKmlEntryName(zip);

View file

@ -101,15 +101,67 @@ def test_sanitize_kml_keeps_data_png_icon():
assert b"data:image/png" in result.data assert b"data:image/png" in result.data
def test_sanitize_kmz_rejects_svg_entry(): def test_sanitize_kml_flattens_cdata_html_description():
kml = b"""<?xml version="1.0" encoding="utf-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Placemark><name>Leaking Tanks</name>
<description><![CDATA[<h2>Hostile</h2><script>alert(1)</script>]]></description>
<Point><coordinates>-117.99,33.78,0</coordinates></Point>
</Placemark>
</Document></kml>"""
result = sanitize_geo_bytes(kml)
assert result.feature_count == 1
assert "html_description" in result.stripped
lower = result.data.lower()
assert b"<script" not in lower
assert b"<h2" not in lower
assert b"hostile" in lower
def test_sanitize_kmz_skips_unreferenced_svg_keeps_placemark():
kml = b"""<?xml version="1.0"?> kml = b"""<?xml version="1.0"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document> <kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark> <Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark>
</Document></kml>""" </Document></kml>"""
data = _kmz({"doc.kml": kml, "icon.svg": b"<svg></svg>"}) data = _kmz({"doc.kml": kml, "icon.svg": b"<svg></svg>"})
with pytest.raises(GeoValidationError) as exc: result = sanitize_geo_bytes(data)
sanitize_geo_bytes(data) assert result.format == "kmz"
assert exc.value.code == "unsafe_kmz_entry" assert result.feature_count == 1
assert "skipped_kmz_entry" in result.stripped
with zipfile.ZipFile(io.BytesIO(result.data)) as zf:
names = [n.replace("\\", "/").lower() for n in zf.namelist() if not n.endswith("/")]
assert "doc.kml" in names
assert "icon.svg" not in names
def test_sanitize_kmz_skips_arcgis_xsl_sidecar():
kml = b"""<?xml version="1.0"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Style><IconStyle><Icon><href>Layer0_Symbol.png</href></Icon></IconStyle></Style>
<Placemark><name>ArcGIS Point</name>
<styleUrl>#s</styleUrl>
<Point><coordinates>1,2,0</coordinates></Point>
</Placemark>
</Document></kml>"""
data = _kmz(
{
"doc.kml": kml,
"F2E8A9CB2E0A446C9BCA87742DD683E5.xsl": (
b"<?xml version='1.0'?>"
b"<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform' version='1.0'/>"
),
"Layer0_Symbol.png": TINY_PNG,
}
)
result = sanitize_geo_bytes(data)
assert result.format == "kmz"
assert result.feature_count == 1
assert "skipped_kmz_entry" in result.stripped
with zipfile.ZipFile(io.BytesIO(result.data)) as zf:
names = [n.replace("\\", "/") for n in zf.namelist() if not n.endswith("/")]
assert "doc.kml" in names
assert "Layer0_Symbol.png" in names
assert not any(n.lower().endswith(".xsl") for n in names)
def test_sanitize_kmz_keeps_zip_local_png(): def test_sanitize_kmz_keeps_zip_local_png():

View file

@ -6,8 +6,11 @@ import pytest
import RNS import RNS
from meshchatx.src.backend.path_utils import ( from meshchatx.src.backend.path_utils import (
LINK_ESTABLISHMENT_MARGIN_S,
MIN_WINDOW_BITRATE, MIN_WINDOW_BITRATE,
PATH_EXCHANGE_BYTES, PATH_EXCHANGE_BYTES,
link_establishment_window,
min_window_bitrate,
path_response_window, path_response_window,
slowest_online_bitrate, slowest_online_bitrate,
) )
@ -19,6 +22,12 @@ def _bitrate_floor_s(bitrate: float) -> float:
return 2 * (PATH_EXCHANGE_BYTES * 8 / max(bitrate, MIN_WINDOW_BITRATE)) + 10 return 2 * (PATH_EXCHANGE_BYTES * 8 / max(bitrate, MIN_WINDOW_BITRATE)) + 10
def test_min_window_bitrate_matches_rns_minimum():
assert min_window_bitrate() == float(RNS.Reticulum.MINIMUM_BITRATE)
assert MIN_WINDOW_BITRATE == float(RNS.Reticulum.MINIMUM_BITRATE)
assert MIN_WINDOW_BITRATE == 5.0
def test_slowest_online_bitrate_picks_min_up_interface(): def test_slowest_online_bitrate_picks_min_up_interface():
reticulum = MagicMock() reticulum = MagicMock()
reticulum.get_interface_stats.return_value = { reticulum.get_interface_stats.return_value = {
@ -60,6 +69,18 @@ def test_path_response_window_uses_slow_bitrate_airtime_floor():
assert expected == pytest.approx(48.4) assert expected == pytest.approx(48.4)
def test_path_response_window_uses_five_bps_not_a_fifty_bps_clamp():
reticulum = MagicMock()
reticulum.get_first_hop_timeout.return_value = 2.0
reticulum.get_interface_stats.return_value = {
"interfaces": [{"status": True, "bitrate": 5}],
}
window = path_response_window(DEST, reticulum)
expected = _bitrate_floor_s(5)
assert expected == pytest.approx(778.0)
assert window == pytest.approx(expected)
def test_path_response_window_never_below_rns_path_request_timeout(): def test_path_response_window_never_below_rns_path_request_timeout():
reticulum = MagicMock() reticulum = MagicMock()
reticulum.get_first_hop_timeout.return_value = 0.1 reticulum.get_first_hop_timeout.return_value = 0.1
@ -69,3 +90,29 @@ def test_path_response_window_never_below_rns_path_request_timeout():
window = path_response_window(DEST, reticulum) window = path_response_window(DEST, reticulum)
assert window >= RNS.Transport.PATH_REQUEST_TIMEOUT assert window >= RNS.Transport.PATH_REQUEST_TIMEOUT
assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT) assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT)
def test_path_response_window_survives_missing_reticulum_instance():
reticulum = MagicMock()
reticulum.get_first_hop_timeout.side_effect = RuntimeError("no instance")
reticulum.get_interface_stats.side_effect = RuntimeError("no instance")
window = path_response_window(DEST, reticulum)
assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT)
def test_link_establishment_window_uses_rns_timeout_plus_margin():
link = MagicMock()
link.establishment_timeout = 12.0
assert link_establishment_window(link) == 12.0 + LINK_ESTABLISHMENT_MARGIN_S
def test_link_establishment_window_falls_back_to_path_window():
link = MagicMock()
link.establishment_timeout = None
reticulum = MagicMock()
reticulum.get_first_hop_timeout.return_value = 2.0
reticulum.get_interface_stats.return_value = {
"interfaces": [{"status": True, "bitrate": 1_000_000}],
}
window = link_establishment_window(link, DEST, reticulum)
assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT)

View file

@ -220,11 +220,21 @@ def test_prepare_fresh_uses_expire_path_without_reticulum():
req.assert_called_once_with(DEST) req.assert_called_once_with(DEST)
def test_lxmf_path_wait_cap_uses_rns_default(): def test_lxmf_path_wait_cap_uses_rns_default_without_destination():
v = rp.lxmf_path_wait_cap_seconds() v = rp.lxmf_path_wait_cap_seconds()
assert 30.0 <= v <= 120.0 assert 30.0 <= v <= 120.0
def test_lxmf_path_wait_cap_uses_path_response_window_for_destination():
reticulum = MagicMock()
with patch(
"meshchatx.src.backend.reticulum_pathfinding.path_response_window",
return_value=86.8,
) as mocked:
assert rp.lxmf_path_wait_cap_seconds(DEST, reticulum) == 86.8
mocked.assert_called_once_with(DEST, reticulum)
def test_lxmf_path_wait_cap_falls_back_when_float_fails(): def test_lxmf_path_wait_cap_falls_back_when_float_fails():
with patch.object(RNS.Transport, "PATH_REQUEST_TIMEOUT", "x"): with patch.object(RNS.Transport, "PATH_REQUEST_TIMEOUT", "x"):
assert rp.lxmf_path_wait_cap_seconds() == 30.0 assert rp.lxmf_path_wait_cap_seconds() == 30.0

View file

@ -160,6 +160,43 @@ async def test_cancel_between_identity_resolved_and_path_request(telephone_manag
assert not telephone_manager.telephone.call.called assert not telephone_manager.telephone.call.called
@pytest.mark.asyncio
async def test_initiate_path_wait_uses_adaptive_window_not_ten_second_cap(
telephone_manager,
):
destination_hash = bytes.fromhex("ab" * 16)
captured = {}
async def fake_await_path(_dest, timeout_seconds=15):
captured["timeout"] = timeout_seconds
return True
telephone_manager.telephone.call.side_effect = lambda *_a, **_k: setattr(
telephone_manager.telephone,
"call_status",
0,
)
with (
patch(
"meshchatx.src.backend.telephone_manager.RNS.Identity.recall",
return_value=MagicMock(),
),
patch(
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
return_value=False,
),
patch(
"meshchatx.src.backend.telephone_manager.path_response_window",
return_value=86.8,
),
patch.object(telephone_manager, "_await_path", side_effect=fake_await_path),
):
await telephone_manager.initiate(destination_hash, timeout_seconds=15)
assert captured["timeout"] == 86.8
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_after_path_found_before_dialling_stabilizes(telephone_manager): async def test_cancel_after_path_found_before_dialling_stabilizes(telephone_manager):
destination_hash = bytes.fromhex("ee" * 16) destination_hash = bytes.fromhex("ee" * 16)

View file

@ -85,6 +85,19 @@ describe("AppSidebarNav edit hold", () => {
wrapper.unmount(); wrapper.unmount();
}); });
it("centers collapsed nav items and the More toggle", () => {
const wrapper = mountGrouped({ isCollapsed: true });
const more = wrapper.get('[data-testid="sidebar-more-toggle"]');
expect(more.classes()).toContain("justify-center");
expect(more.classes()).not.toContain("px-4");
const item = wrapper.get('[data-nav-item-id="messages"]');
expect(item.classes()).toContain("justify-center");
const link = item.find("a.w-full");
expect(link.classes()).toContain("justify-center");
expect(link.classes()).not.toContain("mr-2");
wrapper.unmount();
});
it("does not emit edit-start when the sidebar is collapsed", async () => { it("does not emit edit-start when the sidebar is collapsed", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const wrapper = mountGrouped({ isCollapsed: true }); const wrapper = mountGrouped({ isCollapsed: true });
@ -131,6 +144,27 @@ describe("AppSidebarNav edit hold", () => {
wrapper.unmount(); wrapper.unmount();
}); });
it("classic nav centers collapsed items", () => {
const wrapper = mount(AppSidebarClassicNav, {
props: {
navItems: groups[0].items,
isCollapsed: true,
isEditing: false,
},
global: {
plugins: [i18n],
stubs: {
RouterLink: RouterLinkStub,
MaterialDesignIcon: { template: '<span class="md-stub" />' },
},
},
});
const item = wrapper.get('[data-nav-item-id="messages"]');
expect(item.classes()).toContain("justify-center");
expect(item.find("a.w-full").classes()).toContain("justify-center");
wrapper.unmount();
});
it("classic nav also holds to edit when expanded", async () => { it("classic nav also holds to edit when expanded", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const wrapper = mount(AppSidebarClassicNav, { const wrapper = mount(AppSidebarClassicNav, {

View file

@ -57,6 +57,17 @@ describe("SidebarLink UI", () => {
expect(wrapper.vm.isCollapsed).toBe(true); expect(wrapper.vm.isCollapsed).toBe(true);
}); });
it("centers the icon and hides text when collapsed", () => {
const wrapper = mountSidebarLink({ isCollapsed: true });
const innerLink = wrapper.find("a.justify-center");
expect(innerLink.exists()).toBe(true);
expect(innerLink.classes()).toContain("justify-center");
expect(innerLink.classes()).not.toContain("mr-2");
expect(innerLink.classes()).toContain("rounded-lg");
expect(wrapper.text()).not.toContain("Messages");
expect(wrapper.find(".icon-slot").exists()).toBe(true);
});
it("does not navigate when editMode is on", async () => { it("does not navigate when editMode is on", async () => {
const wrapper = mountSidebarLink({ editMode: true }); const wrapper = mountSidebarLink({ editMode: true });
const innerLink = wrapper.find("a.rounded-r-full"); const innerLink = wrapper.find("a.rounded-r-full");

View file

@ -8,7 +8,7 @@ exports[`UI snapshot regression > FormSubLabel.vue > default sub-label 1`] = `"<
exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-9 h-9 flex items-center justify-center shrink-0 transition-all duration-200"><span class="icon">+</span></button>"`; exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-9 h-9 flex items-center justify-center shrink-0 transition-all duration-200"><span class="icon">+</span></button>"`;
exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" draggable="false" class="hover:bg-gray-100 dark:hover:bg-zinc-700 overflow-hidden w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`; exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" draggable="false" class="hover:bg-gray-100 dark:hover:bg-zinc-700 overflow-hidden rounded-r-full mr-2 w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 p-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`;
exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`; exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;

View file

@ -116,15 +116,60 @@ describe("kmlSanitize oracle", () => {
expect(out.stripped).toContain("remote_href"); 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 = `<?xml version="1.0" encoding="utf-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Placemark><name>Leaking Tanks</name>
<description><![CDATA[<h2>Hostile</h2></br><script>alert(1)</script>]]></description>
<Point><coordinates>-117.99,33.78,0</coordinates></Point>
</Placemark></Document></kml>`;
const out = sanitizeKmlText(kml);
expect(out.stripped).toContain("html_description");
expect(out.text).not.toMatch(/\]\]>/);
expect(out.text.toLowerCase()).not.toContain("<script");
expect(out.text.toLowerCase()).not.toContain("<h2");
const parsed = new DOMParser().parseFromString(out.text, "application/xml");
expect(parsed.getElementsByTagName("parsererror").length).toBe(0);
const features = readKmlToFeatures(kml, "EPSG:3857");
expect(features.length).toBe(1);
expect(String(features[0].get("name") || "")).toContain("Leaking");
});
it("skips unreferenced svg kmz entry and keeps placemarks", async () => {
const kml = `<?xml version="1.0"?> const kml = `<?xml version="1.0"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document> <kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark> <Placemark><name>Keep</name><Point><coordinates>1,2,0</coordinates></Point></Placemark>
</Document></kml>`; </Document></kml>`;
const zip = new JSZip(); const zip = new JSZip();
zip.file("doc.kml", kml); zip.file("doc.kml", kml);
zip.file("icon.svg", "<svg></svg>"); zip.file("icon.svg", "<svg></svg>");
const buf = await zip.generateAsync({ type: "arraybuffer" }); 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 = `<?xml version="1.0"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Style id="s"><IconStyle><Icon><href>Layer0_Symbol.png</href></Icon></IconStyle></Style>
<Placemark><name>ArcGIS Point</name>
<styleUrl>#s</styleUrl>
<Point><coordinates>1,2,0</coordinates></Point>
</Placemark></Document></kml>`;
const zip = new JSZip();
zip.file("doc.kml", kml);
zip.file(
"F2E8A9CB2E0A446C9BCA87742DD683E5.xsl",
"<?xml version='1.0'?><xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform' version='1.0'/>"
);
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");
}); });
}); });

View file

@ -75,16 +75,37 @@ def is_local_destination(destination_hash: bytes) -> bool:
return False 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( def wait_for_path(
destination_hash: bytes, destination_hash: bytes,
timeout: float = PATH_TIMEOUT_DEFAULT, timeout: float | None = None,
) -> bool: ) -> bool:
wait_s = adaptive_path_timeout(destination_hash, timeout)
if RNS.Transport.has_path(destination_hash) or is_local_destination( if RNS.Transport.has_path(destination_hash) or is_local_destination(
destination_hash, destination_hash,
): ):
return True return True
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
deadline = time.time() + timeout deadline = time.time() + wait_s
while time.time() < deadline: while time.time() < deadline:
if RNS.Transport.has_path(destination_hash) or is_local_destination( if RNS.Transport.has_path(destination_hash) or is_local_destination(
destination_hash, destination_hash,
@ -111,13 +132,19 @@ def establish_link(
*, *,
established_callback: Callable | None = None, established_callback: Callable | None = None,
closed_callback: Callable | None = None, closed_callback: Callable | None = None,
timeout: float = LINK_TIMEOUT_DEFAULT, timeout: float | None = None,
): ):
link = RNS.Link( link = RNS.Link(
destination, destination,
established_callback=established_callback, established_callback=established_callback,
closed_callback=closed_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 deadline = time.time() + timeout
while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED): while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED):
if time.time() > deadline: if time.time() > deadline:

View file

@ -18,7 +18,6 @@ from rns_filesync.constants import (
APP_NAME, APP_NAME,
ASPECT, ASPECT,
BLOCK_SIZE, BLOCK_SIZE,
LINK_TIMEOUT_DEFAULT,
PATH_TIMEOUT_DEFAULT, PATH_TIMEOUT_DEFAULT,
RECONNECT_BASE_INTERVAL, RECONNECT_BASE_INTERVAL,
RECONNECT_MAX_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.paths import PathJailError, normalize_relpath, resolve_under_root
from rns_filesync.peers import ( from rns_filesync.peers import (
adaptive_path_timeout,
create_outbound_destination, create_outbound_destination,
establish_link, establish_link,
hex_hash, hex_hash,
@ -276,7 +276,7 @@ class FileSyncService:
def connect_peer( def connect_peer(
self, self,
identity_hash: str | bytes, identity_hash: str | bytes,
timeout: float = PATH_TIMEOUT_DEFAULT, timeout: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Connect using identity hash (destination hash accepted as fallback).""" """Connect using identity hash (destination hash accepted as fallback)."""
peer_hash = parse_hash(identity_hash) peer_hash = parse_hash(identity_hash)
@ -291,8 +291,7 @@ class FileSyncService:
identity, how = resolve_peer_identity(peer_hash) identity, how = resolve_peer_identity(peer_hash)
if identity is None: if identity is None:
# Path request may populate known destinations for destination hashes. wait_for_path(peer_hash, timeout=timeout)
wait_for_path(peer_hash, timeout=min(timeout, 5.0))
identity, how = resolve_peer_identity(peer_hash) identity, how = resolve_peer_identity(peer_hash)
if identity is None: if identity is None:
msg = f"could not recall identity for {peer_hex}" msg = f"could not recall identity for {peer_hex}"
@ -309,7 +308,6 @@ class FileSyncService:
destination, destination,
established_callback=self._on_link_established, established_callback=self._on_link_established,
closed_callback=self._on_link_closed, closed_callback=self._on_link_closed,
timeout=LINK_TIMEOUT_DEFAULT,
) )
if link is None: if link is None:
msg = f"link failed for {peer_hex}" msg = f"link failed for {peer_hex}"
@ -1053,7 +1051,11 @@ class FileSyncService:
def job(target=peer_id): def job(target=peer_id):
try: 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: with self._lock:
if result.get("ok"): if result.get("ok"):
self._reconnect_backoff.pop(target, None) self._reconnect_backoff.pop(target, None)