feat: muxterm process supervision (start, restart on crash, stop)
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
muxterm process supervision — start, monitor, restart on crash, stop.
|
||||
|
||||
Module state:
|
||||
_muxterm_process — the currently running muxterm subprocess (or None)
|
||||
_restart_task — the background monitor/restart task (or None)
|
||||
|
||||
Public API:
|
||||
start_muxterm(secret, port, binary_path, auto_restart)
|
||||
stop_muxterm()
|
||||
|
||||
Internal:
|
||||
_find_muxterm_binary(binary_path)
|
||||
_monitor_and_restart(secret, port, binary_path)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_muxterm_process: asyncio.subprocess.Process | None = None
|
||||
_restart_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_muxterm_binary(binary_path: str | None = None) -> str:
|
||||
"""Locate the muxterm binary.
|
||||
|
||||
Checks explicit *binary_path* first, then falls back to
|
||||
``shutil.which('muxterm')``. Raises :exc:`FileNotFoundError` if
|
||||
the binary cannot be found.
|
||||
"""
|
||||
if binary_path is not None:
|
||||
return binary_path
|
||||
found = shutil.which("muxterm")
|
||||
if found is None:
|
||||
raise FileNotFoundError(
|
||||
"muxterm binary not found on PATH; pass binary_path explicitly"
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def start_muxterm(
|
||||
secret: str,
|
||||
port: int = 7682,
|
||||
binary_path: str | None = None,
|
||||
auto_restart: bool = True,
|
||||
) -> None:
|
||||
"""Start the muxterm process.
|
||||
|
||||
Finds the binary via :func:`_find_muxterm_binary`, spawns it with
|
||||
``--addr 127.0.0.1:<port> --secret <secret>``, and optionally starts
|
||||
the background monitor/restart loop.
|
||||
"""
|
||||
global _muxterm_process, _restart_task
|
||||
|
||||
binary = _find_muxterm_binary(binary_path)
|
||||
|
||||
_muxterm_process = await asyncio.create_subprocess_exec(
|
||||
binary,
|
||||
"--addr",
|
||||
f"127.0.0.1:{port}",
|
||||
"--secret",
|
||||
secret,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
logger.info("muxterm started (pid=%s, port=%s)", _muxterm_process.pid, port)
|
||||
|
||||
if auto_restart:
|
||||
_restart_task = asyncio.create_task(
|
||||
_monitor_and_restart(secret, port, binary_path)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monitor / restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _monitor_and_restart(
|
||||
secret: str,
|
||||
port: int,
|
||||
binary_path: str | None,
|
||||
) -> None:
|
||||
"""Wait for process exit; restart on non-zero exit codes.
|
||||
|
||||
Normal exit (rc=0) ends the loop. On crash, waits 1 s before
|
||||
restarting, with 5 s backoff on repeated failures.
|
||||
"""
|
||||
global _muxterm_process
|
||||
|
||||
backoff = 1.0
|
||||
while True:
|
||||
if _muxterm_process is None:
|
||||
return
|
||||
|
||||
rc = await _muxterm_process.wait()
|
||||
|
||||
if rc == 0:
|
||||
logger.info("muxterm exited cleanly (rc=0)")
|
||||
return
|
||||
|
||||
logger.warning("muxterm exited with rc=%s, restarting in %.0fs", rc, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
try:
|
||||
binary = _find_muxterm_binary(binary_path)
|
||||
_muxterm_process = await asyncio.create_subprocess_exec(
|
||||
binary,
|
||||
"--addr",
|
||||
f"127.0.0.1:{port}",
|
||||
"--secret",
|
||||
secret,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
logger.info(
|
||||
"muxterm restarted (pid=%s, port=%s)", _muxterm_process.pid, port
|
||||
)
|
||||
# Reset backoff on successful spawn, increase on repeated failures
|
||||
backoff = min(backoff + 1.0, 5.0)
|
||||
except Exception:
|
||||
logger.exception("failed to restart muxterm, retrying in 5s")
|
||||
backoff = 5.0
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def stop_muxterm() -> None:
|
||||
"""Stop the muxterm process and cancel the restart task.
|
||||
|
||||
Terminates with a 5 s grace period, then kills if still alive.
|
||||
"""
|
||||
global _muxterm_process, _restart_task
|
||||
|
||||
if _restart_task is not None:
|
||||
_restart_task.cancel()
|
||||
_restart_task = None
|
||||
|
||||
if _muxterm_process is not None:
|
||||
_muxterm_process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(_muxterm_process.wait(), timeout=5.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
_muxterm_process.kill()
|
||||
_muxterm_process = None
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Tests for muxplex/muxterm.py — muxterm process supervision (start, restart, stop).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import muxplex.muxterm as muxterm_mod
|
||||
from muxplex.muxterm import (
|
||||
_find_muxterm_binary,
|
||||
start_muxterm,
|
||||
stop_muxterm,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# autouse fixture — reset module-level state between tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_muxterm_state():
|
||||
"""Reset module-level muxterm state before every test."""
|
||||
muxterm_mod._muxterm_process = None
|
||||
muxterm_mod._restart_task = None
|
||||
yield
|
||||
# Teardown: clean up again
|
||||
muxterm_mod._muxterm_process = None
|
||||
muxterm_mod._restart_task = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_muxterm_binary tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_muxterm_binary_explicit_path(tmp_path):
|
||||
"""Explicit binary path is returned as-is."""
|
||||
binary = tmp_path / "muxterm"
|
||||
binary.touch()
|
||||
binary.chmod(0o755)
|
||||
result = _find_muxterm_binary(str(binary))
|
||||
assert result == str(binary)
|
||||
|
||||
|
||||
def test_find_muxterm_binary_uses_which():
|
||||
"""Falls back to shutil.which('muxterm') when no explicit path."""
|
||||
with patch("shutil.which", return_value="/usr/bin/muxterm") as mock_which:
|
||||
result = _find_muxterm_binary()
|
||||
mock_which.assert_called_once_with("muxterm")
|
||||
assert result == "/usr/bin/muxterm"
|
||||
|
||||
|
||||
def test_find_muxterm_binary_raises_when_not_found():
|
||||
"""Raises FileNotFoundError if muxterm is not found."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_find_muxterm_binary()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_muxterm tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_start_muxterm_spawns_process():
|
||||
"""start_muxterm calls create_subprocess_exec with correct args and stores process."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"muxplex.muxterm._find_muxterm_binary",
|
||||
return_value="/usr/local/bin/muxterm",
|
||||
),
|
||||
patch(
|
||||
"asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_proc),
|
||||
) as mock_create,
|
||||
):
|
||||
await start_muxterm("test-secret", port=7682, auto_restart=False)
|
||||
|
||||
# Verify the binary path was passed as first arg
|
||||
mock_create.assert_called_once()
|
||||
call_args = mock_create.call_args
|
||||
assert call_args[0][0] == "/usr/local/bin/muxterm"
|
||||
assert "--addr" in call_args[0]
|
||||
assert "127.0.0.1:7682" in call_args[0]
|
||||
assert "--secret" in call_args[0]
|
||||
assert "test-secret" in call_args[0]
|
||||
|
||||
# Verify process stored in module state
|
||||
assert muxterm_mod._muxterm_process is mock_proc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stop_muxterm tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stop_muxterm_terminates_process():
|
||||
"""stop_muxterm terminates the process and sets state to None."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.terminate = MagicMock()
|
||||
mock_proc.kill = MagicMock()
|
||||
mock_proc.wait = AsyncMock()
|
||||
|
||||
muxterm_mod._muxterm_process = mock_proc
|
||||
|
||||
await stop_muxterm()
|
||||
|
||||
mock_proc.terminate.assert_called_once()
|
||||
assert muxterm_mod._muxterm_process is None
|
||||
|
||||
|
||||
async def test_stop_muxterm_cancels_restart_task():
|
||||
"""stop_muxterm cancels the restart task if active."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.terminate = MagicMock()
|
||||
mock_proc.kill = MagicMock()
|
||||
mock_proc.wait = AsyncMock()
|
||||
|
||||
mock_task = MagicMock()
|
||||
mock_task.cancel = MagicMock()
|
||||
|
||||
muxterm_mod._muxterm_process = mock_proc
|
||||
muxterm_mod._restart_task = mock_task
|
||||
|
||||
await stop_muxterm()
|
||||
|
||||
mock_task.cancel.assert_called_once()
|
||||
assert muxterm_mod._restart_task is None
|
||||
|
||||
|
||||
async def test_stop_muxterm_kills_on_timeout():
|
||||
"""stop_muxterm kills the process if terminate doesn't finish in time."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.terminate = MagicMock()
|
||||
mock_proc.kill = MagicMock()
|
||||
mock_proc.wait = AsyncMock(side_effect=asyncio.TimeoutError)
|
||||
|
||||
muxterm_mod._muxterm_process = mock_proc
|
||||
|
||||
await stop_muxterm()
|
||||
|
||||
mock_proc.terminate.assert_called_once()
|
||||
mock_proc.kill.assert_called_once()
|
||||
assert muxterm_mod._muxterm_process is None
|
||||
Reference in New Issue
Block a user