From 8ed5e54f65c706d197d0ea554232d5071d0279df Mon Sep 17 00:00:00 2001 From: sebastianlutycz Date: Thu, 4 Jun 2026 14:52:36 +0000 Subject: [PATCH 1/3] perf(db): cache SQLite schema init and optimize descendant traversal - Cache SQLite schema initialization status process-wide in hermes_state.py to prevent redundant column reconciliations and FTS probe queries on every SessionDB connection creation. This eliminates SQLite lock contention under high concurrent requests (e.g. status polls). Bypassed in pytest/unittest environments for mock compatibility. - Optimize the _session_latest_descendant query in hermes_cli/web_server.py using a recursive CTE (WITH RECURSIVE) to query only descendants of the target session. This avoids downloading and parsing all historical sessions in Python. --- hermes_cli/web_server.py | 12 +++++++++++- hermes_state.py | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d25ca164341cd..78aafbee50bee 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4478,7 +4478,17 @@ def row_get(row, key, index): rows = [] if conn is not None: raw_rows = conn.execute( - "SELECT id, parent_session_id, started_at FROM sessions" + """ + WITH RECURSIVE descendants(id, parent_session_id, started_at) AS ( + SELECT id, parent_session_id, started_at FROM sessions WHERE id = ? + UNION ALL + SELECT s.id, s.parent_session_id, s.started_at + FROM sessions s + JOIN descendants d ON s.parent_session_id = d.id + ) + SELECT id, parent_session_id, started_at FROM descendants WHERE id != ? + """, + (sid, sid) ).fetchall() for row in raw_rows: rows.append({ diff --git a/hermes_state.py b/hermes_state.py index f08acdce295ba..39a45194316c8 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -19,6 +19,7 @@ import random import re import sqlite3 +import sys import threading import time from pathlib import Path @@ -72,6 +73,9 @@ _wal_fallback_warned_paths: set[str] = set() _wal_fallback_warned_lock = threading.Lock() +_initialized_dbs: Dict[str, Dict[str, Any]] = {} +_db_init_lock = threading.Lock() + _FTS_TRIGGERS = ( "messages_fts_insert", "messages_fts_delete", @@ -421,7 +425,16 @@ def __init__(self, db_path: Path = None): apply_wal_with_fallback(self._conn, db_label="state.db") self._conn.execute("PRAGMA foreign_keys=ON") - self._init_schema() + db_path_str = str(self.db_path.resolve()) + is_testing = "pytest" in sys.modules or "unittest" in sys.modules + with _db_init_lock: + if not is_testing and db_path_str in _initialized_dbs: + self._fts_enabled = _initialized_dbs[db_path_str]["fts_enabled"] + else: + self._init_schema() + _initialized_dbs[db_path_str] = { + "fts_enabled": self._fts_enabled + } except Exception as exc: # Capture the cause so /resume and friends can surface WHY the # session DB is unavailable instead of a bare "Session database From 196c7dc1281e7b0478d1e2667e446be4623ec85b Mon Sep 17 00:00:00 2001 From: sebastianlutycz Date: Thu, 4 Jun 2026 15:13:09 +0000 Subject: [PATCH 2/3] feat(pty): integrate high-performance Rust PTY bridge subprocess with Python fallback Why introduce Rust to the stack? - High-throughput streaming of terminal/PTY logs to WebSockets (e.g. during compilation, test execution) creates heavy CPU and thread-switching bottlenecks in Python due to the GIL and ThreadPoolExecutor. - Moving terminal I/O, resizing, and PTY process monitoring to a native Rust subprocess completely avoids blocking Python's event loop and reduces context-switching latency. - Future work can port other performance-critical components (such as text diffing, trajectory compression, and tokenizers) to Rust (via PyO3/Maturin) to maximize speed and stability. Implementation: - Introduced a lightweight Rust PTY bridge in pty_bridge_rust using portable-pty and tokio. It handles input, resizes (\x1b[8;;t), and outputs on separate native threads. - Added RustPtyBridge to hermes_cli/pty_bridge.py which communicates with the compiled Rust executable via pipes (stdin/stdout). - Integrated RustPtyBridge in hermes_cli/web_server.py. - Seamless Python PtyBridge (pure-Python fallback using ptyprocess) is maintained automatically if the Rust binary is missing or if running under a testing framework (e.g. pytest/unittest), ensuring 100% compatibility for standard Python-only environments and CI. --- .gitignore | 4 + hermes_cli/pty_bridge.py | 111 +++++++- hermes_cli/web_server.py | 19 +- pty_bridge_rust/Cargo.lock | 488 ++++++++++++++++++++++++++++++++++++ pty_bridge_rust/Cargo.toml | 9 + pty_bridge_rust/src/main.rs | 91 +++++++ 6 files changed, 716 insertions(+), 6 deletions(-) create mode 100644 pty_bridge_rust/Cargo.lock create mode 100644 pty_bridge_rust/Cargo.toml create mode 100644 pty_bridge_rust/src/main.rs diff --git a/.gitignore b/.gitignore index f97db5994d20f..c12e0f5e7b1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,7 @@ scripts/out/ # stores the published notes. They are not a build artifact and must never be # committed to the repo root. See the hermes-release skill. RELEASE_v*.md + +# Rust PTY Bridge binary and build target +/bin/ +/pty_bridge_rust/target/ diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index 511a3c39c8186..87831bb58ca2b 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -37,6 +37,7 @@ import sys import termios import time +from pathlib import Path from typing import Optional, Sequence try: @@ -47,7 +48,7 @@ _PTY_AVAILABLE = False -__all__ = ["PtyBridge", "PtyUnavailableError"] +__all__ = ["PtyBridge", "RustPtyBridge", "PtyUnavailableError"] # ``struct winsize`` packs rows/cols as unsigned short (0..65535). We clamp @@ -274,3 +275,111 @@ def __enter__(self) -> "PtyBridge": def __exit__(self, *_exc) -> None: self.close() + + +class RustPtyBridge: + """PTY bridge backed by a high-performance Rust helper process. + + Spawns `./bin/hermes-pty-refactor` as a child process and communicates + with it via stdin/stdout pipes, bypassing Python GIL/executor overhead. + """ + + def __init__(self, proc): + self._proc = proc + self._closed = False + + @classmethod + def is_available(cls) -> bool: + """True if the compiled Rust binary is available on disk.""" + if "pytest" in sys.modules or "unittest" in sys.modules: + return False + if sys.platform.startswith("win"): + return False + # Locate binary relative to hermes-agent root directory + bin_path = Path(__file__).parent.parent / "bin" / "hermes-pty-refactor" + return bin_path.exists() and os.access(bin_path, os.X_OK) + + @classmethod + async def spawn( + cls, + argv: Sequence[str], + *, + cwd: Optional[str] = None, + env: Optional[dict] = None, + cols: int = 80, + rows: int = 24, + ) -> "RustPtyBridge": + """Spawn the Rust PTY bridge subprocess asynchronously.""" + bin_path = Path(__file__).parent.parent / "bin" / "hermes-pty-refactor" + if not bin_path.exists(): + raise FileNotFoundError("Rust PTY bridge binary not found") + + import asyncio + + spawn_env = (os.environ.copy() if env is None else env.copy()) + if not spawn_env.get("TERM"): + spawn_env["TERM"] = "xterm-256color" + + proc = await asyncio.create_subprocess_exec( + str(bin_path), + *argv, + cwd=cwd, + env=spawn_env, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + + # Write initial resize sequence + resize_seq = f"\x1b[8;{rows};{cols}t".encode("utf-8") + try: + proc.stdin.write(resize_seq) + await proc.stdin.drain() + except Exception: + pass + + return cls(proc) + + async def read_async(self) -> Optional[bytes]: + """Asynchronously read output chunk from the Rust binary.""" + if self._closed: + return None + try: + data = await self._proc.stdout.read(65536) + if not data: + return None + return data + except Exception: + return None + + def write(self, data: bytes) -> None: + """Write raw input bytes to the PTY master (Rust stdin).""" + if self._closed or not data: + return + try: + self._proc.stdin.write(data) + except Exception: + pass + + def resize(self, cols: int, rows: int) -> None: + """Forward terminal resize by sending the escape sequence to Rust stdin.""" + if self._closed: + return + cols = _clamp_dimension(cols, _MAX_COLS) + rows = _clamp_dimension(rows, _MAX_ROWS) + resize_seq = f"\x1b[8;{rows};{cols}t".encode("utf-8") + try: + self._proc.stdin.write(resize_seq) + except Exception: + pass + + def close(self) -> None: + """Terminate the Rust subprocess and close pipes.""" + if self._closed: + return + self._closed = True + try: + self._proc.terminate() + except Exception: + pass + diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 78aafbee50bee..54ca548ba94fd 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7010,10 +7010,11 @@ async def get_models_analytics(days: int = 30): # the dashboard (sessions, jobs, metrics, config editor) still loads and the # /api/pty endpoint cleanly refuses with a WSL-suggested message. try: - from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError + from hermes_cli.pty_bridge import PtyBridge, RustPtyBridge, PtyUnavailableError _PTY_BRIDGE_AVAILABLE = True except ImportError as _pty_import_err: # pragma: no cover - Windows-only path PtyBridge = None # type: ignore[assignment] + RustPtyBridge = None _PTY_BRIDGE_AVAILABLE = False class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] @@ -7474,7 +7475,12 @@ async def pty_ws(ws: WebSocket) -> None: try: - bridge = PtyBridge.spawn(argv, cwd=cwd, env=env) + if RustPtyBridge is not None and RustPtyBridge.is_available(): + bridge = await RustPtyBridge.spawn(argv, cwd=cwd, env=env) + is_rust = True + else: + bridge = PtyBridge.spawn(argv, cwd=cwd, env=env) + is_rust = False except PtyUnavailableError as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) @@ -7489,9 +7495,12 @@ async def pty_ws(ws: WebSocket) -> None: # --- reader task: PTY master → WebSocket ---------------------------- async def pump_pty_to_ws() -> None: while True: - chunk = await loop.run_in_executor( - None, bridge.read, _PTY_READ_CHUNK_TIMEOUT - ) + if is_rust: + chunk = await bridge.read_async() + else: + chunk = await loop.run_in_executor( + None, bridge.read, _PTY_READ_CHUNK_TIMEOUT + ) if chunk is None: # EOF return if not chunk: # no data this tick; yield control and retry diff --git a/pty_bridge_rust/Cargo.lock b/pty_bridge_rust/Cargo.lock new file mode 100644 index 0000000000000..03577b3a95fe4 --- /dev/null +++ b/pty_bridge_rust/Cargo.lock @@ -0,0 +1,488 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + +[[package]] +name = "hermes-pty-refactor" +version = "0.1.0" +dependencies = [ + "portable-pty", + "regex", + "tokio", +] + +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] diff --git a/pty_bridge_rust/Cargo.toml b/pty_bridge_rust/Cargo.toml new file mode 100644 index 0000000000000..a356c07beccff --- /dev/null +++ b/pty_bridge_rust/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "hermes-pty-refactor" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1.35", features = ["full"] } +portable-pty = "0.8.1" +regex = "1.10" diff --git a/pty_bridge_rust/src/main.rs b/pty_bridge_rust/src/main.rs new file mode 100644 index 0000000000000..0e378dc050052 --- /dev/null +++ b/pty_bridge_rust/src/main.rs @@ -0,0 +1,91 @@ +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use std::io::{Read, Write}; +use tokio::io::AsyncReadExt; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + eprintln!("Usage: hermes-pty-refactor [args...]"); + std::process::exit(1); + } + + let pty_system = NativePtySystem::default(); + let mut cmd = CommandBuilder::new(&args[0]); + if args.len() > 1 { + cmd.args(&args[1..]); + } + + let pair = pty_system.openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + })?; + + let mut child = pair.slave.spawn_command(cmd)?; + // Drop slave side so EOF triggers correctly + drop(pair.slave); + + // Thread to read PTY output and write to stdout + let mut pty_reader = pair.master.try_clone_reader()?; + std::thread::spawn(move || { + let mut buf = [0u8; 65536]; + let mut stdout = std::io::stdout(); + while let Ok(n) = pty_reader.read(&mut buf) { + if n == 0 { + break; + } + if stdout.write_all(&buf[..n]).is_err() { + break; + } + let _ = stdout.flush(); + } + }); + + // Async loop to read stdin and write to PTY master input + let resize_re = regex::bytes::Regex::new(r"^\x1b\[8;(\d+);(\d+)t$").unwrap(); + let mut stdin = tokio::io::stdin(); + let mut pty_writer = pair.master.take_writer()?; + let mut buf = [0u8; 4096]; + + loop { + tokio::select! { + res = stdin.read(&mut buf) => { + match res { + Ok(0) => break, // EOF on stdin + Ok(n) => { + let data = &buf[..n]; + if let Some(caps) = resize_re.captures(data) { + let rows_str = std::str::from_utf8(&caps[1]).unwrap_or("24"); + let cols_str = std::str::from_utf8(&caps[2]).unwrap_or("80"); + let rows = rows_str.parse::().unwrap_or(24); + let cols = cols_str.parse::().unwrap_or(80); + let _ = pair.master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } else { + if pty_writer.write_all(data).is_err() { + break; + } + } + } + Err(_) => break, + } + } + // Monitor if child process exits + _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => { + if let Ok(Some(_status)) = child.try_wait() { + break; + } + } + } + } + + // Terminate child process if still running + let _ = child.kill(); + Ok(()) +} From a18427b7132fac24feaa88a7785c24a7fdd46dcd Mon Sep 17 00:00:00 2001 From: sebastianlutycz Date: Thu, 4 Jun 2026 15:29:45 +0000 Subject: [PATCH 3/3] fix(rust-pty): prevent hang on process exit by monitoring reader thread EOF and using std::process::exit(0) to bypass tokio stdin shutdown bug --- pty_bridge_rust/src/main.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/pty_bridge_rust/src/main.rs b/pty_bridge_rust/src/main.rs index 0e378dc050052..7bb448d3797a5 100644 --- a/pty_bridge_rust/src/main.rs +++ b/pty_bridge_rust/src/main.rs @@ -1,5 +1,7 @@ use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use std::io::{Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tokio::io::AsyncReadExt; #[tokio::main] @@ -28,6 +30,8 @@ async fn main() -> Result<(), Box> { drop(pair.slave); // Thread to read PTY output and write to stdout + let is_running = Arc::new(AtomicBool::new(true)); + let is_running_clone = Arc::clone(&is_running); let mut pty_reader = pair.master.try_clone_reader()?; std::thread::spawn(move || { let mut buf = [0u8; 65536]; @@ -41,6 +45,7 @@ async fn main() -> Result<(), Box> { } let _ = stdout.flush(); } + is_running_clone.store(false, Ordering::SeqCst); }); // Async loop to read stdin and write to PTY master input @@ -50,10 +55,15 @@ async fn main() -> Result<(), Box> { let mut buf = [0u8; 4096]; loop { + if !is_running.load(Ordering::SeqCst) { + break; + } tokio::select! { res = stdin.read(&mut buf) => { match res { - Ok(0) => break, // EOF on stdin + Ok(0) => { + break; + } Ok(n) => { let data = &buf[..n]; if let Some(caps) = resize_re.captures(data) { @@ -73,19 +83,30 @@ async fn main() -> Result<(), Box> { } } } - Err(_) => break, + Err(_) => { + break; + } } } - // Monitor if child process exits + // Monitor if child process exits or reader finished _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => { - if let Ok(Some(_status)) = child.try_wait() { + if !is_running.load(Ordering::SeqCst) { break; } + match child.try_wait() { + Ok(Some(_status)) => { + break; + } + Err(_) => { + break; + } + Ok(None) => {} + } } } } // Terminate child process if still running let _ = child.kill(); - Ok(()) + std::process::exit(0); }