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
This commit is contained in:
Fusion
2026-05-21 12:03:36 +02:00
parent 6e597b9e21
commit 56b54e269a
+55 -31
View File
@@ -1,6 +1,11 @@
"""Terminal session management for tool instances.""" """Terminal session management for tool instances."""
import asyncio import asyncio
import os
import pty
import select
import struct
import fcntl
import uuid import uuid
from typing import Any from typing import Any
@@ -14,11 +19,19 @@ class TerminalSession:
self.container_id = container_id self.container_id = container_id
self.process: asyncio.subprocess.Process | None = None self.process: asyncio.subprocess.Process | None = None
self._closed = False self._closed = False
self._master_fd: int | None = None
self._slave_fd: int | None = None
async def start(self) -> None: async def start(self) -> None:
"""Start the docker exec process with a shell.""" """Start the docker exec process with a shell using a PTY."""
# Use docker exec -it to allocate a proper TTY # Create a pseudo-terminal on the host
# This gives bash a real terminal and avoids ioctl/job control errors 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( self.process = await asyncio.create_subprocess_exec(
"docker", "docker",
"exec", "exec",
@@ -28,50 +41,54 @@ class TerminalSession:
self.container_id, self.container_id,
"bash", "bash",
"-il", "-il",
stdin=asyncio.subprocess.PIPE, stdin=self._slave_fd,
stdout=asyncio.subprocess.PIPE, stdout=self._slave_fd,
stderr=asyncio.subprocess.STDOUT, 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: async def read_output(self) -> bytes:
"""Read output from the process.""" """Read output from the PTY master."""
if self.process is None or self.process.stdout is None: if self._master_fd is None or self._closed:
return b"" return b""
try: try:
return await self.process.stdout.read(4096) # Use select to check if data is available
except (asyncio.CancelledError, BrokenPipeError): 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"" return b""
async def write_input(self, data: bytes) -> None: async def write_input(self, data: bytes) -> None:
"""Write input to the process.""" """Write input to the PTY master."""
if self.process is None or self.process.stdin is None or self._closed: if self._master_fd is None or self._closed:
return return
try: try:
self.process.stdin.write(data) os.write(self._master_fd, data)
await self.process.stdin.drain() except (OSError, IOError):
except (BrokenPipeError, ConnectionResetError):
pass pass
async def resize(self, cols: int, rows: int) -> None: async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal.""" """Resize the terminal."""
if self._closed: if self._closed:
return return
try: self._set_terminal_size(cols, rows)
proc = await asyncio.create_subprocess_exec(
"docker",
"exec",
self.container_id,
"stty",
"cols",
str(cols),
"rows",
str(rows),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
except Exception:
pass
async def close(self) -> None: async def close(self) -> None:
"""Close the session and cleanup.""" """Close the session and cleanup."""
@@ -79,6 +96,13 @@ class TerminalSession:
return return
self._closed = True 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: if self.process is not None:
try: try:
self.process.kill() self.process.kill()