From 40307bd8cb2e71032764236b80df0adca8bd1c00 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 30 Jul 2026 13:16:16 -0700 Subject: [PATCH] feat(mesh): enhance mesh graph performance and caching - Implemented caching for multi-byte mesh aggregation, allowing concurrent requests to share computation and reducing CPU load. - Updated the web viewer to coalesce live edge events and refresh the mesh graph every 30 seconds, improving responsiveness on busy networks. - Adjusted logging behavior in the web viewer to respect the configured log level, ensuring efficient logging without excessive duplicate entries. - Added configuration options for mesh graph caching duration, enhancing user control over performance settings. --- CHANGELOG.md | 6 + config.ini.example | 5 + docs/path-command-config.md | 6 + modules/config_schema.py | 1 + modules/web_viewer/app.py | 162 ++++++- modules/web_viewer/templates/mesh.html | 378 ++++++++++++--- tests/test_web_viewer.py | 66 ++- tests/test_web_viewer_app.py | 639 +++++++++++++++++++++++++ 8 files changed, 1184 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f384ce3..f20db05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,12 @@ semantic versioning. lock on SD-card installations. - Linux service installers now use 1GB memory and 200% CPU ceilings, providing Raspberry Pi graph and web-viewer workloads with practical headroom. +- The mesh map now coalesces live edge events, serializes graph reloads, pauses + hidden-tab refreshes, and caches concurrent multi-byte aggregation requests, + preventing an open graph page from creating a CPU and SQLite I/O request + storm on busy meshes. +- The web-viewer rotating-file handler now honors `[Logging] log_level`, while + its journal handler retains an INFO floor to avoid duplicate debug writes. ### Removed diff --git a/config.ini.example b/config.ini.example index 17b985a..9682d34 100644 --- a/config.ini.example +++ b/config.ini.example @@ -1614,6 +1614,11 @@ auto_start = false # true: expose page/nav/API endpoint multibyte_monitor_enabled = false +# Cache the expensive lifetime multi-byte mesh aggregation for this many seconds. +# Concurrent graph requests share one computation. The mesh page also coalesces +# live edge events into one refresh every 30 seconds. +mesh_graph_cache_seconds = 30 + # Dashboard snapshot refresher # A background thread in the viewer process recomputes the landing page's # statistics on an interval and stores them in two tables (daily_rollup and diff --git a/docs/path-command-config.md b/docs/path-command-config.md index 8744306..0d752d0 100644 --- a/docs/path-command-config.md +++ b/docs/path-command-config.md @@ -296,6 +296,12 @@ The installed systemd service allows up to 1GB of memory and 200% CPU (two cores). These are upper limits rather than reserved resources. A USB SSD is still the most effective way to reduce SD-card wear on high-volume nodes. +The web viewer caches the lifetime multi-byte edge aggregate for 30 seconds and +coalesces live mesh events into one refresh per 30 seconds. On unusually large +meshes, increase `[Web_Viewer] mesh_graph_cache_seconds` to 60. Keep +`[Logging] log_level = INFO` during normal operation; `DEBUG` emits multiple +records per observed edge and can create substantial SD-card write traffic. + ## Preset Configurations ### `balanced` (Default) diff --git a/modules/config_schema.py b/modules/config_schema.py index 6684d11..5914b02 100644 --- a/modules/config_schema.py +++ b/modules/config_schema.py @@ -201,6 +201,7 @@ SECTIONS: dict[str, SectionMeta] = { "sqlite_busy_timeout_ms": KeyMeta(type="int", default="60000"), "sqlite_foreign_keys": KeyMeta(type="bool", default="true"), "sqlite_journal_mode": KeyMeta(default="WAL"), + "mesh_graph_cache_seconds": KeyMeta(type="int", default="30"), # Dashboard snapshot refresher — moves the landing page's aggregate # queries off the request path and accumulates trends that outlive the # raw tables' retention. diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 6ae543b..8d2cfc9 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -307,6 +307,28 @@ class BotDataViewer: self._contacts_badge_cache_lock = threading.Lock() self._contacts_badge_cache: dict[int | None, tuple[tuple, set[str]]] = {} + # The multi-byte mesh endpoint derives lifetime edge identity from every + # retained multi-byte observed path. Cache that expensive aggregate and + # ensure concurrent requests share one computation. View-specific day + # and observation filters remain cheap and are applied to the cached + # lifetime result. + try: + mesh_cache_seconds = self.config.getint( + 'Web_Viewer', + 'mesh_graph_cache_seconds', + fallback=30, + ) + except (configparser.Error, ValueError, TypeError): + mesh_cache_seconds = 30 + self._mesh_graph_cache_seconds = max(5, min(mesh_cache_seconds, 300)) + self._multibyte_graph_cache_condition = threading.Condition() + self._multibyte_graph_cache_edges: list[dict[str, Any]] | None = None + self._multibyte_graph_cache_created_at = 0.0 + self._multibyte_graph_cache_computing = False + self._multibyte_graph_cache_failure_at = 0.0 + self._multibyte_graph_cache_failure: tuple[str, str] | None = None + self._multibyte_graph_cache_retry_seconds = 5.0 + # Use [Bot] db_path when [Web_Viewer] db_path is unset bot_db = self.config.get('Bot', 'db_path', fallback='meshcore_bot.db') if (self.config.has_section('Web_Viewer') and self.config.has_option('Web_Viewer', 'db_path') @@ -396,9 +418,20 @@ class BotDataViewer: except (configparser.Error, ValueError, TypeError): pass + log_level_name = 'INFO' + if getattr(self, 'config', None) is not None and self.config.has_section('Logging'): + log_level_name = self.config.get( + 'Logging', + 'log_level', + fallback='INFO', + ).strip().upper() + log_level = getattr(logging, log_level_name, logging.INFO) + if not isinstance(log_level, int): + log_level = logging.INFO + # Get or create logger (don't use basicConfig as it may conflict with existing logging) self.logger = logging.getLogger('modern_web_viewer') - self.logger.setLevel(logging.DEBUG) + self.logger.setLevel(log_level) # Remove existing handlers to avoid duplicates self.logger.handlers.clear() @@ -407,7 +440,9 @@ class BotDataViewer: # Console handler (captured by journald under systemd) console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) + # Keep DEBUG out of journald even when explicitly enabled for the + # rotating diagnostic file, avoiding duplicate high-volume SD writes. + console_handler.setLevel(max(log_level, logging.INFO)) console_handler.setFormatter(formatter) self.logger.addHandler(console_handler) @@ -429,7 +464,7 @@ class BotDataViewer: backupCount=log_backup_count, encoding='utf-8', ) - file_handler.setLevel(logging.DEBUG) + file_handler.setLevel(log_level) file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) self.logger.info( @@ -609,10 +644,16 @@ class BotDataViewer: conn.close() def _derive_multibyte_evidence_edges( - self, days: int | None = None, min_observations: int | None = None + self, + days: int | None = None, + min_observations: int | None = None, + *, + force_refresh: bool = False, ) -> list[dict[str, Any]]: """Return lifetime-derived multi-byte edges filtered for the API view.""" - all_edges = self._aggregate_multibyte_evidence_edges() + all_edges = self._aggregate_multibyte_evidence_edges( + force_refresh=force_refresh + ) return self._filter_multibyte_evidence_edges( all_edges, days=days, @@ -620,10 +661,16 @@ class BotDataViewer: ) def _derive_multibyte_evidence_graph( - self, days: int | None = None, min_observations: int | None = None + self, + days: int | None = None, + min_observations: int | None = None, + *, + force_refresh: bool = False, ) -> tuple[list[dict[str, Any]], int]: """Return filtered edges plus the lifetime graph's prefix resolution.""" - all_edges = self._aggregate_multibyte_evidence_edges() + all_edges = self._aggregate_multibyte_evidence_edges( + force_refresh=force_refresh + ) prefix_hex_chars = max( (len(edge['from_prefix']) for edge in all_edges), default=2, @@ -675,7 +722,101 @@ class BotDataViewer: result.append(edge) return result - def _aggregate_multibyte_evidence_edges(self) -> list[dict[str, Any]]: + def _aggregate_multibyte_evidence_edges( + self, *, force_refresh: bool = False + ) -> list[dict[str, Any]]: + """Return a bounded-age, single-flight lifetime multi-byte aggregate.""" + def previous_failure(message: str) -> RuntimeError: + failure = self._multibyte_graph_cache_failure + if failure is None: + return RuntimeError(message) + failure_type, failure_message = failure + return RuntimeError( + f"{message}: {failure_type}: {failure_message}" + ) + + now = time.monotonic() + with self._multibyte_graph_cache_condition: + cached = self._multibyte_graph_cache_edges + cache_age = now - self._multibyte_graph_cache_created_at + failure_age = now - self._multibyte_graph_cache_failure_at + retry_suppressed = ( + self._multibyte_graph_cache_failure is not None + and failure_age < self._multibyte_graph_cache_retry_seconds + ) + if retry_suppressed: + if cached is not None and not force_refresh: + return cached + raise previous_failure( + "Multi-byte mesh aggregation retry suppressed after failure" + ) + if ( + not force_refresh + and cached is not None + and cache_age < self._mesh_graph_cache_seconds + ): + return cached + + if self._multibyte_graph_cache_computing: + # Prefer a slightly stale result to making concurrent clients + # duplicate the same expensive SQLite aggregation. + if cached is not None and not force_refresh: + return cached + while self._multibyte_graph_cache_computing: + self._multibyte_graph_cache_condition.wait() + cached = self._multibyte_graph_cache_edges + if self._multibyte_graph_cache_failure is not None: + if cached is not None and not force_refresh: + return cached + raise previous_failure( + "Concurrent multi-byte mesh aggregation failed" + ) + if cached is not None: + return cached + + self._multibyte_graph_cache_computing = True + stale = cached + + started_at = time.monotonic() + try: + computed = self._compute_multibyte_evidence_edges() + except Exception as exc: + self.logger.warning( + "Multi-byte mesh aggregation failed%s", + "; serving cached data" + if stale is not None and not force_refresh + else "", + exc_info=True, + ) + with self._multibyte_graph_cache_condition: + self._multibyte_graph_cache_failure = ( + type(exc).__name__, + str(exc), + ) + self._multibyte_graph_cache_failure_at = time.monotonic() + self._multibyte_graph_cache_computing = False + self._multibyte_graph_cache_condition.notify_all() + if stale is not None and not force_refresh: + return stale + raise + + elapsed = time.monotonic() - started_at + with self._multibyte_graph_cache_condition: + self._multibyte_graph_cache_edges = computed + self._multibyte_graph_cache_created_at = time.monotonic() + self._multibyte_graph_cache_failure = None + self._multibyte_graph_cache_failure_at = 0.0 + self._multibyte_graph_cache_computing = False + self._multibyte_graph_cache_condition.notify_all() + + self.logger.debug( + "Computed %d multi-byte mesh edges in %.3fs", + len(computed), + elapsed, + ) + return computed + + def _compute_multibyte_evidence_edges(self) -> list[dict[str, Any]]: """Derive mesh edges purely from multi-byte path evidence. Splits each observed_paths row with bytes_per_hop >= 2 into consecutive @@ -2573,10 +2714,13 @@ class BotDataViewer: min_distance = request.args.get('min_distance', type=float) max_distance = request.args.get('max_distance', type=float) evidence = request.args.get('evidence', 'all') + force_refresh = request.args.get('refresh') == '1' if evidence == 'multibyte': edges, prefix_hex_chars = self._derive_multibyte_evidence_graph( - days=days, min_observations=min_observations + days=days, + min_observations=min_observations, + force_refresh=force_refresh, ) return jsonify({ 'edges': edges, diff --git a/modules/web_viewer/templates/mesh.html b/modules/web_viewer/templates/mesh.html index dad7f56..0f94489 100644 --- a/modules/web_viewer/templates/mesh.html +++ b/modules/web_viewer/templates/mesh.html @@ -648,8 +648,20 @@ let isInitialMapLoad = true; // Track if this is the first time rendering the map with nodes let highlightedPath = null; // Currently highlighted path data let pathHighlightTimeout = null; // Debounce timer for path resolution + let meshLoadInFlight = null; + let pendingMeshLoad = null; + let statsLoadInFlight = null; + let meshLiveRefreshTimer = null; + let meshLiveRefreshPending = false; + let meshLiveRefreshNeedsNodes = false; + let meshLiveRefreshRunning = false; + let meshLiveRefreshRetryCount = 0; + let meshLiveRefreshActiveReloadsNodes = false; const PREFIX_HEX_CHARS = parseInt('{{ prefix_hex_chars|default(2) }}', 10); + const MESH_LIVE_REFRESH_MS = 30000; + const MESH_LIVE_REFRESH_RETRY_MS = 6000; + const MESH_LIVE_REFRESH_MAX_RETRIES = 2; // Graph prefix length from edges API (2, 4, or 6 hex chars); used for node prefix display and node fetch. let graphPrefixHexChars = PREFIX_HEX_CHARS; // Format prefix for display: add byte count when multi-byte (2 or 3 bytes) @@ -1437,26 +1449,82 @@ }); }); - // Load statistics - async function loadStats() { + function isValidMeshNode(node) { + return Boolean( + node + && typeof node === 'object' + && typeof node.public_key === 'string' + && node.public_key.length > 0 + && typeof node.prefix === 'string' + && node.prefix.length > 0 + && typeof node.name === 'string' + && Number.isFinite(node.latitude) + && Number.isFinite(node.longitude) + ); + } + + async function fetchMeshJson(url, requiredArrayKey = null) { + const response = await fetch(url); + let data; try { - const response = await fetch('/api/mesh/stats'); - const stats = await response.json(); - - document.getElementById('stat-node-count').textContent = stats.node_count || 0; - document.getElementById('stat-edge-count').textContent = stats.total_edges || 0; - if (stats.multibyte_edges !== undefined) { - document.getElementById('stat-edge-detail').textContent = - `${stats.multibyte_edges} multi-byte confirmed`; - } - document.getElementById('stat-avg-obs').textContent = stats.avg_observations || 0; - document.getElementById('stat-avg-dist').textContent = stats.avg_distance ? stats.avg_distance + ' km' : 'N/A'; - if (stats.bot_location) { - botLocation = stats.bot_location; // used to frame the initial map view - } + data = await response.json(); } catch (error) { - console.error('Error loading stats:', error); + throw new Error(`Invalid JSON response from ${url}`); } + if (!response.ok || !data || typeof data !== 'object' || data.error) { + const detail = data && data.error ? `: ${data.error}` : ''; + throw new Error(`Mesh request failed (${response.status})${detail}`); + } + if (requiredArrayKey && !Array.isArray(data[requiredArrayKey])) { + throw new Error( + `Mesh response from ${url} is missing ${requiredArrayKey}` + ); + } + if ( + requiredArrayKey === 'edges' + && !data.edges.every(edge => + edge + && typeof edge === 'object' + && typeof edge.from_prefix === 'string' + && typeof edge.to_prefix === 'string' + ) + ) { + throw new Error(`Mesh response from ${url} contains invalid edges`); + } + if ( + requiredArrayKey === 'nodes' + && !data.nodes.every(isValidMeshNode) + ) { + throw new Error(`Mesh response from ${url} contains invalid nodes`); + } + return data; + } + + // Load statistics + function loadStats() { + if (statsLoadInFlight) return statsLoadInFlight; + statsLoadInFlight = (async () => { + try { + const stats = await fetchMeshJson('/api/mesh/stats'); + + document.getElementById('stat-node-count').textContent = stats.node_count || 0; + document.getElementById('stat-edge-count').textContent = stats.total_edges || 0; + if (stats.multibyte_edges !== undefined) { + document.getElementById('stat-edge-detail').textContent = + `${stats.multibyte_edges} multi-byte confirmed`; + } + document.getElementById('stat-avg-obs').textContent = stats.avg_observations || 0; + document.getElementById('stat-avg-dist').textContent = stats.avg_distance ? stats.avg_distance + ' km' : 'N/A'; + if (stats.bot_location) { + botLocation = stats.bot_location; // used to frame the initial map view + } + } catch (error) { + console.error('Error loading stats:', error); + } + })().finally(() => { + statsLoadInFlight = null; + }); + return statsLoadInFlight; } // --- Min Observations slider --- @@ -1504,13 +1572,64 @@ saveFilters(); } - // Load nodes and edges - // Options may skip rendering or retain either already-loaded dataset. - // Used by socket handlers so graph view is not re-rendered on live updates (avoids chaotic re-stabilization). - async function loadData(options) { - const skipRender = options && options.skipRender === true; - const reloadEdges = !options || options.reloadEdges !== false; - const reloadNodes = !options || options.reloadNodes !== false; + function normalizeMeshLoadOptions(options) { + return { + skipRender: Boolean(options && options.skipRender === true), + reloadEdges: !options || options.reloadEdges !== false, + reloadNodes: !options || options.reloadNodes !== false, + forceRefresh: Boolean(options && options.forceRefresh === true) + }; + } + + function mergeMeshLoadOptions(existing, incoming) { + if (!existing) return incoming; + return { + // Render when any queued caller needs a render. + skipRender: existing.skipRender && incoming.skipRender, + reloadEdges: existing.reloadEdges || incoming.reloadEdges, + reloadNodes: existing.reloadNodes || incoming.reloadNodes, + forceRefresh: existing.forceRefresh || incoming.forceRefresh + }; + } + + // Load nodes and edges. Calls are serialized and requests that arrive while + // one is active are merged into one trailing load. + function loadData(options) { + pendingMeshLoad = mergeMeshLoadOptions( + pendingMeshLoad, + normalizeMeshLoadOptions(options) + ); + if (!meshLoadInFlight) { + meshLoadInFlight = drainMeshLoads().finally(() => { + meshLoadInFlight = null; + }); + } + return meshLoadInFlight; + } + + async function drainMeshLoads() { + let lastError = null; + while (pendingMeshLoad) { + const options = pendingMeshLoad; + pendingMeshLoad = null; + try { + await performMeshLoad(options); + lastError = null; + } catch (error) { + // Continue with work queued while the failed request was active. + lastError = error; + } + } + if (lastError) { + throw lastError; + } + } + + async function performMeshLoad(options) { + const skipRender = options.skipRender; + const reloadEdges = options.reloadEdges; + const reloadNodes = options.reloadNodes; + const forceRefresh = options.forceRefresh; try { const evidence = document.getElementById('filter-evidence').value; @@ -1519,6 +1638,7 @@ const edgeParams = new URLSearchParams(); if (evidence === 'multibyte') edgeParams.set('evidence', 'multibyte'); if (edgeDays) edgeParams.set('days', edgeDays); + if (forceRefresh) edgeParams.set('refresh', '1'); const nodeParams = new URLSearchParams(); if (nodeDays) nodeParams.set('days', nodeDays); const edgeQuery = edgeParams.toString(); @@ -1533,43 +1653,60 @@ // below once the edge prefix length is known, so the nodes request does // not have to wait for the edges response const [edgesData, nodesData] = await Promise.all([ - reloadEdges ? fetch(edgesUrl).then(r => r.json()) : Promise.resolve(null), - reloadNodes ? fetch(nodesUrl).then(r => r.json()) : Promise.resolve(null) + reloadEdges ? fetchMeshJson(edgesUrl, 'edges') : Promise.resolve(null), + reloadNodes ? fetchMeshJson(nodesUrl, 'nodes') : Promise.resolve(null) ]); - if (edgesData) { - allEdges = edgesData.edges || []; - graphPrefixHexChars = (edgesData.prefix_hex_chars && [2, 4, 6].includes(edgesData.prefix_hex_chars)) + const nextEdges = edgesData ? edgesData.edges : allEdges; + const nextPrefixHexChars = edgesData + ? ((edgesData.prefix_hex_chars && [2, 4, 6].includes(edgesData.prefix_hex_chars)) ? edgesData.prefix_hex_chars - : PREFIX_HEX_CHARS; + : PREFIX_HEX_CHARS) + : graphPrefixHexChars; + const sourceNodes = nodesData ? nodesData.nodes : allNodes; + if (!nextEdges.every(edge => + edge + && typeof edge === 'object' + && typeof edge.from_prefix === 'string' + && typeof edge.to_prefix === 'string' + )) { + throw new Error(`Mesh response from ${edgesUrl} contains invalid edges`); + } + if (!sourceNodes.every(isValidMeshNode)) { + throw new Error(`Mesh response from ${nodesUrl} contains invalid nodes`); } - if (nodesData) { - allNodes = nodesData.nodes || []; - } - // Keep node.prefix in sync with edge prefixes (e.g. 2-byte graphs) - allNodes.forEach(node => { + // Prepare a complete candidate graph before replacing any current + // state, so malformed responses cannot blank a working display. + const nextNodes = sourceNodes.map(node => { + const nextNode = { ...node }; + if (nextNode.public_key) { + nextNode.prefix = nextNode.public_key + .substring(0, nextPrefixHexChars) + .toLowerCase(); + } + return nextNode; + }); + const nextNodeMap = {}; + const nextNodeMapByKey = {}; + nextNodes.forEach(node => { + nextNodeMap[node.prefix] = node; if (node.public_key) { - node.prefix = node.public_key.substring(0, graphPrefixHexChars).toLowerCase(); + nextNodeMapByKey[node.public_key] = node; } }); - - // Build node maps - nodeMap = {}; - nodeMapByKey = {}; - allNodes.forEach(node => { - nodeMap[node.prefix] = node; // Last node with this prefix wins (for backward compatibility) - if (node.public_key) { - nodeMapByKey[node.public_key] = node; // Map by public key for accurate lookups - } - }); - - // Build edge map - edgeMap = {}; - allEdges.forEach(edge => { + const nextEdgeMap = {}; + nextEdges.forEach(edge => { const key = `${edge.from_prefix}-${edge.to_prefix}`; - edgeMap[key] = edge; + nextEdgeMap[key] = edge; }); + allEdges = nextEdges; + allNodes = nextNodes; + graphPrefixHexChars = nextPrefixHexChars; + nodeMap = nextNodeMap; + nodeMapByKey = nextNodeMapByKey; + edgeMap = nextEdgeMap; + // Rescale the min-observations slider to the new edge distribution if (reloadEdges) { syncMinObsSlider(); @@ -3164,9 +3301,111 @@ return `${first8Bytes}...${last8Bytes}`; } - function refreshData() { - loadStats(); - loadData(); + async function refreshData() { + const absorbedPending = meshLiveRefreshPending + || meshLiveRefreshTimer !== null; + const absorbedNeedsNodes = meshLiveRefreshNeedsNodes; + if (meshLiveRefreshTimer) { + clearTimeout(meshLiveRefreshTimer); + meshLiveRefreshTimer = null; + } + meshLiveRefreshPending = false; + meshLiveRefreshNeedsNodes = false; + meshLiveRefreshRetryCount = 0; + const activeScheduledRefresh = meshLiveRefreshRunning; + const activeReloadsNodes = meshLiveRefreshActiveReloadsNodes; + try { + if (activeScheduledRefresh && meshLoadInFlight) { + await Promise.all([loadStats(), meshLoadInFlight]); + if (activeReloadsNodes) { + applyFilters(); + } else { + // The active forced edge refresh is sufficient; fetch only + // the nodes it omitted and render from the combined state. + await loadData({ + reloadEdges: false, + reloadNodes: true + }); + } + } else { + await Promise.all([ + loadStats(), + loadData({ forceRefresh: true }) + ]); + } + } catch (error) { + // A manual refresh may have absorbed a pending live update. Restore + // it and let the bounded scheduled-retry policy recover. + scheduleMeshLiveRefresh(true, MESH_LIVE_REFRESH_RETRY_MS); + throw error; + } + if (absorbedPending && activeScheduledRefresh) { + // Events arriving during an active database snapshot may not be + // represented by it, so retain that trailing refresh. + scheduleMeshLiveRefresh(absorbedNeedsNodes); + } + } + + function scheduleMeshLiveRefresh( + reloadNodes, + delayMs = MESH_LIVE_REFRESH_MS + ) { + meshLiveRefreshPending = true; + meshLiveRefreshNeedsNodes = meshLiveRefreshNeedsNodes || Boolean(reloadNodes); + if (document.hidden || meshLiveRefreshTimer || meshLiveRefreshRunning) return; + + meshLiveRefreshTimer = setTimeout(() => { + meshLiveRefreshTimer = null; + runScheduledMeshRefresh(); + }, delayMs); + } + + async function runScheduledMeshRefresh() { + if ( + document.hidden + || meshLiveRefreshRunning + || !meshLiveRefreshPending + ) { + return; + } + + const reloadNodes = meshLiveRefreshNeedsNodes; + meshLiveRefreshPending = false; + meshLiveRefreshNeedsNodes = false; + meshLiveRefreshRunning = true; + meshLiveRefreshActiveReloadsNodes = reloadNodes; + let retryDelayMs = MESH_LIVE_REFRESH_MS; + try { + await Promise.all([ + loadStats(), + loadData({ + reloadNodes, + skipRender: true, + forceRefresh: true + }) + ]); + if (currentView === 'map') { + applyFilters(); + } + meshLiveRefreshRetryCount = 0; + } catch (error) { + console.error('Error refreshing live mesh data:', error); + if (meshLiveRefreshRetryCount < MESH_LIVE_REFRESH_MAX_RETRIES) { + meshLiveRefreshRetryCount++; + meshLiveRefreshPending = true; + meshLiveRefreshNeedsNodes = + meshLiveRefreshNeedsNodes || reloadNodes; + retryDelayMs = MESH_LIVE_REFRESH_RETRY_MS; + } else { + meshLiveRefreshRetryCount = 0; + } + } finally { + meshLiveRefreshRunning = false; + meshLiveRefreshActiveReloadsNodes = false; + if (meshLiveRefreshPending) { + scheduleMeshLiveRefresh(false, retryDelayMs); + } + } } function exportView() { @@ -3267,27 +3506,30 @@ // Socket.IO setup for real-time updates // Live updates are applied only when the map view is active. When the graph view is active, - // we refresh data in the background but do not re-render the graph, to avoid the chaotic - // re-stabilization layout (nodes clumping, edges tangling) that occurs when vis-network - // runs physics again. User can refresh or switch to map and back to see updated graph. + // events are coalesced into a bounded background refresh and do not re-render + // the graph, avoiding both request storms and chaotic physics re-stabilization. + document.addEventListener('visibilitychange', () => { + if (!document.hidden && meshLiveRefreshPending) { + if (meshLiveRefreshTimer) { + clearTimeout(meshLiveRefreshTimer); + meshLiveRefreshTimer = null; + } + runScheduledMeshRefresh(); + } + }); + function setupSocketIO() { const socket = window.connectionManager.socket; socket.emit('subscribe_mesh'); - function onMeshUpdate(data, label) { - console.log(label, data); - loadStats(); - loadData({ skipRender: true }).then(() => { - if (currentView === 'map') { - applyFilters(); - } - }); + function onMeshUpdate(reloadNodes) { + scheduleMeshLiveRefresh(reloadNodes); } - socket.on('mesh_edge_added', (data) => onMeshUpdate(data, 'New edge added:')); - socket.on('mesh_edge_updated', (data) => onMeshUpdate(data, 'Edge updated:')); - socket.on('mesh_node_added', (data) => onMeshUpdate(data, 'New node added:')); + socket.on('mesh_edge_added', () => onMeshUpdate(false)); + socket.on('mesh_edge_updated', () => onMeshUpdate(false)); + socket.on('mesh_node_added', () => onMeshUpdate(true)); } diff --git a/tests/test_web_viewer.py b/tests/test_web_viewer.py index badfb1d..1d9bdde 100644 --- a/tests/test_web_viewer.py +++ b/tests/test_web_viewer.py @@ -3565,14 +3565,22 @@ class TestDbPathResolutionFromConfigDir: class TestWebViewerLoggingRespectsLogFile: """Web viewer file logging follows [Logging] log_file like the main bot.""" - def _write_config(self, config_dir: Path, log_file: str | None) -> str: + def _write_config( + self, + config_dir: Path, + log_file: str | None, + log_level: str = "INFO", + ) -> str: cfg = configparser.ConfigParser() cfg["Connection"] = {"connection_type": "serial", "serial_port": "/dev/ttyUSB0"} cfg["Bot"] = {"bot_name": "TestBot", "db_path": "bot.db", "prefix_bytes": "1"} cfg["Channels"] = {"monitor_channels": "general"} cfg["Path_Command"] = {"max_hops": "5", "timeout": "30"} if log_file is not None: - cfg["Logging"] = {"log_file": log_file} + cfg["Logging"] = { + "log_file": log_file, + "log_level": log_level, + } config_path = str(config_dir / "config.ini") with open(config_path, "w") as fh: cfg.write(fh) @@ -3614,6 +3622,60 @@ class TestWebViewerLoggingRespectsLogFile: assert not (config_dir / "logs").exists() assert not (tmp_path / "logs").exists() + def test_handlers_respect_configured_log_level(self, tmp_path: Path) -> None: + import logging + + config_dir = tmp_path / "cfg" + log_dir = tmp_path / "varlog" + config_dir.mkdir() + log_dir.mkdir() + config_path = self._write_config( + config_dir, + log_file=str(log_dir / "meshcore_bot.log"), + log_level="WARNING", + ) + + viewer = self._make_viewer(config_path) + + assert viewer.logger.level == logging.WARNING + assert viewer.logger.handlers + assert all( + handler.level == logging.WARNING + for handler in viewer.logger.handlers + ) + + def test_debug_file_logging_keeps_info_floor_for_journal( + self, tmp_path: Path + ) -> None: + import logging + from logging.handlers import RotatingFileHandler + + config_dir = tmp_path / "cfg" + log_dir = tmp_path / "varlog" + config_dir.mkdir() + log_dir.mkdir() + config_path = self._write_config( + config_dir, + log_file=str(log_dir / "meshcore_bot.log"), + log_level="DEBUG", + ) + + viewer = self._make_viewer(config_path) + file_handlers = [ + handler + for handler in viewer.logger.handlers + if isinstance(handler, RotatingFileHandler) + ] + console_handlers = [ + handler + for handler in viewer.logger.handlers + if not isinstance(handler, RotatingFileHandler) + ] + + assert viewer.logger.level == logging.DEBUG + assert [handler.level for handler in file_handlers] == [logging.DEBUG] + assert [handler.level for handler in console_handlers] == [logging.INFO] + class TestRadioDebugConfig: """Tests for GET/POST /api/config/radio-debug endpoints.""" diff --git a/tests/test_web_viewer_app.py b/tests/test_web_viewer_app.py index ac2b5f4..d297937 100644 --- a/tests/test_web_viewer_app.py +++ b/tests/test_web_viewer_app.py @@ -1,10 +1,14 @@ """Tests for modules.web_viewer.app — BotDataViewer Flask app.""" import json +import shutil import sqlite3 +import subprocess +import threading import time from configparser import ConfigParser from datetime import datetime, timedelta, timezone +from pathlib import Path from unittest.mock import patch import pytest @@ -625,6 +629,641 @@ class TestApiMeshEdgesEvidence: assert stats['multibyte_edges'] == 2 +class TestMultibyteMeshAggregateCache: + def test_reuses_lifetime_aggregate_across_filtered_requests( + self, viewer_with_db + ): + _seed_observed_path( + viewer_with_db.db_path, + 'aaaa11bbbb22', + 3, + observation_count=5, + ) + + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + wraps=viewer_with_db._compute_multibyte_evidence_edges, + ) as compute: + with viewer_with_db.app.test_client() as client: + first = client.get('/api/mesh/edges?evidence=multibyte') + second = client.get( + '/api/mesh/edges?evidence=multibyte&min_observations=4&days=7' + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert compute.call_count == 1 + + def test_recomputes_after_cache_window(self, viewer_with_db): + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + return_value=[], + ) as compute: + viewer_with_db._aggregate_multibyte_evidence_edges() + viewer_with_db._multibyte_graph_cache_created_at -= ( + viewer_with_db._mesh_graph_cache_seconds + 1 + ) + viewer_with_db._aggregate_multibyte_evidence_edges() + + assert compute.call_count == 2 + + def test_forced_refresh_bypasses_warm_cache(self, viewer_with_db): + _seed_observed_path( + viewer_with_db.db_path, + 'aaaa11bbbb22', + 3, + ) + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + wraps=viewer_with_db._compute_multibyte_evidence_edges, + ) as compute: + with viewer_with_db.app.test_client() as client: + first = client.get('/api/mesh/edges?evidence=multibyte') + _seed_observed_path( + viewer_with_db.db_path, + 'cccc55dddd66', + 3, + ) + cached = client.get('/api/mesh/edges?evidence=multibyte') + refreshed = client.get( + '/api/mesh/edges?evidence=multibyte&refresh=1' + ) + + assert { + edge['from_prefix'] for edge in first.get_json()['edges'] + } == {'aaaa11'} + assert { + edge['from_prefix'] for edge in cached.get_json()['edges'] + } == {'aaaa11'} + assert { + edge['from_prefix'] for edge in refreshed.get_json()['edges'] + } == {'aaaa11', 'cccc55'} + assert compute.call_count == 2 + + def test_concurrent_cold_requests_share_one_computation( + self, viewer_with_db + ): + started = threading.Event() + release = threading.Event() + computed = [{'from_prefix': 'aaaa', 'to_prefix': 'bbbb'}] + + def slow_compute(): + started.set() + assert release.wait(timeout=2) + return computed + + results = [] + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + side_effect=slow_compute, + ) as compute: + first = threading.Thread( + target=lambda: results.append( + viewer_with_db._aggregate_multibyte_evidence_edges() + ) + ) + second = threading.Thread( + target=lambda: results.append( + viewer_with_db._aggregate_multibyte_evidence_edges() + ) + ) + first.start() + assert started.wait(timeout=2) + second.start() + release.set() + first.join(timeout=2) + second.join(timeout=2) + + assert not first.is_alive() + assert not second.is_alive() + assert compute.call_count == 1 + assert results == [computed, computed] + + def test_concurrent_refresh_serves_stale_cache(self, viewer_with_db): + stale = [{'from_prefix': 'old', 'to_prefix': 'edge'}] + fresh = [{'from_prefix': 'new', 'to_prefix': 'edge'}] + viewer_with_db._multibyte_graph_cache_edges = stale + viewer_with_db._multibyte_graph_cache_created_at = 0 + started = threading.Event() + release = threading.Event() + + def slow_compute(): + started.set() + assert release.wait(timeout=2) + return fresh + + refreshed = [] + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + side_effect=slow_compute, + ) as compute: + worker = threading.Thread( + target=lambda: refreshed.append( + viewer_with_db._aggregate_multibyte_evidence_edges() + ) + ) + worker.start() + assert started.wait(timeout=2) + concurrent = viewer_with_db._aggregate_multibyte_evidence_edges() + release.set() + worker.join(timeout=2) + + assert not worker.is_alive() + assert concurrent is stale + assert refreshed == [fresh] + assert compute.call_count == 1 + + def test_concurrent_cold_failure_is_shared(self, viewer_with_db): + started = threading.Event() + release = threading.Event() + errors = [] + + def failing_compute(): + started.set() + assert release.wait(timeout=2) + raise sqlite3.OperationalError('temporary failure') + + def aggregate(): + try: + viewer_with_db._aggregate_multibyte_evidence_edges() + except Exception as exc: + errors.append(exc) + + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + side_effect=failing_compute, + ) as compute: + first = threading.Thread(target=aggregate) + second = threading.Thread(target=aggregate) + first.start() + assert started.wait(timeout=2) + second.start() + release.set() + first.join(timeout=2) + second.join(timeout=2) + + assert not first.is_alive() + assert not second.is_alive() + assert compute.call_count == 1 + assert len(errors) == 2 + assert any(isinstance(exc, sqlite3.OperationalError) for exc in errors) + assert any(isinstance(exc, RuntimeError) for exc in errors) + + def test_stale_failure_uses_retry_backoff(self, viewer_with_db): + stale = [{'from_prefix': 'old', 'to_prefix': 'edge'}] + viewer_with_db._multibyte_graph_cache_edges = stale + viewer_with_db._multibyte_graph_cache_created_at = 0 + + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + side_effect=sqlite3.OperationalError('temporary failure'), + ) as compute: + first = viewer_with_db._aggregate_multibyte_evidence_edges() + with pytest.raises(RuntimeError, match='retry suppressed'): + viewer_with_db._aggregate_multibyte_evidence_edges( + force_refresh=True + ) + viewer_with_db._multibyte_graph_cache_failure_at -= ( + viewer_with_db._multibyte_graph_cache_retry_seconds + 1 + ) + third = viewer_with_db._aggregate_multibyte_evidence_edges() + + assert first is stale + assert third is stale + assert compute.call_count == 2 + assert viewer_with_db._multibyte_graph_cache_failure == ( + 'OperationalError', + 'temporary failure', + ) + + def test_forced_failure_returns_500_while_normal_request_serves_stale( + self, viewer_with_db + ): + viewer_with_db._multibyte_graph_cache_edges = [] + viewer_with_db._multibyte_graph_cache_created_at = 0 + + with patch.object( + viewer_with_db, + '_compute_multibyte_evidence_edges', + side_effect=sqlite3.OperationalError('temporary failure'), + ) as compute: + with viewer_with_db.app.test_client() as client: + forced = client.get( + '/api/mesh/edges?evidence=multibyte&refresh=1' + ) + normal = client.get('/api/mesh/edges?evidence=multibyte') + + assert forced.status_code == 500 + assert forced.get_json()['error'] == 'An internal error occurred' + assert normal.status_code == 200 + assert normal.get_json()['edges'] == [] + assert compute.call_count == 1 + + +def test_mesh_template_coalesces_live_refreshes(): + source = ( + Path(__file__).parents[1] + / 'modules' + / 'web_viewer' + / 'templates' + / 'mesh.html' + ).read_text(encoding='utf-8') + + assert 'const MESH_LIVE_REFRESH_MS = 30000;' in source + assert 'const MESH_LIVE_REFRESH_RETRY_MS = 6000;' in source + assert "document.addEventListener('visibilitychange'" in source + assert 'scheduleMeshLiveRefresh(reloadNodes);' in source + assert "socket.on('mesh_edge_updated', () => onMeshUpdate(false));" in source + assert 'while (pendingMeshLoad)' in source + assert "fetchMeshJson(edgesUrl, 'edges')" in source + assert "loadData({ skipRender: true }).then" not in source + + +@pytest.mark.skipif(shutil.which('node') is None, reason='Node.js not installed') +def test_mesh_refresh_coordinator_handles_bursts_visibility_and_failures(): + source = ( + Path(__file__).parents[1] + / 'modules' + / 'web_viewer' + / 'templates' + / 'mesh.html' + ).read_text(encoding='utf-8') + + def extract(start, end): + return source[source.index(start):source.index(end)] + + coordinator = '\n'.join([ + extract( + ' function normalizeMeshLoadOptions', + ' function mergeMeshLoadOptions', + ), + extract( + ' function mergeMeshLoadOptions', + ' // Load nodes and edges.', + ), + extract(' function loadData', ' async function drainMeshLoads'), + extract( + ' async function drainMeshLoads', + ' async function performMeshLoad', + ), + extract( + ' async function refreshData', + ' function scheduleMeshLiveRefresh', + ), + extract( + ' function scheduleMeshLiveRefresh', + ' async function runScheduledMeshRefresh', + ), + extract( + ' async function runScheduledMeshRefresh', + ' function exportView', + ), + ]) + + script = f""" + let meshLoadInFlight = null; + let pendingMeshLoad = null; + let meshLiveRefreshTimer = null; + let meshLiveRefreshPending = false; + let meshLiveRefreshNeedsNodes = false; + let meshLiveRefreshRunning = false; + let meshLiveRefreshRetryCount = 0; + let meshLiveRefreshActiveReloadsNodes = false; + const MESH_LIVE_REFRESH_MS = 10; + const MESH_LIVE_REFRESH_RETRY_MS = 10; + const MESH_LIVE_REFRESH_MAX_RETRIES = 2; + let currentView = 'graph'; + let document = {{hidden: false}}; + let loadCount = 0; + let statsCount = 0; + let concurrent = 0; + let maxConcurrent = 0; + let forcedLoads = 0; + let blockFirst = false; + let failBlockedFirst = false; + let firstStartedResolve; + let firstReleaseResolve; + let firstStarted = new Promise(resolve => firstStartedResolve = resolve); + let firstRelease = new Promise(resolve => firstReleaseResolve = resolve); + + async function performMeshLoad(options) {{ + loadCount++; + if (options.forceRefresh) forcedLoads++; + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + if (blockFirst && loadCount === 1) {{ + firstStartedResolve(); + await firstRelease; + concurrent--; + if (failBlockedFirst) {{ + throw new Error('expected first-load failure'); + }} + return; + }} + await new Promise(resolve => setTimeout(resolve, 2)); + concurrent--; + }} + async function loadStats() {{ statsCount++; }} + function applyFilters() {{}} + + {coordinator} + + (async () => {{ + for (let i = 0; i < 100; i++) scheduleMeshLiveRefresh(false); + await new Promise(resolve => setTimeout(resolve, 40)); + const burst = {{loadCount, statsCount, maxConcurrent}}; + + loadCount = 0; + statsCount = 0; + maxConcurrent = 0; + document.hidden = true; + scheduleMeshLiveRefresh(false); + await new Promise(resolve => setTimeout(resolve, 20)); + const hiddenLoads = loadCount; + document.hidden = false; + await runScheduledMeshRefresh(); + const visibleLoads = loadCount; + + loadCount = 0; + maxConcurrent = 0; + blockFirst = true; + failBlockedFirst = true; + firstStarted = new Promise(resolve => firstStartedResolve = resolve); + firstRelease = new Promise(resolve => firstReleaseResolve = resolve); + const first = loadData({{reloadNodes: true}}); + await firstStarted; + const trailing = loadData({{reloadNodes: false}}); + firstReleaseResolve(); + await Promise.all([first, trailing]); + const failureQueue = {{ + loadCount, + maxConcurrent, + pending: pendingMeshLoad !== null + }}; + + loadCount = 0; + statsCount = 0; + forcedLoads = 0; + blockFirst = true; + failBlockedFirst = true; + firstStarted = new Promise(resolve => firstStartedResolve = resolve); + firstRelease = new Promise(resolve => firstReleaseResolve = resolve); + scheduleMeshLiveRefresh(true, 10); + await firstStarted; + firstReleaseResolve(); + await new Promise(resolve => setTimeout(resolve, 35)); + const scheduledRetry = {{ + loadCount, + statsCount, + forcedLoads, + pending: meshLiveRefreshPending, + retries: meshLiveRefreshRetryCount + }}; + + loadCount = 0; + statsCount = 0; + forcedLoads = 0; + blockFirst = false; + failBlockedFirst = false; + scheduleMeshLiveRefresh(false, 20); + await refreshData(); + await new Promise(resolve => setTimeout(resolve, 30)); + const manualAbsorbsTimer = {{ + loadCount, + statsCount, + forcedLoads, + pending: meshLiveRefreshPending + }}; + + loadCount = 0; + statsCount = 0; + forcedLoads = 0; + blockFirst = true; + failBlockedFirst = true; + firstStarted = new Promise(resolve => firstStartedResolve = resolve); + firstRelease = new Promise(resolve => firstReleaseResolve = resolve); + scheduleMeshLiveRefresh(false, 20); + const failedManualPromise = refreshData().catch(() => {{}}); + await firstStarted; + firstReleaseResolve(); + await failedManualPromise; + await new Promise(resolve => setTimeout(resolve, 35)); + const failedManualRetries = {{ + loadCount, + forcedLoads, + pending: meshLiveRefreshPending, + retries: meshLiveRefreshRetryCount + }}; + + loadCount = 0; + statsCount = 0; + forcedLoads = 0; + blockFirst = true; + failBlockedFirst = false; + firstStarted = new Promise(resolve => firstStartedResolve = resolve); + firstRelease = new Promise(resolve => firstReleaseResolve = resolve); + scheduleMeshLiveRefresh(true, 0); + await firstStarted; + const activeManualPromise = refreshData(); + firstReleaseResolve(); + await activeManualPromise; + await new Promise(resolve => setTimeout(resolve, 10)); + const manualReusesActiveRefresh = {{ + loadCount, + forcedLoads, + pending: meshLiveRefreshPending + }}; + + console.log(JSON.stringify({{ + burst, + hiddenLoads, + visibleLoads, + failureQueue, + scheduledRetry, + manualAbsorbsTimer, + failedManualRetries, + manualReusesActiveRefresh + }})); + }})().catch(error => {{ + console.error(error); + process.exitCode = 1; + }}); + """ + + completed = subprocess.run( + [shutil.which('node'), '-'], + input=script, + text=True, + capture_output=True, + check=True, + timeout=5, + ) + result = json.loads(completed.stdout.strip()) + + assert result['burst'] == { + 'loadCount': 1, + 'statsCount': 1, + 'maxConcurrent': 1, + } + assert result['hiddenLoads'] == 0 + assert result['visibleLoads'] == 1 + assert result['failureQueue'] == { + 'loadCount': 2, + 'maxConcurrent': 1, + 'pending': False, + } + assert result['scheduledRetry'] == { + 'loadCount': 2, + 'statsCount': 2, + 'forcedLoads': 2, + 'pending': False, + 'retries': 0, + } + assert result['manualAbsorbsTimer'] == { + 'loadCount': 1, + 'statsCount': 1, + 'forcedLoads': 1, + 'pending': False, + } + assert result['failedManualRetries'] == { + 'loadCount': 2, + 'forcedLoads': 2, + 'pending': False, + 'retries': 0, + } + assert result['manualReusesActiveRefresh'] == { + 'loadCount': 1, + 'forcedLoads': 1, + 'pending': False, + } + + +@pytest.mark.skipif(shutil.which('node') is None, reason='Node.js not installed') +def test_mesh_fetch_rejects_http_errors_and_invalid_payloads(): + source = ( + Path(__file__).parents[1] + / 'modules' + / 'web_viewer' + / 'templates' + / 'mesh.html' + ).read_text(encoding='utf-8') + helper = source[ + source.index(' function isValidMeshNode'): + source.index(' // Load statistics') + ] + script = f""" + {helper} + (async () => {{ + global.fetch = async () => ({{ + ok: false, + status: 500, + json: async () => ({{error: 'temporary failure'}}) + }}); + let httpError = ''; + try {{ + await fetchMeshJson('/api/mesh/edges', 'edges'); + }} catch (error) {{ + httpError = error.message; + }} + + global.fetch = async () => ({{ + ok: true, + status: 200, + json: async () => ({{edges: null}}) + }}); + let shapeError = ''; + try {{ + await fetchMeshJson('/api/mesh/edges', 'edges'); + }} catch (error) {{ + shapeError = error.message; + }} + + global.fetch = async () => ({{ + ok: true, + status: 200, + json: async () => ({{edges: [null]}}) + }}); + let nestedShapeError = ''; + try {{ + await fetchMeshJson('/api/mesh/edges', 'edges'); + }} catch (error) {{ + nestedShapeError = error.message; + }} + + global.fetch = async () => ({{ + ok: true, + status: 200, + json: async () => ({{nodes: [{{}}]}}) + }}); + let emptyNodeError = ''; + try {{ + await fetchMeshJson('/api/mesh/nodes', 'nodes'); + }} catch (error) {{ + emptyNodeError = error.message; + }} + + global.fetch = async () => ({{ + ok: true, + status: 200, + json: async () => ({{nodes: [{{ + public_key: 'aabb', + prefix: 'aa', + name: 'Node', + latitude: '1.0', + longitude: 2.0 + }}]}}) + }}); + let typedNodeError = ''; + try {{ + await fetchMeshJson('/api/mesh/nodes', 'nodes'); + }} catch (error) {{ + typedNodeError = error.message; + }} + console.log(JSON.stringify({{ + httpError, + shapeError, + nestedShapeError, + emptyNodeError, + typedNodeError + }})); + }})().catch(error => {{ + console.error(error); + process.exitCode = 1; + }}); + """ + completed = subprocess.run( + [shutil.which('node'), '-'], + input=script, + text=True, + capture_output=True, + check=True, + timeout=5, + ) + result = json.loads(completed.stdout.strip()) + assert result['httpError'] == ( + 'Mesh request failed (500): temporary failure' + ) + assert result['shapeError'] == ( + 'Mesh response from /api/mesh/edges is missing edges' + ) + assert result['nestedShapeError'] == ( + 'Mesh response from /api/mesh/edges contains invalid edges' + ) + assert result['emptyNodeError'] == ( + 'Mesh response from /api/mesh/nodes contains invalid nodes' + ) + assert result['typedNodeError'] == ( + 'Mesh response from /api/mesh/nodes contains invalid nodes' + ) + + # --------------------------------------------------------------------------- # api_geocode_contact # ---------------------------------------------------------------------------