- Add test_instance_info_federation_enabled_true_when_key_exists: writes a
key file to tmp_path, patches FEDERATION_KEY_PATH, and asserts that
federation_enabled is True — completing the positive/negative pair for
task-9's coverage gap (suggested by code quality review).
- Replace 'with TestClient(app) as _:' with 'with TestClient(app):' — the
discard binding was unnecessary (ruff-clean but stylistically noisy).
main.py: Wrapped `app.state.federation_client.aclose()` in a `try/finally` block so the poll task cleanup always runs even if `aclose()` raises unexpectedly.
test_api.py: Extended `test_federation_client_exists_on_app_state` to capture the client reference and assert `client_ref.is_closed` after the `TestClient` context exits, verifying the lifespan shutdown closes the client.
🤖 Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
httpx is imported unconditionally in muxplex/main.py (module-level import),
so it must be a production dependency — not dev-only. Move httpx>=0.27.0
from [project.optional-dependencies].dev to [project.dependencies] to
prevent ModuleNotFoundError on startup in non-dev deployments.
- Add docstring to load_federation_key() for module consistency
- Strengthen test_load_federation_key_uses_default_path to call the
function and assert it returns "" when file absent
- Add test_load_federation_key_uses_env_var_override to cover the
MUXPLEX_FEDERATION_KEY_FILE env var override path
All 31 tests pass.
Sessions with cursor near the top (e.g. a fresh tunnel/ssh session)
have content at rows 1-2 and rows 3-40 blank in a 40-row terminal.
The previous fix trimmed AFTER slice(-20), but slice(-20) of a 40-line
snapshot grabs the last 20 rows — all blank. Trimming after slice then
removed everything, leaving an empty <pre>.
Fix: trim trailing blank lines from the full snapshot first, then
slice the last N lines of what remains. This way a session with 2
content lines at the top is reduced to those 2 lines before slicing,
and both appear in the preview.
Fixes both buildTileHTML (grid tiles) and buildSidebarHTML (sidebar
cards). Tests updated to cover the 40-row terminal scenario.
Sessions with cursor near the top (e.g. just a prompt) have 18+ empty
lines below. slice(-20) grabbed all blanks, making previews appear
empty. Fix: pop trailing blank lines after slicing so the pre element
contains only meaningful content. CSS bottom:0 anchors it correctly.
Tests: 4 new tests cover content-at-top trimming and all-blank edge case.
- Backend: AuthMiddleware.dispatch() checks X-Muxplex-Token header
after cookie auth, allowing cross-origin federation requests to
authenticate using a session token sent as a header instead of a
SameSite=Strict cookie.
- Backend: GET /api/auth/token endpoint returns the current session
token for authenticated requests. Used by login popups to relay
the token back to the opener window via postMessage.
- Frontend (app.js): api() injects X-Muxplex-Token header for
cross-origin requests when a token is stored in localStorage under
muxplex.federation_tokens keyed by origin.
- Frontend (app.js): storeFederationToken() helper stores tokens.
window.addEventListener('message') handler catches muxplex-auth-token
postMessages and stores the token, then retriggers a poll so the
auth_required source transitions to authenticated immediately.
- Frontend (app.js): _saveRemoteInstances() prunes stale tokens for
URLs no longer in the remote instances list.
- Frontend (index.html): Popup relay script fetches /api/auth/token
and sends the token to window.opener via postMessage when loaded
as a login popup (window.opener present).
Tests added:
test_auth.py: X-Muxplex-Token header authenticates / invalid rejected
test_api.py: GET /api/auth/token returns token / 401 when unauthed
test_app.mjs: api() header injection, storeFederationToken storage,
index.html popup script presence
Root cause (4th iteration): The WS proxy called websocket.accept() before
verifying ttyd was alive. The browser 'open' event fired immediately, resetting
_reconnectAttempts to 0. Counter bounced 0→1→0→1 forever — the client-side
/connect POST at >= 2 attempts never fired.
Server fix: _ttyd_is_listening() TCP probe (socket.create_connection, <1ms)
before websocket.accept(). If ttyd is dead, auto-spawns it from active_session
in state (kill_ttyd → spawn_ttyd → sleep 0.8s) THEN accepts. Browser 'open'
only fires when ttyd is confirmed reachable.
Client fix: _reconnectAttempts reset moved from 'open' handler to 'message'
handler. First data message proves ttyd is alive and relaying — not just that
the proxy accepted. Belt-and-suspenders: client-side /connect fetch still fires
at attempt >= 2 as a fallback.
Tests added:
- test_ttyd_is_listening_function_exists: function importable from main
- test_ws_proxy_checks_ttyd_before_accepting: source inspection ensures
_ttyd_is_listening() call appears before await websocket.accept()
- test_ws_proxy_auto_spawns_ttyd_when_dead: verifies spawn_ttyd called with
active_session when _ttyd_is_listening returns False
- terminal.mjs: _reconnectAttempts = 0 NOT in open handler, IS in message
The /connect POST that respawns ttyd was fire-and-forget — fetch fired
and the next line immediately created a new WebSocket. The WS raced
ttyd's startup and lost every time, staying stuck on 'Reconnecting...'.
Fix: extract WS creation into _connectWebSocket(). When
_reconnectAttempts >= 2, chain via .then() after the /connect fetch
+ 800ms settle delay for ttyd to bind its port, then return early to
prevent fallthrough. When < 2, call _connectWebSocket() directly
(normal reconnect — ttyd is still alive).
Exposes ~/.config/muxplex/settings.json management without hand-editing:
config list — show all settings with (modified) markers
config get <key> — print one value
config set <key> <value> — set with auto type coercion (bool/int/str/list)
config reset [key] — reset one or all to defaults
Example: muxplex config set host 0.0.0.0 && muxplex service restart
updateFaviconBadge() was creating new Image() on every call (every 2s via
poll cycle). Setting img.src triggers a network fetch of favicon-32.png
each time, causing favicon-32.png to appear in network traffic alongside
every heartbeat + sessions poll.
Fix: extract _drawFaviconBadge() helper that owns the Image lifecycle.
It lazily creates _faviconImage once (module-level var), caches it, and
reuses on all subsequent calls. If the image isn't loaded yet it registers
an onload callback to retry. updateFaviconBadge() simply calls
_drawFaviconBadge() — no Image construction in the hot path.
After a service restart ttyd dies. The reconnect loop was retrying the
WebSocket every 2s but never calling POST /api/sessions/{name}/connect
to respawn ttyd. The proxy had nothing to connect to so it looped forever
showing 'Reconnecting...'.
Fix:
- Add _reconnectAttempts counter at module level
- Close handler now uses exponential backoff: 1s, 2s, 4s, 8s, cap 15s + jitter
- After 2 failed WS attempts, fire-and-forget POST to /api/sessions/{name}/connect
to respawn ttyd before the next WebSocket retry
- Reset _reconnectAttempts to 0 on successful open, openTerminal, closeTerminal
Root cause: open event fires and reconnect loop retried the WS address but
ttyd was dead so the proxy rejected it — POST /connect was only called from
openSession() on page load/restore, never during reconnect.
Removed install-service from argparse subparsers so it doesn't appear
in the usage line or help listing. Intercepted before parse_args() for
backward compatibility — prints deprecation warning and forwards to
service install.
Without start_new_session=True, ttyd inherits the parent's process group
and gets cleaned up when the asyncio transport is garbage collected after
the HTTP handler completes. start_new_session=True detaches ttyd into
its own process group so it survives independently of the parent.
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>
- Remove inner 'import sys' inside install-service branch (sys already
imported at module level on line 8; was triggering PLC0415 smell)
- Update two fallback messages in upgrade() from 'muxplex install-service'
to 'muxplex service install' — consistent with deprecation warning and
doctor() messages updated in Task 5
The multi-device enable checkbox is the gating control; device name
only matters when multi-device is on. Place the checkbox first to
reinforce the logical dependency between the two settings.
No behavioral change — no tests enforce element ordering within a panel.
- Add serve config display in doctor() showing host, port, auth, and
session_ttl from settings.json (inserted after Settings file block)
- Update two 'not installed' messages from 'muxplex install-service'
to 'muxplex service install' (macOS launchd and Linux systemd sections)
- Add test_doctor_shows_serve_config verifying custom settings values
(0.0.0.0, 9999, password) appear in doctor() output
- Add test_install_service_subcommand_prints_deprecation_warning to
verify that 'muxplex install-service' prints a deprecation warning
to stderr containing 'deprecated' and 'muxplex service install'
- Update the deprecation warning message in main() to include
'muxplex service install' as the recommended replacement command
Refs: task-4