mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-28 15:44:14 +00:00
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:
+47
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 Форвардер",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user