* fix(dashboard): prevent PTY input from blocking event loop * fix(win-pty): don't terminate a healthy ConPTY on write cancellation; log leaked write workers Review follow-up to the backpressure fix. CancelledError on WinPtyBridge.write() ran the same path as a timeout and force-terminated the ConPTY. Cancellation means the owning socket went away mid-write, which is the keep-alive session's normal reattach case, not a wedged child; killing the process there defeats the PTY-outlives-socket design. Give the in-flight write the shutdown grace window and only terminate if it never lands. When terminate() fails to unblock pywinpty, the worker stays parked in the default executor. That was swallowed by a bare except; log it so a slow thread-pool starvation is diagnosable. --------- Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
262 lines
10 KiB
Python
262 lines
10 KiB
Python
"""PTY bridge for `hermes dashboard` chat tab.
|
|
|
|
Wraps a child process behind a pseudo-terminal so its ANSI output can be streamed to xterm.js and
|
|
keystrokes fed back in; the only caller is the ``/api/pty`` WebSocket endpoint in
|
|
``hermes_cli.web_server``. POSIX-only: depends on ``fcntl``, ``termios`` and ``ptyprocess`` (native
|
|
Windows would need a separate ConPTY/``pywinpty`` implementation).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import errno
|
|
import fcntl
|
|
import os
|
|
import select
|
|
import signal
|
|
import struct
|
|
import sys
|
|
import termios
|
|
import time
|
|
from typing import Optional, Sequence
|
|
|
|
try:
|
|
import ptyprocess # type: ignore
|
|
_PTY_AVAILABLE = not sys.platform.startswith("win")
|
|
except ImportError: # pragma: no cover - dev env without ptyprocess
|
|
ptyprocess = None # type: ignore
|
|
_PTY_AVAILABLE = False
|
|
|
|
|
|
__all__ = ["PTY_HOST_DASHBOARD", "PTY_HOST_ENV", "PtyBridge", "PtyUnavailableError"]
|
|
|
|
# Set on the spawned TUI so Ink knows which emulator is hosting it. Mirrored in
|
|
# ui-tui/packages/hermes-ink/src/ink/termio/host.ts — keep the two in sync.
|
|
PTY_HOST_ENV = "HERMES_PTY_HOST"
|
|
PTY_HOST_DASHBOARD = "dashboard"
|
|
|
|
|
|
# ``struct winsize`` packs rows/cols as unsigned short; we clamp well below that ceiling because a
|
|
# value above it is a broken probe (WSL2 reports columns=131072), not a genuine ultrawide. Lower
|
|
# bound is 1 — a zero/negative dimension is the classic "no size yet" signal.
|
|
_MIN_DIMENSION = 1
|
|
_MAX_COLS = 2000
|
|
_MAX_ROWS = 1000
|
|
|
|
|
|
def _clamp_dimension(value: int, maximum: int) -> int:
|
|
"""Clamp into ``[_MIN_DIMENSION, maximum]``; non-integer / non-finite values fall back to
|
|
``_MIN_DIMENSION`` so a bad probe can never reach ``struct.pack``.
|
|
"""
|
|
try:
|
|
n = int(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return _MIN_DIMENSION
|
|
return max(_MIN_DIMENSION, min(n, maximum))
|
|
|
|
|
|
class PtyUnavailableError(RuntimeError):
|
|
"""PTY cannot be created here (native Windows, or ``ptyprocess`` missing); the dashboard
|
|
surfaces the message as a chat-tab banner.
|
|
"""
|
|
|
|
|
|
class PtyBridge:
|
|
"""Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming. Not thread-safe: owned by
|
|
the WebSocket handler that spawned it; reads run in an executor thread, writes are awaited on
|
|
the loop. The master fd is non-blocking so input backpressure suspends only the owning
|
|
WebSocket task, never the dashboard event loop.
|
|
"""
|
|
|
|
def __init__(self, proc: "ptyprocess.PtyProcess"): # type: ignore[name-defined]
|
|
self._proc = proc
|
|
self._fd: int = proc.fd
|
|
self._closed = False
|
|
os.set_blocking(self._fd, False)
|
|
|
|
@classmethod
|
|
def is_available(cls) -> bool:
|
|
"""True if a PTY can be spawned on this platform."""
|
|
return bool(_PTY_AVAILABLE)
|
|
|
|
@classmethod
|
|
def spawn(
|
|
cls, argv: Sequence[str], *, cwd: Optional[str] = None, env: Optional[dict] = None, cols: int = 80, rows: int = 24
|
|
) -> "PtyBridge":
|
|
"""Spawn ``argv`` behind a new PTY and return a bridge."""
|
|
if not _PTY_AVAILABLE:
|
|
if sys.platform.startswith("win"):
|
|
raise PtyUnavailableError("Pseudo-terminals are unavailable on this platform. "
|
|
"Hermes Agent supports Windows only via WSL.")
|
|
raise PtyUnavailableError("The `ptyprocess` package is missing. " # only other way _PTY_AVAILABLE is False
|
|
"Install with: pip install ptyprocess (or pip install -e '.[pty]').")
|
|
# env=None: callers own env policy (process_registry already sanitizes), so inherit via the
|
|
# factory with exact preservation. Backfill TERM when missing/blank — CI often lacks it and
|
|
# probes like `tput cols` then fail before winsize reads; explicit overrides are kept.
|
|
from tools.environments.local import build_subprocess_env
|
|
spawn_env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) if env is None else env.copy()
|
|
if not spawn_env.get("TERM"):
|
|
spawn_env["TERM"] = "xterm-256color"
|
|
# Tell the child TUI it is hosted by the dashboard's xterm.js. Ink uses this to skip the
|
|
# focus-in erase+repaint it does for native emulators that coalesce hidden-tab output
|
|
# (xterm.js never drops frames, so under the dashboard that repaint was a visible flash on
|
|
# every OS app-switch).
|
|
spawn_env[PTY_HOST_ENV] = PTY_HOST_DASHBOARD
|
|
proc = ptyprocess.PtyProcess.spawn(list(argv), cwd=cwd, env=spawn_env, dimensions=(rows, cols)) # type: ignore[union-attr]
|
|
return cls(proc)
|
|
|
|
@property
|
|
def pid(self) -> int:
|
|
return int(self._proc.pid)
|
|
|
|
def is_alive(self) -> bool:
|
|
try:
|
|
return not self._closed and bool(self._proc.isalive())
|
|
except Exception:
|
|
return False
|
|
|
|
def read(self, timeout: float = 0.2) -> Optional[bytes]:
|
|
"""Read up to 64 KiB from the PTY master, blocking at most ``timeout`` seconds.
|
|
|
|
``b""`` = nothing yet; ``None`` = EOF / closed (also after :meth:`close`).
|
|
"""
|
|
if self._closed:
|
|
return None
|
|
try:
|
|
readable, _, _ = select.select([self._fd], [], [], timeout)
|
|
except (OSError, ValueError):
|
|
return None
|
|
if not readable:
|
|
return b""
|
|
try:
|
|
data = os.read(self._fd, 65536)
|
|
except OSError as exc:
|
|
# EIO on Linux = slave side closed. EBADF = already closed.
|
|
if exc.errno in {errno.EIO, errno.EBADF}:
|
|
return None
|
|
# The fd is deliberately non-blocking. Readiness can disappear
|
|
# between select() and os.read() when close/output races occur.
|
|
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
|
return b""
|
|
raise
|
|
return data or None
|
|
|
|
async def _wait_writable(self, timeout: float) -> bool:
|
|
"""Wait without blocking the event loop until the master accepts input."""
|
|
if self._closed or timeout <= 0:
|
|
return False
|
|
loop = asyncio.get_running_loop()
|
|
ready = loop.create_future()
|
|
|
|
def _mark_ready() -> None:
|
|
if not ready.done():
|
|
ready.set_result(None)
|
|
|
|
try:
|
|
loop.add_writer(self._fd, _mark_ready)
|
|
await asyncio.wait_for(ready, timeout=timeout)
|
|
return not self._closed
|
|
except (asyncio.TimeoutError, OSError, ValueError):
|
|
return False
|
|
finally:
|
|
try:
|
|
loop.remove_writer(self._fd)
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
async def write(self, data: bytes, *, timeout: float = 10.0) -> bool:
|
|
"""Write all raw bytes without ever blocking the dashboard event loop.
|
|
|
|
Returns ``False`` when the bridge closes or the child leaves its input
|
|
buffer full for ``timeout`` seconds. Callers can then recycle only the
|
|
affected terminal session while the rest of the dashboard stays live.
|
|
"""
|
|
if self._closed:
|
|
return False
|
|
if not data:
|
|
return True
|
|
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + max(0.0, timeout)
|
|
view = memoryview(data)
|
|
while view:
|
|
if self._closed:
|
|
return False
|
|
try:
|
|
n = os.write(self._fd, view)
|
|
except OSError as exc:
|
|
if exc.errno in {errno.EIO, errno.EBADF, errno.EPIPE}:
|
|
return False
|
|
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
|
n = 0
|
|
else:
|
|
raise
|
|
if n > 0:
|
|
view = view[n:]
|
|
# A very large paste can otherwise monopolize the loop while
|
|
# the child drains quickly enough to keep the fd writable.
|
|
if view:
|
|
await asyncio.sleep(0)
|
|
continue
|
|
|
|
remaining = deadline - loop.time()
|
|
if not await self._wait_writable(remaining):
|
|
return False
|
|
return True
|
|
|
|
def resize(self, cols: int, rows: int) -> None:
|
|
"""Forward a terminal resize to the child via ``TIOCSWINSZ``.
|
|
|
|
Clamped first: WSL2 via xterm.js reports garbage like ``columns=131072, rows=1`` and an
|
|
unclamped unsigned-short pack raises ``struct.error`` (not ``OSError``), leaving the TUI
|
|
laid out for a one-row screen — the blank/disappearing-text symptom.
|
|
"""
|
|
if self._closed:
|
|
return
|
|
# struct winsize: rows, cols, xpixel, ypixel (all unsigned short)
|
|
winsize = struct.pack("HHHH", _clamp_dimension(rows, _MAX_ROWS), _clamp_dimension(cols, _MAX_COLS), 0, 0)
|
|
try:
|
|
fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize)
|
|
except OSError:
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
"""Terminate the child (SIGHUP → SIGTERM → SIGKILL, 0.5s grace each), reap it so the
|
|
dashboard process never leaks zombies, and close fds. Idempotent.
|
|
"""
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
|
|
try:
|
|
pgid = os.getpgid(self._proc.pid) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
|
except Exception:
|
|
pgid = None
|
|
|
|
# Signal the whole process group, not just the PTY leader: the dashboard TUI starts helper
|
|
# children (e.g. the Python slash worker) and killing only the leader strands them.
|
|
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
|
if not self._proc.isalive():
|
|
break
|
|
try:
|
|
if pgid is not None:
|
|
os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
|
else:
|
|
self._proc.kill(sig)
|
|
except Exception:
|
|
pass
|
|
deadline = time.monotonic() + 0.5
|
|
while self._proc.isalive() and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
|
|
try:
|
|
self._proc.close(force=True)
|
|
except Exception:
|
|
pass
|
|
|
|
def __enter__(self) -> "PtyBridge":
|
|
return self
|
|
|
|
def __exit__(self, *_exc) -> None:
|
|
self.close()
|