"""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 # windows-footgun: ok — POSIX-only module by design (see docstring) import math import os import select import signal import struct import sys import termios # windows-footgun: ok — POSIX-only module by design (see docstring) 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. """ # Longer than the TUI gateway's own SIGHUP shutdown grace, so a helper that is saving state on # SIGHUP finishes before it is SIGKILLed. _HELPER_SHUTDOWN_GRACE_S = 1.5 # Upper bound: PTY_REGISTRY.close_all() runs inside the backend's SIGTERM -> SIGKILL budget # (dashboard_procs._POSIX_TERM_GRACE_SECONDS), so a raised gateway grace cannot stretch it. _MAX_HELPER_SHUTDOWN_GRACE_S = 2.0 def _helper_shutdown_grace() -> float: # The gateway's grace is 1.0 s unless HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S raises it # (tui_gateway/entry.py); the PTY child inherits that env. Read it here rather than import # tui_gateway.entry, which installs signal handlers. from utils import env_float gateway = env_float("HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S", 1.0) if not math.isfinite(gateway) or gateway <= 0: gateway = 1.0 return min(max(_HELPER_SHUTDOWN_GRACE_S, gateway + 0.5), _MAX_HELPER_SHUTDOWN_GRACE_S) def _process_group_exists(pgid: int) -> bool: try: os.killpg(pgid, 0) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) except OSError: return False return True def _psutil_alive(proc) -> bool: """Best-effort 'is this psutil Process still alive' for the shutdown cadence; zombies count as dead and it never raises.""" try: import psutil # type: ignore return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE except Exception: return False 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 # Recorded at spawn: once the leader is reaped getpgid() can no longer find its group, # but the helpers it started still belong to it. try: self._pgid: Optional[int] = os.getpgid(proc.pid) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) except OSError: self._pgid = None 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 "Run hermes pm repair, then restart Hermes.") # 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 _discard_output(self, timeout: float) -> None: # Exiting children flush their pending terminal output first; on macOS a process stays in # exit (and a SIGHUP save outlasts the grace) until someone drains the master. The drain # task no longer consumes the output, so read and drop while waiting. try: readable, _, _ = select.select([self._fd], [], [], timeout) if readable and os.read(self._fd, 65536): return except (OSError, ValueError): pass time.sleep(timeout) # nothing read, EOF (b"") or EIO: don't spin on a readable dead fd def _wait_for_group_exit(self, pgid: int, timeout: float) -> None: deadline = time.monotonic() + timeout while _process_group_exists(pgid) and time.monotonic() < deadline: self._discard_output(0.02) def close(self) -> None: """Terminate the child's process group (SIGHUP → SIGTERM → SIGKILL, 0.5s grace each while the leader lives, then up to the helper grace for the rest of the group), reap the leader so the dashboard never leaks zombies, and close fds. Idempotent; blocks, so call it off the event loop. """ if self._closed: return self._closed = True pgid = self._pgid leader_was_alive = self._proc.isalive() # No group signal is safe for a shared-group child, so its descendants must be # snapshotted BEFORE the parent is signalled: once the parent exits they reparent # and psutil can no longer find them (browser_tool_lifecycle._legacy_kill_process_tree, # process_registry._terminate_host_pid). The sweep below then kills them individually # so a SIGHUP-ignoring helper cannot keep the PTY slave open (#76759). non_leader_descendants: list = [] if pgid is not None and pgid != self._proc.pid: # Not a group leader: the child shares OUR process group, so killpg would # take the TUI down with it. Signal the child directly instead. if leader_was_alive: try: import psutil # type: ignore non_leader_descendants = psutil.Process(self._proc.pid).children(recursive=True) except Exception: non_leader_descendants = [] 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: self._discard_output(0.02) # Helpers can outlive the leader and keep the PTY slave open, so no EOF ever arrives (#76759): # one that ignores SIGHUP, or any helper of a leader that died first (crash, OOM kill). When # the group already got SIGHUP above, only wait: the TUI gateway saves its sessions on SIGHUP # within its shutdown grace, and a second SIGHUP or an early SIGKILL would cut that short. # The group id cannot be reused while any member is alive; if the group emptied after the # leader was reaped, a pid wrap inside that window could hand it to a new group (killpg # then hits that group). The reaper closes a dead session within one tick, so the window is short. if pgid is not None: grace = _helper_shutdown_grace() sweep = (signal.SIGKILL,) if leader_was_alive else (signal.SIGHUP, signal.SIGKILL) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) if leader_was_alive: self._wait_for_group_exit(pgid, grace) for sig in sweep: try: os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) except OSError: break # ESRCH: the group is empty self._wait_for_group_exit(pgid, grace if sig == signal.SIGHUP else 0.5) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) elif non_leader_descendants: # The shared-group branch: the helpers-outlive-leader sweep above cannot run # (no killpg is safe), so end the snapshotted descendants individually. The main # loop above signalled only the child PID, so the descendants got no SIGHUP — # send it now and allow the helper grace, then SIGKILL survivors: a helper # saving state on SIGHUP still finishes, and one that ignores SIGHUP cannot # outlive the close (mirrors the group sweep's leader-dead cadence, #76759). grace = _helper_shutdown_grace() for sig in (signal.SIGHUP, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) for child in non_leader_descendants: try: if sig == signal.SIGHUP: # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) child.send_signal(sig) else: child.kill() except Exception: pass # already gone; psutil raises NoSuchProcess/Zombie if sig == signal.SIGHUP: # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) deadline = time.monotonic() + grace while any(_psutil_alive(c) for c in non_leader_descendants) and time.monotonic() < deadline: self._discard_output(0.02) try: self._proc.close(force=True) except Exception: pass def __enter__(self) -> "PtyBridge": return self def __exit__(self, *_exc) -> None: self.close()