fix: muxterm binary discovery — find local build before PATH search

_find_muxterm_binary() now resolves in this order:
  1. Explicit binary_path argument (unchanged)
  2. MUXTERM_BINARY env var (new)
  3. <repo_root>/muxterm/muxterm — the go build output for dev installs (new)
  4. shutil.which('muxterm') — PATH fallback (unchanged)

Before this fix the service logged 'muxterm binary not found; terminal
feature disabled' on every restart because the binary is not on PATH
and no explicit path was passed from main.py.

Also fix stop_muxterm() to catch ProcessLookupError from terminate()
when the process has already exited (was only caught in the wait_for
block, not the terminate() call itself), which caused a noisy
'Application shutdown failed' traceback in the service journal.
This commit is contained in:
Ken
2026-05-28 08:16:06 +00:00
parent 469acd7e3f
commit c3b0502297
2 changed files with 47 additions and 10 deletions
+33 -4
View File
@@ -16,6 +16,8 @@ Internal:
import asyncio
import logging
import os
import pathlib
import shutil
logger = logging.getLogger(__name__)
@@ -35,16 +37,37 @@ _restart_task: asyncio.Task | None = None # type: ignore[type-arg]
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.
Resolution order:
1. Explicit *binary_path* argument.
2. ``MUXTERM_BINARY`` environment variable.
3. ``<repo_root>/muxterm/muxterm`` — the build output for dev/local installs
(package lives at ``<repo_root>/muxplex/``, binary at
``<repo_root>/muxterm/muxterm``).
4. ``shutil.which('muxterm')`` — installed on PATH.
Raises :exc:`FileNotFoundError` if none of the above resolves.
"""
if binary_path is not None:
return binary_path
env_path = os.environ.get("MUXTERM_BINARY")
if env_path:
candidate = pathlib.Path(env_path)
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
# Dev/local-install: binary is built next to the Python package directory.
# muxterm.py lives in <repo_root>/muxplex/
# muxterm binary is at <repo_root>/muxterm/muxterm
local_candidate = pathlib.Path(__file__).parent.parent / "muxterm" / "muxterm"
if local_candidate.is_file() and os.access(local_candidate, os.X_OK):
return str(local_candidate)
found = shutil.which("muxterm")
if found is None:
raise FileNotFoundError(
"muxterm binary not found on PATH; pass binary_path explicitly"
"muxterm binary not found; tried MUXTERM_BINARY env var, "
f"{local_candidate}, and PATH. Build it with `cd muxterm && go build .`"
)
return found
@@ -157,9 +180,15 @@ async def stop_muxterm() -> None:
_restart_task = None
if _muxterm_process is not None:
try:
_muxterm_process.terminate()
except ProcessLookupError:
pass # already exited
try:
await asyncio.wait_for(_muxterm_process.wait(), timeout=5.0)
except (asyncio.TimeoutError, ProcessLookupError):
try:
_muxterm_process.kill()
except ProcessLookupError:
pass
_muxterm_process = None
+12 -4
View File
@@ -46,16 +46,24 @@ def test_find_muxterm_binary_explicit_path(tmp_path):
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:
"""Falls back to shutil.which('muxterm') when no env var and no local binary."""
with (
patch.dict("os.environ", {"MUXTERM_BINARY": ""}), # suppress env override
patch("muxplex.muxterm.pathlib.Path.is_file", return_value=False), # no local build
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):
"""Raises FileNotFoundError if muxterm is not found anywhere."""
with (
patch.dict("os.environ", {"MUXTERM_BINARY": ""}), # suppress env override
patch("muxplex.muxterm.pathlib.Path.is_file", return_value=False), # no local build
patch("shutil.which", return_value=None),
):
with pytest.raises(FileNotFoundError):
_find_muxterm_binary()