- Mount xterm as an absolute inset-0 fill so it always has an explicit box and fit() measures the real size when the dock expands (was collapsing via the flex/percentage height chain). - Acquire the pty slave as the child's controlling terminal (setsid + TIOCSCTTY) so the kernel delivers SIGWINCH on a browser resize. Previously the initial size was read once but live resizes were dropped, so the pane stayed at its startup size — content never grew with the window.
173 lines
5.9 KiB
Python
173 lines
5.9 KiB
Python
"""Run a command inside a pseudo-terminal and stream it over asyncio.
|
|
|
|
Castle stays assistant-agnostic: it just launches a command in a real TTY and
|
|
shunts raw bytes both ways. The command's own TUI (an agent CLI, a shell, …)
|
|
renders exactly as it would in a terminal — Castle never parses its output.
|
|
|
|
The PTY master is a raw OS fd, not an asyncio stream, so we watch it with
|
|
``loop.add_reader`` (no extra thread, no busy-poll). Output is delivered to an
|
|
``on_output`` callback (a fan-out layer above can broadcast it to any number of
|
|
attached clients and buffer scrollback), so a session's lifetime is decoupled
|
|
from any one WebSocket connection. Keystroke writes are tiny, so a plain
|
|
``os.write`` is fine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import fcntl
|
|
import os
|
|
import pty
|
|
import signal
|
|
import struct
|
|
import termios
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
|
|
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
|
"""Apply a terminal window size via TIOCSWINSZ (struct winsize)."""
|
|
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
|
|
|
|
def _acquire_controlling_tty() -> None:
|
|
"""Child-side setup (preexec), run after the pty slave is dup'd to fd 0/1/2.
|
|
|
|
``setsid()`` starts a new session (own process group, so cleanup can
|
|
``killpg`` the whole tree). ``TIOCSCTTY`` then makes the slave our
|
|
*controlling terminal* — without it the kernel has no foreground process
|
|
group to deliver ``SIGWINCH`` to, so a later winsize change (a browser resize)
|
|
is silently dropped and the app never reflows. This is what makes live resize
|
|
work, not just the initial size.
|
|
"""
|
|
os.setsid()
|
|
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
|
|
|
|
|
@dataclass
|
|
class PtySession:
|
|
"""A child process attached to a pty master.
|
|
|
|
Output is pushed to ``on_output(bytes)`` as it arrives and ``on_exit()`` is
|
|
fired once when the child closes the tty. Both run in the event-loop thread.
|
|
"""
|
|
|
|
proc: asyncio.subprocess.Process
|
|
master_fd: int
|
|
on_output: Callable[[bytes], None] | None = None
|
|
on_exit: Callable[[], None] | None = None
|
|
cols: int = 80
|
|
rows: int = 24
|
|
_closed: bool = field(default=False)
|
|
|
|
@classmethod
|
|
async def start(
|
|
cls,
|
|
command: str,
|
|
args: list[str] | None = None,
|
|
cwd: str | Path | None = None,
|
|
env: dict[str, str] | None = None,
|
|
cols: int = 80,
|
|
rows: int = 24,
|
|
on_output: Callable[[bytes], None] | None = None,
|
|
on_exit: Callable[[], None] | None = None,
|
|
) -> PtySession:
|
|
"""Spawn ``command args`` on a fresh pty, sized ``cols`` x ``rows``."""
|
|
master_fd, slave_fd = pty.openpty()
|
|
# Size the tty before exec so the first TUI render lays out correctly.
|
|
_set_winsize(master_fd, rows, cols)
|
|
|
|
proc_env = {**os.environ, "TERM": "xterm-256color"}
|
|
if env:
|
|
proc_env.update(env)
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
command,
|
|
*(args or []),
|
|
stdin=slave_fd,
|
|
stdout=slave_fd,
|
|
stderr=slave_fd,
|
|
cwd=str(cwd) if cwd else None,
|
|
env=proc_env,
|
|
# setsid + TIOCSCTTY in the child: own session/group (for killpg) AND
|
|
# the slave as controlling tty (for SIGWINCH resize delivery).
|
|
preexec_fn=_acquire_controlling_tty,
|
|
close_fds=True,
|
|
)
|
|
os.close(slave_fd) # parent keeps only the master
|
|
os.set_blocking(master_fd, False)
|
|
|
|
session = cls(proc=proc, master_fd=master_fd, cols=cols, rows=rows)
|
|
if on_output is not None:
|
|
session.on_output = on_output
|
|
if on_exit is not None:
|
|
session.on_exit = on_exit
|
|
asyncio.get_running_loop().add_reader(master_fd, session._drain_master)
|
|
return session
|
|
|
|
def _drain_master(self) -> None:
|
|
"""Reader callback: pull available bytes off the master and fan them out."""
|
|
try:
|
|
data = os.read(self.master_fd, 65536)
|
|
except (BlockingIOError, InterruptedError):
|
|
return
|
|
except OSError:
|
|
data = b"" # slave closed → child gone
|
|
if not data:
|
|
try:
|
|
asyncio.get_running_loop().remove_reader(self.master_fd)
|
|
except (ValueError, OSError):
|
|
pass
|
|
if self.on_exit is not None:
|
|
self.on_exit()
|
|
return
|
|
if self.on_output is not None:
|
|
self.on_output(data)
|
|
|
|
def write(self, data: bytes) -> None:
|
|
"""Forward keystroke bytes to the child's tty."""
|
|
try:
|
|
os.write(self.master_fd, data)
|
|
except OSError:
|
|
pass
|
|
|
|
def resize(self, cols: int, rows: int) -> None:
|
|
"""Resize the tty (browser terminal changed dimensions)."""
|
|
self.cols, self.rows = cols, rows
|
|
try:
|
|
_set_winsize(self.master_fd, rows, cols)
|
|
except OSError:
|
|
pass
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return self.proc.returncode is None
|
|
|
|
async def close(self) -> None:
|
|
"""Kill the whole process group and release the master fd (idempotent)."""
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
try:
|
|
asyncio.get_running_loop().remove_reader(self.master_fd)
|
|
except (ValueError, OSError):
|
|
pass
|
|
if self.proc.returncode is None:
|
|
try:
|
|
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(self.proc.wait(), timeout=3)
|
|
except asyncio.TimeoutError:
|
|
try:
|
|
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
os.close(self.master_fd)
|
|
except OSError:
|
|
pass
|