From 8c167453de17d6922ecbcaf724464bf1dcb1e460 Mon Sep 17 00:00:00 2001 From: Ken Date: Thu, 28 May 2026 06:32:16 +0000 Subject: [PATCH] feat: wire muxterm into FastAPI lifespan (start on boot, stop on shutdown) --- muxplex/main.py | 31 ++-- muxplex/tests/test_api.py | 16 +- muxplex/tests/test_lifespan_muxterm.py | 202 +++++++++++++++++++++++++ muxplex/tests/test_main.py | 14 +- muxplex/tests/test_vendor_ghostty.py | 12 +- muxplex/tests/test_ws_proxy.py | 16 +- 6 files changed, 237 insertions(+), 54 deletions(-) create mode 100644 muxplex/tests/test_lifespan_muxterm.py diff --git a/muxplex/main.py b/muxplex/main.py index f23aa29..d86d7ee 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -19,6 +19,7 @@ import os import pathlib import pwd import re +import secrets as _secrets_mod import socket import ssl import shlex @@ -79,16 +80,7 @@ from muxplex.settings import ( from muxplex.pruning import load_pruning_state, save_pruning_state from muxplex.views import normalize_session_keys, prune_stale_keys from muxplex.identity import load_device_id -from muxplex.ttyd import ( - 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 -) +from muxplex.muxterm import start_muxterm, stop_muxterm # --------------------------------------------------------------------------- # 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")) 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__) @@ -377,9 +371,15 @@ async def lifespan(app: FastAPI): global _poll_task global _federation_client - # Startup: kill any orphaned ttyd from a previous muxplex run, then - # start the background poll loop. - await kill_orphan_ttyd() + # Startup: start muxterm (if secret is configured) then start the + # background poll loop. + 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()) # 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: _log.exception("federation_client aclose error") 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: _poll_task.cancel() try: diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 8ce1e71..10d573d 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -15,24 +15,18 @@ from muxplex.main import app @pytest.fixture(autouse=True) 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 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) - # Redirect PID files - tmp_pid_dir = tmp_path / "ttyd" - 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 start_muxterm/stop_muxterm so startup doesn't touch real processes + from unittest.mock import AsyncMock - # Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async) - async def _mock_kill_orphan(): - return False - - monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan) + monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock()) + monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock()) # Replace _poll_loop with a no-op so tests don't spin up real poll cycles async def noop_poll_loop() -> None: diff --git a/muxplex/tests/test_lifespan_muxterm.py b/muxplex/tests/test_lifespan_muxterm.py new file mode 100644 index 0000000..dd83299 --- /dev/null +++ b/muxplex/tests/test_lifespan_muxterm.py @@ -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 diff --git a/muxplex/tests/test_main.py b/muxplex/tests/test_main.py index d4b1171..72ace09 100644 --- a/muxplex/tests/test_main.py +++ b/muxplex/tests/test_main.py @@ -23,21 +23,17 @@ from muxplex.main import app @pytest.fixture(autouse=True) 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_path = tmp_state_dir / "state.json" monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) - tmp_pid_dir = tmp_path / "ttyd" - 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 start_muxterm/stop_muxterm so startup doesn't touch real processes + from unittest.mock import AsyncMock - async def _mock_kill_orphan(): - return False - - monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan) + monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock()) + monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock()) async def noop_poll_loop() -> None: pass diff --git a/muxplex/tests/test_vendor_ghostty.py b/muxplex/tests/test_vendor_ghostty.py index 4f6e184..ab3a4d5 100644 --- a/muxplex/tests/test_vendor_ghostty.py +++ b/muxplex/tests/test_vendor_ghostty.py @@ -68,15 +68,11 @@ def client(tmp_path, monkeypatch): monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) - tmp_pid_dir = tmp_path / "ttyd" - 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 start_muxterm/stop_muxterm so startup doesn't touch real processes + from unittest.mock import AsyncMock - async def _mock_kill_orphan(): - return False - - monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan) + monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock()) + monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock()) async def noop_poll_loop() -> None: pass diff --git a/muxplex/tests/test_ws_proxy.py b/muxplex/tests/test_ws_proxy.py index f6e9eba..8aecb04 100644 --- a/muxplex/tests/test_ws_proxy.py +++ b/muxplex/tests/test_ws_proxy.py @@ -42,24 +42,18 @@ def _wait_for(condition, timeout: float = 2.0, interval: float = 0.01) -> bool: @pytest.fixture(autouse=True) 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 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) - # Redirect PID files - tmp_pid_dir = tmp_path / "ttyd" - 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 start_muxterm/stop_muxterm so startup doesn't touch real processes + from unittest.mock import AsyncMock - # Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async) - async def _mock_kill_orphan(): - return False - - monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan) + monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock()) + monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock()) # Replace _poll_loop with a no-op so tests don't spin up real poll cycles async def noop_poll_loop() -> None: