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:
+39
-4
@@ -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)
|
||||
|
||||
@@ -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 <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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user