Files
2026-07-13 13:12:33 +08:00

461 lines
14 KiB
Python

from __future__ import annotations
import errno
import os
import re
import shutil
import struct
import subprocess
import sys
import time
from dataclasses import dataclass, field
from itertools import count
from pathlib import Path
from typing import Literal, Protocol
try:
import pty
except ImportError: # pragma: no cover - exercised only on platforms without pty
pty = None # type: ignore[assignment]
DriverKind = Literal["tmux", "pty"]
DriverSelection = Literal["auto", "tmux", "pty"]
_RUN_ID_COUNTER = count()
@dataclass(frozen=True)
class TerminalSize:
cols: int = 100
rows: int = 30
def __post_init__(self) -> None:
if self.cols <= 0 or self.rows <= 0:
raise ValueError("terminal size must be positive")
@dataclass(frozen=True)
class TerminalFrame:
checkpoint: str
text: str
captured_at_ms: int
size: TerminalSize
@dataclass(frozen=True)
class TerminalCapabilities:
tmux_available: bool
pty_available: bool
screenshot_available: bool
resize_available: bool
preferred_driver: Literal["tmux", "pty", "none"]
skip_reason: str | None = None
class RealTerminalSession(Protocol):
run_id: str
kind: DriverKind
size: TerminalSize
def start(self) -> None: ...
def send_text(self, text: str) -> None: ...
def send_key(self, key: str) -> None: ...
def paste(self, text: str) -> None: ...
def resize(self, size: TerminalSize) -> None: ...
def capture_text(self, checkpoint: str) -> TerminalFrame: ...
def capture_scrollback_text(self, checkpoint: str) -> TerminalFrame: ...
def wait_for_text(
self,
needle: str,
*,
timeout_s: float,
checkpoint: str,
) -> TerminalFrame: ...
def is_alive(self) -> bool: ...
def terminate(self) -> None: ...
_SAFE_ID_RE = re.compile(r"[^A-Za-z0-9_-]+")
_ANSI_RE = re.compile(
r"\x1b(?:"
r"\[[0-?]*[ -/]*[@-~]"
r"|\][^\x07\x1b]*(?:\x07|\x1b\\)"
r"|P[^\x1b]*\x1b\\"
r"|[@-Z\\-_]"
r")"
)
def _now_ms() -> int:
return time.time_ns() // 1_000_000
def _run_id_suffix() -> str:
return f"{time.time_ns()}-{os.getpid()}-{next(_RUN_ID_COUNTER)}"
def build_run_id(scenario_id: str) -> str:
safe = _SAFE_ID_RE.sub("-", scenario_id.strip().lower()).strip("-") or "scenario"
return f"opensquilla-tui-{safe}-{_run_id_suffix()}"
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def probe_terminal_capabilities() -> TerminalCapabilities:
tmux_available = shutil.which("tmux") is not None
pty_available = sys.platform != "win32" and pty is not None and hasattr(pty, "openpty")
if tmux_available:
preferred_driver: Literal["tmux", "pty", "none"] = "tmux"
elif pty_available:
preferred_driver = "pty"
else:
preferred_driver = "none"
if preferred_driver != "none":
skip_reason: str | None = None
elif sys.platform == "win32":
skip_reason = (
"real-terminal harness needs tmux or a Unix PTY, which native Windows "
"lacks; run under WSL2 (see docs/tui-real-terminal-harness.md)"
)
else:
skip_reason = "tmux and PTY are unavailable"
return TerminalCapabilities(
tmux_available=tmux_available,
pty_available=pty_available,
screenshot_available=False,
resize_available=tmux_available or pty_available,
preferred_driver=preferred_driver,
skip_reason=skip_reason,
)
@dataclass
class _BaseTerminalSession:
command: list[str]
cwd: Path
env: dict[str, str]
run_id: str
size: TerminalSize
terminal_log: Path
kind: DriverKind = field(init=False)
def _append_log(self, text: str) -> None:
self.terminal_log.parent.mkdir(parents=True, exist_ok=True)
with self.terminal_log.open("a", encoding="utf-8") as fh:
fh.write(text)
if text and not text.endswith("\n"):
fh.write("\n")
@dataclass
class TmuxTerminalSession(_BaseTerminalSession):
kind: DriverKind = field(init=False, default="tmux")
def start(self) -> None:
env_prefix = ["env", *[f"{key}={value}" for key, value in sorted(self.env.items())]]
subprocess.run(
[
"tmux",
"new-session",
"-d",
"-s",
self.run_id,
"-x",
str(self.size.cols),
"-y",
str(self.size.rows),
"-c",
str(self.cwd),
*env_prefix,
*self.command,
],
check=True,
)
def send_text(self, text: str) -> None:
subprocess.run(["tmux", "send-keys", "-t", self.run_id, "-l", text], check=True)
subprocess.run(["tmux", "send-keys", "-t", self.run_id, "Enter"], check=True)
def send_key(self, key: str) -> None:
subprocess.run(["tmux", "send-keys", "-t", self.run_id, key], check=True)
def paste(self, text: str) -> None:
subprocess.run(
["tmux", "load-buffer", "-b", self.run_id, "-"],
check=True,
input=text,
text=True,
)
subprocess.run(
["tmux", "paste-buffer", "-p", "-t", self.run_id, "-b", self.run_id],
check=True,
)
def resize(self, size: TerminalSize) -> None:
self.size = size
subprocess.run(
[
"tmux",
"resize-window",
"-t",
self.run_id,
"-x",
str(size.cols),
"-y",
str(size.rows),
],
check=True,
)
def capture_text(self, checkpoint: str) -> TerminalFrame:
result = subprocess.run(
["tmux", "capture-pane", "-t", self.run_id, "-p", "-J"],
check=True,
text=True,
stdout=subprocess.PIPE,
)
frame = TerminalFrame(checkpoint, result.stdout, _now_ms(), self.size)
self._append_log(f"\n--- {checkpoint} ---\n{frame.text}")
return frame
def capture_scrollback_text(self, checkpoint: str) -> TerminalFrame:
result = subprocess.run(
["tmux", "capture-pane", "-t", self.run_id, "-S", "-", "-p", "-J"],
check=True,
text=True,
stdout=subprocess.PIPE,
)
frame = TerminalFrame(checkpoint, result.stdout, _now_ms(), self.size)
self._append_log(f"\n--- {checkpoint} scrollback ---\n{frame.text}")
return frame
def alternate_screen_active(self) -> bool:
# tmux tracks the pane's alternate-screen mode itself, so this probes
# real terminal state rather than inferring it from captured text: an
# app that exited without leaving the alternate screen still shows
# later shell output, just drawn over the stale alternate screen.
result = subprocess.run(
["tmux", "display-message", "-p", "-t", self.run_id, "#{alternate_on}"],
check=True,
text=True,
stdout=subprocess.PIPE,
)
return result.stdout.strip() == "1"
def wait_for_text(
self,
needle: str,
*,
timeout_s: float,
checkpoint: str,
) -> TerminalFrame:
deadline = time.monotonic() + timeout_s
last = self.capture_text(checkpoint)
if needle in last.text:
return last
while time.monotonic() < deadline:
time.sleep(0.05)
last = self.capture_text(checkpoint)
if needle in last.text:
return last
raise TimeoutError(f"timed out waiting for {needle!r}; last screen: {last.text}")
def is_alive(self) -> bool:
return (
subprocess.run(
["tmux", "has-session", "-t", self.run_id],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode
== 0
)
def terminate(self) -> None:
subprocess.run(
["tmux", "kill-session", "-t", self.run_id],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
@dataclass
class PtyTerminalSession(_BaseTerminalSession):
kind: DriverKind = field(init=False, default="pty")
_master_fd: int | None = field(init=False, default=None)
_process: subprocess.Popen[bytes] | None = field(init=False, default=None)
_buffer: bytearray = field(init=False, default_factory=bytearray)
def start(self) -> None:
if pty is None or not hasattr(pty, "openpty"):
raise RuntimeError("PTY terminal driver is unavailable")
master_fd, slave_fd = pty.openpty()
self._master_fd = master_fd
self._configure_master_fd()
self._set_pty_size()
env = self._process_env()
try:
self._process = subprocess.Popen(
self.command,
cwd=self.cwd,
env=env,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
)
finally:
os.close(slave_fd)
def send_text(self, text: str) -> None:
self._write(text.encode("utf-8"))
self.send_key("Enter")
def send_key(self, key: str) -> None:
sequences = {
"Enter": b"\r",
"C-c": b"\x03",
"Ctrl-C": b"\x03",
"C-d": b"\x04",
"EOF": b"\x04",
"Escape": b"\x1b",
"Tab": b"\t",
"Backspace": b"\x7f",
}
self._write(sequences.get(key, key.encode("utf-8")))
def paste(self, text: str) -> None:
payload = f"\x1b[200~{text}\x1b[201~"
self._write(payload.encode("utf-8"))
def resize(self, size: TerminalSize) -> None:
self.size = size
if self._master_fd is not None:
self._set_pty_size()
def capture_text(self, checkpoint: str) -> TerminalFrame:
self._drain_output()
text = _strip_ansi(self._buffer.decode("utf-8", errors="replace"))
frame = TerminalFrame(checkpoint, text, _now_ms(), self.size)
self._append_log(f"\n--- {checkpoint} ---\n{frame.text}")
return frame
def capture_scrollback_text(self, checkpoint: str) -> TerminalFrame:
return self.capture_text(checkpoint)
def wait_for_text(
self,
needle: str,
*,
timeout_s: float,
checkpoint: str,
) -> TerminalFrame:
deadline = time.monotonic() + timeout_s
last = self.capture_text(checkpoint)
if needle in last.text:
return last
while time.monotonic() < deadline:
time.sleep(0.05)
last = self.capture_text(checkpoint)
if needle in last.text:
return last
raise TimeoutError(f"timed out waiting for {needle!r}; last screen: {last.text}")
def is_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def terminate(self) -> None:
process = self._process
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1)
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
def _configure_master_fd(self) -> None:
if self._master_fd is None:
return
os.set_blocking(self._master_fd, False)
def _process_env(self) -> dict[str, str]:
env = os.environ.copy()
env.update(self.env)
env.setdefault("TERM", "xterm-256color")
env["COLUMNS"] = str(self.size.cols)
env["LINES"] = str(self.size.rows)
return env
def _set_pty_size(self) -> None:
if self._master_fd is None:
return
import fcntl
import termios
packed_size = struct.pack("HHHH", self.size.rows, self.size.cols, 0, 0)
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, packed_size)
def _write(self, data: bytes) -> None:
if self._master_fd is None:
raise RuntimeError("PTY session has not started")
os.write(self._master_fd, data)
def _drain_output(self) -> None:
if self._master_fd is None:
return
while True:
try:
chunk = os.read(self._master_fd, 4096)
except BlockingIOError:
return
except OSError as exc:
if exc.errno in {errno.EIO, errno.EBADF}:
return
raise
if not chunk:
return
self._buffer.extend(chunk)
def open_real_terminal_session(
*,
command: list[str],
cwd: Path,
env: dict[str, str],
run_id: str,
size: TerminalSize,
artifact_dir: Path,
driver: DriverSelection = "auto",
) -> RealTerminalSession:
capabilities = probe_terminal_capabilities()
selected = capabilities.preferred_driver if driver == "auto" else driver
terminal_log = artifact_dir / "terminal.log"
if selected == "tmux" and capabilities.tmux_available:
return TmuxTerminalSession(command, cwd, env, run_id, size, terminal_log)
if selected == "pty" and capabilities.pty_available:
return PtyTerminalSession(command, cwd, env, run_id, size, terminal_log)
if driver != "auto":
raise RuntimeError(f"requested terminal driver {selected!r} is unavailable")
reason = capabilities.skip_reason or f"requested terminal driver {selected!r} is unavailable"
raise RuntimeError(reason)