Terminal Tmux Persistence¶
Motivation¶
The browser terminal was intended to reconnect to a persistent tmux session, but the entrypoint launched ttyd directly against /bin/bash --login. When the local api sidecar reloaded or a browser WebSocket reconnected, ttyd opened a fresh login shell and printed the ElasticBLAST banner again, making the terminal look like it had reset.
User-facing change¶
The terminal sidecar now runs ttyd against tmux new-session -A -D -s elb /bin/bash --login, so reconnects attach to the existing terminal session instead of creating a new shell every time. New browser attaches also detach stale tmux clients, preventing multiple clients from fighting over pane size.
The browser terminal reconnect loop is now generation-guarded: stale WebSocket events are ignored, duplicate reconnect timers are suppressed, old input handlers are disposed before a new connection opens, and reconnect status stays in the page header instead of being written into the terminal scrollback.
The full local compose stack now publishes the Vite dev server on 127.0.0.1:8081 and configures Vite HMR to use that port, while the primary app still loads through the api proxy on 127.0.0.1:18080. This keeps Vite's HMR WebSocket from hitting the api proxy root and producing 403 noise while testing the terminal page.
API/IaC diff summary¶
- Updated
terminal/entrypoint.shto use the persistent tmux session described by the existing terminal architecture docs, with-Dto detach stale clients. - Hardened
RemoteTerminalreconnect handling to avoid stale WebSocket handlers, duplicate reconnect attempts, noisy local reconnect banners, and mixed redraw scrollback after reconnect. - Published the full-compose frontend dev server on loopback port
8081and added compose-scoped Vite HMR host/port variables; the primary app entry remains the api proxy on18080. - Added a regression test that prevents the entrypoint from returning to direct bash launch.
- No API or IaC changes.
Validation evidence¶
uv run pytest -q api/tests/test_terminal_entrypoint.py api/tests/test_terminal_banner.py api/tests/test_terminal_history.pypassed: 10 tests.cd web && npm run buildpassed.docker compose -p elb-control-local -f scripts/dev/docker-compose.full.yml config --quietpassed.git --no-pager diff --check -- terminal/entrypoint.sh api/tests/test_terminal_entrypoint.py web/src/pages/RemoteTerminal.tsx web/vite.config.ts scripts/dev/docker-compose.full.yml docs/features_change/2026-05/2026-05-16-terminal-tmux-persistence.mdpassed.- Local compose rebuild passed:
docker compose -p elb-control-local -f scripts/dev/docker-compose.full.yml up -d --build terminal. - Local compose frontend recreation passed:
docker compose -p elb-control-local -f scripts/dev/docker-compose.full.yml up -d frontend;curl -sI http://127.0.0.1:8081/returnedHTTP/1.1 200 OK. GET http://127.0.0.1:18080/api/terminal/healthreturned{"status":"ok","upstream_status":200}.- Process inspection confirmed ttyd now runs
/usr/bin/tmux new-session -A -D -s elb /bin/bash --login. - After two forced WebSocket attach/close cycles, process counts were
tmux_clients=0 bash_login_shells=1; after the visible browser reconnected, counts weretmux_clients=1 bash_login_shells=1. - Browser verification on
/terminalshowedconnected, oneElasticBlast CLIbanner, visible prompt, and tmux status line.