opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
+111
-1109
File diff suppressed because it is too large
Load Diff
@@ -9,60 +9,11 @@ import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import psutil
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
from rich.console import Console
|
||||
|
||||
# Env-overridable so the integration test can drive sub-second timing.
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
"""Parse a float env knob, falling back to ``default`` on absent/malformed
|
||||
values. A bare ``float(os.environ.get(...))`` would raise ValueError at
|
||||
import time on a typo (e.g. ``HERMES_SLASH_WATCHDOG_POLL_S=2s``) and kill
|
||||
the worker before it can serve a single command."""
|
||||
raw = os.environ.get(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
_WATCHDOG_POLL_S = max(0.05, _env_float("HERMES_SLASH_WATCHDOG_POLL_S", 2.0))
|
||||
_ORPHAN_GRACE_S = max(0.0, _env_float("HERMES_SLASH_WATCHDOG_GRACE_S", 5.0))
|
||||
_in_flight = threading.Event() # set while a command is executing
|
||||
|
||||
|
||||
def _is_orphaned(original_ppid, parent_create_time, getppid=os.getppid) -> bool:
|
||||
"""True once our spawning gateway is gone. Compare to the ORIGINAL ppid
|
||||
(never ==1: Linux reparents to a subreaper) and guard PID reuse via
|
||||
create_time."""
|
||||
if getppid() != original_ppid:
|
||||
return True
|
||||
try:
|
||||
if not psutil.pid_exists(original_ppid):
|
||||
return True
|
||||
return psutil.Process(original_ppid).create_time() != parent_create_time
|
||||
except psutil.Error:
|
||||
return True
|
||||
|
||||
|
||||
def _start_parent_death_watchdog(original_ppid, parent_create_time) -> None:
|
||||
def _loop():
|
||||
while not _is_orphaned(original_ppid, parent_create_time):
|
||||
time.sleep(_WATCHDOG_POLL_S)
|
||||
deadline = time.monotonic() + _ORPHAN_GRACE_S
|
||||
while _in_flight.is_set() and time.monotonic() < deadline:
|
||||
time.sleep(0.05) # let an in-flight command finish/flush
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_loop, daemon=True).start()
|
||||
|
||||
|
||||
def _run(cli: HermesCLI, command: str) -> str:
|
||||
cmd = (command or "").strip()
|
||||
@@ -101,15 +52,6 @@ def main():
|
||||
os.environ["HERMES_SESSION_KEY"] = args.session_key
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
|
||||
# Start before the (hundreds-of-ms) HermesCLI build — that window is itself
|
||||
# an orphan risk if the gateway dies mid-spawn.
|
||||
orig_ppid = os.getppid()
|
||||
try:
|
||||
parent_create_time = psutil.Process(orig_ppid).create_time()
|
||||
except psutil.Error:
|
||||
parent_create_time = 0.0
|
||||
_start_parent_death_watchdog(orig_ppid, parent_create_time)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
|
||||
|
||||
@@ -118,7 +60,6 @@ def main():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
_in_flight.set()
|
||||
rid = None
|
||||
try:
|
||||
req = json.loads(line)
|
||||
@@ -129,8 +70,6 @@ def main():
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
_in_flight.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+23
-22
@@ -283,44 +283,45 @@ async def handle_ws(ws: Any) -> None:
|
||||
)
|
||||
break
|
||||
finally:
|
||||
reaped_sessions = 0
|
||||
detached_sessions = 0
|
||||
reaped_scheduled = 0
|
||||
if transport is not None:
|
||||
transport.close()
|
||||
|
||||
# Reap sessions this transport owned (close_on_disconnect sidecar
|
||||
# sessions) or detach the rest to the drop sentinel so later emits
|
||||
# don't crash into a closed socket or fall through to desktop stdout
|
||||
# logs. Detached sessions are handed to the grace-windowed WS-orphan
|
||||
# reaper inside _close_sessions_for_transport (a quick reconnect /
|
||||
# session.resume cancels it). This is the single WS-disconnect
|
||||
# teardown path.
|
||||
# Detach the transport from any sessions it owned so later emits
|
||||
# fall back to stdio instead of crashing into a closed socket.
|
||||
#
|
||||
# Offloaded: _close_session_by_id does a blocking worker.close()
|
||||
# (terminate + waits) plus a synchronous DB write — inline that
|
||||
# would freeze the uvicorn event loop for every other live
|
||||
# connection.
|
||||
try:
|
||||
reaped_sessions, detached_sessions = await asyncio.to_thread(
|
||||
server._close_sessions_for_transport,
|
||||
transport,
|
||||
end_reason="ws_disconnect",
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("ws transport teardown failed peer=%s", peer)
|
||||
# In the dashboard's in-process gateway that stdio fallback has no
|
||||
# real reader, so a detached session would otherwise sit forever
|
||||
# holding its _SlashWorker subprocess open (one leaked python proc
|
||||
# per browser refresh — #38591 fallout). Schedule a grace-delayed
|
||||
# reap; a quick reconnect / session.resume re-binds a live
|
||||
# transport and cancels it (see _ws_session_is_orphaned).
|
||||
for _sid, sess in list(server._sessions.items()):
|
||||
if sess.get("transport") is transport:
|
||||
sess["transport"] = server._stdio_transport
|
||||
detached_sessions += 1
|
||||
try:
|
||||
server._schedule_ws_orphan_reap(_sid)
|
||||
reaped_scheduled += 1
|
||||
except Exception:
|
||||
_log.exception(
|
||||
"ws orphan-reap schedule failed peer=%s sid=%s",
|
||||
peer,
|
||||
_sid,
|
||||
)
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception as exc:
|
||||
_log.debug("ws close failed peer=%s error=%s", peer, exc)
|
||||
_log.info(
|
||||
"ws closed peer=%s reason=%s messages=%d parse_errors=%d "
|
||||
"dispatch_crashes=%d send_failures=%d reaped_sessions=%d detached_sessions=%d",
|
||||
"dispatch_crashes=%d send_failures=%d detached_sessions=%d",
|
||||
peer,
|
||||
disconnect_reason,
|
||||
messages,
|
||||
parse_errors,
|
||||
dispatch_crashes,
|
||||
send_failures,
|
||||
reaped_sessions,
|
||||
detached_sessions,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user