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:
Brian Krabach
2026-03-30 08:52:43 -07:00
parent b53d1abbd2
commit 320c9f3ba7
2 changed files with 144 additions and 12 deletions
+73 -11
View File
@@ -287,7 +287,18 @@ def doctor() -> None:
if sys.platform == "darwin": if sys.platform == "darwin":
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist" plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
if plist.exists(): 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: else:
print( print(
f" {warn_mark} Service: not installed (run: muxplex install-service)" f" {warn_mark} Service: not installed (run: muxplex install-service)"
@@ -336,21 +347,25 @@ def _install_launchd(executable: str) -> None:
import shutil import shutil
# Prefer the entry point script ('muxplex') so macOS shows the correct # Prefer the entry point script ('muxplex') so macOS shows the correct
# process name in Activity Monitor, launchctl list, and login items. # process name in Activity Monitor and launchctl list.
# Fall back to 'python -m muxplex' if the entry point isn't on PATH.
muxplex_bin = shutil.which("muxplex") muxplex_bin = shutil.which("muxplex")
if muxplex_bin: if muxplex_bin:
program_args = f""" <array> program_args = f""" <array>
<string>{muxplex_bin}</string> <string>{muxplex_bin}</string>
<string>--host</string>
<string>0.0.0.0</string>
</array>""" </array>"""
else: else:
program_args = f""" <array> program_args = f""" <array>
<string>{executable}</string> <string>{executable}</string>
<string>-m</string> <string>-m</string>
<string>muxplex</string> <string>muxplex</string>
<string>--host</string>
<string>0.0.0.0</string>
</array>""" </array>"""
label = "com.muxplex" label = "com.muxplex"
uid = os.getuid()
plist = f"""<?xml version="1.0" encoding="UTF-8"?> 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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
@@ -372,12 +387,44 @@ def _install_launchd(executable: str) -> None:
""" """
path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
path.parent.mkdir(parents=True, exist_ok=True) 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) path.write_text(plist)
print(f"Launch agent written to {path}") print(f"Launch agent written to {path}")
print("Enable with:") print()
print(f" launchctl load {path}")
print("Disable with:") # Load the service using the modern bootstrap API
print(f" launchctl unload {path}") 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: 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 # 1. Detect platform and stop service
if sys.platform == "darwin": 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(): if plist.exists():
print(" Stopping launchd service...") 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: else:
print(" No launchd service found (skipping stop)") print(" No launchd service found (skipping stop)")
else: else:
@@ -521,11 +572,22 @@ def upgrade(*, force: bool = False) -> None:
# 4. Restart service # 4. Restart service
if sys.platform == "darwin": 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(): if plist.exists():
print(" Starting launchd service...") 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") 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: else:
print(" Service file not found — run: muxplex install-service") print(" Service file not found — run: muxplex install-service")
else: else:
+70
View File
@@ -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): def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
"""install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/.""" """install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/."""
import subprocess
from muxplex.cli import install_service from muxplex.cli import install_service
fake_home = tmp_path / "home" fake_home = tmp_path / "home"
fake_home.mkdir() fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setattr("sys.platform", "darwin") monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setattr(
subprocess,
"run",
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
)
install_service(system=False) 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): def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
"""install_service() on macOS must NOT write a systemd unit file.""" """install_service() on macOS must NOT write a systemd unit file."""
import subprocess
from muxplex.cli import install_service from muxplex.cli import install_service
fake_home = tmp_path / "home" fake_home = tmp_path / "home"
fake_home.mkdir() fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setattr("sys.platform", "darwin") monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setattr(
subprocess,
"run",
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
)
install_service(system=False) 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" 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): def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch):
"""install_service() on Linux writes a systemd unit to ~/.config/systemd/user/.""" """install_service() on Linux writes a systemd unit to ~/.config/systemd/user/."""
from muxplex.cli import install_service from muxplex.cli import install_service