diff --git a/android/app/src/main/python/meshchat_wrapper.py b/android/app/src/main/python/meshchat_wrapper.py index 5833c40d..86ab9d44 100644 --- a/android/app/src/main/python/meshchat_wrapper.py +++ b/android/app/src/main/python/meshchat_wrapper.py @@ -1,9 +1,11 @@ # SPDX-License-Identifier: 0BSD +import contextlib import os import signal import sys import threading +import time # Prevents a second meshchat main() if Java starts two threads (e.g. activity edge cases). _server_loop_lock = threading.Lock() @@ -134,6 +136,72 @@ def _install_android_rnode_support(activity=None): print(f"meshchat_wrapper: Android RNode support skipped: {exc}") +def _probe_local_status_payload(port, timeout=1.5): + import http.client + import json + import ssl + + def _fetch(use_https): + conn = None + try: + if use_https: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + conn = http.client.HTTPSConnection( + "127.0.0.1", + port, + timeout=timeout, + context=context, + ) + else: + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout) + conn.request("GET", "/api/v1/status") + response = conn.getresponse() + body = response.read() + if response.status != 200: + return None + return json.loads(body.decode("utf-8")) + except Exception: + return None + finally: + if conn is not None: + with contextlib.suppress(Exception): + conn.close() + + return _fetch(True) or _fetch(False) + + +def _local_backend_matches(port, timeout=1.5): + payload = _probe_local_status_payload(port, timeout=timeout) + if not isinstance(payload, dict): + return False + expected_keys = {"stage", "network_ready", "listen_port", "https_enabled"} + if not expected_keys.issubset(payload.keys()): + return False + return payload.get("listen_port") == port + + +def _wait_for_own_backend_or_free_port( + port, + *, + wait_seconds=3.0, + poll_interval=0.3, + probe_timeout=1.5, +): + from meshchatx.src.backend.interface_port_check import is_port_in_use + + deadline = time.monotonic() + wait_seconds + while True: + if not is_port_in_use("127.0.0.1", port): + return "free" + if _local_backend_matches(port, timeout=probe_timeout): + return "serving" + if time.monotonic() >= deadline: + return "busy" + time.sleep(poll_interval) + + def start_server(port=8000, app_files_dir=None, activity=None): global _server_loop_active with _server_loop_lock: @@ -153,6 +221,26 @@ def start_server(port=8000, app_files_dir=None, activity=None): _ensure_android_reticulum_config(reticulum_config_dir) _clear_stale_storage_lock(storage_dir) + try: + port_outcome = _wait_for_own_backend_or_free_port(port) + except Exception as port_check_exc: + port_outcome = "free" + print( + f"meshchat_wrapper: port availability check skipped: {port_check_exc}" + ) + + if port_outcome == "serving": + print( + f"meshchat_wrapper: MeshChatX backend already serving on port {port}, " + "skipping duplicate start_server (activity was likely recreated)", + ) + return + if port_outcome == "busy": + print( + f"meshchat_wrapper: port {port} is still occupied by another process " + "after waiting, attempting to start anyway", + ) + original_signal = signal.signal def _safe_signal(sig, handler): diff --git a/meshchatx.rsm b/meshchatx.rsm index 6d722303..48055124 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/backend/page_node_manager.py b/meshchatx/src/backend/page_node_manager.py index bddd894e..d01c6d14 100644 --- a/meshchatx/src/backend/page_node_manager.py +++ b/meshchatx/src/backend/page_node_manager.py @@ -10,7 +10,10 @@ storage/page_nodes//. import os import uuid -from meshchatx.src.backend.page_node import PageNode, normalize_announce_interval_seconds +from meshchatx.src.backend.page_node import ( + PageNode, + normalize_announce_interval_seconds, +) class PageNodeManager: diff --git a/meshchatx/src/backend/rns_startup_recovery.py b/meshchatx/src/backend/rns_startup_recovery.py index c835af35..1d8ffcae 100644 --- a/meshchatx/src/backend/rns_startup_recovery.py +++ b/meshchatx/src/backend/rns_startup_recovery.py @@ -29,9 +29,7 @@ _ORIGINAL_PANIC = None _ORIGINAL_EXIT = None _EXIT_IN_PROGRESS = False -# Prefer disabling these types first when init fails without a named culprit. _HIGH_RISK_TYPES = ( - "I2PInterface", "RNodeMultiInterface", "RNodeInterface", "RNodeIPInterface", @@ -40,8 +38,46 @@ _HIGH_RISK_TYPES = ( "KISSInterface", "AX25KISSInterface", "PipeInterface", + "I2PInterface", ) +_CAPTURED_ERROR_LOG_LINES: list[str] = [] +_ORIGINAL_RNS_LOG = None + + +def _capturing_log(msg, level=3, *args, **kwargs): + try: + import RNS + + if level <= RNS.LOG_ERROR: + _CAPTURED_ERROR_LOG_LINES.append(str(msg)) + del _CAPTURED_ERROR_LOG_LINES[:-20] + except Exception: + pass + return _ORIGINAL_RNS_LOG(msg, level, *args, **kwargs) + + +@contextlib.contextmanager +def _capture_rns_error_logs(): + global _ORIGINAL_RNS_LOG + try: + import RNS + except Exception: + yield + return + + _CAPTURED_ERROR_LOG_LINES.clear() + _ORIGINAL_RNS_LOG = RNS.log + RNS.log = _capturing_log + try: + yield + finally: + RNS.log = _ORIGINAL_RNS_LOG + + +def _mentions_i2p(text: str) -> bool: + return "i2p" in str(text).lower() + class RnsPanicError(RuntimeError): """Raised instead of os._exit when RNS would panic or hard-exit.""" @@ -75,6 +111,8 @@ def install_rns_panic_containment(*, force: bool = False) -> bool: if _args: message = f"RNS.panic(): {_args[0]}" # Avoid logging handlers here. Panic can run under signal context. + if _CAPTURED_ERROR_LOG_LINES: + message = message + " | " + " | ".join(_CAPTURED_ERROR_LOG_LINES[-5:]) raise RnsPanicError(message) def _contained_exit(code: int = 0): @@ -289,7 +327,7 @@ def apply_startup_recovery_step( """Disable something that might be blocking RNS init. Returns disabled names. Steps escalate with *attempt*: - 0. Named interfaces from the error (if any), else I2P + 0. Named interfaces from the error (if any), else I2P when error mentions I2P 1. RNode / serial / kiss family 2. AutoInterface 3. Any remaining enabled high-risk interface (one at a time) @@ -308,7 +346,7 @@ def apply_startup_recovery_step( return disabled if attempt <= 0: - if i2p_support.disable_all_i2p_in_config(config_path): + if _mentions_i2p(error) and i2p_support.disable_all_i2p_in_config(config_path): # Names unknown here, so report a synthetic marker for logs/tests. disabled.append("__i2p__") return disabled @@ -354,7 +392,8 @@ def create_reticulum_with_recovery( last_exc: Exception | None = None for attempt in range(max_attempts): try: - return construct() + with _capture_rns_error_logs(): + return construct() except Exception as exc: last_exc = exc disabled = apply_startup_recovery_step( diff --git a/tests/backend/test_rns_startup_recovery.py b/tests/backend/test_rns_startup_recovery.py index e2b74821..68c06534 100644 --- a/tests/backend/test_rns_startup_recovery.py +++ b/tests/backend/test_rns_startup_recovery.py @@ -150,6 +150,84 @@ peers = aaa.b32.i2p ) +def test_apply_startup_recovery_step_does_not_blindly_disable_i2p(tmp_path): + config_path = tmp_path / "config" + config_path.write_text( + """[reticulum] +enable_transport = True +[interfaces] +[[MyI2P]] +type = I2PInterface +interface_enabled = true +peers = aaa.b32.i2p +""", + encoding="utf-8", + ) + disabled = recovery.apply_startup_recovery_step( + str(config_path), + "some unrelated bind failure with no interface name", + attempt=0, + ) + assert disabled == [] + cfg = ConfigObj(str(config_path)) + assert str(cfg["interfaces"]["MyI2P"]["interface_enabled"]).lower() in ( + "true", + "yes", + "1", + ) + + +def test_create_reticulum_with_recovery_uses_rns_log_for_unnamed_panic(tmp_path): + import RNS + + config_path = tmp_path / "config" + config_path.write_text( + """[reticulum] +enable_transport = True +[interfaces] +[[MyI2P]] +type = I2PInterface +interface_enabled = true +peers = aaa.b32.i2p +[[FlakyTcp]] +type = TCPClientInterface +interface_enabled = true +""", + encoding="utf-8", + ) + recovery.install_rns_panic_containment(force=True) + calls = {"n": 0} + + def construct(): + calls["n"] += 1 + if calls["n"] == 1: + RNS.log( + 'The interface "FlakyTcp" could not be created. Check your ' + "configuration file for errors!", + RNS.LOG_ERROR, + ) + RNS.panic() + return "ok" + + result = recovery.create_reticulum_with_recovery( + str(tmp_path), + construct=construct, + max_attempts=3, + ) + assert result == "ok" + cfg = ConfigObj(str(config_path)) + assert str(cfg["interfaces"]["FlakyTcp"]["interface_enabled"]).lower() in ( + "false", + "no", + "0", + ) + assert str(cfg["interfaces"]["MyI2P"]["interface_enabled"]).lower() in ( + "true", + "yes", + "1", + ) + + def test_extract_interface_names_from_error(): names = recovery.extract_interface_names_from_error( 'AutoInterface[HomeLAN] failed; also interface "Radio1" offline', diff --git a/tests/test_android_meshchat_wrapper.py b/tests/test_android_meshchat_wrapper.py index c87a78d5..f685dd8d 100644 --- a/tests/test_android_meshchat_wrapper.py +++ b/tests/test_android_meshchat_wrapper.py @@ -4,9 +4,13 @@ from __future__ import annotations +import http.server import importlib +import json +import socket import sys import threading +import time from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[1] @@ -15,6 +19,40 @@ if str(_ANDROID_PY) not in sys.path: sys.path.insert(0, str(_ANDROID_PY)) +def _free_port(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +class _FakeStatusHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/api/v1/status": + self.send_response(404) + self.end_headers() + return + body = json.dumps( + { + "status": "ok", + "stage": "ready", + "network_ready": True, + "network_degraded": False, + "listen_port": self.server.server_port, + "https_enabled": True, + }, + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args, **_kwargs): + pass + + def test_start_server_second_call_skips_while_main_blocks(monkeypatch): import meshchatx.meshchat as mm @@ -114,3 +152,80 @@ def test_start_server_systemexit_includes_cause(monkeypatch): message = str(excinfo.value) assert "code=1" in message assert "storage lock held by pid 12345" in message + + +def test_wait_for_own_backend_or_free_port_reports_free_port(): + import meshchat_wrapper + + importlib.reload(meshchat_wrapper) + port = _free_port() + outcome = meshchat_wrapper._wait_for_own_backend_or_free_port( + port, + wait_seconds=1.0, + poll_interval=0.1, + ) + assert outcome == "free" + + +def test_wait_for_own_backend_or_free_port_detects_existing_backend(): + import meshchat_wrapper + + importlib.reload(meshchat_wrapper) + port = _free_port() + server = http.server.HTTPServer(("127.0.0.1", port), _FakeStatusHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + started = time.monotonic() + outcome = meshchat_wrapper._wait_for_own_backend_or_free_port( + port, + wait_seconds=5.0, + poll_interval=0.1, + probe_timeout=1.0, + ) + elapsed = time.monotonic() - started + assert outcome == "serving" + assert elapsed < 4.0 + finally: + server.shutdown() + thread.join(timeout=5.0) + + +def test_wait_for_own_backend_or_free_port_reports_busy_for_foreign_listener(): + import meshchat_wrapper + + importlib.reload(meshchat_wrapper) + port = _free_port() + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", port)) + listener.listen(1) + try: + outcome = meshchat_wrapper._wait_for_own_backend_or_free_port( + port, + wait_seconds=0.2, + poll_interval=0.1, + probe_timeout=0.3, + ) + assert outcome == "busy" + finally: + listener.close() + + +def test_start_server_skips_duplicate_when_backend_already_serving(monkeypatch): + import meshchatx.meshchat as mm + + calls: list[int] = [] + monkeypatch.setattr(mm, "main", lambda: calls.append(1)) + + import meshchat_wrapper + + importlib.reload(meshchat_wrapper) + monkeypatch.setattr( + meshchat_wrapper, + "_wait_for_own_backend_or_free_port", + lambda *_a, **_k: "serving", + ) + + meshchat_wrapper.start_server(8000, None) + assert calls == [] + assert meshchat_wrapper._server_loop_active is False