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:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user