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)
|
||||
|
||||
Reference in New Issue
Block a user