feat: wire muxterm into FastAPI lifespan (start on boot, stop on shutdown)
This commit is contained in:
+16
-15
@@ -19,6 +19,7 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
|
import secrets as _secrets_mod
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import shlex
|
import shlex
|
||||||
@@ -79,16 +80,7 @@ from muxplex.settings import (
|
|||||||
from muxplex.pruning import load_pruning_state, save_pruning_state
|
from muxplex.pruning import load_pruning_state, save_pruning_state
|
||||||
from muxplex.views import normalize_session_keys, prune_stale_keys
|
from muxplex.views import normalize_session_keys, prune_stale_keys
|
||||||
from muxplex.identity import load_device_id
|
from muxplex.identity import load_device_id
|
||||||
from muxplex.ttyd import (
|
from muxplex.muxterm import start_muxterm, stop_muxterm
|
||||||
TTYD_PORT,
|
|
||||||
_ttyd_is_listening,
|
|
||||||
get_or_spawn,
|
|
||||||
kill_orphan_ttyd,
|
|
||||||
kill_session,
|
|
||||||
kill_ttyd, # noqa: F401 — backward compat re-export used by tests
|
|
||||||
pool_port,
|
|
||||||
spawn_ttyd, # noqa: F401 — backward compat re-export used by tests
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -99,7 +91,9 @@ SERVER_PORT: int = int(os.environ.get("MUXPLEX_PORT", "8088"))
|
|||||||
MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
|
MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
|
||||||
SETTINGS_SYNC_INTERVAL: int = 15 # sync every ~30 seconds (15 * 2s poll interval)
|
SETTINGS_SYNC_INTERVAL: int = 15 # sync every ~30 seconds (15 * 2s poll interval)
|
||||||
|
|
||||||
_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "")
|
_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "") or _secrets_mod.token_hex(
|
||||||
|
32
|
||||||
|
)
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -377,9 +371,15 @@ async def lifespan(app: FastAPI):
|
|||||||
global _poll_task
|
global _poll_task
|
||||||
global _federation_client
|
global _federation_client
|
||||||
|
|
||||||
# Startup: kill any orphaned ttyd from a previous muxplex run, then
|
# Startup: start muxterm (if secret is configured) then start the
|
||||||
# start the background poll loop.
|
# background poll loop.
|
||||||
await kill_orphan_ttyd()
|
if _muxterm_secret:
|
||||||
|
try:
|
||||||
|
await start_muxterm(secret=_muxterm_secret, port=MUXTERM_PORT)
|
||||||
|
except FileNotFoundError:
|
||||||
|
_log.warning("muxterm binary not found; terminal feature disabled")
|
||||||
|
except Exception:
|
||||||
|
_log.warning("failed to start muxterm", exc_info=True)
|
||||||
_poll_task = asyncio.create_task(_poll_loop())
|
_poll_task = asyncio.create_task(_poll_loop())
|
||||||
|
|
||||||
# Register tmux alert-bell hook so bells are detected even when clients are attached.
|
# Register tmux alert-bell hook so bells are detected even when clients are attached.
|
||||||
@@ -416,7 +416,8 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("federation_client aclose error")
|
_log.exception("federation_client aclose error")
|
||||||
finally:
|
finally:
|
||||||
# Cleanup: cancel the poll loop task and wait for it to finish.
|
# Cleanup: stop muxterm and cancel the poll loop task.
|
||||||
|
await stop_muxterm()
|
||||||
if _poll_task is not None:
|
if _poll_task is not None:
|
||||||
_poll_task.cancel()
|
_poll_task.cancel()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,24 +15,18 @@ from muxplex.main import app
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_startup_and_state(tmp_path, monkeypatch):
|
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||||
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op."""
|
"""Redirect state files to tmp_path, mock muxterm start/stop, replace _poll_loop with no-op."""
|
||||||
# Redirect state files
|
# Redirect state files
|
||||||
tmp_state_dir = tmp_path / "state"
|
tmp_state_dir = tmp_path / "state"
|
||||||
tmp_state_path = tmp_state_dir / "state.json"
|
tmp_state_path = tmp_state_dir / "state.json"
|
||||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
# Redirect PID files
|
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
|
||||||
tmp_pid_dir = tmp_path / "ttyd"
|
from unittest.mock import AsyncMock
|
||||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
|
||||||
|
|
||||||
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
|
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||||
async def _mock_kill_orphan():
|
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
|
||||||
|
|
||||||
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
||||||
async def noop_poll_loop() -> None:
|
async def noop_poll_loop() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Tests for muxterm wiring into FastAPI lifespan (task-8).
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- ttyd imports removed, muxterm imports present
|
||||||
|
- _muxterm_secret auto-generates when MUXTERM_SECRET env var is empty
|
||||||
|
- lifespan calls start_muxterm on boot with correct args
|
||||||
|
- lifespan calls stop_muxterm on shutdown
|
||||||
|
- FileNotFoundError from start_muxterm is caught gracefully
|
||||||
|
- Generic Exception from start_muxterm is caught gracefully
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import structure tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_does_not_import_ttyd_names():
|
||||||
|
"""main.py must not export ttyd names (kill_orphan_ttyd, spawn_ttyd, etc.)."""
|
||||||
|
import muxplex.main as main_mod
|
||||||
|
|
||||||
|
assert not hasattr(main_mod, "kill_orphan_ttyd")
|
||||||
|
assert not hasattr(main_mod, "spawn_ttyd")
|
||||||
|
assert not hasattr(main_mod, "kill_ttyd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_imports_muxterm_functions():
|
||||||
|
"""main.py must import start_muxterm and stop_muxterm from muxterm module."""
|
||||||
|
import muxplex.main as main_mod
|
||||||
|
|
||||||
|
assert hasattr(main_mod, "start_muxterm")
|
||||||
|
assert hasattr(main_mod, "stop_muxterm")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secret auto-generation test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_muxterm_secret_auto_generates_when_env_empty():
|
||||||
|
"""_muxterm_secret must be a non-empty hex string when MUXTERM_SECRET env is unset."""
|
||||||
|
import muxplex.main as main_mod
|
||||||
|
|
||||||
|
secret = main_mod._muxterm_secret
|
||||||
|
assert isinstance(secret, str)
|
||||||
|
assert len(secret) > 0, "_muxterm_secret must auto-generate when env var is empty"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifespan wiring tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def patch_startup_deps(tmp_path, monkeypatch):
|
||||||
|
"""Redirect state files, mock muxterm and poll loop for clean lifespan tests."""
|
||||||
|
# Redirect state files
|
||||||
|
tmp_state_dir = tmp_path / "state"
|
||||||
|
tmp_state_path = tmp_state_dir / "state.json"
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
|
# Replace _poll_loop with no-op
|
||||||
|
async def noop_poll_loop() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_federation_cache():
|
||||||
|
"""Clear _federation_cache before and after each test."""
|
||||||
|
import muxplex.main as main_mod
|
||||||
|
|
||||||
|
main_mod._federation_cache.clear()
|
||||||
|
yield
|
||||||
|
main_mod._federation_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_calls_start_muxterm_on_boot(monkeypatch):
|
||||||
|
"""Lifespan must call start_muxterm(secret=..., port=MUXTERM_PORT) on startup."""
|
||||||
|
from muxplex.main import app, MUXTERM_PORT
|
||||||
|
|
||||||
|
mock_start = AsyncMock()
|
||||||
|
mock_stop = AsyncMock()
|
||||||
|
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||||
|
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||||
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||||
|
|
||||||
|
# Redirect state files
|
||||||
|
import tempfile
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||||
|
|
||||||
|
async def noop_poll_loop() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||||
|
|
||||||
|
with TestClient(app):
|
||||||
|
pass # lifespan runs on enter
|
||||||
|
|
||||||
|
mock_start.assert_called_once()
|
||||||
|
call_kwargs = mock_start.call_args
|
||||||
|
# start_muxterm should be called with secret= and port=
|
||||||
|
assert "secret" in call_kwargs.kwargs or len(call_kwargs.args) >= 1
|
||||||
|
assert call_kwargs.kwargs.get("port") == MUXTERM_PORT or (
|
||||||
|
len(call_kwargs.args) >= 2 and call_kwargs.args[1] == MUXTERM_PORT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_calls_stop_muxterm_on_shutdown(monkeypatch):
|
||||||
|
"""Lifespan must call stop_muxterm() during shutdown."""
|
||||||
|
from muxplex.main import app
|
||||||
|
|
||||||
|
mock_start = AsyncMock()
|
||||||
|
mock_stop = AsyncMock()
|
||||||
|
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||||
|
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||||
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||||
|
|
||||||
|
async def noop_poll_loop() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||||
|
|
||||||
|
with TestClient(app):
|
||||||
|
pass # lifespan runs on enter, shutdown on exit
|
||||||
|
|
||||||
|
mock_stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_handles_file_not_found_error(monkeypatch):
|
||||||
|
"""Lifespan must catch FileNotFoundError from start_muxterm gracefully."""
|
||||||
|
from muxplex.main import app
|
||||||
|
|
||||||
|
mock_start = AsyncMock(side_effect=FileNotFoundError("muxterm not found"))
|
||||||
|
mock_stop = AsyncMock()
|
||||||
|
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||||
|
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||||
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||||
|
|
||||||
|
async def noop_poll_loop() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||||
|
|
||||||
|
# Should NOT raise - lifespan catches FileNotFoundError
|
||||||
|
with TestClient(app) as c:
|
||||||
|
resp = c.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_handles_generic_exception(monkeypatch):
|
||||||
|
"""Lifespan must catch generic Exception from start_muxterm gracefully."""
|
||||||
|
from muxplex.main import app
|
||||||
|
|
||||||
|
mock_start = AsyncMock(side_effect=RuntimeError("something went wrong"))
|
||||||
|
mock_stop = AsyncMock()
|
||||||
|
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||||
|
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||||
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||||
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||||
|
|
||||||
|
async def noop_poll_loop() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||||
|
|
||||||
|
# Should NOT raise - lifespan catches generic Exception
|
||||||
|
with TestClient(app) as c:
|
||||||
|
resp = c.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
@@ -23,21 +23,17 @@ from muxplex.main import app
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_startup_and_state(tmp_path, monkeypatch):
|
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||||
"""Redirect state/PID files to tmp_path and stub out long-running startup tasks."""
|
"""Redirect state files to tmp_path and stub out long-running startup tasks."""
|
||||||
tmp_state_dir = tmp_path / "state"
|
tmp_state_dir = tmp_path / "state"
|
||||||
tmp_state_path = tmp_state_dir / "state.json"
|
tmp_state_path = tmp_state_dir / "state.json"
|
||||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
tmp_pid_dir = tmp_path / "ttyd"
|
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
|
||||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
from unittest.mock import AsyncMock
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
|
||||||
|
|
||||||
async def _mock_kill_orphan():
|
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||||
return False
|
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||||
|
|
||||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
|
||||||
|
|
||||||
async def noop_poll_loop() -> None:
|
async def noop_poll_loop() -> None:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -68,15 +68,11 @@ def client(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
tmp_pid_dir = tmp_path / "ttyd"
|
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
|
||||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
from unittest.mock import AsyncMock
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
|
||||||
|
|
||||||
async def _mock_kill_orphan():
|
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||||
return False
|
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||||
|
|
||||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
|
||||||
|
|
||||||
async def noop_poll_loop() -> None:
|
async def noop_poll_loop() -> None:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -42,24 +42,18 @@ def _wait_for(condition, timeout: float = 2.0, interval: float = 0.01) -> bool:
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_startup_and_state(tmp_path, monkeypatch):
|
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||||
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op."""
|
"""Redirect state files to tmp_path, mock muxterm start/stop, replace _poll_loop with no-op."""
|
||||||
# Redirect state files
|
# Redirect state files
|
||||||
tmp_state_dir = tmp_path / "state"
|
tmp_state_dir = tmp_path / "state"
|
||||||
tmp_state_path = tmp_state_dir / "state.json"
|
tmp_state_path = tmp_state_dir / "state.json"
|
||||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
# Redirect PID files
|
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
|
||||||
tmp_pid_dir = tmp_path / "ttyd"
|
from unittest.mock import AsyncMock
|
||||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
|
||||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
|
||||||
|
|
||||||
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
|
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||||
async def _mock_kill_orphan():
|
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
|
||||||
|
|
||||||
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
||||||
async def noop_poll_loop() -> None:
|
async def noop_poll_loop() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user