Commit Graph

10 Commits

Author SHA1 Message Date
Brian Krabach 4abb5186e2 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.
2026-05-17 18:29:19 -07:00
Brian Krabach d7d07ec07e fix(cli): tolerate systems without systemctl in upgrade/doctor flows
Bug: On systems without systemd (Unraid OS 7.2.4, BSD, macOS containers,
and other non-systemd Linux hosts), running `muxplex upgrade` or
`muxplex update` crashed immediately with:

  FileNotFoundError: [Errno 2] No such file or directory: 'systemctl'

The check ran unconditionally before any install step, so the upgrade
aborted without even attempting to fetch the new version. Introduced
in v0.6.0.

Fix: Add a module-level `_have_systemctl() -> bool` helper to cli.py
(and service.py) that gates every systemd-specific operation behind
`shutil.which("systemctl") is not None`.

Call sites guarded in cli.py (upgrade() function):
  1. systemctl --user is-active muxplex  (stop-before-upgrade check)
  2. systemctl --user stop muxplex       (pre-install service stop)
  3. Service file regeneration step      (service_install() call)
  4. systemctl --user is-enabled muxplex (post-install restart check)
  5. systemctl --user daemon-reload      (post-install daemon reload)
  6. systemctl --user start muxplex      (post-install service start)

Behaviour on no-systemd systems:
  - Skips the is-active check (treats as unmanaged; prints skip note).
  - Skips the stop step.
  - Still performs the uv/pip install.
  - Skips service-file regeneration (prints skip note).
  - Skips the daemon-reload / start steps.
  - Prints: '! systemd not detected — restart muxplex manually to pick
    up the new version' with the running PID if pgrep finds one.
  - Still runs `muxplex doctor` for verification (no systemd required).

doctor() change:
  - On non-darwin platforms with no systemctl, now prints:
    '! Service: systemd not available on this platform'
    instead of silently doing nothing or crashing.

service.py change:
  - Public API functions (service_install, service_uninstall,
    service_start, service_stop, service_restart, service_status,
    service_logs) now check _have_systemctl() on the Linux path.
  - When absent, print a clear, friendly error pointing the user to
    `muxplex serve` instead of letting subprocess raise FileNotFoundError.

Tests added (muxplex/tests/test_cli.py, +8 tests):
  - test_have_systemctl_helper_exists
  - test_have_systemctl_returns_bool
  - test_upgrade_no_systemctl_runs_to_completion (regression)
  - test_upgrade_no_systemctl_prints_skip_note
  - test_upgrade_no_systemctl_prints_manual_restart_note
  - test_upgrade_with_systemctl_runs_systemd_commands
  - test_doctor_no_systemctl_shows_graceful_message
  - test_doctor_no_systemctl_does_not_crash
2026-05-17 12:26:20 -07:00
Brian Krabach c92689dab2 feat: TLS nudge in doctor and service install output
When host is set to network access (not 127.0.0.1) and TLS is not
configured, doctor shows 'Run: muxplex setup-tls' and service install
shows a tip line. Hidden on localhost-only setups since clipboard
works without HTTPS there.
2026-04-04 03:40:44 -07:00
Brian Krabach 000c71c40d fix: prevent service crash-loop on port-in-use at startup
serve() now kills any stale process holding the configured port before
uvicorn tries to bind. Prevents the crash-loop where systemd restarts
muxplex but the old process is still holding port 8088 (observed:
2075+ restarts before manual intervention).

New helper _kill_stale_port_holder(port):
- Runs lsof -ti :<port> to find occupying PIDs
- Sends SIGTERM to all foreign PIDs (skips own PID)
- Waits 1 second for the port to free
- Silently swallows all errors (missing lsof, permission denied)
  so a broken environment never prevents startup

Also adds TimeoutStopSec=10 and KillMode=mixed to the systemd unit
template so the old process gets SIGKILL'd if it does not exit on
SIGTERM within 10 seconds — preventing the SIGTERM-ignored zombie
scenario entirely.
2026-04-02 00:51:20 -07:00
Brian Krabach 2de19bd988 fix: clean exit on Ctrl+C in muxplex service logs
subprocess.run with check=True raised CalledProcessError then
KeyboardInterrupt on Ctrl+C, printing an ugly traceback. Fix: remove
check=True, catch KeyboardInterrupt, exit silently.
2026-04-01 13:00:00 -07:00
Brian Krabach 91faab9b94 fix: remove check=True from idempotent service ops; guard input() against EOFError
C1: _systemd_status, _systemd_stop, _systemd_uninstall (stop+disable),
    _launchd_status, _launchd_stop, _launchd_uninstall (bootout) no longer
    pass check=True. systemctl status exits 3 on stopped service; bootout
    exits non-zero on unloaded service — both are informational, not errors.

C2: _prompt_host_if_localhost wraps input() in try/except (EOFError,
    KeyboardInterrupt) so service install doesn't crash in CI or piped
    stdin environments. Also fixes settings["host"] → settings.get("host")
    to prevent KeyError on partial/missing settings files.

Added 9 regression tests in test_service.py:
- test_systemd_status_no_check_true
- test_systemd_stop_no_check_true
- test_systemd_uninstall_stop_and_disable_no_check_true
- test_launchd_status_no_check_true
- test_launchd_stop_no_check_true
- test_launchd_uninstall_no_check_true
- test_prompt_host_eoferror_defaults_to_no_change
- test_prompt_host_keyboard_interrupt_defaults_to_no_change
- test_prompt_host_missing_host_key_no_keyerror

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
2026-03-31 18:12:35 -07:00
Brian Krabach 8637b740c0 fix: launchd quality — use _LAUNCHD_LABEL in plist template, add restart/status tests 2026-03-31 16:53:17 -07:00
Brian Krabach fbfbab8bb8 feat: implement launchd service commands (install, uninstall, start, stop, restart, status, logs) 2026-03-31 16:46:31 -07:00
Brian Krabach 376583bf23 feat: implement systemd service commands (install, uninstall, start, stop, restart, status, logs) 2026-03-31 16:38:46 -07:00
Brian Krabach f360dea767 feat: create service.py module skeleton with platform dispatch 2026-03-31 16:21:49 -07:00