From 56b54e269a611603c418af4daa31574d723986f4 Mon Sep 17 00:00:00 2001 From: Fusion Date: Thu, 21 May 2026 12:03:36 +0200 Subject: [PATCH] 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 --- apps/api/src/services/terminal_session.py | 86 +++++++++++++++-------- 1 file changed, 55 insertions(+), 31 deletions(-) diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index adbbd79..4ee7af8 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -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()