feat(nomadnet): implement caching for active links and better download phase tracking in NomadnetDownloader

Also added stats for page load time and size
This commit is contained in:
Ivan
2026-04-07 15:37:03 -05:00
parent 9c156fe381
commit c3aa68adb1
9 changed files with 437 additions and 84 deletions
+47 -10
View File
@@ -96,7 +96,7 @@ from meshchatx.src.backend.meshchat_utils import (
from meshchatx.src.backend.nomadnet_downloader import (
NomadnetFileDownloader,
NomadnetPageDownloader,
nomadnet_cached_links,
get_cached_active_link,
)
from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_field_data_to_map,
@@ -8140,15 +8140,14 @@ class ReticulumMeshChat:
destination_hash = bytes.fromhex(destination_hash)
# identify to existing active link
if destination_hash in nomadnet_cached_links:
link = nomadnet_cached_links[destination_hash]
if link.status is RNS.Link.ACTIVE:
link.identify(self.identity)
return web.json_response(
{
"message": "Identity has been sent!",
},
)
link = get_cached_active_link(destination_hash)
if link is not None:
link.identify(self.identity)
return web.json_response(
{
"message": "Identity has been sent!",
},
)
# failed to identify
return web.json_response(
@@ -10623,6 +10622,24 @@ class ReticulumMeshChat:
),
)
def on_file_download_phase(phase: str):
AsyncUtils.run_async(
client.send_str(
json.dumps(
{
"type": "nomadnet.file.download",
"download_id": download_id,
"nomadnet_file_download": {
"status": "phase",
"load_phase": phase,
"destination_hash": destination_hash.hex(),
"file_path": file_path,
},
},
),
),
)
# download the file
downloader = NomadnetFileDownloader(
destination_hash,
@@ -10630,6 +10647,7 @@ class ReticulumMeshChat:
on_file_download_success,
on_file_download_failure,
on_file_download_progress,
on_phase=on_file_download_phase,
)
downloader.start_time = time.time()
self.active_downloads[download_id] = downloader
@@ -10797,6 +10815,24 @@ class ReticulumMeshChat:
),
)
def on_page_download_phase(phase: str):
AsyncUtils.run_async(
client.send_str(
json.dumps(
{
"type": "nomadnet.page.download",
"download_id": download_id,
"nomadnet_page_download": {
"status": "phase",
"load_phase": phase,
"destination_hash": destination_hash.hex(),
"page_path": page_path,
},
},
),
),
)
# download the page
downloader = NomadnetPageDownloader(
destination_hash,
@@ -10805,6 +10841,7 @@ class ReticulumMeshChat:
on_page_download_success,
on_page_download_failure,
on_page_download_progress,
on_phase=on_page_download_phase,
)
self.active_downloads[download_id] = downloader
+110 -61
View File
@@ -1,13 +1,52 @@
import asyncio
import io
import os
import threading
import time
from collections.abc import Callable
import RNS
# global cache for nomadnet links to avoid re-establishing them for every request
nomadnet_cached_links = {}
# Global cache for Nomad Network links (reuse instead of reconnecting per request).
# Protected by _nomadnet_links_lock for callers that may touch Reticulum from multiple threads.
nomadnet_cached_links: dict[bytes, object] = {}
_nomadnet_links_lock = threading.Lock()
# Wait granularity while polling for path / link (seconds). Smaller = faster reaction, slightly more wakeups.
_POLL_INTERVAL_S = 0.02
def get_cached_active_link(destination_hash: bytes):
"""Return a cached link if present and ACTIVE; drop stale entries."""
with _nomadnet_links_lock:
link = nomadnet_cached_links.get(destination_hash)
if link is None:
return None
if link.status is RNS.Link.ACTIVE:
return link
try:
del nomadnet_cached_links[destination_hash]
except KeyError:
pass
return None
def _cache_link_if_active(destination_hash: bytes, link) -> None:
if link is None or link.status is not RNS.Link.ACTIVE:
return
with _nomadnet_links_lock:
nomadnet_cached_links[destination_hash] = link
def _uncache_link_if_matches(destination_hash: bytes, link) -> None:
if link is None:
return
with _nomadnet_links_lock:
if nomadnet_cached_links.get(destination_hash) is link:
try:
del nomadnet_cached_links[destination_hash]
except KeyError:
pass
class NomadnetDownloader:
@@ -20,6 +59,8 @@ class NomadnetDownloader:
on_download_failure: Callable[[str], None],
on_progress_update: Callable[[float], None],
timeout: int | None = None,
*,
on_phase: Callable[[str], None] | None = None,
):
self.app_name = "nomadnetwork"
self.aspects = "node"
@@ -30,77 +71,83 @@ class NomadnetDownloader:
self._download_success_callback = on_download_success
self._download_failure_callback = on_download_failure
self.on_progress_update = on_progress_update
self._on_phase = on_phase
self.request_receipt = None
self.is_cancelled = False
self.link = None
# cancel the download
def _emit_phase(self, phase: str) -> None:
if self._on_phase is None:
return
try:
self._on_phase(phase)
except Exception:
pass
def cancel(self):
self.is_cancelled = True
# cancel the request if it exists
if self.request_receipt is not None:
try:
self.request_receipt.cancel()
except Exception as e:
print(f"Failed to cancel request: {e}")
# clean up the link if we created it
if self.link is not None:
_uncache_link_if_matches(self.destination_hash, self.link)
try:
self.link.teardown()
except Exception as e:
print(f"Failed to teardown link: {e}")
# notify that download was cancelled
self._download_failure_callback("cancelled")
# setup link to destination and request download
async def download(
self,
path_lookup_timeout: int = 15,
link_establishment_timeout: int = 15,
):
# check if cancelled before starting
if self.is_cancelled:
return
# use existing established link if it's active
if self.destination_hash in nomadnet_cached_links:
link = nomadnet_cached_links[self.destination_hash]
if link.status is RNS.Link.ACTIVE:
print("[NomadnetDownloader] using existing link for request")
self.link_established(link)
return
cached = get_cached_active_link(self.destination_hash)
if cached is not None:
print("[NomadnetDownloader] using existing link for request")
self._emit_phase("requesting_page")
self.link = cached
self.link_established(cached)
return
# determine when to timeout
timeout_after_seconds = time.time() + path_lookup_timeout
# check if we have a path to the destination
if not RNS.Transport.has_path(self.destination_hash):
# we don't have a path, so we need to request it
self._emit_phase("finding_path")
RNS.Transport.request_path(self.destination_hash)
# wait until we have a path, or give up after the configured timeout
while (
not RNS.Transport.has_path(self.destination_hash)
and time.time() < timeout_after_seconds
):
# check if cancelled during path lookup
if self.is_cancelled:
return
await asyncio.sleep(0.1)
await asyncio.sleep(_POLL_INTERVAL_S)
# if we still don't have a path, we can't establish a link, so bail out
if not RNS.Transport.has_path(self.destination_hash):
self._download_failure_callback("Could not find path to destination.")
return
# check if cancelled before establishing link
cached = get_cached_active_link(self.destination_hash)
if cached is not None:
print("[NomadnetDownloader] using link cached while waiting for path")
self._emit_phase("requesting_page")
self.link = cached
self.link_established(cached)
return
if self.is_cancelled:
return
# create destination to nomadnet node
self._emit_phase("establishing_link")
identity = RNS.Identity.recall(self.destination_hash)
destination = RNS.Destination(
identity,
@@ -110,37 +157,36 @@ class NomadnetDownloader:
self.aspects,
)
# create link to destination
cached = get_cached_active_link(self.destination_hash)
if cached is not None:
print("[NomadnetDownloader] using link cached before establishing new link")
self._emit_phase("requesting_page")
self.link = cached
self.link_established(cached)
return
print("[NomadnetDownloader] establishing new link for request")
link = RNS.Link(destination, established_callback=self.link_established)
self.link = link
# determine when to timeout
timeout_after_seconds = time.time() + link_establishment_timeout
# wait until we have established a link, or give up after the configured timeout
while (
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
):
# check if cancelled during link establishment
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
if self.is_cancelled:
return
await asyncio.sleep(0.1)
await asyncio.sleep(_POLL_INTERVAL_S)
# if we still haven't established a link, bail out
if link.status is not RNS.Link.ACTIVE:
self._download_failure_callback("Could not establish link to destination.")
# link to destination was established, we should now request the download
def link_established(self, link):
# check if cancelled before requesting
if self.is_cancelled:
return
# cache link for using in future requests
nomadnet_cached_links[self.destination_hash] = link
self._emit_phase("transferring")
_cache_link_if_active(self.destination_hash, link)
# request download over link
self.request_receipt = link.request(
self.path,
data=self.data,
@@ -150,15 +196,12 @@ class NomadnetDownloader:
timeout=self.timeout,
)
# handle successful download
def on_response(self, request_receipt: RNS.RequestReceipt):
self._download_success_callback(request_receipt)
# handle failure
def on_failed(self, request_receipt=None):
self._download_failure_callback("request_failed")
# handle download progress
def on_progress(self, request_receipt):
self.on_progress_update(request_receipt.progress)
@@ -173,6 +216,8 @@ class NomadnetPageDownloader(NomadnetDownloader):
on_page_download_failure: Callable[[str], None],
on_progress_update: Callable[[float], None],
timeout: int | None = None,
*,
on_phase: Callable[[str], None] | None = None,
):
self.on_page_download_success = on_page_download_success
self.on_page_download_failure = on_page_download_failure
@@ -184,14 +229,21 @@ class NomadnetPageDownloader(NomadnetDownloader):
self.on_download_failure,
on_progress_update,
timeout,
on_phase=on_phase,
)
# page download was successful, decode the response and send to provided callback
def on_download_success(self, request_receipt: RNS.RequestReceipt):
micron_markup_response = request_receipt.response.decode("utf-8")
raw = request_receipt.response
if raw is None:
self.on_page_download_failure("empty_response")
return
try:
micron_markup_response = raw.decode("utf-8", errors="replace")
except (AttributeError, TypeError):
self.on_page_download_failure("invalid_response_body")
return
self.on_page_download_success(micron_markup_response)
# page download failed, send error to provided callback
def on_download_failure(self, failure_reason):
self.on_page_download_failure(failure_reason)
@@ -205,6 +257,8 @@ class NomadnetFileDownloader(NomadnetDownloader):
on_file_download_failure: Callable[[str], None],
on_progress_update: Callable[[float], None],
timeout: int | None = None,
*,
on_phase: Callable[[str], None] | None = None,
):
self.on_file_download_success = on_file_download_success
self.on_file_download_failure = on_file_download_failure
@@ -216,46 +270,42 @@ class NomadnetFileDownloader(NomadnetDownloader):
self.on_download_failure,
on_progress_update,
timeout,
on_phase=on_phase,
)
# file download was successful, decode the response and send to provided callback
def on_download_success(self, request_receipt: RNS.RequestReceipt):
# get response
response = request_receipt.response
# handle buffered reader response
if isinstance(response, io.BufferedReader):
# get file name from metadata
file_name = "downloaded_file"
metadata = request_receipt.metadata
if metadata is not None and "name" in metadata:
file_path = metadata["name"].decode("utf-8")
file_name = os.path.basename(file_path)
try:
file_path = metadata["name"].decode("utf-8", errors="replace")
file_name = os.path.basename(file_path)
except (AttributeError, TypeError):
pass
# get file data
file_data: bytes = response.read()
self.on_file_download_success(file_name, file_data)
return
# check for list response with bytes in position 0, and metadata dict in position 1
# e.g: [file_bytes, {name: "filename.ext"}]
if isinstance(response, list) and isinstance(response[1], dict):
if isinstance(response, list) and len(response) > 1 and isinstance(response[1], dict):
file_data: bytes = response[0]
metadata: dict = response[1]
# get file name from metadata
file_name = "downloaded_file"
if metadata is not None and "name" in metadata:
file_path = metadata["name"].decode("utf-8")
file_name = os.path.basename(file_path)
try:
file_path = metadata["name"].decode("utf-8", errors="replace")
file_name = os.path.basename(file_path)
except (AttributeError, TypeError):
pass
self.on_file_download_success(file_name, file_data)
return
# try using original response format
# unsure if this is actually used anymore now that a buffered reader is provided
# have left here just in case...
try:
file_name: str = response[0]
file_data: bytes = response[1]
@@ -263,6 +313,5 @@ class NomadnetFileDownloader(NomadnetDownloader):
except Exception:
self.on_download_failure("unsupported_response")
# page download failed, send error to provided callback
def on_download_failure(self, failure_reason):
self.on_file_download_failure(failure_reason)
@@ -79,8 +79,14 @@
@click="onDestinationPathClick(selectedNodePath)"
>
- {{ selectedNodePath.hops }}
{{ selectedNodePath.hops === 1 ? $t("app.hop") : $t("app.hops_plural") }} away</span
>
{{ selectedNodePath.hops === 1 ? $t("app.hop") : $t("app.hops_plural") }}
{{ $t("nomadnet.path_away_suffix") }}
<template v-if="navbarPageStats">
<span class="text-gray-500 dark:text-gray-400 font-normal">
- {{ navbarPageStats.duration }} - {{ navbarPageStats.sizeLabel }}
</span>
</template>
</span>
</div>
<!-- identify button -->
@@ -249,7 +255,7 @@
></path>
</svg>
</div>
<div class="my-auto flex-1">Loading {{ nodePageProgress }}%</div>
<div class="my-auto flex-1">{{ nomadnetPageLoadingLine }}</div>
<button
type="button"
class="my-auto text-white bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 rounded px-3 py-1 text-sm font-semibold cursor-pointer ml-3"
@@ -397,6 +403,10 @@ export default {
nodePagePathUrlInput: null,
nodePageContent: null,
nodePageProgress: 0,
nodePageLoadPhase: null,
pageLoadStartedAt: null,
lastPageLoadDurationMs: null,
lastPageContentBytes: null,
nodePagePathHistory: [],
nodePageCache: {},
currentPageDownloadId: null,
@@ -440,6 +450,32 @@ export default {
isPopoutMode() {
return this.popoutRouteType === "nomad";
},
navbarPageStats() {
if (
this.lastPageLoadDurationMs == null ||
this.lastPageContentBytes == null ||
!this.selectedNodePath
) {
return null;
}
return {
duration: this.formatShortDuration(this.lastPageLoadDurationMs),
sizeLabel: Utils.formatBytes(this.lastPageContentBytes),
};
},
nomadnetPageLoadingLine() {
const phase = this.nodePageLoadPhase || "finding_path";
const key = `nomadnet.load_phase_${phase}`;
const translated = this.$t(key);
const base =
typeof translated === "string" && translated !== key
? translated
: this.$t("nomadnet.load_phase_default");
if (this.nodePageProgress > 0 && (phase === "transferring" || phase === "requesting_page")) {
return `${base} (${this.nodePageProgress}%)`;
}
return base;
},
},
watch: {
selectedNode: {
@@ -578,7 +614,14 @@ export default {
this.nodePageContent = nomadnetPageDownload.page_content;
this.nodePageProgress = 100;
this.isLoadingNodePage = false;
this.nodePageLoadPhase = null;
this.currentPageDownloadId = null;
{
const pc = nomadnetPageDownload.page_content || "";
this.lastPageLoadDurationMs =
this.pageLoadStartedAt != null ? Date.now() - this.pageLoadStartedAt : 0;
this.lastPageContentBytes = new TextEncoder().encode(pc).length;
}
this.fetchArchives();
return;
}
@@ -598,6 +641,18 @@ export default {
// handle started status
if (nomadnetPageDownload.status === "started") {
this.currentPageDownloadId = downloadId;
this.nodePageLoadPhase = "finding_path";
return;
}
if (nomadnetPageDownload.status === "phase") {
if (this.currentPageDownloadId !== downloadId) {
return;
}
if (this.nodePagePath && responsePagePath !== this.nodePagePath) {
return;
}
this.nodePageLoadPhase = nomadnetPageDownload.load_phase || "finding_path";
return;
}
@@ -903,6 +958,10 @@ export default {
this.nodePageContent = null;
this.pageArchives = [];
this.nodePageProgress = 0;
this.nodePageLoadPhase = "finding_path";
this.pageLoadStartedAt = Date.now();
this.lastPageLoadDurationMs = null;
this.lastPageContentBytes = null;
this.clearPartials();
// update url bar
@@ -926,6 +985,9 @@ export default {
if (cachedNodePageContent != null) {
this.nodePageContent = cachedNodePageContent;
this.isLoadingNodePage = false;
this.nodePageLoadPhase = null;
this.lastPageLoadDurationMs = 0;
this.lastPageContentBytes = new TextEncoder().encode(cachedNodePageContent).length;
this.fetchArchives();
return;
}
@@ -950,6 +1012,11 @@ export default {
// update status
this.isLoadingNodePage = false;
this.nodePageLoadPhase = null;
if (this.pageLoadStartedAt != null) {
this.lastPageLoadDurationMs = Date.now() - this.pageLoadStartedAt;
}
this.lastPageContentBytes = new TextEncoder().encode(pageContent).length;
// update node path
this.getNodePath(destinationHash);
@@ -966,6 +1033,9 @@ export default {
// update page content
this.nodePageContent = `Failed loading page: ${failureReason}`;
this.isLoadingNodePage = false;
this.nodePageLoadPhase = null;
this.lastPageLoadDurationMs = null;
this.lastPageContentBytes = null;
// update node path
this.getNodePath(destinationHash);
@@ -1468,6 +1538,8 @@ export default {
this.isShowingArchivedVersion = false;
this.archivedAt = null;
this.nodePageProgress = 0;
this.pageLoadStartedAt = Date.now();
this.nodePageLoadPhase = "finding_path";
const archive = this.pageArchives.find((a) => a.id === archiveId);
if (archive) {
@@ -1505,6 +1577,21 @@ export default {
if (isNaN(date.getTime())) return "Invalid Date";
return date.toLocaleString();
},
formatShortDuration(ms) {
if (ms == null || ms < 0) {
return "";
}
if (ms < 1000) {
return `${Math.round(ms)} ms`;
}
const s = ms / 1000;
if (s < 60) {
return s < 10 ? `${s.toFixed(1)} s` : `${Math.round(s)} s`;
}
const m = Math.floor(s / 60);
const rs = Math.round(s - m * 60);
return `${m}m ${rs}s`;
},
async getNodePath(destinationHash) {
// clear previous known path
this.selectedNodePath = null;
+7 -1
View File
@@ -797,6 +797,7 @@
"announced_time_ago": "Vor {time} angekündigt",
"block_node": "Knoten blockieren",
"no_announces_yet": "Noch keine Ankündigungen",
"no_search_results_peers": "Keine Peers gefunden, die zu Ihrer Suche passen.",
"listening_for_peers": "Höre auf Peers im Mesh.",
"block_node_confirm": "Sind Sie sicher, dass Sie {name} blockieren möchten? Seine Ankündigungen werden ignoriert und er erscheint nicht mehr im Ankündigungs-Stream.",
"node_blocked_successfully": "Knoten erfolgreich blockiert",
@@ -816,7 +817,12 @@
"rename_section": "Rename section",
"delete_section_confirm": "Delete this section? Favourites will move to the main section.",
"unsupported_url": "unsupported url: ",
"no_search_results_peers": "No peers found matching your search."
"path_away_suffix": "entfernt",
"load_phase_default": "Seite wird geladen",
"load_phase_finding_path": "Netzwerkpfad zum Knoten wird gesucht",
"load_phase_establishing_link": "Verbindung zum Knoten wird aufgebaut",
"load_phase_requesting_page": "Seitenanfrage wird gesendet",
"load_phase_transferring": "Seite wird empfangen"
},
"forwarder": {
"title": "LXMF-Weiterleiter",
+7 -1
View File
@@ -873,7 +873,13 @@
"new_section": "New Section",
"rename_section": "Rename section",
"delete_section_confirm": "Delete this section? Favourites will move to the main section.",
"unsupported_url": "unsupported url: "
"unsupported_url": "unsupported url: ",
"path_away_suffix": "away",
"load_phase_default": "Loading page",
"load_phase_finding_path": "Looking for a network path to the node",
"load_phase_establishing_link": "Establishing link to the node",
"load_phase_requesting_page": "Sending page request",
"load_phase_transferring": "Receiving page"
},
"forwarder": {
"title": "LXMF Forwarder",
+7 -1
View File
@@ -919,7 +919,13 @@
"new_section": "Nuova Sezione",
"rename_section": "Rinomina sezione",
"delete_section_confirm": "Eliminare questa sezione? I preferiti verranno spostati nella sezione principale.",
"unsupported_url": "url non supportato: "
"unsupported_url": "url non supportato: ",
"path_away_suffix": "di distanza",
"load_phase_default": "Caricamento pagina",
"load_phase_finding_path": "Ricerca del percorso di rete verso il nodo",
"load_phase_establishing_link": "Connessione al nodo in corso",
"load_phase_requesting_page": "Invio richiesta pagina",
"load_phase_transferring": "Ricezione pagina"
},
"forwarder": {
"title": "Inoltro LXMF",
+7 -1
View File
@@ -797,6 +797,7 @@
"announced_time_ago": "Анонсировано {time} назад",
"block_node": "Заблокировать узел",
"no_announces_yet": "Анонсов пока нет",
"no_search_results_peers": "Нет узлов по вашему запросу.",
"listening_for_peers": "Ожидание узлов в сети.",
"block_node_confirm": "Вы уверены, что хотите заблокировать {name}? Его анонсы будут игнорироваться, и он не будет отображаться в списке.",
"node_blocked_successfully": "Узел успешно заблокирован",
@@ -816,7 +817,12 @@
"rename_section": "Rename section",
"delete_section_confirm": "Delete this section? Favourites will move to the main section.",
"unsupported_url": "unsupported url: ",
"no_search_results_peers": "No peers found matching your search."
"path_away_suffix": "от вас",
"load_phase_default": "Загрузка страницы",
"load_phase_finding_path": "Поиск сетевого пути к узлу",
"load_phase_establishing_link": "Установка соединения с узлом",
"load_phase_requesting_page": "Отправка запроса страницы",
"load_phase_transferring": "Приём страницы"
},
"forwarder": {
"title": "LXMF Форвардер",
+152 -6
View File
@@ -1,7 +1,26 @@
import threading
import pytest
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.nomadnet_downloader import NomadnetDownloader
import RNS
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.nomadnet_downloader import (
NomadnetDownloader,
NomadnetFileDownloader,
NomadnetPageDownloader,
get_cached_active_link,
nomadnet_cached_links,
_nomadnet_links_lock,
)
@pytest.fixture(autouse=True)
def clear_nomadnet_link_cache():
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
yield
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
@pytest.fixture
@@ -26,6 +45,34 @@ def test_downloader_cancel(downloader):
downloader._download_failure_callback.assert_called_with("cancelled")
def test_cancel_removes_link_from_cache():
mock_link = MagicMock()
mock_link.status = RNS.Link.ACTIVE
with _nomadnet_links_lock:
nomadnet_cached_links[b"x"] = mock_link
on_success = MagicMock()
on_failure = MagicMock()
on_progress = MagicMock()
d = NomadnetDownloader(b"x", "/p", None, on_success, on_failure, on_progress)
d.link = mock_link
d.cancel()
assert get_cached_active_link(b"x") is None
mock_link.teardown.assert_called_once()
def test_get_cached_active_link_evicts_stale():
dead = MagicMock()
dead.status = None
with _nomadnet_links_lock:
nomadnet_cached_links[b"z"] = dead
assert get_cached_active_link(b"z") is None
with _nomadnet_links_lock:
assert b"z" not in nomadnet_cached_links
@pytest.mark.asyncio
async def test_download_no_path(downloader):
with (
@@ -42,12 +89,111 @@ async def test_download_no_path(downloader):
async def test_download_cached_link(downloader):
mock_link = MagicMock()
mock_link.status = RNS.Link.ACTIVE
from meshchatx.src.backend.nomadnet_downloader import nomadnet_cached_links
nomadnet_cached_links[b"dest"] = mock_link
with _nomadnet_links_lock:
nomadnet_cached_links[b"dest"] = mock_link
with patch.object(downloader, "link_established") as mock_established:
await downloader.download()
mock_established.assert_called_with(mock_link)
del nomadnet_cached_links[b"dest"]
@pytest.mark.asyncio
async def test_download_uses_path_wait_cache_hit(downloader):
"""Another task may populate the cache while we wait for a path."""
mock_link = MagicMock()
mock_link.status = RNS.Link.ACTIVE
call_count = {"n": 0}
def has_path_side_effect(dest):
call_count["n"] += 1
if call_count["n"] == 1:
return False
if call_count["n"] == 2:
with _nomadnet_links_lock:
nomadnet_cached_links[dest] = mock_link
return True
with (
patch.object(RNS.Transport, "has_path", side_effect=has_path_side_effect),
patch.object(RNS.Transport, "request_path"),
):
with patch.object(downloader, "link_established") as mock_established:
await downloader.download(path_lookup_timeout=5, link_establishment_timeout=5)
mock_established.assert_called_once_with(mock_link)
def test_page_downloader_invalid_utf8_replaced():
on_ok = MagicMock()
on_fail = MagicMock()
on_progress = MagicMock()
pd = NomadnetPageDownloader(
b"ab" * 8,
"/page.mu",
None,
on_ok,
on_fail,
on_progress,
)
rr = MagicMock()
rr.response = b"hello\xff\xfeinvalid"
pd.on_download_success(rr)
on_ok.assert_called_once()
assert "\ufffd" in on_ok.call_args[0][0]
on_fail.assert_not_called()
def test_page_downloader_empty_response():
on_ok = MagicMock()
on_fail = MagicMock()
pd = NomadnetPageDownloader(
b"ab" * 8,
"/page.mu",
None,
on_ok,
on_fail,
MagicMock(),
)
rr = MagicMock()
rr.response = None
pd.on_download_success(rr)
on_fail.assert_called_once_with("empty_response")
on_ok.assert_not_called()
def test_file_downloader_list_response_short_list_no_crash():
on_ok = MagicMock()
on_fail = MagicMock()
fd = NomadnetFileDownloader(
b"ab" * 8,
"/f.bin",
on_ok,
on_fail,
MagicMock(),
)
rr = MagicMock()
rr.response = [b"only"]
fd.on_download_success(rr)
on_fail.assert_called_once_with("unsupported_response")
def test_cache_lock_serializes_mutations():
mock_link = MagicMock()
mock_link.status = RNS.Link.ACTIVE
errors = []
def worker():
try:
with _nomadnet_links_lock:
nomadnet_cached_links[b"t"] = mock_link
assert nomadnet_cached_links[b"t"] is mock_link
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
+10
View File
@@ -138,4 +138,14 @@ describe("NomadNetworkPage.vue", () => {
expect(html).toContain('data-dest="' + dest + '"');
});
});
describe("page load stats", () => {
it("formatShortDuration formats ms and seconds", () => {
const wrapper = mountNomadNetworkPage();
expect(wrapper.vm.formatShortDuration(0)).toBe("0 ms");
expect(wrapper.vm.formatShortDuration(500)).toBe("500 ms");
expect(wrapper.vm.formatShortDuration(1500)).toMatch(/1\.5 s/);
expect(wrapper.vm.formatShortDuration(120000)).toMatch(/2m/);
});
});
});