feat: add local backend status probing and port availability checks in meshchat_wrapper

This commit is contained in:
Ivan 2026-08-01 17:47:19 -05:00
parent b5f9665d99
commit 4e4ef0526a
No known key found for this signature in database
6 changed files with 329 additions and 6 deletions

View file

@ -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):

Binary file not shown.

View file

@ -10,7 +10,10 @@ storage/page_nodes/<node_id>/.
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:

View file

@ -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(

View file

@ -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',

View file

@ -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