linux fixes

This commit is contained in:
AI Christianson 2025-02-25 13:14:26 -05:00
parent d163a74c47
commit ea44e5dd6a
1 changed files with 175 additions and 141 deletions

View File

@ -42,6 +42,7 @@ else:
import termios import termios
import fcntl import fcntl
import pty import pty
import tty
def get_terminal_size(): def get_terminal_size():
"""Get the current terminal size.""" """Get the current terminal size."""
@ -101,90 +102,105 @@ def create_process(cmd: List[str]) -> Tuple[subprocess.Popen, Optional[int]]:
return proc, master_fd return proc, master_fd
def run_interactive_command( def run_interactive_command(
cmd: List[str], cmd: List[str],
expected_runtime_seconds: int = 1800, expected_runtime_seconds: int = 1800,
ratio: float = 0.5 ratio: float = 0.5
) -> Tuple[bytes, int]: ) -> Tuple[bytes, int]:
""" """
Runs an interactive command with a pseudo-tty, capturing final scrollback history. Runs an interactive command with a pseudo-tty, capturing final scrollback history.
Assumptions and constraints:
- Running on a Linux system or Windows.
- `cmd` is a non-empty list where cmd[0] is the executable.
- The executable is on PATH.
Args: Args:
cmd: A list containing the command and its arguments. cmd: A list containing the command and its arguments.
expected_runtime_seconds: Expected runtime in seconds, defaults to 1800. expected_runtime_seconds: Expected runtime in seconds, defaults to 1800.
If process exceeds 2x this value, it will be terminated gracefully. ratio: Ratio of history to keep from top vs bottom (default: 0.5)
If process exceeds 3x this value, it will be killed forcefully.
Must be between 1 and 1800 seconds (30 minutes).
ratio: Ratio of history to keep from top vs bottom (default: 0.5)
Returns: Returns:
A tuple of (captured_output, return_code), where captured_output is a UTF-8 encoded A tuple of (captured_output, return_code)
bytes object containing the trimmed non-empty history lines from the terminal session.
Raises:
ValueError: If no command is provided.
FileNotFoundError: If the command is not found in PATH.
ValueError: If expected_runtime_seconds is less than or equal to 0 or greater than 1800.
RuntimeError: If an error occurs during execution.
""" """
if not cmd: if not cmd:
raise ValueError("No command provided.") raise ValueError("No command provided")
if shutil.which(cmd[0]) is None: if not 0 < expected_runtime_seconds <= 1800:
raise FileNotFoundError(f"Command '{cmd[0]}' not found in PATH.") raise ValueError("Expected runtime must be between 1 and 1800 seconds")
if expected_runtime_seconds <= 0 or expected_runtime_seconds > 1800:
raise ValueError(
"expected_runtime_seconds must be between 1 and 1800 seconds (30 minutes)"
)
try: try:
term_size = get_terminal_size() term_size = os.get_terminal_size()
cols, rows = term_size.columns, term_size.lines cols, rows = term_size.columns, term_size.lines
except OSError: except OSError:
cols, rows = 80, 24 cols, rows = 80, 24
# Set up pyte screen and stream to capture terminal output.
screen = pyte.HistoryScreen(cols, rows, history=2000, ratio=ratio) screen = pyte.HistoryScreen(cols, rows, history=2000, ratio=ratio)
stream = pyte.Stream(screen) stream = pyte.Stream(screen)
proc, master_fd = create_process(cmd) # Set up environment variables for the subprocess
env = os.environ.copy()
env.update({
"DEBIAN_FRONTEND": "noninteractive",
"GIT_PAGER": "",
"PYTHONUNBUFFERED": "1",
"CI": "true",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"COLUMNS": str(cols),
"LINES": str(rows),
"FORCE_COLOR": "1",
"GIT_TERMINAL_PROMPT": "0",
"PYTHONDONTWRITEBYTECODE": "1",
"NODE_OPTIONS": "--unhandled-rejections=strict",
})
# Create process with proper PTY handling
if sys.platform == "win32":
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
bufsize=0,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
)
master_fd = None
else:
master_fd, slave_fd = os.openpty()
os.set_blocking(master_fd, False)
proc = subprocess.Popen(
cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
env=env,
bufsize=0,
close_fds=True,
preexec_fn=os.setsid
)
os.close(slave_fd)
try:
stdin_fd = sys.stdin.fileno()
except (AttributeError, io.UnsupportedOperation):
stdin_fd = None
captured_data = [] captured_data = []
start_time = time.time() start_time = time.time()
was_terminated = False was_terminated = False
timeout_type = None
def check_timeout(): def check_timeout():
nonlocal timeout_type
elapsed = time.time() - start_time elapsed = time.time() - start_time
if elapsed > 3 * expected_runtime_seconds: if elapsed > 3 * expected_runtime_seconds:
if sys.platform == "win32": if sys.platform == "win32":
print("\nProcess exceeded hard timeout limit, forcefully terminating...") proc.kill()
proc.terminate()
time.sleep(0.5)
if proc.poll() is None:
print("Process did not respond to termination, killing...")
proc.kill()
else: else:
print("\nProcess exceeded hard timeout limit, sending SIGKILL...")
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
timeout_type = "hard_timeout"
return True return True
elif elapsed > 2 * expected_runtime_seconds: elif elapsed > 2 * expected_runtime_seconds:
if sys.platform == "win32": if sys.platform == "win32":
print("\nProcess exceeded soft timeout limit, attempting graceful termination...")
proc.terminate() proc.terminate()
else: else:
print("\nProcess exceeded soft timeout limit, sending SIGTERM...")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
timeout_type = "soft_timeout"
return True return True
return False return False
# Interactive mode: forward input if running in a TTY.
if sys.platform == "win32": if sys.platform == "win32":
# Windows handling # Windows handling
try: try:
@ -192,112 +208,130 @@ def run_interactive_command(
if check_timeout(): if check_timeout():
was_terminated = True was_terminated = True
break break
if proc.poll() is not None:
break
try: try:
# Check stdout with proper error handling output = proc.stdout.read1(1024)
stdout_data = proc.stdout.read1(1024) if output:
if stdout_data: captured_data.append(output)
captured_data.append(stdout_data) stream.feed(output.decode('utf-8', errors='replace'))
try: os.write(1, output) # Write to stdout
stream.feed(stdout_data.decode(errors='ignore'))
except Exception as e:
print(f"Warning: Error processing stdout: {e}")
# Check stderr with proper error handling
stderr_data = proc.stderr.read1(1024)
if stderr_data:
captured_data.append(stderr_data)
try:
stream.feed(stderr_data.decode(errors='ignore'))
except Exception as e:
print(f"Warning: Error processing stderr: {e}")
# Check for input with proper error handling
if msvcrt.kbhit(): if msvcrt.kbhit():
try: char = msvcrt.getch()
char = msvcrt.getch() proc.stdin.write(char)
proc.stdin.write(char) proc.stdin.flush()
proc.stdin.flush()
except (IOError, OSError) as e:
print(f"Warning: Error handling keyboard input: {e}")
break
except (IOError, OSError) as e: except (IOError, OSError) as e:
if isinstance(e, OSError) and e.winerror == 6: # Invalid handle
break
print(f"Warning: I/O error during process communication: {e}")
break break
except Exception as e:
print(f"Error in Windows process handling: {e}")
proc.terminate()
else:
# Unix handling
import tty
try:
old_settings = termios.tcgetattr(sys.stdin)
tty.setraw(sys.stdin)
while True:
if check_timeout():
was_terminated = True
break
rlist, _, _ = select.select([master_fd, sys.stdin], [], [], 0.1)
for fd in rlist:
try:
if fd == master_fd:
data = os.read(master_fd, 1024)
if not data:
break
captured_data.append(data)
stream.feed(data.decode(errors='ignore'))
else:
data = os.read(fd, 1024)
os.write(master_fd, data)
except (IOError, OSError):
break
except KeyboardInterrupt: except KeyboardInterrupt:
proc.terminate() proc.terminate()
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) else:
# Unix handling with proper TTY passthrough
if not sys.platform == "win32" and master_fd is not None: if stdin_fd is not None and sys.stdin.isatty():
old_settings = termios.tcgetattr(stdin_fd)
tty.setraw(stdin_fd)
try:
while True:
if check_timeout():
was_terminated = True
break
rlist, _, _ = select.select([master_fd, stdin_fd], [], [], 0.1)
if master_fd in rlist:
try:
data = os.read(master_fd, 1024)
except OSError as e:
if e.errno == errno.EIO:
break
raise
if not data:
break
captured_data.append(data)
stream.feed(data.decode('utf-8', errors='replace'))
os.write(1, data) # Write to stdout
if stdin_fd in rlist:
try:
input_data = os.read(stdin_fd, 1024)
except OSError:
input_data = b""
if input_data:
os.write(master_fd, input_data)
except KeyboardInterrupt:
proc.terminate()
finally:
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
else:
# Non-interactive mode
try:
while True:
if check_timeout():
was_terminated = True
break
rlist, _, _ = select.select([master_fd], [], [], 0.1)
if not rlist:
continue
try:
data = os.read(master_fd, 1024)
except OSError as e:
if e.errno == errno.EIO:
break
raise
if not data:
break
captured_data.append(data)
stream.feed(data.decode('utf-8', errors='replace'))
os.write(1, data) # Write to stdout
except KeyboardInterrupt:
proc.terminate()
# Cleanup
if master_fd is not None:
os.close(master_fd) os.close(master_fd)
proc.wait() if proc.poll() is None:
try:
# Assemble full scrollback: combine history.top, the current display, and history.bottom. proc.terminate()
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
# Get the final screen content
def render_line(line, width): def render_line(line, width):
return ''.join(char.data for char in line[:width]).rstrip() return ''.join(char.data for char in line[:width]).rstrip()
# Combine history and current screen content # Combine history and current screen content
final_output = [] top_lines = [render_line(line, cols) for line in screen.history.top]
# Add lines from history
history_lines = [render_line(line, cols) for line in screen.history.top]
final_output.extend(line for line in history_lines if line.strip())
# Add current screen content
screen_lines = [render_line(line, cols) for line in screen.display]
final_output.extend(line for line in screen_lines if line.strip())
# Add bottom history
bottom_lines = [render_line(line, cols) for line in screen.history.bottom] bottom_lines = [render_line(line, cols) for line in screen.history.bottom]
final_output.extend(line for line in bottom_lines if line.strip()) display_lines = screen.display
all_lines = top_lines + display_lines + bottom_lines
# Filter empty lines
trimmed_lines = [line for line in all_lines if line.strip()]
final_output = '\n'.join(trimmed_lines)
# Add timeout message if process was terminated # Add timeout message if process was terminated
if was_terminated: if was_terminated:
if timeout_type == "hard_timeout": timeout_msg = f"\n[Process exceeded timeout ({expected_runtime_seconds} seconds expected)]"
timeout_msg = f"\n[Process forcefully terminated after exceeding {3 * expected_runtime_seconds:.1f} seconds (expected: {expected_runtime_seconds} seconds)]" final_output += timeout_msg
else:
timeout_msg = f"\n[Process gracefully terminated after exceeding {2 * expected_runtime_seconds:.1f} seconds (expected: {expected_runtime_seconds} seconds)]"
final_output.append(timeout_msg)
# Limit output size # Limit output size
final_output = final_output[-8000:] final_output = final_output[-8000:]
final_output = '\n'.join(final_output)
return final_output.encode('utf-8'), proc.returncode return final_output.encode('utf-8'), proc.returncode