From 4abb5186e261b9df1c3e1481f09e6c7d7e961ddd Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 17 May 2026 18:29:19 -0700 Subject: [PATCH] fix(service): emit launchd ProgramArguments as separate strings (not embedded spaces) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On one MacBook the plist generated by 'muxplex service install' contained: ProgramArguments /Users/brkrabac/.../python3 -m muxplex serve launchd treats each element as a literal, unsplit argv token. The first element was a single string with embedded spaces, so launchd tried to exec a binary literally named 'python3 -m muxplex' (including the spaces) — which doesn't exist. Because KeepAlive=true, launchd respawned the failed exec every few seconds, so 'pgrep' showed a PID and 'muxplex doctor' reported 'Service: launchd agent running' even though the daemon NEVER bound to port 8088. The user had no visible signal. Root cause: _resolve_muxplex_bin() returned a fallback string of the form "$sys.executable -m muxplex" (a single string with spaces), which was placed verbatim into a single tag. Fix: * Add _resolve_muxplex_bin_for_launchd() which returns a list[str] of tokens: 1. Prefer ~/.local/bin/muxplex (stable uv-tool console-script symlink, survives 'uv tool reinstall' without changing path — Option A from spec). 2. Fall back to shutil.which('muxplex'). 3. Last resort: [sys.executable, '-m', 'muxplex'] — correctly split. * Update _LAUNCHD_PLIST_TEMPLATE to take a {program_arguments_xml} placeholder instead of a single {muxplex_bin}. * In _launchd_install(), build argv = bin_args + ['serve'] and render each token as its own element. Now the generated plist reads: ProgramArguments /home/user/.local/bin/muxplex serve Users who have an existing malformed plist will pick up the fix the next time they run 'muxplex service install' (or after the upgrade flow regenerates the service file). Test added (test_service.py, under 'v0.6.7 fixes'): - test_launchd_plist_program_arguments_are_separate_strings: calls _launchd_install(), parses the result with plistlib.loads, asserts ProgramArguments is a list with >= 2 elements and that NO element contains a space. --- muxplex/service.py | 43 ++++++++++++++++++++++++++++--- muxplex/tests/test_service.py | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/muxplex/service.py b/muxplex/service.py index b3674d1..f5992cf 100644 --- a/muxplex/service.py +++ b/muxplex/service.py @@ -44,8 +44,7 @@ _LAUNCHD_PLIST_TEMPLATE = """\ {label} ProgramArguments - {muxplex_bin} - serve +{program_arguments_xml} EnvironmentVariables @@ -91,6 +90,33 @@ def _resolve_muxplex_bin() -> str: return f"{sys.executable} -m muxplex" +def _resolve_muxplex_bin_for_launchd() -> list[str]: + """Return the argv token list for the muxplex binary in a launchd plist. + + Uses Option A: prefer ``~/.local/bin/muxplex`` (stable uv-tool + console-script symlink that survives ``uv tool reinstall``). Falls back + to ``shutil.which("muxplex")``, then to ``[sys.executable, "-m", + "muxplex"]`` as explicitly split tokens. + + Each element must become its own ```` in ProgramArguments. + launchd does **not** shell-split inside a ````; an element like + ``"python3 -m muxplex"`` is treated as a literal executable name, causing + the daemon to silently fail to start. + """ + # Option A: stable console-script symlink installed by `uv tool` + local_bin = Path.home() / ".local" / "bin" / "muxplex" + if local_bin.exists() and os.access(str(local_bin), os.X_OK): + return [str(local_bin)] + + # Fall back to PATH lookup + which = shutil.which("muxplex") + if which: + return [which] + + # Last resort: explicit python -m invocation — correctly split into tokens + return [sys.executable, "-m", "muxplex"] + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- @@ -184,11 +210,20 @@ def _systemd_logs() -> None: def _launchd_install() -> None: - muxplex_bin = _resolve_muxplex_bin() + bin_args = _resolve_muxplex_bin_for_launchd() + argv = bin_args + ["serve"] + # Each argv token is its own element. launchd does NOT + # shell-split inside a , so we must NOT put the whole command + # (e.g. "python3 -m muxplex") into a single element. + program_arguments_xml = "\n".join( + f" {arg}" for arg in argv + ) base_path = os.environ.get("PATH", "/usr/bin:/bin") safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}" plist_content = _LAUNCHD_PLIST_TEMPLATE.format( - label=_LAUNCHD_LABEL, muxplex_bin=muxplex_bin, safe_path=safe_path + label=_LAUNCHD_LABEL, + program_arguments_xml=program_arguments_xml, + safe_path=safe_path, ) _LAUNCHD_PLIST_DIR.mkdir(parents=True, exist_ok=True) _LAUNCHD_PLIST_PATH.write_text(plist_content) diff --git a/muxplex/tests/test_service.py b/muxplex/tests/test_service.py index 790d047..89b7298 100644 --- a/muxplex/tests/test_service.py +++ b/muxplex/tests/test_service.py @@ -667,3 +667,51 @@ def test_service_install_hides_tls_tip_on_localhost(capsys, tmp_path, monkeypatc assert "muxplex setup-tls" not in out, ( f"TLS tip must NOT appear in service install output when host is 127.0.0.1, got: {out!r}" ) + + +# --------------------------------------------------------------------------- +# v0.6.7 fix — launchd plist ProgramArguments must use separate tokens +# --------------------------------------------------------------------------- + + +def test_launchd_plist_program_arguments_are_separate_strings(monkeypatch, tmp_path): + """_launchd_install emits each argv token as its own in ProgramArguments. + + The v0.6.6 bug: a single containing e.g. + "python3 -m muxplex" caused launchd to look for a literal executable + named "python3 -m muxplex" (with spaces) — which doesn't exist — so the + daemon silently failed to start on every boot. + """ + import os + import plistlib + + import muxplex.service as svc + + plist_dir = tmp_path / "LaunchAgents" + plist_path = plist_dir / "com.muxplex.plist" + + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_DIR", plist_dir) + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_PATH", plist_path) + monkeypatch.setattr(os, "getuid", lambda: 501) + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: None) + monkeypatch.setattr(svc, "_prompt_host_if_localhost", lambda: None) + monkeypatch.setattr(svc, "_show_tls_nudge_if_needed", lambda: None) + + svc._launchd_install() + + assert plist_path.exists(), "plist file must be written by _launchd_install" + + plist_data = plistlib.loads(plist_path.read_bytes()) + prog_args = plist_data.get("ProgramArguments", []) + + assert len(prog_args) >= 2, ( + f"ProgramArguments must have at least 2 elements, got: {prog_args!r}" + ) + assert prog_args[-1] == "serve", ( + f"Last ProgramArguments element must be 'serve', got: {prog_args!r}" + ) + for arg in prog_args: + assert " " not in arg, ( + f"ProgramArguments element must not contain spaces " + f"(embedded-space arg trap): {arg!r} in {prog_args!r}" + )