Refresh gateway runtime source import
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"""Stdlib PTY session manager for the gateway terminal vertical slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
import shlex
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Deque, Iterable
|
||||
|
||||
|
||||
OutputCallback = Callable[[str, int, bytes], None]
|
||||
UnsubscribeCallback = Callable[[], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputChunk:
|
||||
seq: int
|
||||
data: bytes
|
||||
ts: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttachResult:
|
||||
terminal_id: str
|
||||
chunks: tuple[OutputChunk, ...]
|
||||
replay_gap: bool
|
||||
requested_after_seq: int | None
|
||||
oldest_seq: int | None
|
||||
newest_seq: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TerminalInfo:
|
||||
terminal_id: str
|
||||
pid: int
|
||||
command: tuple[str, ...]
|
||||
cwd: str
|
||||
rows: int
|
||||
cols: int
|
||||
output_seq: int
|
||||
oldest_output_seq: int | None
|
||||
created_at: float
|
||||
last_activity: float
|
||||
closed: bool
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
terminal_id: str,
|
||||
process: subprocess.Popen[bytes],
|
||||
master_fd: int,
|
||||
command: tuple[str, ...],
|
||||
cwd: str,
|
||||
rows: int,
|
||||
cols: int,
|
||||
ring_chunks: int,
|
||||
output_callback: OutputCallback | None,
|
||||
) -> None:
|
||||
self.terminal_id = terminal_id
|
||||
self.process = process
|
||||
self.master_fd = master_fd
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.rows = rows
|
||||
self.cols = cols
|
||||
self.created_at = time.time()
|
||||
self.last_activity = self.created_at
|
||||
self.output_seq = 0
|
||||
self.last_input_seq: int | None = None
|
||||
self.closed = False
|
||||
self.exit_code: int | None = None
|
||||
self._ring: Deque[OutputChunk] = deque(maxlen=ring_chunks)
|
||||
self._callbacks: dict[str, OutputCallback] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._output_callback = output_callback
|
||||
self._reader = threading.Thread(
|
||||
target=self._read_loop,
|
||||
name=f"napseer-terminal-{terminal_id}",
|
||||
daemon=True,
|
||||
)
|
||||
self._reader.start()
|
||||
|
||||
def write(self, data: bytes, input_seq: int | None = None) -> bool:
|
||||
if not isinstance(data, bytes):
|
||||
raise TypeError("terminal input data must be bytes")
|
||||
if not data:
|
||||
return False
|
||||
with self._lock:
|
||||
self._ensure_open()
|
||||
if input_seq is not None:
|
||||
if self.last_input_seq is not None and input_seq <= self.last_input_seq:
|
||||
return False
|
||||
self.last_input_seq = input_seq
|
||||
self.last_activity = time.time()
|
||||
fd = self.master_fd
|
||||
self._write_all(fd, data)
|
||||
return True
|
||||
|
||||
def resize(self, rows: int, cols: int) -> None:
|
||||
rows, cols = _normalize_size(rows, cols)
|
||||
with self._lock:
|
||||
self._ensure_open()
|
||||
self.rows = rows
|
||||
self.cols = cols
|
||||
self.last_activity = time.time()
|
||||
fd = self.master_fd
|
||||
_set_winsize(fd, rows, cols)
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGWINCH)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def attach(self, last_output_seq: int | None = None) -> AttachResult:
|
||||
with self._lock:
|
||||
chunks = tuple(self._ring)
|
||||
newest_seq = self.output_seq
|
||||
oldest_seq = chunks[0].seq if chunks else None
|
||||
replay_gap = False
|
||||
if last_output_seq is None:
|
||||
selected = chunks
|
||||
elif oldest_seq is not None and last_output_seq < oldest_seq - 1:
|
||||
selected = chunks
|
||||
replay_gap = True
|
||||
else:
|
||||
selected = tuple(chunk for chunk in chunks if chunk.seq > last_output_seq)
|
||||
self.last_activity = time.time()
|
||||
return AttachResult(
|
||||
terminal_id=self.terminal_id,
|
||||
chunks=selected,
|
||||
replay_gap=replay_gap,
|
||||
requested_after_seq=last_output_seq,
|
||||
oldest_seq=oldest_seq,
|
||||
newest_seq=newest_seq,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
process: subprocess.Popen[bytes]
|
||||
fd: int
|
||||
with self._lock:
|
||||
if self.closed:
|
||||
return
|
||||
self.closed = True
|
||||
self.last_activity = time.time()
|
||||
process = self.process
|
||||
fd = self.master_fd
|
||||
self._stop.set()
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait(timeout=2.0)
|
||||
with self._lock:
|
||||
self.exit_code = process.poll()
|
||||
|
||||
def info(self) -> TerminalInfo:
|
||||
with self._lock:
|
||||
oldest_seq = self._ring[0].seq if self._ring else None
|
||||
exit_code = self.process.poll()
|
||||
if exit_code is not None:
|
||||
self.exit_code = exit_code
|
||||
return TerminalInfo(
|
||||
terminal_id=self.terminal_id,
|
||||
pid=self.process.pid,
|
||||
command=self.command,
|
||||
cwd=self.cwd,
|
||||
rows=self.rows,
|
||||
cols=self.cols,
|
||||
output_seq=self.output_seq,
|
||||
oldest_output_seq=oldest_seq,
|
||||
created_at=self.created_at,
|
||||
last_activity=self.last_activity,
|
||||
closed=self.closed,
|
||||
exit_code=self.exit_code,
|
||||
)
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
ready, _, _ = select.select([self.master_fd], [], [], 0.05)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not ready:
|
||||
if self.process.poll() is not None:
|
||||
break
|
||||
continue
|
||||
try:
|
||||
data = os.read(self.master_fd, 8192)
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.EIO, errno.EBADF):
|
||||
break
|
||||
continue
|
||||
if not data:
|
||||
break
|
||||
with self._lock:
|
||||
self.output_seq += 1
|
||||
seq = self.output_seq
|
||||
chunk = OutputChunk(seq=seq, data=data, ts=time.time())
|
||||
self._ring.append(chunk)
|
||||
self.last_activity = chunk.ts
|
||||
callback = self._output_callback
|
||||
callbacks = tuple(self._callbacks.values())
|
||||
if callback is not None:
|
||||
callback(self.terminal_id, seq, data)
|
||||
for listener in callbacks:
|
||||
listener(self.terminal_id, seq, data)
|
||||
with self._lock:
|
||||
self.exit_code = self.process.poll()
|
||||
self.closed = True
|
||||
self._stop.set()
|
||||
|
||||
def _ensure_open(self) -> None:
|
||||
if self.closed or self.process.poll() is not None:
|
||||
self.closed = True
|
||||
self.exit_code = self.process.poll()
|
||||
raise RuntimeError(f"terminal {self.terminal_id} is closed")
|
||||
|
||||
def subscribe(self, callback: OutputCallback) -> UnsubscribeCallback:
|
||||
subscription_id = uuid.uuid4().hex
|
||||
with self._lock:
|
||||
self._callbacks[subscription_id] = callback
|
||||
|
||||
def unsubscribe() -> None:
|
||||
with self._lock:
|
||||
self._callbacks.pop(subscription_id, None)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
@staticmethod
|
||||
def _write_all(fd: int, data: bytes) -> None:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
try:
|
||||
written = os.write(fd, view)
|
||||
except BlockingIOError:
|
||||
select.select([], [fd], [], 1.0)
|
||||
continue
|
||||
view = view[written:]
|
||||
|
||||
|
||||
class PtySessionManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ring_chunks: int = 512,
|
||||
output_callback: OutputCallback | None = None,
|
||||
) -> None:
|
||||
if ring_chunks < 1:
|
||||
raise ValueError("ring_chunks must be positive")
|
||||
self._ring_chunks = ring_chunks
|
||||
self._output_callback = output_callback
|
||||
self._sessions: dict[str, TerminalSession] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def open(
|
||||
self,
|
||||
command: str | Iterable[str] | None = None,
|
||||
cwd: str | None = None,
|
||||
rows: int = 24,
|
||||
cols: int = 80,
|
||||
) -> TerminalInfo:
|
||||
rows, cols = _normalize_size(rows, cols)
|
||||
command_tuple = _normalize_command(command)
|
||||
cwd_path = os.path.abspath(cwd or os.getcwd())
|
||||
if not os.path.isdir(cwd_path):
|
||||
raise FileNotFoundError(f"terminal cwd does not exist: {cwd_path}")
|
||||
|
||||
master_fd, slave_fd = os.openpty()
|
||||
try:
|
||||
_set_winsize(slave_fd, rows, cols)
|
||||
process = subprocess.Popen(
|
||||
command_tuple,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=cwd_path,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
finally:
|
||||
os.close(slave_fd)
|
||||
|
||||
_set_nonblocking(master_fd)
|
||||
terminal_id = f"term_{uuid.uuid4().hex}"
|
||||
session = TerminalSession(
|
||||
terminal_id=terminal_id,
|
||||
process=process,
|
||||
master_fd=master_fd,
|
||||
command=command_tuple,
|
||||
cwd=cwd_path,
|
||||
rows=rows,
|
||||
cols=cols,
|
||||
ring_chunks=self._ring_chunks,
|
||||
output_callback=self._output_callback,
|
||||
)
|
||||
with self._lock:
|
||||
self._sessions[terminal_id] = session
|
||||
return session.info()
|
||||
|
||||
def write(self, terminal_id: str, data: bytes, input_seq: int | None = None) -> bool:
|
||||
return self._get(terminal_id).write(data, input_seq=input_seq)
|
||||
|
||||
def resize(self, terminal_id: str, rows: int, cols: int) -> TerminalInfo:
|
||||
session = self._get(terminal_id)
|
||||
session.resize(rows, cols)
|
||||
return session.info()
|
||||
|
||||
def attach(
|
||||
self,
|
||||
terminal_id: str,
|
||||
last_output_seq: int | None = None,
|
||||
) -> AttachResult:
|
||||
return self._get(terminal_id).attach(last_output_seq=last_output_seq)
|
||||
|
||||
def subscribe(self, terminal_id: str, callback: OutputCallback) -> UnsubscribeCallback:
|
||||
return self._get(terminal_id).subscribe(callback)
|
||||
|
||||
def close(self, terminal_id: str) -> None:
|
||||
session = self._get(terminal_id)
|
||||
session.close()
|
||||
with self._lock:
|
||||
self._sessions.pop(terminal_id, None)
|
||||
|
||||
def list(self) -> list[TerminalInfo]:
|
||||
with self._lock:
|
||||
sessions = tuple(self._sessions.values())
|
||||
return [session.info() for session in sessions]
|
||||
|
||||
def _get(self, terminal_id: str) -> TerminalSession:
|
||||
with self._lock:
|
||||
session = self._sessions.get(terminal_id)
|
||||
if session is None:
|
||||
raise KeyError(f"unknown terminal: {terminal_id}")
|
||||
return session
|
||||
|
||||
|
||||
def _normalize_command(command: str | Iterable[str] | None) -> tuple[str, ...]:
|
||||
if command is None:
|
||||
shell = os.environ.get("SHELL") or "/bin/sh"
|
||||
return (shell,)
|
||||
if isinstance(command, str):
|
||||
return tuple(shlex.split(command))
|
||||
command_tuple = tuple(command)
|
||||
if not command_tuple:
|
||||
raise ValueError("terminal command cannot be empty")
|
||||
return command_tuple
|
||||
|
||||
|
||||
def _normalize_size(rows: int, cols: int) -> tuple[int, int]:
|
||||
rows = int(rows)
|
||||
cols = int(cols)
|
||||
if rows < 1 or cols < 1:
|
||||
raise ValueError("terminal rows and cols must be positive")
|
||||
return rows, cols
|
||||
|
||||
|
||||
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
def _set_nonblocking(fd: int) -> None:
|
||||
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
|
||||
default_manager = PtySessionManager()
|
||||
|
||||
|
||||
def open(
|
||||
command: str | Iterable[str] | None = None,
|
||||
cwd: str | None = None,
|
||||
rows: int = 24,
|
||||
cols: int = 80,
|
||||
) -> TerminalInfo:
|
||||
return default_manager.open(command=command, cwd=cwd, rows=rows, cols=cols)
|
||||
|
||||
|
||||
def write(terminal_id: str, data: bytes, input_seq: int | None = None) -> bool:
|
||||
return default_manager.write(terminal_id, data, input_seq=input_seq)
|
||||
|
||||
|
||||
def resize(terminal_id: str, rows: int, cols: int) -> TerminalInfo:
|
||||
return default_manager.resize(terminal_id, rows, cols)
|
||||
|
||||
|
||||
def attach(terminal_id: str, last_output_seq: int | None = None) -> AttachResult:
|
||||
return default_manager.attach(terminal_id, last_output_seq=last_output_seq)
|
||||
|
||||
|
||||
def subscribe(terminal_id: str, callback: OutputCallback) -> UnsubscribeCallback:
|
||||
return default_manager.subscribe(terminal_id, callback)
|
||||
|
||||
|
||||
def close(terminal_id: str) -> None:
|
||||
default_manager.close(terminal_id)
|
||||
|
||||
|
||||
def list() -> list[TerminalInfo]:
|
||||
return default_manager.list()
|
||||
Reference in New Issue
Block a user