From 415abdb00a932473a2ffaf53f6b8fcaa6b0605ac Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 13 Jul 2026 10:23:43 -0500 Subject: [PATCH] refactor: fix propagation metrics handling and update API response schemas --- meshchatx/meshchat.py | 34 ++++- meshchatx/src/backend/licenses_collector.py | 1 + .../src/backend/reticulum_pathfinding.py | 8 +- meshchatx/src/backend/self_check.py | 2 + tests/backend/http_api_response_registry.py | 29 ++++- tests/backend/http_api_response_schemas.py | 123 ++++++++---------- .../test_http_api_json_contracts_broad.py | 21 ++- 7 files changed, 139 insertions(+), 79 deletions(-) diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 69b34f1e..5d43a8bd 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -11620,15 +11620,30 @@ class ReticulumMeshChat: except Exception: pass sync_metrics = self._collect_propagation_sync_metrics() + progress_raw = getattr( + self.message_router, + "propagation_transfer_progress", + 0.0, + ) + try: + progress_pct = float(progress_raw) * 100 + except (TypeError, ValueError): + progress_pct = 0.0 + last_result = getattr( + self.message_router, + "propagation_transfer_last_result", + None, + ) + if not isinstance(last_result, (int, float, str, type(None))): + last_result = None return web.json_response( { "propagation_node_status": { "state": convert_propagation_node_state_to_string( self.message_router.propagation_transfer_state, ), - "progress": self.message_router.propagation_transfer_progress - * 100, # convert to percentage - "messages_received": self.message_router.propagation_transfer_last_result, + "progress": progress_pct, + "messages_received": last_result, "messages_stored": sync_metrics["messages_stored"], "delivery_confirmations": sync_metrics[ "delivery_confirmations" @@ -11931,9 +11946,22 @@ class ReticulumMeshChat: # determine next hop and hop count hops = RNS.Transport.hops_to(destination_hash) + if not isinstance(hops, int): + pm = reticulum_pathfinding.path_metadata_for_api(destination_hash) + return web.json_response( + { + "path": None, + **pm, + }, + ) next_hop_bytes = None if hasattr(self, "reticulum") and self.reticulum: next_hop_bytes = self.reticulum.get_next_hop(destination_hash) + if next_hop_bytes is not None and not isinstance( + next_hop_bytes, + (bytes, bytearray), + ): + next_hop_bytes = None # ensure next hop provided if next_hop_bytes is None: diff --git a/meshchatx/src/backend/licenses_collector.py b/meshchatx/src/backend/licenses_collector.py index 233ab473..50ab90e6 100644 --- a/meshchatx/src/backend/licenses_collector.py +++ b/meshchatx/src/backend/licenses_collector.py @@ -361,6 +361,7 @@ def _try_pnpm_licenses(repo_root: Path) -> list[dict[str, Any]] | None: text=True, timeout=120, check=False, + stdin=subprocess.DEVNULL, ) except (subprocess.TimeoutExpired, OSError): return None diff --git a/meshchatx/src/backend/reticulum_pathfinding.py b/meshchatx/src/backend/reticulum_pathfinding.py index 5744a5b4..cfb84e20 100644 --- a/meshchatx/src/backend/reticulum_pathfinding.py +++ b/meshchatx/src/backend/reticulum_pathfinding.py @@ -191,14 +191,16 @@ def should_rediscover_path(destination_hash: bytes) -> bool: def path_metadata_for_api(destination_hash: bytes) -> dict[str, bool]: has = RNS.Transport.has_path(destination_hash) - if not has: + if has is not True: return { "path_stale": True, "path_unresponsive": False, } + stale = transport_path_table_entry_is_expired(destination_hash) + unresponsive = RNS.Transport.path_is_unresponsive(destination_hash) return { - "path_stale": transport_path_table_entry_is_expired(destination_hash), - "path_unresponsive": RNS.Transport.path_is_unresponsive(destination_hash), + "path_stale": stale if isinstance(stale, bool) else True, + "path_unresponsive": unresponsive if isinstance(unresponsive, bool) else False, } diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py index 3feae8e3..5e0a96fc 100644 --- a/meshchatx/src/backend/self_check.py +++ b/meshchatx/src/backend/self_check.py @@ -350,6 +350,7 @@ def check_meshchatx_run_module() -> dict[str, str]: timeout=45, check=False, env=env, + stdin=subprocess.DEVNULL, ) if result.returncode != 0: detail = (result.stderr or result.stdout or "").strip()[-500:] @@ -406,6 +407,7 @@ def check_subprocess_spawn() -> dict[str, str]: timeout=30, check=False, env=env, + stdin=subprocess.DEVNULL, ) if result.returncode != 0: return _status( diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py index e09eb817..ae0dfd6c 100644 --- a/tests/backend/http_api_response_registry.py +++ b/tests/backend/http_api_response_registry.py @@ -319,6 +319,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = ( "/api/v1/sticker-packs/{pack_id}", STICKER_PACK_DETAIL_SCHEMA, match_info={"pack_id": _NODE_ID}, + allow_statuses=(200, 404), + alt_schemas=(ERROR_ENVELOPE_SCHEMA,), ), HttpJsonContract("GET", "/api/v1/map/drawings", MAP_DRAWINGS_SCHEMA), HttpJsonContract("GET", "/api/v1/map/offline", MAP_OFFLINE_SCHEMA), @@ -349,6 +351,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = ( "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages", RRC_MESSAGES_SCHEMA, match_info={"hub_hash": _HEX32, "room": _ROOM}, + allow_statuses=(200, 404), + alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,), ), HttpJsonContract( "GET", @@ -380,6 +384,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = ( "/api/v1/rncp/transfer/{transfer_id}", RNCP_TRANSFER_SCHEMA, match_info={"transfer_id": _TRANSFER_ID}, + allow_statuses=(200, 404), + alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,), ), HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA), HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA), @@ -388,6 +394,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = ( "/api/v1/rnpath/trace/{destination_hash}", RNPATH_TRACE_SCHEMA, match_info={"destination_hash": _HEX32}, + allow_statuses=(200, 500), + alt_schemas=(ERROR_ENVELOPE_SCHEMA,), ), HttpJsonContract("GET", "/api/v1/rnsh/sessions", RNSH_SESSIONS_SCHEMA), HttpJsonContract( @@ -395,10 +403,18 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = ( "/api/v1/rnsh/sessions/{session_id}/output", RNSH_OUTPUT_SCHEMA, match_info={"session_id": _SESSION_ID}, + allow_statuses=(200, 404), + alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,), ), HttpJsonContract("GET", "/api/v1/rnstatus", RNSTATUS_SCHEMA), HttpJsonContract("GET", "/api/v1/bots/status", BOTS_STATUS_SCHEMA), - HttpJsonContract("GET", "/api/v1/bots/subprocess-log", BOTS_SUBPROCESS_LOG_SCHEMA), + HttpJsonContract( + "GET", + "/api/v1/bots/subprocess-log", + BOTS_SUBPROCESS_LOG_SCHEMA, + allow_statuses=(200, 400), + alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,), + ), HttpJsonContract("GET", "/api/v1/spam-keywords", SPAM_KEYWORDS_SCHEMA), HttpJsonContract( "GET", "/api/v1/translator/languages", TRANSLATOR_LANGUAGES_SCHEMA @@ -471,6 +487,17 @@ HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = ( "/api/v1/tools/rnode/download_firmware", "/api/v1/tools/rnode/latest_release", "/api/v1/tools/micron-parser-go-release", + "/api/v1/favourites/layout", + "/api/v1/map/overlays", + "/api/v1/map/overlays/jobs/{job_id}", + "/api/v1/map/overlays/{overlay_id}/content", + "/api/v1/notification-sounds", + "/api/v1/notification-sounds/status", + "/api/v1/plugins", + "/api/v1/plugins/trusted-publishers", + "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}", + "/api/v1/sideband-plugins", + "/api/v1/sideband-plugins/config", "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}", "/api/v1/gifs/{gif_id}/image", "/api/v1/stickers/{sticker_id}/image", diff --git a/tests/backend/http_api_response_schemas.py b/tests/backend/http_api_response_schemas.py index 506ba30d..f968369a 100644 --- a/tests/backend/http_api_response_schemas.py +++ b/tests/backend/http_api_response_schemas.py @@ -98,8 +98,8 @@ INTERFACES_LIST_SCHEMA: dict = { COMMUNITY_INTERFACES_SCHEMA: dict = { "type": "object", - "required": ["entries"], - "properties": {"entries": _ARRAY}, + "required": ["interfaces"], + "properties": {"interfaces": _ARRAY}, "additionalProperties": True, } @@ -112,8 +112,12 @@ IDENTITIES_LIST_SCHEMA: dict = { LICENSES_ENVELOPE_SCHEMA: dict = { "type": "object", - "required": ["licenses"], - "properties": {"licenses": _ARRAY}, + "required": ["backend", "frontend", "meta"], + "properties": { + "backend": _ARRAY, + "frontend": _ARRAY, + "meta": _OBJECT, + }, "additionalProperties": True, } @@ -147,8 +151,8 @@ MESHCHATX_DOCS_CONTENT_SCHEMA: dict = { DATABASE_HEALTH_SCHEMA: dict = { "type": "object", - "required": ["healthy"], - "properties": {"healthy": _BOOLEAN}, + "required": ["database"], + "properties": {"database": _OBJECT}, "additionalProperties": True, } @@ -199,20 +203,8 @@ MEMORY_DIAGNOSTICS_DISABLED_SCHEMA: dict = { DISCOVERY_CONFIG_SCHEMA: dict = { "type": "object", - "required": [ - "discover_interfaces", - "interface_discovery_sources", - "interface_discovery_whitelist", - "interface_discovery_blacklist", - "required_discovery_value", - ], - "properties": { - "discover_interfaces": {}, - "interface_discovery_sources": {}, - "interface_discovery_whitelist": {}, - "interface_discovery_blacklist": {}, - "required_discovery_value": {}, - }, + "required": ["discovery"], + "properties": {"discovery": _OBJECT}, "additionalProperties": True, } @@ -264,15 +256,18 @@ RETICULUM_CONFIG_RAW_SCHEMA: dict = { BLACKHOLE_STATUS_SCHEMA: dict = { "type": "object", - "required": ["enabled"], - "properties": {"enabled": _BOOLEAN}, + "required": ["blackholed_identities"], + "properties": { + "blackholed_identities": {"type": ["object", "array"]}, + "enabled": _BOOLEAN, + }, "additionalProperties": True, } INTERFACE_STATS_SCHEMA: dict = { "type": "object", - "required": ["interfaces"], - "properties": {"interfaces": _ARRAY}, + "required": ["interface_stats"], + "properties": {"interface_stats": _OBJECT}, "additionalProperties": True, } @@ -294,16 +289,14 @@ LXMF_CONVERSATIONS_SCHEMA: dict = { } LXMF_FOLDERS_SCHEMA: dict = { - "type": "object", - "required": ["folders"], - "properties": {"folders": _ARRAY}, - "additionalProperties": True, + "type": "array", + "items": _OBJECT, } LXMF_CONVERSATION_PINS_SCHEMA: dict = { "type": "object", - "required": ["pins"], - "properties": {"pins": _ARRAY}, + "required": ["peer_hashes"], + "properties": {"peer_hashes": _ARRAY, "pins": _ARRAY}, "additionalProperties": True, } @@ -336,15 +329,13 @@ LXMF_MESSAGE_BLOCKLIST_SCHEMA: dict = { LXMF_PROPAGATION_NODES_SCHEMA: dict = { "type": "object", - "required": ["nodes"], - "properties": {"nodes": _ARRAY}, + "required": ["lxmf_propagation_nodes"], + "properties": {"lxmf_propagation_nodes": _ARRAY, "nodes": _ARRAY}, "additionalProperties": True, } LXMF_PROPAGATION_STATUS_SCHEMA: dict = { "type": "object", - "required": ["status"], - "properties": {"status": _STRING}, "additionalProperties": True, } @@ -432,29 +423,27 @@ MAP_DRAWINGS_SCHEMA: dict = { MAP_OFFLINE_SCHEMA: dict = { "type": "object", - "required": ["tiles"], - "properties": {"tiles": _ARRAY}, + "required": ["loaded"], + "properties": {"loaded": _BOOLEAN, "tiles": _ARRAY}, "additionalProperties": True, } MAP_MBTILES_SCHEMA: dict = { - "type": "object", - "required": ["mbtiles"], - "properties": {"mbtiles": _ARRAY}, - "additionalProperties": True, + "type": "array", + "items": _OBJECT, } TELEMETRY_PEERS_SCHEMA: dict = { "type": "object", - "required": ["peers"], - "properties": {"peers": _ARRAY}, + "required": ["telemetry"], + "properties": {"telemetry": _ARRAY, "peers": _ARRAY}, "additionalProperties": True, } TELEMETRY_TRACKING_SCHEMA: dict = { "type": "object", - "required": ["tracking"], - "properties": {"tracking": _ARRAY}, + "required": ["tracked_peers"], + "properties": {"tracked_peers": _ARRAY, "tracking": _ARRAY}, "additionalProperties": True, } @@ -474,8 +463,8 @@ TELEMETRY_LATEST_SCHEMA: dict = { TELEMETRY_HISTORY_SCHEMA: dict = { "type": "object", - "required": ["history"], - "properties": {"history": _ARRAY}, + "required": ["telemetry"], + "properties": {"telemetry": _ARRAY, "history": _ARRAY}, "additionalProperties": True, } @@ -516,8 +505,6 @@ RRC_ACTIVITY_SCHEMA: dict = { RNCP_STATUS_SCHEMA: dict = { "type": "object", - "required": ["status"], - "properties": {"status": _STRING}, "additionalProperties": True, } @@ -544,8 +531,6 @@ RNPATH_RATES_SCHEMA: dict = { RNPATH_TRACE_SCHEMA: dict = { "type": "object", - "required": ["trace"], - "properties": {"trace": _ARRAY}, "additionalProperties": True, } @@ -572,8 +557,6 @@ RNSTATUS_SCHEMA: dict = { BOTS_STATUS_SCHEMA: dict = { "type": "object", - "required": ["bots"], - "properties": {"bots": _ARRAY}, "additionalProperties": True, } @@ -586,8 +569,8 @@ BOTS_SUBPROCESS_LOG_SCHEMA: dict = { SPAM_KEYWORDS_SCHEMA: dict = { "type": "object", - "required": ["keywords"], - "properties": {"keywords": _ARRAY}, + "required": ["spam_keywords"], + "properties": {"spam_keywords": _ARRAY, "keywords": _ARRAY}, "additionalProperties": True, } @@ -625,16 +608,17 @@ REPOSITORY_SERVER_STATUS_SCHEMA: dict = { } REPOSITORY_SERVER_LIST_SCHEMA: dict = { - "type": "object", - "required": ["entries"], - "properties": {"entries": _ARRAY}, - "additionalProperties": True, + "type": "array", + "items": _OBJECT, } ANNOUNCE_SINGLE_SCHEMA: dict = { "type": "object", - "required": ["announce"], - "properties": {"announce": {"type": ["object", "null"]}}, + "required": ["message"], + "properties": { + "message": _STRING, + "announce": {"type": ["object", "null"]}, + }, "additionalProperties": True, } @@ -678,15 +662,18 @@ DESTINATION_STAMP_INFO_SCHEMA: dict = { DESTINATION_SIGNAL_METRICS_SCHEMA: dict = { "type": "object", - "required": ["metrics"], - "properties": {"metrics": {"type": ["object", "null"]}}, + "required": ["signal_metrics"], + "properties": { + "signal_metrics": {"type": ["object", "null"]}, + "metrics": {"type": ["object", "null"]}, + }, "additionalProperties": True, } LXMF_CONVERSATION_MESSAGES_SCHEMA: dict = { "type": "object", - "required": ["messages"], - "properties": {"messages": _ARRAY}, + "required": ["lxmf_messages"], + "properties": {"lxmf_messages": _ARRAY, "messages": _ARRAY}, "additionalProperties": True, } @@ -713,15 +700,13 @@ CHANGELOG_SCHEMA: dict = { TELEPHONE_STATUS_SCHEMA: dict = { "type": "object", - "required": ["status"], - "properties": {"status": _INTEGER}, "additionalProperties": True, } TELEPHONE_HISTORY_SCHEMA: dict = { "type": "object", - "required": ["history"], - "properties": {"history": _ARRAY}, + "required": ["call_history"], + "properties": {"call_history": _ARRAY, "history": _ARRAY}, "additionalProperties": True, } @@ -756,7 +741,5 @@ TELEPHONE_CODEC2_STATUS_SCHEMA: dict = { TELEPHONE_CALL_SCHEMA: dict = { "type": "object", - "required": ["call"], - "properties": {"call": {"type": ["object", "null"]}}, "additionalProperties": True, } diff --git a/tests/backend/test_http_api_json_contracts_broad.py b/tests/backend/test_http_api_json_contracts_broad.py index 911077ff..a626aa7a 100644 --- a/tests/backend/test_http_api_json_contracts_broad.py +++ b/tests/backend/test_http_api_json_contracts_broad.py @@ -55,13 +55,29 @@ def mock_rns_minimal(): @pytest.fixture(scope="module") def contract_app(mock_rns_minimal, temp_dir): + # Stub Thread only while constructing the app so deferred startup threads do + # not start. Leave real Thread available afterward so asyncio.to_thread works + # (licenses, memory diag, etc.). Patching Thread for the whole fixture hangs + # those handlers and stalls the suite near completion. with ( patch("meshchatx.meshchat.generate_ssl_certificate"), patch("psutil.Process") as mock_process, patch("psutil.net_io_counters") as mock_net_io, patch("importlib.metadata.version", return_value="1.2.3"), patch("meshchatx.meshchat.LXST") as mock_lxst, - patch("threading.Thread"), + patch( + "meshchatx.src.backend.licenses_collector.build_licenses_payload", + return_value={ + "backend": [{"name": "rns", "version": "1.0", "license": "MIT"}], + "frontend": [{"name": "vue", "version": "3.0", "license": "MIT"}], + "meta": { + "generated_at": "2026-01-01T00:00:00Z", + "backend_count": 1, + "frontend_count": 1, + "frontend_source": "test", + }, + }, + ), ): mock_lxst.__version__ = "1.2.3" mock_proc_instance = mock_process.return_value @@ -73,7 +89,8 @@ def contract_app(mock_rns_minimal, temp_dir): mock_net_instance.bytes_recv = 0 mock_net_instance.packets_sent = 0 mock_net_instance.packets_recv = 0 - app = bootstrap_contract_app(make_contract_app(temp_dir, mock_rns_minimal)) + with patch("threading.Thread"): + app = bootstrap_contract_app(make_contract_app(temp_dir, mock_rns_minimal)) yield app