mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: add local backend status probing and port availability checks in meshchat_wrapper
This commit is contained in:
parent
b5f9665d99
commit
4e4ef0526a
6 changed files with 329 additions and 6 deletions
|
|
@ -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):
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue