Files
headquarter/apps/api/src/services/terminal_session.py
T
Fusion 56b54e269a fix(terminal): create PTY for proper interactive shell
- Use Python pty module to create pseudo-terminal
- Pass slave fd to docker exec for real TTY allocation
- Fixes ioctl errors and job control warnings
- Supports terminal resizing via TIOCSWINSZ

Quality gates: local testing
2026-05-21 12:03:36 +02:00

118 lines
3.6 KiB
Python

"""Terminal session management for tool instances."""
import asyncio
import os
import pty
import select
import struct
import fcntl
import uuid
from typing import Any
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:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.process: asyncio.subprocess.Process | None = None
self._closed = False
self._master_fd: int | None = None
self._slave_fd: int | 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",
self.container_id,
"bash",
"-il",
stdin=self._slave_fd,
stdout=self._slave_fd,
stderr=self._slave_fd,
)
# Close slave fd in parent process
os.close(self._slave_fd)
self._slave_fd = None
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
async def read_output(self) -> 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)
if readable:
return os.read(self._master_fd, 4096)
return b""
except (OSError, IOError, 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:
os.write(self._master_fd, data)
except (OSError, IOError):
pass
async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal."""
if self._closed:
return
self._set_terminal_size(cols, rows)
async def close(self) -> None:
"""Close the session and cleanup."""
if self._closed:
return
self._closed = True
if self._master_fd is not None:
try:
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):
pass
def is_alive(self) -> bool:
"""Check if the session process is still running."""
if self.process is None:
return False
return self.process.returncode is None