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.
- 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.

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.
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

Binary file not shown.

View file

@ -14,6 +14,10 @@ import RNS
from meshchatx.src.backend.announce_handler import AnnounceHandler
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"
REPORT_PATH = "/report"
@ -440,7 +444,10 @@ class BugReportManager:
)
if not RNS.Transport.has_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:
if RNS.Transport.has_path(dest_hash):
break
@ -468,7 +475,11 @@ class BugReportManager:
response_event.set()
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:
link.teardown()
except Exception:

View file

@ -3,7 +3,6 @@
from __future__ import annotations
from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
GeoValidationError,
OutboundHttpBlockedError,
@ -131,6 +130,8 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
zipfile,
)
from meshchatx.src.backend.path_utils import path_response_window
# Same ceiling as RNProbeHandler.MAX_TIMEOUT_S
PATH_PROBE_MIN_TIMEOUT_S = 1
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 == "":
raw = default
if raw is None or raw == "":
return None, None
try:
timeout_seconds = int(raw)
except (TypeError, ValueError):
@ -157,7 +160,7 @@ def parse_path_probe_timeout(raw, *, default=15):
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 {}
if "timeout" in query:
return query.get("timeout")
@ -303,10 +306,16 @@ def register_path_probe_routes(routes, app):
)
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)
if timeout_error:
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):
return local_path_response(destination_hash_hex)
@ -483,13 +492,18 @@ def register_path_probe_routes(routes, app):
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)
if timeout_error:
return web.json_response(
{"message": f"Ping failed. {timeout_error}"},
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.
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()
destination_hash_str = data.get("destination_hash", "")
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))
try:
@ -496,7 +500,11 @@ def register_rn_tools_routes(routes, app):
data = await request.json()
destination_hash_str = data.get("destination_hash", "")
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")
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,
)
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
_log = logging.getLogger("meshchatx.map_data")
@ -545,6 +546,13 @@ class MapDataManager:
if link_manager is None:
raise MapDataError("link_unavailable")
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(
self.config.map_overlay_transfer_timeout_seconds.get() or 120
)
@ -553,7 +561,6 @@ class MapDataManager:
destination_hash,
MAP_ASPECT,
path_lookup_timeout=float(path_timeout),
link_establishment_timeout=float(path_timeout),
)
if link is None:
code = (

View file

@ -5,6 +5,7 @@
from __future__ import annotations
import json
import re
import zipfile
from dataclasses import dataclass, field
from io import BytesIO
@ -114,6 +115,14 @@ def _set_href_on_element(el: ET.Element, value: str | None) -> 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()

View file

@ -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,
)

View file

@ -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 (

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

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

View file

@ -10,9 +10,9 @@
isActive
? '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',
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)"
>
<span class="my-auto shrink-0">

View file

@ -7,7 +7,7 @@
data-testid="sidebar-account-chip"
@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>
<LxmfUserIcon
:icon-name="config.lxmf_user_icon_name"

View file

@ -4,10 +4,11 @@
<div v-if="config">
<div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950">
<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"
>
<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>
<LxmfUserIcon
: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="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"
@click="isShowingAnnounceSection = !isShowingAnnounceSection"
>
<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')"
data-testid="sidebar-announce-radio"
@click.stop="$emit('send-announce')"

View file

@ -2,12 +2,13 @@
<template>
<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
v-for="item in navItems"
:key="item.id"
class="flex items-center"
:class="[
isCollapsed ? 'justify-center' : '',
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
isEditing && dragOverKey === `item:${item.id}`
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'

View file

@ -48,7 +48,8 @@
</template>
</div>
<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)"
@drop.prevent="onGroupListDrop(group.id)"
>
@ -57,6 +58,7 @@
:key="item.id"
class="flex items-center"
:class="[
isCollapsed ? 'justify-center' : '',
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
isEditing && dragOverKey === `item:${item.id}`
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'
@ -145,8 +147,8 @@
>
<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="isCollapsed ? 'justify-center' : ''"
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 px-0' : 'px-4'"
data-testid="sidebar-more-toggle"
@pointerdown="onNavHoldPointerDown"
@pointermove="onNavHoldPointerMove"

View file

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

View file

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

View file

@ -200,7 +200,8 @@ export async function readKmzToFeatures(arrayBuffer, featureProjection) {
throw new KmlSanitizeError("path_traversal");
}
if (!kmzEntryAllowed(name)) {
throw new KmlSanitizeError("unsafe_kmz_entry");
// ArcGIS KMZ exports include unused .xsl balloon stylesheets.
continue;
}
}
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
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 xmlns="http://www.opengis.net/kml/2.2"><Document>
<Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark>
</Document></kml>"""
data = _kmz({"doc.kml": kml, "icon.svg": b"<svg></svg>"})
with pytest.raises(GeoValidationError) as exc:
sanitize_geo_bytes(data)
assert exc.value.code == "unsafe_kmz_entry"
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("\\", "/").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():

View file

@ -6,8 +6,11 @@ import pytest
import RNS
from meshchatx.src.backend.path_utils import (
LINK_ESTABLISHMENT_MARGIN_S,
MIN_WINDOW_BITRATE,
PATH_EXCHANGE_BYTES,
link_establishment_window,
min_window_bitrate,
path_response_window,
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
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():
reticulum = MagicMock()
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)
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():
reticulum = MagicMock()
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)
assert window >= 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)
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()
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():
with patch.object(RNS.Transport, "PATH_REQUEST_TIMEOUT", "x"):
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
@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
async def test_cancel_after_path_found_before_dialling_stabilizes(telephone_manager):
destination_hash = bytes.fromhex("ee" * 16)

View file

@ -85,6 +85,19 @@ describe("AppSidebarNav edit hold", () => {
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 () => {
vi.useFakeTimers();
const wrapper = mountGrouped({ isCollapsed: true });
@ -131,6 +144,27 @@ describe("AppSidebarNav edit hold", () => {
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 () => {
vi.useFakeTimers();
const wrapper = mount(AppSidebarClassicNav, {

View file

@ -57,6 +57,17 @@ describe("SidebarLink UI", () => {
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 () => {
const wrapper = mountSidebarLink({ editMode: true });
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 > 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>"`;

View file

@ -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 = `<?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"?>
<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>`;
const zip = new JSZip();
zip.file("doc.kml", kml);
zip.file("icon.svg", "<svg></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 = `<?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
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:

View file

@ -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)