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>
<key>ProgramArguments</key>
<array>
<string>{muxplex_bin}</string>
<string>serve</string>
{program_arguments_xml}
</array>
<key>EnvironmentVariables</key>
<dict>
@@ -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 ``<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
# ---------------------------------------------------------------------------
@@ -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 <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")
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)