tmux-amq/src/tamq/ptytap.py
tegwick 452235a01f
Some checks failed
tamq-ci / test (push) Failing after 7s
fix: decode TUI input before routing
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
2026-08-25 00:46:25 +02:00

178 lines
5.7 KiB
Python

from __future__ import annotations
import fcntl
import os
import pty
import re
import select
import signal
import sys
import termios
import tty
from collections.abc import Callable, Sequence
from .broker import InputBroker
ENHANCED_ENTER = re.compile(
rb"\x1b(?:\[13(?:;[0-9:]+)*u|\[27;[0-9:]+;13~|OM)"
)
TERMINAL_INPUT_SEQUENCE = re.compile(
rb"\x1b(?:"
rb"\[[0-?]*[ -/]*[@-~]"
rb"|O."
rb"|P.*?(?:\x1b\\)"
rb"|\].*?(?:\x07|\x1b\\)"
rb")",
re.DOTALL,
)
def observed_line(raw: bytes) -> str:
"""Return typed text without terminal replies or local editing controls."""
decoded = TERMINAL_INPUT_SEQUENCE.sub(b"", raw).decode(
"utf-8", errors="replace"
)
text: list[str] = []
for character in decoded:
if character in ("\b", "\x7f"):
if text:
text.pop()
elif character == "\t" or character >= " ":
text.append(character)
return "".join(text)
class PtyTap:
"""Full-duplex PTY proxy. Input is observed, never rewritten."""
def __init__(self, command: Sequence[str], broker: InputBroker, *, on_line: Callable[[str], None] | None = None):
self.command = list(command)
self.broker = broker
self.on_line = on_line
def _observe_input(self, buffer: bytearray, data: bytes) -> None:
buffer.extend(data)
while True:
boundaries = [
(index, index + 1)
for marker in (b"\r", b"\n")
if (index := buffer.find(marker)) >= 0
]
if match := ENHANCED_ENTER.search(buffer):
boundaries.append(match.span())
if not boundaries:
return
start, end = min(boundaries)
raw = bytes(buffer[:start])
del buffer[:end]
line = observed_line(raw)
routed = self.broker.inspect_line(line)
if routed and self.on_line:
self.on_line(line)
def run(self) -> int:
stdin_fd = sys.stdin.fileno()
stdout_fd = sys.stdout.fileno()
master, slave = pty.openpty()
copy_winsize(stdin_fd, slave)
pid = os.fork()
if pid == 0:
try:
os.close(master)
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
for fd in (0, 1, 2):
os.dup2(slave, fd)
if slave > 2:
os.close(slave)
os.execvp(self.command[0], self.command)
except BaseException as exc:
os.write(2, f"tamq tap: cannot start {self.command[0]}: {exc}\n".encode())
os._exit(127)
os.close(slave)
input_buffer = bytearray()
saved_terminal = termios.tcgetattr(stdin_fd) if os.isatty(stdin_fd) else None
saved_handlers: dict[int, signal.Handlers] = {}
input_open = True
def resize(_signum=None, _frame=None) -> None:
copy_winsize(stdin_fd, master)
def forward(signum, _frame) -> None:
try:
os.killpg(pid, signum)
except ProcessLookupError:
pass
status = 0
try:
if saved_terminal is not None:
tty.setraw(stdin_fd)
saved_handlers[signal.SIGWINCH] = signal.signal(signal.SIGWINCH, resize)
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
saved_handlers[signum] = signal.signal(signum, forward)
resize()
while True:
sources = [master]
if input_open:
sources.append(stdin_fd)
try:
readable, _, _ = select.select(sources, [], [])
except InterruptedError:
continue
if master in readable:
try:
data = os.read(master, 65536)
except OSError:
break
if not data:
break
write_all(stdout_fd, data)
if input_open and stdin_fd in readable:
data = os.read(stdin_fd, 65536)
if not data:
input_open = False
try:
os.killpg(pid, signal.SIGHUP)
except ProcessLookupError:
pass
continue
write_all(master, data)
self._observe_input(input_buffer, data)
except BaseException:
try:
os.killpg(pid, signal.SIGHUP)
except ProcessLookupError:
pass
raise
finally:
for signum, handler in saved_handlers.items():
signal.signal(signum, handler)
if saved_terminal is not None:
termios.tcsetattr(stdin_fd, termios.TCSAFLUSH, saved_terminal)
os.close(master)
try:
_, status = os.waitpid(pid, 0)
except ChildProcessError:
status = 0
return os.waitstatus_to_exitcode(status)
def copy_winsize(source_fd: int, target_fd: int) -> bool:
"""Copy terminal rows/columns and pixel dimensions when a source tty exists."""
try:
size = fcntl.ioctl(source_fd, termios.TIOCGWINSZ, b"\0" * 8)
fcntl.ioctl(target_fd, termios.TIOCSWINSZ, size)
return True
except OSError:
return False
def write_all(fd: int, data: bytes) -> None:
view = memoryview(data)
while view:
written = os.write(fd, view)
if written == 0:
raise OSError("terminal write returned zero bytes")
view = view[written:]