fix: macOS launchd service — modern bootstrap API, auto-start, --host 0.0.0.0
Replace deprecated launchctl load/unload with bootstrap/bootout (modern macOS API). Auto-bootout existing service before reinstall (fixes 'I/O error' on reload). Add --host 0.0.0.0 to plist ProgramArguments so the service is actually accessible from the network. install-service now auto-starts the service and prints management commands. upgrade() also uses modern API. doctor() checks if service is actually running.
This commit is contained in:
+73
-11
@@ -287,7 +287,18 @@ def doctor() -> None:
|
||||
if sys.platform == "darwin":
|
||||
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
||||
if plist.exists():
|
||||
print(f" {ok_mark} Service: launchd agent installed ({plist})")
|
||||
uid = os.getuid()
|
||||
result = subprocess.run(
|
||||
["launchctl", "print", f"gui/{uid}/com.muxplex"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" {ok_mark} Service: launchd agent running")
|
||||
else:
|
||||
print(
|
||||
f" {warn_mark} Service: launchd agent installed but not running ({plist})"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" {warn_mark} Service: not installed (run: muxplex install-service)"
|
||||
@@ -336,21 +347,25 @@ def _install_launchd(executable: str) -> None:
|
||||
import shutil
|
||||
|
||||
# Prefer the entry point script ('muxplex') so macOS shows the correct
|
||||
# process name in Activity Monitor, launchctl list, and login items.
|
||||
# Fall back to 'python -m muxplex' if the entry point isn't on PATH.
|
||||
# process name in Activity Monitor and launchctl list.
|
||||
muxplex_bin = shutil.which("muxplex")
|
||||
if muxplex_bin:
|
||||
program_args = f""" <array>
|
||||
<string>{muxplex_bin}</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
</array>"""
|
||||
else:
|
||||
program_args = f""" <array>
|
||||
<string>{executable}</string>
|
||||
<string>-m</string>
|
||||
<string>muxplex</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
</array>"""
|
||||
|
||||
label = "com.muxplex"
|
||||
uid = os.getuid()
|
||||
plist = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -372,12 +387,44 @@ def _install_launchd(executable: str) -> None:
|
||||
"""
|
||||
path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Unload existing service first (ignore errors if not loaded)
|
||||
subprocess.run(
|
||||
["launchctl", "bootout", f"gui/{uid}/{label}"],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
path.write_text(plist)
|
||||
print(f"Launch agent written to {path}")
|
||||
print("Enable with:")
|
||||
print(f" launchctl load {path}")
|
||||
print("Disable with:")
|
||||
print(f" launchctl unload {path}")
|
||||
print()
|
||||
|
||||
# Load the service using the modern bootstrap API
|
||||
result = subprocess.run(
|
||||
["launchctl", "bootstrap", f"gui/{uid}", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" Service started (launchctl bootstrap gui/{uid})")
|
||||
else:
|
||||
# Fallback to legacy load for older macOS
|
||||
result2 = subprocess.run(
|
||||
["launchctl", "load", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result2.returncode == 0:
|
||||
print(" Service started (launchctl load)")
|
||||
else:
|
||||
print(" Could not auto-start. Try manually:")
|
||||
print(f" launchctl bootstrap gui/{uid} {path}")
|
||||
|
||||
print()
|
||||
print("Management commands:")
|
||||
print(f" Stop: launchctl bootout gui/{uid}/{label}")
|
||||
print(f" Start: launchctl bootstrap gui/{uid} {path}")
|
||||
print(f" Status: launchctl print gui/{uid}/{label}")
|
||||
print(" Logs: tail -f /tmp/muxplex.log")
|
||||
|
||||
|
||||
def _install_systemd(executable: str, *, system: bool = False) -> None:
|
||||
@@ -453,10 +500,14 @@ def upgrade(*, force: bool = False) -> None:
|
||||
|
||||
# 1. Detect platform and stop service
|
||||
if sys.platform == "darwin":
|
||||
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
||||
label = "com.muxplex"
|
||||
uid = os.getuid()
|
||||
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||
if plist.exists():
|
||||
print(" Stopping launchd service...")
|
||||
subprocess.run(["launchctl", "unload", str(plist)], capture_output=True)
|
||||
subprocess.run(
|
||||
["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True
|
||||
)
|
||||
else:
|
||||
print(" No launchd service found (skipping stop)")
|
||||
else:
|
||||
@@ -521,11 +572,22 @@ def upgrade(*, force: bool = False) -> None:
|
||||
|
||||
# 4. Restart service
|
||||
if sys.platform == "darwin":
|
||||
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
||||
label = "com.muxplex"
|
||||
uid = os.getuid()
|
||||
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||
if plist.exists():
|
||||
print(" Starting launchd service...")
|
||||
subprocess.run(["launchctl", "load", str(plist)], capture_output=True)
|
||||
result = subprocess.run(
|
||||
["launchctl", "bootstrap", f"gui/{uid}", str(plist)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(" Service started")
|
||||
else:
|
||||
# Fallback to legacy load for older macOS
|
||||
subprocess.run(["launchctl", "load", str(plist)], capture_output=True)
|
||||
print(" Service started (legacy)")
|
||||
else:
|
||||
print(" Service file not found — run: muxplex install-service")
|
||||
else:
|
||||
|
||||
@@ -263,12 +263,18 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys):
|
||||
|
||||
def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
|
||||
"""install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/."""
|
||||
import subprocess
|
||||
from muxplex.cli import install_service
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||
)
|
||||
|
||||
install_service(system=False)
|
||||
|
||||
@@ -283,12 +289,18 @@ def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
|
||||
|
||||
def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
|
||||
"""install_service() on macOS must NOT write a systemd unit file."""
|
||||
import subprocess
|
||||
from muxplex.cli import install_service
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||
)
|
||||
|
||||
install_service(system=False)
|
||||
|
||||
@@ -296,6 +308,64 @@ def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
|
||||
assert not systemd_path.exists(), "No systemd unit file should be written on macOS"
|
||||
|
||||
|
||||
def test_install_service_uses_modern_launchctl_on_macos(tmp_path, monkeypatch, capsys):
|
||||
"""install-service on macOS must use launchctl bootstrap (not load)."""
|
||||
import subprocess
|
||||
from muxplex.cli import install_service
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}")
|
||||
|
||||
calls = []
|
||||
|
||||
def mock_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
|
||||
install_service(system=False)
|
||||
|
||||
launchctl_cmds = [c for c in calls if isinstance(c, list) and "launchctl" in str(c)]
|
||||
assert any(
|
||||
"bootout" in str(c) for c in launchctl_cmds
|
||||
), "must bootout old service before loading"
|
||||
assert any(
|
||||
"bootstrap" in str(c) for c in launchctl_cmds
|
||||
), "must use launchctl bootstrap (modern API)"
|
||||
# Must NOT use deprecated load
|
||||
assert not any(
|
||||
c == ["launchctl", "load"] or (isinstance(c, list) and c[:2] == ["launchctl", "load"])
|
||||
for c in launchctl_cmds
|
||||
), "must NOT use deprecated launchctl load"
|
||||
|
||||
|
||||
def test_install_service_plist_includes_host_flag(tmp_path, monkeypatch):
|
||||
"""The generated plist must include --host 0.0.0.0 for network access."""
|
||||
import subprocess
|
||||
from muxplex.cli import install_service
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||
)
|
||||
|
||||
install_service(system=False)
|
||||
|
||||
plist_path = fake_home / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
||||
content = plist_path.read_text()
|
||||
assert "0.0.0.0" in content, "plist must bind to 0.0.0.0 for network access"
|
||||
|
||||
|
||||
def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch):
|
||||
"""install_service() on Linux writes a systemd unit to ~/.config/systemd/user/."""
|
||||
from muxplex.cli import install_service
|
||||
|
||||
Reference in New Issue
Block a user