fix(service): emit launchd ProgramArguments as separate strings (not embedded spaces)

On one MacBook the plist generated by 'muxplex service install' contained:

  <key>ProgramArguments</key>
  <array>
    <string>/Users/brkrabac/.../python3 -m muxplex</string>
    <string>serve</string>
  </array>

launchd treats each <string> 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 <string> 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 <string> element.

Now the generated plist reads:

  <key>ProgramArguments</key>
  <array>
    <string>/home/user/.local/bin/muxplex</string>
    <string>serve</string>
  </array>

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.
This commit is contained in:
Brian Krabach
2026-05-17 18:29:19 -07:00
parent a80c6a76b5
commit 4abb5186e2
2 changed files with 87 additions and 4 deletions
+39 -4
View File
@@ -44,8 +44,7 @@ _LAUNCHD_PLIST_TEMPLATE = """\
<string>{label}</string> <string>{label}</string>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>{muxplex_bin}</string> {program_arguments_xml}
<string>serve</string>
</array> </array>
<key>EnvironmentVariables</key> <key>EnvironmentVariables</key>
<dict> <dict>
@@ -91,6 +90,33 @@ def _resolve_muxplex_bin() -> str:
return f"{sys.executable} -m muxplex" 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 ``<string>`` in ProgramArguments.
launchd does **not** shell-split inside a ``<string>``; 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 # Helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -184,11 +210,20 @@ def _systemd_logs() -> None:
def _launchd_install() -> 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 <string> element. launchd does NOT
# shell-split inside a <string>, so we must NOT put the whole command
# (e.g. "python3 -m muxplex") into a single element.
program_arguments_xml = "\n".join(
f" <string>{arg}</string>" for arg in argv
)
base_path = os.environ.get("PATH", "/usr/bin:/bin") base_path = os.environ.get("PATH", "/usr/bin:/bin")
safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}" safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}"
plist_content = _LAUNCHD_PLIST_TEMPLATE.format( 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_DIR.mkdir(parents=True, exist_ok=True)
_LAUNCHD_PLIST_PATH.write_text(plist_content) _LAUNCHD_PLIST_PATH.write_text(plist_content)
+48
View File
@@ -667,3 +667,51 @@ def test_service_install_hides_tls_tip_on_localhost(capsys, tmp_path, monkeypatc
assert "muxplex setup-tls" not in out, ( 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}" 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 <string> tokens
# ---------------------------------------------------------------------------
def test_launchd_plist_program_arguments_are_separate_strings(monkeypatch, tmp_path):
"""_launchd_install emits each argv token as its own <string> in ProgramArguments.
The v0.6.6 bug: a single <string> 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}"
)