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."""
import asyncio
import os
import pty
import select
import struct
import fcntl
import uuid
from typing import Any
@@ -14,11 +19,19 @@ class TerminalSession:
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."""
# Use docker exec -it to allocate a proper TTY
# This gives bash a real terminal and avoids ioctl/job control errors
"""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",
@@ -28,50 +41,54 @@ class TerminalSession:
self.container_id,
"bash",
"-il",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
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 process."""
if self.process is None or self.process.stdout is None:
"""Read output from the PTY master."""
if self._master_fd is None or self._closed:
return b""
try:
return await self.process.stdout.read(4096)
except (asyncio.CancelledError, BrokenPipeError):
# 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 process."""
if self.process is None or self.process.stdin is None or self._closed:
"""Write input to the PTY master."""
if self._master_fd is None or self._closed:
return
try:
self.process.stdin.write(data)
await self.process.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
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
try:
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
self._set_terminal_size(cols, rows)
async def close(self) -> None:
"""Close the session and cleanup."""
@@ -79,6 +96,13 @@ class TerminalSession:
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()