feat: responsive web terminal with auto-reconnect, heartbeat, and local echo
Implements a resilient, responsive web terminal that survives network blips, provides instant typing feedback, and restores scrollback on reconnect. Backend changes: - Add heartbeat tracking (15s ping interval, 60s idle timeout) - Add message batching (16ms flush window) for efficient I/O - Add termios echo detection and set_echo_state control messages - Add graceful session_ended notification before close - Add ping/pong protocol support Frontend changes: - Rewrite TerminalComponent with status bar, connection indicator, session-ended overlay, reconnect banner, and ResizeObserver - Add useTerminalConnection hook with: - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts) - Heartbeat/ping-pong with latency tracking - Local echo for printable ASCII with server deduplication - Resize debounce (200ms) + throttle (500ms) - Scrollback serialization via xterm-addon-serialize - Ctrl+Shift+R manual reconnect shortcut - Add WebSocket protocol types and encoding utilities - Add xterm-addon-serialize dependency Tests: - 16 backend unit tests (TerminalSession + TerminalManager) - 13 frontend hook tests (connection lifecycle, reconnect, resize, scrollback, callbacks) Quality gates: - Frontend typecheck: clean - Frontend lint: clean - Frontend tests: 48 passed - Backend unit tests: 101 passed - Backend ruff: clean SDD artifacts: openspec/changes/responsive-terminal/
This commit is contained in:
@@ -1,19 +1,29 @@
|
||||
"""Terminal session management for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import struct
|
||||
import fcntl
|
||||
import termios
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session connected to a docker container."""
|
||||
|
||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
) -> None:
|
||||
"""Initialize a terminal session."""
|
||||
self.session_id = session_id
|
||||
self.instance_id = instance_id
|
||||
self.container_id = container_id
|
||||
@@ -21,23 +31,20 @@ class TerminalSession:
|
||||
self._closed = False
|
||||
self._master_fd: int | None = None
|
||||
self._slave_fd: int | None = None
|
||||
self._echo_enabled = True
|
||||
self._exit_reason: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the docker exec process with a shell using a PTY."""
|
||||
# Create a pseudo-terminal on the host
|
||||
self._master_fd, self._slave_fd = pty.openpty()
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(80, 24)
|
||||
|
||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||
# Using -it because the slave fd IS a TTY
|
||||
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
"-it",
|
||||
"-e",
|
||||
"TERM=xterm",
|
||||
"TERM=xterm-256color",
|
||||
self.container_id,
|
||||
"bash",
|
||||
"-il",
|
||||
@@ -45,44 +52,71 @@ class TerminalSession:
|
||||
stdout=self._slave_fd,
|
||||
stderr=self._slave_fd,
|
||||
)
|
||||
|
||||
# Close slave fd in parent process
|
||||
|
||||
os.close(self._slave_fd)
|
||||
self._slave_fd = None
|
||||
self._echo_enabled = self._detect_echo_state()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
# TIOCSWINSZ = 0x5414 on Linux
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
tiocswinsz = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.ioctl(self._master_fd, tiocswinsz, size)
|
||||
|
||||
async def read_output(self) -> bytes:
|
||||
def _detect_echo_state(self) -> bool:
|
||||
"""Detect whether the PTY has echo enabled via termios."""
|
||||
if self._master_fd is None:
|
||||
return True
|
||||
try:
|
||||
attrs = termios.tcgetattr(self._master_fd)
|
||||
return bool(attrs[3] & termios.ECHO)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
async def check_echo_state(self) -> bool | None:
|
||||
"""Check if echo state changed. Returns new state if changed, None otherwise."""
|
||||
current = self._detect_echo_state()
|
||||
if current != self._echo_enabled:
|
||||
self._echo_enabled = current
|
||||
return current
|
||||
return None
|
||||
|
||||
@property
|
||||
def echo_enabled(self) -> bool:
|
||||
"""Return whether the PTY currently has echo enabled."""
|
||||
return self._echo_enabled
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""Return whether the session has been closed."""
|
||||
return self._closed
|
||||
|
||||
async def read_output(self, select_timeout: float = 0.1) -> bytes:
|
||||
"""Read output from the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return b""
|
||||
try:
|
||||
# Use select to check if data is available
|
||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||
readable, _, _ = select.select(
|
||||
[self._master_fd],
|
||||
[],
|
||||
[],
|
||||
select_timeout,
|
||||
)
|
||||
if readable:
|
||||
return os.read(self._master_fd, 4096)
|
||||
return os.read(self._master_fd, 8192)
|
||||
return b""
|
||||
except (OSError, IOError, ValueError):
|
||||
except (OSError, ValueError):
|
||||
return b""
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.write(self._master_fd, data)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal."""
|
||||
@@ -90,24 +124,35 @@ class TerminalSession:
|
||||
return
|
||||
self._set_terminal_size(cols, rows)
|
||||
|
||||
def get_exit_reason(self) -> str | None:
|
||||
"""Return the reason the session ended, if known."""
|
||||
return self._exit_reason
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session and cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
# Determine exit reason
|
||||
if self.process is not None and self.process.returncode is not None:
|
||||
if self.process.returncode == 0:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "timeout"
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
if self.process is not None:
|
||||
try:
|
||||
self.process.kill()
|
||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
except (TimeoutError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user